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:
@@ -58,6 +58,7 @@ const (
|
||||
OverlayIdeas
|
||||
OverlaySessions
|
||||
OverlayFiles
|
||||
OverlayStatusbar
|
||||
)
|
||||
|
||||
// RouterEntry is one line in a session's conversation transcript.
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -95,17 +95,18 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
if m.sbShow("model") {
|
||||
model := m.currentModel
|
||||
if model == "" {
|
||||
model = "no model"
|
||||
@@ -119,13 +120,14 @@ func (m Model) renderStatus() string {
|
||||
}
|
||||
parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint))
|
||||
}
|
||||
}
|
||||
left := strings.Join(parts, sep)
|
||||
|
||||
var right []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"))
|
||||
|
||||
Reference in New Issue
Block a user