feat(tui-go): idle launcher — centered input + status/keys rail (Phase 1)
Replace the idle two-panel layout (session list + welcome, where the welcome panel duplicated the list) with an opencode-style launcher: a compact, centered input whose lower-right shows the launch target and model (chat by default), a hideable right rail of status + quick keys, and no session list — it's a keypress away via R. - Tab (or w) cycles the launch target: chat → workflows → chat (launcherWf), shown at the input. Enter on chat starts a chat; on a workflow, StartSession with the typed text as the brief. - Right rail (connection, session/active counts, i/Tab/R/?/p keys) hides via the new "rail" palette command. Drops automatically on narrow terminals. - View() suppresses the bottom input bar on idle (the input is the launcher); footer + ? help updated to the launcher keys (help-coverage test extended). The old idle-panel renderers (sessionRows/welcomeRows/workflowRows) are now unused but kept in the tree pending Phase 2 + a decision on where the session dates should live now that the idle list is gone. Multi-line input (Ctrl+J / Alt+Enter newline) is Phase 2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -37,11 +37,15 @@ func (m Model) View() string {
|
||||
return m.theme.Screen.Render("correx — terminal too small")
|
||||
}
|
||||
|
||||
bottom := m.renderInput()
|
||||
bottomH := inputH
|
||||
if m.displayState() == StateApproval {
|
||||
ds := m.displayState()
|
||||
bottom, bottomH := m.renderInput(), inputH
|
||||
switch {
|
||||
case ds == StateApproval:
|
||||
bottomH = m.approvalBandHeight()
|
||||
bottom = m.renderApprovalBand(bottomH)
|
||||
case ds == StateIdle:
|
||||
// Launcher: the input is centered inside the main area, so there's no bottom bar.
|
||||
bottom, bottomH = "", 0
|
||||
}
|
||||
|
||||
mainH := m.height - statusH - footerH - bottomH
|
||||
@@ -49,12 +53,12 @@ func (m Model) View() string {
|
||||
mainH = 3
|
||||
}
|
||||
|
||||
base := lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.renderStatus(),
|
||||
m.renderMain(mainH),
|
||||
bottom,
|
||||
m.renderFooter(),
|
||||
)
|
||||
sections := []string{m.renderStatus(), m.renderMain(mainH)}
|
||||
if bottomH > 0 {
|
||||
sections = append(sections, bottom)
|
||||
}
|
||||
sections = append(sections, m.renderFooter())
|
||||
base := lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||
// Stash the base so center() can composite a modal over a dimmed copy of it.
|
||||
m.lastBase = base
|
||||
|
||||
@@ -244,9 +248,9 @@ func (m Model) renderFooter() string {
|
||||
hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")}
|
||||
case m.displayState() == StateIdle:
|
||||
if nrw {
|
||||
hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")}
|
||||
hints = []string{hint("i", "type"), hint("Tab", "wf"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")}
|
||||
} else {
|
||||
hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")}
|
||||
hints = []string{hint("i", "type"), hint("Tab", "workflow"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")}
|
||||
}
|
||||
default: // StateInSession
|
||||
if nrw {
|
||||
@@ -279,41 +283,122 @@ func (m Model) renderFooter() string {
|
||||
// --- main split ---
|
||||
|
||||
func (m Model) renderMain(h int) string {
|
||||
if m.displayState() == StateIdle {
|
||||
return m.renderLauncher(m.width, h)
|
||||
}
|
||||
if m.narrow() {
|
||||
return m.renderMainNarrow(h)
|
||||
}
|
||||
leftW := m.width * 63 / 100
|
||||
rightW := m.width - leftW
|
||||
|
||||
var leftTitle, rightTitle string
|
||||
var leftBody, rightBody []string
|
||||
leftActive, rightActive := false, false
|
||||
|
||||
switch m.displayState() {
|
||||
case StateIdle:
|
||||
leftTitle = "sessions"
|
||||
if m.wfVisible {
|
||||
leftTitle = "workflows"
|
||||
leftBody = m.workflowRows(leftW - 4)
|
||||
} else {
|
||||
leftBody = m.sessionRows(leftW - 4)
|
||||
}
|
||||
leftActive = true
|
||||
rightTitle = "welcome"
|
||||
rightBody = m.welcomeRows(rightW - 4)
|
||||
case StateInSession, StateApproval:
|
||||
leftTitle = "output"
|
||||
leftBody = m.routerRows(leftW-4, h-2)
|
||||
leftActive = true
|
||||
rightTitle = "events"
|
||||
rightBody = m.eventRows(rightW-4, h)
|
||||
}
|
||||
|
||||
left := m.theme.box(leftTitle, leftBody, leftW, h, leftActive)
|
||||
right := m.theme.box(rightTitle, rightBody, rightW, h, rightActive)
|
||||
// In-session / approval: output transcript over the event stream.
|
||||
left := m.theme.box("output", m.routerRows(leftW-4, h-2), leftW, h, true)
|
||||
right := m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, false)
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
||||
}
|
||||
|
||||
// renderLauncher is the idle screen: a compact, opencode-style input centered in the main
|
||||
// area, with a hideable right rail of status + quick keys. The session list isn't shown —
|
||||
// it's a keypress away (R). The input's lower-right shows <workflow> · <model>, Tab-cycled.
|
||||
func (m Model) renderLauncher(w, h int) string {
|
||||
t := m.theme
|
||||
railW := 26
|
||||
if m.railHidden || m.narrow() || w < 66 {
|
||||
railW = 0
|
||||
}
|
||||
leftW := w - railW
|
||||
|
||||
inW := leftW * 82 / 100
|
||||
if inW > 84 {
|
||||
inW = 84
|
||||
}
|
||||
if inW < 24 {
|
||||
inW = 24
|
||||
}
|
||||
left := lipgloss.Place(leftW, h, lipgloss.Center, lipgloss.Center, m.launcherInput(inW),
|
||||
lipgloss.WithWhitespaceBackground(t.P.Bg))
|
||||
if railW == 0 {
|
||||
return left
|
||||
}
|
||||
rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW),
|
||||
lipgloss.WithWhitespaceBackground(t.P.Bg))
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, rail)
|
||||
}
|
||||
|
||||
// launcherInput renders the compact input box plus a status sub-line (<workflow> · <model>
|
||||
// left, action hints right).
|
||||
func (m Model) launcherInput(w int) string {
|
||||
t := m.theme
|
||||
insert := m.editMode == ModeInsert
|
||||
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("› ")
|
||||
caret := t.span(" ", t.P.Bg)
|
||||
if insert && m.caretVisible() {
|
||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
|
||||
}
|
||||
var line string
|
||||
switch {
|
||||
case m.inputBuffer == "" && !insert:
|
||||
line = prompt + t.span("Ask anything…", t.P.Faint)
|
||||
case m.inputBuffer == "":
|
||||
line = prompt + caret + t.span("Ask anything…", t.P.Faint)
|
||||
default:
|
||||
line = prompt + t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret
|
||||
}
|
||||
box := t.box("", []string{line}, w, 3, insert)
|
||||
|
||||
wf := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(m.launcherWfName())
|
||||
leftSub := " " + wf + t.span(" · ", t.P.Faint) + t.span(m.currentModel, t.P.Faint)
|
||||
hints := t.span("Tab workflow · enter ↵ ", t.P.Faint)
|
||||
return box + "\n" + m.justify(leftSub, hints, w, t.P.Bg)
|
||||
}
|
||||
|
||||
// launcherRail is the idle status + quick-keys panel on the right.
|
||||
func (m Model) launcherRail(w int) string {
|
||||
t := m.theme
|
||||
var rows []string
|
||||
if m.connected {
|
||||
rows = append(rows, lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● connected"))
|
||||
} else {
|
||||
rows = append(rows, lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("● offline"))
|
||||
}
|
||||
count := t.span(plural(len(m.sessions), "session"), t.P.Dim)
|
||||
if a := m.activeSessionCount(); a > 0 {
|
||||
count += t.span(" · "+itoa(a)+" active", t.P.Faint)
|
||||
}
|
||||
key := func(k, d string) string {
|
||||
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(padRaw(k, 4)) + t.span(d, t.P.Dim)
|
||||
}
|
||||
rows = append(rows, count, "",
|
||||
key("i", "type"),
|
||||
key("Tab", "workflow"),
|
||||
key("R", "resume"),
|
||||
key("?", "keys"),
|
||||
key("p", "commands"),
|
||||
)
|
||||
return t.box("correx", rows, w, len(rows)+2, false)
|
||||
}
|
||||
|
||||
// launcherWfName is the active launch target: "chat" (default) or a workflow id, Tab-cycled.
|
||||
func (m Model) launcherWfName() string {
|
||||
if m.launcherWf <= 0 || m.launcherWf > len(m.workflows) {
|
||||
return "chat"
|
||||
}
|
||||
return m.workflows[m.launcherWf-1].ID
|
||||
}
|
||||
|
||||
// activeSessionCount is the number of sessions not in a terminal (completed/failed) state.
|
||||
func (m Model) activeSessionCount() int {
|
||||
n := 0
|
||||
for _, s := range m.sessions {
|
||||
u := strings.ToUpper(s.Status)
|
||||
if !strings.Contains(u, "COMPLETE") && !strings.Contains(u, "FAIL") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// renderMainNarrow gives the main area a single full-width column when the terminal
|
||||
// is too slim for side-by-side panels: the session list when idle, and the output
|
||||
// transcript stacked over the live event strip when in-session (so both still fit
|
||||
|
||||
Reference in New Issue
Block a user