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
|
OverlayIdeas
|
||||||
OverlaySessions
|
OverlaySessions
|
||||||
OverlayFiles
|
OverlayFiles
|
||||||
|
OverlayStatusbar
|
||||||
)
|
)
|
||||||
|
|
||||||
// RouterEntry is one line in a session's conversation transcript.
|
// 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
|
// PendingQueue[PendingIdx] (kept in sync by syncPending) so the render code can
|
||||||
// keep reading a single pointer while multiple gates queue up behind it.
|
// keep reading a single pointer while multiple gates queue up behind it.
|
||||||
Pending *Approval
|
Pending *Approval
|
||||||
PendingQueue []*Approval // all pending gates, oldest first; navigated with ↑/↓
|
PendingQueue []*Approval // all pending gates, oldest first; navigated with ↑/↓
|
||||||
PendingIdx int // selected index into PendingQueue
|
PendingIdx int // selected index into PendingQueue
|
||||||
Clar *Clarification // open questions awaiting answers (clarification view)
|
Clar *Clarification // open questions awaiting answers (clarification view)
|
||||||
Propose *Proposal // router workflow suggestion awaiting a pick (propose view)
|
Propose *Proposal // router workflow suggestion awaiting a pick (propose view)
|
||||||
Active bool // an inference/tool is in flight (drives the spinner)
|
Active bool // an inference/tool is in flight (drives the spinner)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workflow is a launchable workflow advertised by the server.
|
// 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)
|
fileFilter string // the query typed after @ (narrows the list)
|
||||||
fileIndex int
|
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
|
// animation
|
||||||
frame int // tick counter; drives spinner + caret blink
|
frame int // tick counter; drives spinner + caret blink
|
||||||
ticking bool // true while a tick loop is scheduled. The loop is gated on animating()
|
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,
|
snapshotPhase: true,
|
||||||
eventStripShown: true,
|
eventStripShown: true,
|
||||||
transcriptSel: -1,
|
transcriptSel: -1,
|
||||||
|
sbHidden: loadStatusbarHidden(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ func (m Model) renderOverlay(base string) string {
|
|||||||
return m.center(m.sessionsModal())
|
return m.center(m.sessionsModal())
|
||||||
case OverlayFiles:
|
case OverlayFiles:
|
||||||
return m.center(m.filesModal())
|
return m.center(m.filesModal())
|
||||||
|
case OverlayStatusbar:
|
||||||
|
return m.center(m.statusbarModal())
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
@@ -513,8 +515,13 @@ func (m Model) paletteModal() string {
|
|||||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
|
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
|
||||||
titleFg = t.P.FgStrong
|
titleFg = t.P.FgStrong
|
||||||
}
|
}
|
||||||
b.WriteString(marker +
|
// keybind column: the bare-key shortcut, so the palette teaches the shortcuts.
|
||||||
lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 18)) +
|
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")
|
mbg(t, " "+c.hint, t.P.Faint) + "\n")
|
||||||
}
|
}
|
||||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "run"}, {"esc", "close"}}))
|
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)
|
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 {
|
func (m Model) toolPaletteModal() string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
w := m.modalWidth()
|
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.fileFilter += string(k.Runes)
|
||||||
m.fileIndex = 0
|
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
|
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
|
// openArtifacts opens the artifact viewer for the selected session and requests its
|
||||||
// artifacts from the server. No-op when no session is selected.
|
// artifacts from the server. No-op when no session is selected.
|
||||||
func (m *Model) openArtifacts() {
|
func (m *Model) openArtifacts() {
|
||||||
@@ -821,22 +854,26 @@ func (m Model) handlePaletteKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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 {
|
func paletteCommands() []paletteCmd {
|
||||||
return []paletteCmd{
|
return []paletteCmd{
|
||||||
{"workflows", "start workflow", "open the workflow picker"},
|
{"workflows", "w", "start workflow", "open the workflow picker"},
|
||||||
{"tools", "tool palette", "tools for the current stage"},
|
{"tools", "t", "tool palette", "tools for the current stage"},
|
||||||
{"models", "swap model", "pick / pin the local model"},
|
{"models", "m", "swap model", "pick / pin the local model"},
|
||||||
{"events", "event inspector", "browse the event stream"},
|
{"events", "e", "event inspector", "browse the event stream"},
|
||||||
{"artifacts", "view artifacts", "browse this session's artifacts"},
|
{"artifacts", "v", "view artifacts", "browse this session's artifacts"},
|
||||||
{"stats", "session stats", "metrics for the selected session"},
|
{"stats", "S", "session stats", "metrics for the selected session"},
|
||||||
{"sessions", "resume session", "browse / resume recent sessions"},
|
{"sessions", "R", "resume session", "browse / resume recent sessions"},
|
||||||
{"config", "edit config", "view / change correx settings"},
|
{"config", "g", "edit config", "view / change correx settings"},
|
||||||
{"mode", "toggle mode", "switch chat / steering"},
|
{"statusbar", "", "status bar", "show / hide status-bar segments"},
|
||||||
{"cancel", "cancel session", "stop the selected session"},
|
{"mode", "s", "toggle mode", "switch chat / steering"},
|
||||||
{"back", "back to list", "leave the current session"},
|
{"cancel", "c", "cancel session", "stop the selected session"},
|
||||||
{"quit", "quit", "exit correx"},
|
{"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
|
var out []paletteCmd
|
||||||
for _, c := range paletteCommands() {
|
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)
|
out = append(out, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -875,6 +912,9 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.openSessions()
|
return m, m.openSessions()
|
||||||
case "config":
|
case "config":
|
||||||
m.openConfig()
|
m.openConfig()
|
||||||
|
case "statusbar":
|
||||||
|
m.overlay = OverlayStatusbar
|
||||||
|
m.sbIndex = 0
|
||||||
case "mode":
|
case "mode":
|
||||||
m.cycleChatMode()
|
m.cycleChatMode()
|
||||||
case "cancel":
|
case "cancel":
|
||||||
|
|||||||
@@ -95,29 +95,31 @@ func (m Model) renderStatus() string {
|
|||||||
|
|
||||||
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
|
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
|
||||||
parts = append(parts, span(s.Name, t.P.FgStrong))
|
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))
|
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)))
|
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))
|
parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
model := m.currentModel
|
if m.sbShow("model") {
|
||||||
if model == "" {
|
model := m.currentModel
|
||||||
model = "no model"
|
if model == "" {
|
||||||
}
|
model = "no model"
|
||||||
if nrw {
|
}
|
||||||
parts = append(parts, span(model, t.P.Fg))
|
if nrw {
|
||||||
} else {
|
parts = append(parts, span(model, t.P.Fg))
|
||||||
loc := "local"
|
} else {
|
||||||
if m.providerType == "REMOTE" {
|
loc := "local"
|
||||||
loc = "remote"
|
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)
|
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
|
// 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
|
// mistaken for a healthy one. Updates every frame tick; turns warn-colored when an
|
||||||
// active session has gone quiet past the stale threshold.
|
// 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
|
gap := nowMillis() - s.LastEventAt
|
||||||
clockFg := t.P.Faint
|
clockFg := t.P.Faint
|
||||||
if s.Active && gap > staleEventThresholdMs {
|
if s.Active && gap > staleEventThresholdMs {
|
||||||
@@ -137,7 +139,7 @@ func (m Model) renderStatus() string {
|
|||||||
}
|
}
|
||||||
right = append(right, span(label, clockFg))
|
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))
|
right = append(right, span(g, t.P.Faint))
|
||||||
}
|
}
|
||||||
if s := m.session(m.selectedID); s != nil && s.Active {
|
if s := m.session(m.selectedID); s != nil && s.Active {
|
||||||
@@ -249,7 +251,9 @@ func (m Model) renderFooter() string {
|
|||||||
if nrw {
|
if nrw {
|
||||||
hints = []string{hint("i", "msg"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
|
hints = []string{hint("i", "msg"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
|
||||||
} else {
|
} 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 {
|
if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames {
|
||||||
hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied"))
|
hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied"))
|
||||||
|
|||||||
Reference in New Issue
Block a user