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
+122 -37
View File
@@ -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