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
|
// 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) ---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user