feat(tui-go): command-palette keybinds + configurable status bar

Palette: each command now shows its bare-key shortcut in a key column (and the filter
matches on it), so the palette teaches the shortcuts instead of hiding them.

Status bar: a new "status bar" palette command opens a toggle overlay to show/hide the
non-essential segments (current stage, session status, workspace path, model, last-event
clock, resource gauge); correx/connection/session-name/spinner stay. Choices persist
TUI-local in tui-prefs.json (JSON in the config dir — display state, kept out of the
shared/replayed server config) and reload on launch.

Also trims the in-session footer (drops stats/model — both reachable via `p cmds`) so it
no longer overflows + truncates `q quit` at ~100 cols after the transcript-nav additions.
Tests cover toggle→render gating and prefs persistence; verified via cmd/preview renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 10:50:28 +00:00
parent c616982b7b
commit 54f94a549f
6 changed files with 261 additions and 37 deletions
+11 -4
View File
@@ -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(),
}
}
+38 -2
View File
@@ -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()
+80
View File
@@ -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)
}
}
@@ -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")
}
}
+54 -14
View File
@@ -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":
+21 -17
View File
@@ -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"))