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
+30 -6
View File
@@ -206,6 +206,12 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
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 {
return m.handleInsertKey(k)
}
@@ -300,13 +306,9 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case "m":
m.openModelsOverlay()
case "w":
// Launcher: w is an alias for Tab — cycle the launch target (chat / workflow…).
if ds == StateIdle {
m.wfVisible = !m.wfVisible
if m.wfVisible {
m.wfIndex = 0
} else {
m.wfIndex = -1
}
m.cycleLauncherWf()
}
case "l":
if ds != StateIdle {
@@ -1074,6 +1076,7 @@ func paletteCommands() []paletteCmd {
{"config", "g", "edit config", "view / change correx settings"},
{"grants", "G", "grants", "view / revoke standing grants"},
{"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"},
{"help", "?", "help", "keybinding cheat-sheet"},
{"mode", "s", "toggle mode", "switch chat / steering"},
@@ -1122,6 +1125,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
case "statusbar":
m.overlay = OverlayStatusbar
m.sbIndex = 0
case "rail":
m.railHidden = !m.railHidden
case "actions":
m.toggleInlineActions()
case "help":
@@ -1164,6 +1169,11 @@ func (m *Model) backspace() {
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() {
if m.chatMode == ChatModeChat {
m.chatMode = ChatModeSteering
@@ -1211,6 +1221,20 @@ func (m Model) submit() (tea.Model, tea.Cmd) {
m.beginIntent(m.workflows[m.wfIndex])
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).
if text == "" && m.selectedID != "" {
m.sessionEntered = true