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:
@@ -17,6 +17,7 @@ func TestHelpCoversEveryKeybind(t *testing.T) {
|
||||
// navigate
|
||||
"move the session / list selection", // ↑↓ / j k
|
||||
"open the selected session", // enter
|
||||
"cycle launch target", // Tab / w
|
||||
"filter the session list", // /
|
||||
"jump to your previous / next message", // ^↑ / ^↓
|
||||
"scroll output", // PgUp / PgDn (+ ^u / ^d)
|
||||
|
||||
@@ -227,6 +227,11 @@ type Model struct {
|
||||
wfVisible bool
|
||||
wfPendingID string // workflow chosen, awaiting an intent line before StartSession
|
||||
wfPendingName string
|
||||
// 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
|
||||
|
||||
@@ -821,6 +821,7 @@ func (m Model) helpBody() []string {
|
||||
section("navigate", []kb{
|
||||
{"↑↓ / j k", "move the session / list selection"},
|
||||
{"enter", "open the selected session"},
|
||||
{"Tab / w", "idle: cycle launch target (chat / workflow)"},
|
||||
{"/", "filter the session list"},
|
||||
{"^↑ / ^↓", "jump to your previous / next message"},
|
||||
{"PgUp / PgDn", "scroll output (^u / ^d half-page)"},
|
||||
|
||||
@@ -494,18 +494,18 @@ func matrixCases() []matrixCase {
|
||||
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",
|
||||
kinds: []string{protocol.TypeWorkflowList},
|
||||
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 {
|
||||
return protocol.ServerMessage{Type: protocol.TypeWorkflowList, Workflows: []protocol.WorkflowDto{
|
||||
{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) ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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