diff --git a/apps/tui-go/internal/app/help_complete_test.go b/apps/tui-go/internal/app/help_complete_test.go index 38458693..4f995079 100644 --- a/apps/tui-go/internal/app/help_complete_test.go +++ b/apps/tui-go/internal/app/help_complete_test.go @@ -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) diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 2289bef7..ebce1517 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -227,7 +227,12 @@ type Model struct { wfVisible bool wfPendingID string // workflow chosen, awaiting an intent line before StartSession 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 editMode EditMode diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 5d3787d4..a9655a43 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -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)"}, diff --git a/apps/tui-go/internal/app/render_matrix_test.go b/apps/tui-go/internal/app/render_matrix_test.go index 9bb00896..2089d57e 100644 --- a/apps/tui-go/internal/app/render_matrix_test.go +++ b/apps/tui-go/internal/app/render_matrix_test.go @@ -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) --- diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 04da8531..22963c56 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -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 diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 26bd5c77..ca67a7e6 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -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 · , 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 ( · +// 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