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:
2026-06-22 11:33:19 +00:00
parent 81280c5bd5
commit 9d00612742
6 changed files with 163 additions and 47 deletions
@@ -17,6 +17,7 @@ func TestHelpCoversEveryKeybind(t *testing.T) {
// navigate // navigate
"move the session / list selection", // ↑↓ / j k "move the session / list selection", // ↑↓ / j k
"open the selected session", // enter "open the selected session", // enter
"cycle launch target", // Tab / w
"filter the session list", // / "filter the session list", // /
"jump to your previous / next message", // ^↑ / ^↓ "jump to your previous / next message", // ^↑ / ^↓
"scroll output", // PgUp / PgDn (+ ^u / ^d) "scroll output", // PgUp / PgDn (+ ^u / ^d)
+6 -1
View File
@@ -227,7 +227,12 @@ type Model struct {
wfVisible bool wfVisible bool
wfPendingID string // workflow chosen, awaiting an intent line before StartSession wfPendingID string // workflow chosen, awaiting an intent line before StartSession
wfPendingName string wfPendingName string
bgUpdates int // launcher (idle screen): the active "what to launch" selection — 0 = chat, 1..N =
// workflows[i-1] — cycled with Tab and shown at the input's lower-right. railHidden
// folds away the idle status/quick-keys rail.
launcherWf int
railHidden bool
bgUpdates int
// input // input
editMode EditMode editMode EditMode
+1
View File
@@ -821,6 +821,7 @@ func (m Model) helpBody() []string {
section("navigate", []kb{ section("navigate", []kb{
{"↑↓ / j k", "move the session / list selection"}, {"↑↓ / j k", "move the session / list selection"},
{"enter", "open the selected session"}, {"enter", "open the selected session"},
{"Tab / w", "idle: cycle launch target (chat / workflow)"},
{"/", "filter the session list"}, {"/", "filter the session list"},
{"^↑ / ^↓", "jump to your previous / next message"}, {"^↑ / ^↓", "jump to your previous / next message"},
{"PgUp / PgDn", "scroll output (^u / ^d half-page)"}, {"PgUp / PgDn", "scroll output (^u / ^d half-page)"},
@@ -494,18 +494,18 @@ func matrixCases() []matrixCase {
want: []string{"VRAM 4096/8192M", "GPU 42%"}, want: []string{"VRAM 4096/8192M", "GPU 42%"},
}, },
// --- workflow list (idle left pane) --- // --- workflow list idle launch target (Tab-selected, shown at the input) ---
{ {
name: "workflow.list", name: "workflow.list",
kinds: []string{protocol.TypeWorkflowList}, kinds: []string{protocol.TypeWorkflowList},
surface: surfaceView, surface: surfaceView,
prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false; m.wfVisible = true; m.wfIndex = 0 }, prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false; m.launcherWf = 1 },
build: func() protocol.ServerMessage { build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeWorkflowList, Workflows: []protocol.WorkflowDto{ return protocol.ServerMessage{Type: protocol.TypeWorkflowList, Workflows: []protocol.WorkflowDto{
{WorkflowID: "healthcheck", Description: "kick the tires"}, {WorkflowID: "healthcheck", Description: "kick the tires"},
}} }}
}, },
want: []string{"healthcheck", "kick the tires"}, want: []string{"healthcheck"},
}, },
// --- session snapshot (reopen: rebuilds a session from the recent-events tail) --- // --- session snapshot (reopen: rebuilds a session from the recent-events tail) ---
+30 -6
View File
@@ -206,6 +206,12 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
} }
// On the idle launcher, Tab cycles the launch target (chat → workflows → chat) in any
// edit mode, so it works whether or not the input is focused.
if m.displayState() == StateIdle && k.Type == tea.KeyTab {
m.cycleLauncherWf()
return m, nil
}
if m.editMode == ModeInsert { if m.editMode == ModeInsert {
return m.handleInsertKey(k) return m.handleInsertKey(k)
} }
@@ -300,13 +306,9 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case "m": case "m":
m.openModelsOverlay() m.openModelsOverlay()
case "w": case "w":
// Launcher: w is an alias for Tab — cycle the launch target (chat / workflow…).
if ds == StateIdle { if ds == StateIdle {
m.wfVisible = !m.wfVisible m.cycleLauncherWf()
if m.wfVisible {
m.wfIndex = 0
} else {
m.wfIndex = -1
}
} }
case "l": case "l":
if ds != StateIdle { if ds != StateIdle {
@@ -1074,6 +1076,7 @@ func paletteCommands() []paletteCmd {
{"config", "g", "edit config", "view / change correx settings"}, {"config", "g", "edit config", "view / change correx settings"},
{"grants", "G", "grants", "view / revoke standing grants"}, {"grants", "G", "grants", "view / revoke standing grants"},
{"statusbar", "", "status bar", "show / hide status-bar segments"}, {"statusbar", "", "status bar", "show / hide status-bar segments"},
{"rail", "", "idle rail", "show / hide the idle status + keys rail"},
{"actions", "", "inline actions", "show / hide tool & action rows in output"}, {"actions", "", "inline actions", "show / hide tool & action rows in output"},
{"help", "?", "help", "keybinding cheat-sheet"}, {"help", "?", "help", "keybinding cheat-sheet"},
{"mode", "s", "toggle mode", "switch chat / steering"}, {"mode", "s", "toggle mode", "switch chat / steering"},
@@ -1122,6 +1125,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
case "statusbar": case "statusbar":
m.overlay = OverlayStatusbar m.overlay = OverlayStatusbar
m.sbIndex = 0 m.sbIndex = 0
case "rail":
m.railHidden = !m.railHidden
case "actions": case "actions":
m.toggleInlineActions() m.toggleInlineActions()
case "help": case "help":
@@ -1164,6 +1169,11 @@ func (m *Model) backspace() {
m.inputCursor = c - 1 m.inputCursor = c - 1
} }
// cycleLauncherWf advances the idle launch target: 0 = chat, 1..N = workflows[i-1], wrapping.
func (m *Model) cycleLauncherWf() {
m.launcherWf = (m.launcherWf + 1) % (len(m.workflows) + 1)
}
func (m *Model) cycleChatMode() { func (m *Model) cycleChatMode() {
if m.chatMode == ChatModeChat { if m.chatMode == ChatModeChat {
m.chatMode = ChatModeSteering m.chatMode = ChatModeSteering
@@ -1211,6 +1221,20 @@ func (m Model) submit() (tea.Model, tea.Cmd) {
m.beginIntent(m.workflows[m.wfIndex]) m.beginIntent(m.workflows[m.wfIndex])
return m, nil return m, nil
} }
// Launcher: a workflow is Tab-selected → start it with the typed text as its brief
// (empty brief is allowed, like the intent flow). The server makes the session; we
// focus it when its events arrive (pendingWorkflowFocus).
if m.launcherWf > 0 && m.launcherWf <= len(m.workflows) {
wf := m.workflows[m.launcherWf-1]
m.recordInput(text)
m.client.Send(protocol.StartSession(wf.ID, text))
m.clearInput()
m.inputMode = ModeRouter
m.editMode = ModeNormal
m.pendingWorkflowFocus = true
m.launcherWf = 0
return m, nil
}
// Entering an already-selected session (blank submit). // Entering an already-selected session (blank submit).
if text == "" && m.selectedID != "" { if text == "" && m.selectedID != "" {
m.sessionEntered = true m.sessionEntered = true
+122 -37
View File
@@ -37,11 +37,15 @@ func (m Model) View() string {
return m.theme.Screen.Render("correx — terminal too small") return m.theme.Screen.Render("correx — terminal too small")
} }
bottom := m.renderInput() ds := m.displayState()
bottomH := inputH bottom, bottomH := m.renderInput(), inputH
if m.displayState() == StateApproval { switch {
case ds == StateApproval:
bottomH = m.approvalBandHeight() bottomH = m.approvalBandHeight()
bottom = m.renderApprovalBand(bottomH) 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 mainH := m.height - statusH - footerH - bottomH
@@ -49,12 +53,12 @@ func (m Model) View() string {
mainH = 3 mainH = 3
} }
base := lipgloss.JoinVertical(lipgloss.Left, sections := []string{m.renderStatus(), m.renderMain(mainH)}
m.renderStatus(), if bottomH > 0 {
m.renderMain(mainH), sections = append(sections, bottom)
bottom, }
m.renderFooter(), 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. // Stash the base so center() can composite a modal over a dimmed copy of it.
m.lastBase = base 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")} hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")}
case m.displayState() == StateIdle: case m.displayState() == StateIdle:
if nrw { 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 { } 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 default: // StateInSession
if nrw { if nrw {
@@ -279,41 +283,122 @@ func (m Model) renderFooter() string {
// --- main split --- // --- main split ---
func (m Model) renderMain(h int) string { func (m Model) renderMain(h int) string {
if m.displayState() == StateIdle {
return m.renderLauncher(m.width, h)
}
if m.narrow() { if m.narrow() {
return m.renderMainNarrow(h) return m.renderMainNarrow(h)
} }
leftW := m.width * 63 / 100 leftW := m.width * 63 / 100
rightW := m.width - leftW rightW := m.width - leftW
var leftTitle, rightTitle string // In-session / approval: output transcript over the event stream.
var leftBody, rightBody []string left := m.theme.box("output", m.routerRows(leftW-4, h-2), leftW, h, true)
leftActive, rightActive := false, false right := m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, 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)
return lipgloss.JoinHorizontal(lipgloss.Top, left, right) 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 // 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 // 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 // transcript stacked over the live event strip when in-session (so both still fit