diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 9da2dc0c..f3a733a4 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -58,6 +58,7 @@ const ( OverlayIdeas OverlaySessions OverlayFiles + OverlayStatusbar ) // RouterEntry is one line in a session's conversation transcript. @@ -188,11 +189,11 @@ type Session struct { // PendingQueue[PendingIdx] (kept in sync by syncPending) so the render code can // keep reading a single pointer while multiple gates queue up behind it. Pending *Approval - PendingQueue []*Approval // all pending gates, oldest first; navigated with ↑/↓ - PendingIdx int // selected index into PendingQueue + PendingQueue []*Approval // all pending gates, oldest first; navigated with ↑/↓ + PendingIdx int // selected index into PendingQueue Clar *Clarification // open questions awaiting answers (clarification view) - Propose *Proposal // router workflow suggestion awaiting a pick (propose view) - Active bool // an inference/tool is in flight (drives the spinner) + Propose *Proposal // router workflow suggestion awaiting a pick (propose view) + Active bool // an inference/tool is in flight (drives the spinner) } // Workflow is a launchable workflow advertised by the server. @@ -314,6 +315,11 @@ type Model struct { fileFilter string // the query typed after @ (narrows the list) fileIndex int + // status-bar segment visibility (OverlayStatusbar) — persisted TUI-local in tui-prefs.json. + // A segment id present in sbHidden is hidden; absent = shown. sbIndex is the toggle cursor. + sbHidden map[string]bool + sbIndex int + // animation frame int // tick counter; drives spinner + caret blink ticking bool // true while a tick loop is scheduled. The loop is gated on animating() @@ -367,6 +373,7 @@ func NewModel(client *ws.Client) Model { snapshotPhase: true, eventStripShown: true, transcriptSel: -1, + sbHidden: loadStatusbarHidden(), } } diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 12d55146..832938b6 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -126,6 +126,8 @@ func (m Model) renderOverlay(base string) string { return m.center(m.sessionsModal()) case OverlayFiles: return m.center(m.filesModal()) + case OverlayStatusbar: + return m.center(m.statusbarModal()) } return base } @@ -513,8 +515,13 @@ func (m Model) paletteModal() string { marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") titleFg = t.P.FgStrong } - b.WriteString(marker + - lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 18)) + + // keybind column: the bare-key shortcut, so the palette teaches the shortcuts. + keyCell := mbg(t, padRaw("", 4), t.P.BgPanel) + if c.key != "" { + keyCell = lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(padRaw(c.key, 4)) + } + b.WriteString(marker + keyCell + + lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 16)) + mbg(t, " "+c.hint, t.P.Faint) + "\n") } b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "run"}, {"esc", "close"}})) @@ -579,6 +586,35 @@ func (m Model) filesModal() string { return m.center(modal) } +// statusbarModal toggles which status-bar segments are shown. Choices persist to the local +// tui-prefs.json so the bar stays as configured across launches. +func (m Model) statusbarModal() string { + t := m.theme + w := m.modalWidth() + + var b strings.Builder + b.WriteString(m.titleLine("status bar") + "\n\n") + b.WriteString(mbg(t, " show / hide segments (correx, connection, session name stay)", t.P.Faint) + "\n\n") + + for i, seg := range statusSegments { + marker := mbg(t, " ", t.P.BgPanel) + labelFg := t.P.Fg + if i == m.sbIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") + labelFg = t.P.FgStrong + } + box := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render("[✓] ") + if !m.sbShow(seg.id) { + box = mbg(t, "[ ] ", t.P.Faint) + } + b.WriteString(marker + box + + lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(seg.label) + "\n") + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"space", "toggle"}, {"esc", "close"}})) + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + func (m Model) toolPaletteModal() string { t := m.theme w := m.modalWidth() diff --git a/apps/tui-go/internal/app/prefs.go b/apps/tui-go/internal/app/prefs.go new file mode 100644 index 00000000..550616da --- /dev/null +++ b/apps/tui-go/internal/app/prefs.go @@ -0,0 +1,80 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// prefs is the TUI's local, persisted preferences (display-only client state — kept out +// of the server config, which is shared/replayed). Stored as JSON in the correx config dir. +type prefs struct { + StatusbarHidden []string `json:"statusbarHidden"` +} + +// statusSegment is one toggleable status-bar segment. The always-on segments (correx label, +// connection, session name, spinner, background-updates) are not listed — they are load-bearing. +type statusSegment struct{ id, label string } + +var statusSegments = []statusSegment{ + {"stage", "current stage (⟐)"}, + {"status", "session status"}, + {"workspace", "workspace path (⌂)"}, + {"model", "model name"}, + {"clock", "last-event clock"}, + {"gauge", "resource gauge"}, +} + +func prefsPath() string { + dir := os.Getenv("CORREX_CONFIG_HOME") + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + dir = filepath.Join(home, ".config", "correx") + } + return filepath.Join(dir, "tui-prefs.json") +} + +// loadStatusbarHidden reads the persisted hidden-segment set (best-effort: a missing or +// corrupt file yields an empty set, i.e. everything shown). +func loadStatusbarHidden() map[string]bool { + out := map[string]bool{} + path := prefsPath() + if path == "" { + return out + } + data, err := os.ReadFile(path) + if err != nil { + return out + } + var p prefs + if json.Unmarshal(data, &p) == nil { + for _, id := range p.StatusbarHidden { + out[id] = true + } + } + return out +} + +// saveStatusbarHidden writes the hidden set back to disk (best-effort; ignores IO errors so +// a read-only home never crashes the UI). Order follows statusSegments for a stable file. +func saveStatusbarHidden(hidden map[string]bool) { + path := prefsPath() + if path == "" { + return + } + var ids []string + for _, seg := range statusSegments { + if hidden[seg.id] { + ids = append(ids, seg.id) + } + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + if data, err := json.MarshalIndent(prefs{StatusbarHidden: ids}, "", " "); err == nil { + _ = os.WriteFile(path, data, 0o644) + } +} diff --git a/apps/tui-go/internal/app/status_bar_test.go b/apps/tui-go/internal/app/status_bar_test.go new file mode 100644 index 00000000..c42aa053 --- /dev/null +++ b/apps/tui-go/internal/app/status_bar_test.go @@ -0,0 +1,57 @@ +package app + +import ( + "strings" + "testing" +) + +// Toggling a segment hides it from the rendered status bar; toggling again restores it. +// Reuses inSessionModel (adaptive_layout_test.go): stage "write_script", a long model id. +func TestStatusBarToggleHidesSegment(t *testing.T) { + t.Setenv("CORREX_CONFIG_HOME", t.TempDir()) // isolate persistence + m := inSessionModel(220, 30) + m.sbHidden = map[string]bool{} // start from a clean prefs state + + if !strings.Contains(m.renderStatus(), "write_script") { + t.Fatal("stage should be visible by default") + } + m.toggleStatusSegment("stage") + if strings.Contains(m.renderStatus(), "write_script") { + t.Fatal("stage should be hidden after toggle") + } + if !strings.Contains(m.renderStatus(), "llama-cpp") { + t.Fatal("model should still be visible (only stage was hidden)") + } + m.toggleStatusSegment("stage") + if !strings.Contains(m.renderStatus(), "write_script") { + t.Fatal("stage should be visible again after second toggle") + } +} + +func TestStatusBarModelHide(t *testing.T) { + t.Setenv("CORREX_CONFIG_HOME", t.TempDir()) + m := inSessionModel(220, 30) + m.sbHidden = map[string]bool{} + m.toggleStatusSegment("model") + if strings.Contains(m.renderStatus(), "llama-cpp") { + t.Fatal("model should be hidden after toggle") + } +} + +// A toggle persists to tui-prefs.json and is reloaded on the next launch. +func TestStatusBarPrefsPersist(t *testing.T) { + t.Setenv("CORREX_CONFIG_HOME", t.TempDir()) + m := inSessionModel(220, 30) + m.sbHidden = map[string]bool{} + + m.toggleStatusSegment("gauge") + m.toggleStatusSegment("clock") + + reloaded := loadStatusbarHidden() + if !reloaded["gauge"] || !reloaded["clock"] { + t.Fatalf("persisted hidden set = %v, want gauge+clock hidden", reloaded) + } + if reloaded["stage"] { + t.Fatal("stage was never toggled; should not be persisted as hidden") + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 2dfd3b04..c54de861 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -731,10 +731,43 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.fileFilter += string(k.Runes) m.fileIndex = 0 } + case OverlayStatusbar: + switch { + case k.Type == tea.KeyUp || runeIs(k, "k"): + if m.sbIndex > 0 { + m.sbIndex-- + } + case k.Type == tea.KeyDown || runeIs(k, "j"): + if m.sbIndex < len(statusSegments)-1 { + m.sbIndex++ + } + case k.Type == tea.KeySpace || k.Type == tea.KeyEnter || runeIs(k, "x"): + if m.sbIndex >= 0 && m.sbIndex < len(statusSegments) { + m.toggleStatusSegment(statusSegments[m.sbIndex].id) + } + case runeIs(k, "g"): + m.overlay = OverlayNone + } } return m, nil } +// toggleStatusSegment flips a status-bar segment's visibility and persists the change. +func (m *Model) toggleStatusSegment(id string) { + if m.sbHidden == nil { + m.sbHidden = map[string]bool{} + } + if m.sbHidden[id] { + delete(m.sbHidden, id) + } else { + m.sbHidden[id] = true + } + saveStatusbarHidden(m.sbHidden) +} + +// sbShow reports whether a status-bar segment is currently visible. +func (m Model) sbShow(id string) bool { return !m.sbHidden[id] } + // openArtifacts opens the artifact viewer for the selected session and requests its // artifacts from the server. No-op when no session is selected. func (m *Model) openArtifacts() { @@ -821,22 +854,26 @@ func (m Model) handlePaletteKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -type paletteCmd struct{ id, title, hint string } +// paletteCmd is one command-palette row. key is the bare-key shortcut that runs the same +// action directly (shown in the palette so the shortcuts are discoverable); empty when the +// command has no direct key. +type paletteCmd struct{ id, key, title, hint string } func paletteCommands() []paletteCmd { return []paletteCmd{ - {"workflows", "start workflow", "open the workflow picker"}, - {"tools", "tool palette", "tools for the current stage"}, - {"models", "swap model", "pick / pin the local model"}, - {"events", "event inspector", "browse the event stream"}, - {"artifacts", "view artifacts", "browse this session's artifacts"}, - {"stats", "session stats", "metrics for the selected session"}, - {"sessions", "resume session", "browse / resume recent sessions"}, - {"config", "edit config", "view / change correx settings"}, - {"mode", "toggle mode", "switch chat / steering"}, - {"cancel", "cancel session", "stop the selected session"}, - {"back", "back to list", "leave the current session"}, - {"quit", "quit", "exit correx"}, + {"workflows", "w", "start workflow", "open the workflow picker"}, + {"tools", "t", "tool palette", "tools for the current stage"}, + {"models", "m", "swap model", "pick / pin the local model"}, + {"events", "e", "event inspector", "browse the event stream"}, + {"artifacts", "v", "view artifacts", "browse this session's artifacts"}, + {"stats", "S", "session stats", "metrics for the selected session"}, + {"sessions", "R", "resume session", "browse / resume recent sessions"}, + {"config", "g", "edit config", "view / change correx settings"}, + {"statusbar", "", "status bar", "show / hide status-bar segments"}, + {"mode", "s", "toggle mode", "switch chat / steering"}, + {"cancel", "c", "cancel session", "stop the selected session"}, + {"back", "l", "back to list", "leave the current session"}, + {"quit", "q", "quit", "exit correx"}, } } @@ -847,7 +884,7 @@ func (m Model) filteredPalette() []paletteCmd { } var out []paletteCmd for _, c := range paletteCommands() { - if strings.Contains(strings.ToLower(c.title+" "+c.hint), f) { + if strings.Contains(strings.ToLower(c.key+" "+c.title+" "+c.hint), f) { out = append(out, c) } } @@ -875,6 +912,9 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { return m, m.openSessions() case "config": m.openConfig() + case "statusbar": + m.overlay = OverlayStatusbar + m.sbIndex = 0 case "mode": m.cycleChatMode() case "cancel": diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index ec977b75..2c81307b 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -95,29 +95,31 @@ func (m Model) renderStatus() string { if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle { parts = append(parts, span(s.Name, t.P.FgStrong)) - if s.CurrentStage != "" { + if s.CurrentStage != "" && m.sbShow("stage") { parts = append(parts, span("⟐ "+s.CurrentStage, t.P.Accent2)) } - if s.Status != "" { + if s.Status != "" && m.sbShow("status") { parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status))) } - if s.WorkspaceRoot != "" && !nrw { + if s.WorkspaceRoot != "" && !nrw && m.sbShow("workspace") { parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint)) } } - model := m.currentModel - if model == "" { - model = "no model" - } - if nrw { - parts = append(parts, span(model, t.P.Fg)) - } else { - loc := "local" - if m.providerType == "REMOTE" { - loc = "remote" + if m.sbShow("model") { + model := m.currentModel + if model == "" { + model = "no model" + } + if nrw { + parts = append(parts, span(model, t.P.Fg)) + } else { + loc := "local" + if m.providerType == "REMOTE" { + loc = "remote" + } + parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint)) } - parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint)) } left := strings.Join(parts, sep) @@ -125,7 +127,7 @@ func (m Model) renderStatus() string { // Last-event clock (§2): always visible in-session, so a silent agent is never // mistaken for a healthy one. Updates every frame tick; turns warn-colored when an // active session has gone quiet past the stale threshold. - if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle { + if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle && m.sbShow("clock") { gap := nowMillis() - s.LastEventAt clockFg := t.P.Faint if s.Active && gap > staleEventThresholdMs { @@ -137,7 +139,7 @@ func (m Model) renderStatus() string { } right = append(right, span(label, clockFg)) } - if g := m.gaugeText(); g != "" && !nrw { + if g := m.gaugeText(); g != "" && !nrw && m.sbShow("gauge") { right = append(right, span(g, t.P.Faint)) } if s := m.session(m.selectedID); s != nil && s.Active { @@ -249,7 +251,9 @@ func (m Model) renderFooter() string { if nrw { hints = []string{hint("i", "msg"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} } else { - hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + // Trimmed to fit ~100 cols: tools/stats/model are all reachable via `p cmds`, + // so the footer keeps the high-traffic keys + the new transcript nav/copy. + hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("t", "tools"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} } if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames { hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied"))