detached 1840cdb

feat(tui): show and highlight plan progress (#183)

* feat(tui): show and highlight plan progress

* fix(tui): preserve compact status styling

* fix(tui): refine status value rendering

* fix(tui): remove obsolete status helper
commit
1840cdb79c2c6f30669978dcd085fe93f053aa25
author
bnema · · 2026-09-06 10:14 UTC
committer
GitHub · · 2026-09-06 10:14 UTC
parent
130798c
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
8 files · +182 -51
M README.md +2 -2
@@ -126,10 +126,10 @@ Run `zut` and type `/login`. Pick one of two methods:
126126 The interactive status bar combines the model and reasoning level and rounds cache/context percentages and costs:
127127
128128 ```text
129-gpt-6-astra:medium ↑261k ↓34k R12M C98% $8.52 weekly:69% ctx31%/500k
129+gpt-6-astra:medium ↑261k ↓34k R12M C98% $8.52 weekly:69% plan:2/3 ctx31%/500k
130130 ```
131131
132-`↑` / `↓` are session input/output tokens, `R` is cache-read tokens, `C` is the measured cache-hit rate, and `ctx` is context used / model capacity. The provider prefix and `(sub)` tag are omitted from this compact view. For subscription sessions, the dollar amount is an API-equivalent estimate, **not an additional charge**. Detailed session/subagent surfaces retain their existing precision and billing labels; `/session info` shows the provider.
132+`↑` / `↓` are session input/output tokens, `R` is cache-read tokens, `C` is the measured cache-hit rate, `plan` is the current step / total steps while a plan is active, and `ctx` is context used / model capacity. Key status values use stronger contrast than their labels for quicker scanning in both dark and light terminals. The provider prefix and `(sub)` tag are omitted from this compact view. For subscription sessions, the dollar amount is an API-equivalent estimate, **not an additional charge**. Detailed session/subagent surfaces retain their existing precision and billing labels; `/session info` shows the provider.
133133
134134 For the default `openai-codex` subscription route, `weekly:69%` means **69% of the account's seven-day allowance remains**, across clients—not just this session. zut reads ChatGPT's usage endpoint asynchronously at startup and every five minutes while Codex is selected. Requests time out after ten seconds and are canceled when the session exits or leaves Codex. `weekly:?` means loading, unavailable, stale, or no seven-day window was reported; it never means zero remaining. The meter only reads stored credentials; if the token expires, it stays unavailable until a normal model request or login refreshes it. Custom endpoints and other providers do not receive these usage requests. The meter does not show the separate short-term limit or model-specific allowances and is not a guarantee that the next request will be accepted.
135135
@@ -126,10 +126,10 @@ Run `zut` and type `/login`. Pick one of two methods:
126 The interactive status bar combines the model and reasoning level and rounds cache/context percentages and costs:126 The interactive status bar combines the model and reasoning level and rounds cache/context percentages and costs:
127 127
128 ```text128 ```text
129-gpt-6-astra:medium ↑261k ↓34k R12M C98% $8.52 weekly:69% ctx31%/500k129+gpt-6-astra:medium ↑261k ↓34k R12M C98% $8.52 weekly:69% plan:2/3 ctx31%/500k
130 ```130 ```
131 131
132-`↑` / `↓` are session input/output tokens, `R` is cache-read tokens, `C` is the measured cache-hit rate, and `ctx` is context used / model capacity. The provider prefix and `(sub)` tag are omitted from this compact view. For subscription sessions, the dollar amount is an API-equivalent estimate, **not an additional charge**. Detailed session/subagent surfaces retain their existing precision and billing labels; `/session info` shows the provider.132+`↑` / `↓` are session input/output tokens, `R` is cache-read tokens, `C` is the measured cache-hit rate, `plan` is the current step / total steps while a plan is active, and `ctx` is context used / model capacity. Key status values use stronger contrast than their labels for quicker scanning in both dark and light terminals. The provider prefix and `(sub)` tag are omitted from this compact view. For subscription sessions, the dollar amount is an API-equivalent estimate, **not an additional charge**. Detailed session/subagent surfaces retain their existing precision and billing labels; `/session info` shows the provider.
133 133
134 For the default `openai-codex` subscription route, `weekly:69%` means **69% of the account's seven-day allowance remains**, across clients—not just this session. zut reads ChatGPT's usage endpoint asynchronously at startup and every five minutes while Codex is selected. Requests time out after ten seconds and are canceled when the session exits or leaves Codex. `weekly:?` means loading, unavailable, stale, or no seven-day window was reported; it never means zero remaining. The meter only reads stored credentials; if the token expires, it stays unavailable until a normal model request or login refreshes it. Custom endpoints and other providers do not receive these usage requests. The meter does not show the separate short-term limit or model-specific allowances and is not a guarantee that the next request will be accepted.134 For the default `openai-codex` subscription route, `weekly:69%` means **69% of the account's seven-day allowance remains**, across clients—not just this session. zut reads ChatGPT's usage endpoint asynchronously at startup and every five minutes while Codex is selected. Requests time out after ten seconds and are canceled when the session exits or leaves Codex. `weekly:?` means loading, unavailable, stale, or no seven-day window was reported; it never means zero remaining. The meter only reads stored credentials; if the token expires, it stays unavailable until a normal model request or login refreshes it. Custom endpoints and other providers do not receive these usage requests. The meter does not show the separate short-term limit or model-specific allowances and is not a guarantee that the next request will be accepted.
135 135
M packages/agent/modes/interactive.go +2 -0
@@ -698,6 +698,8 @@ type Interactive struct {
698698 statusErr string
699699 statusOK string
700700 goalStatus core.GoalStatus
701+ planCurrent int
702+ planTotal int
701703 goalRun *goalContinuationRun
702704 reloadStatusSeq uint64
703705 extStatuses map[string]map[string]extensionStatus
@@ -698,6 +698,8 @@ type Interactive struct {
698 statusErr string698 statusErr string
699 statusOK string699 statusOK string
700 goalStatus core.GoalStatus700 goalStatus core.GoalStatus
701+ planCurrent int
702+ planTotal int
701 goalRun *goalContinuationRun703 goalRun *goalContinuationRun
702 reloadStatusSeq uint64704 reloadStatusSeq uint64
703 extStatuses map[string]map[string]extensionStatus705 extStatuses map[string]map[string]extensionStatus
M packages/agent/modes/interactive_events.go +13 -0
@@ -19,6 +19,17 @@ func eventAffectsPresentation(ev core.AgentEvent) bool {
1919 return true
2020 }
2121 }
22+
23+func planProgress(plan []core.PlanStep) (current, total int) {
24+ total = len(plan)
25+ for idx, step := range plan {
26+ if step.Status == core.PlanInProgress {
27+ return idx + 1, total
28+ }
29+ }
30+ return 0, total
31+}
32+
2233 func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) {
2334 i.toolRenderRevision++
2435 if i.toolRenderRevision == 0 {
@@ -172,6 +183,8 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
172183 i.goalStatus = update.Status
173184 }
174185 }
186+ case core.EvPlanUpdate:
187+ i.planCurrent, i.planTotal = planProgress(e.Update.Plan)
175188 case core.EvUsage:
176189 i.cumUsage = e.Cumulative
177190 if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
@@ -19,6 +19,17 @@ func eventAffectsPresentation(ev core.AgentEvent) bool {
19 return true19 return true
20 }20 }
21 }21 }
22+
23+func planProgress(plan []core.PlanStep) (current, total int) {
24+ total = len(plan)
25+ for idx, step := range plan {
26+ if step.Status == core.PlanInProgress {
27+ return idx + 1, total
28+ }
29+ }
30+ return 0, total
31+}
32+
22 func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) {33 func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) {
23 i.toolRenderRevision++34 i.toolRenderRevision++
24 if i.toolRenderRevision == 0 {35 if i.toolRenderRevision == 0 {
@@ -172,6 +183,8 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
172 i.goalStatus = update.Status183 i.goalStatus = update.Status
173 }184 }
174 }185 }
186+ case core.EvPlanUpdate:
187+ i.planCurrent, i.planTotal = planProgress(e.Update.Plan)
175 case core.EvUsage:188 case core.EvUsage:
176 i.cumUsage = e.Cumulative189 i.cumUsage = e.Cumulative
177 if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {190 if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
A packages/agent/modes/interactive_plan_test.go +27 -0
@@ -0,0 +1,27 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/core"
7+)
8+
9+func TestInteractiveTracksCurrentPlanStep(t *testing.T) {
10+ interactive := &Interactive{}
11+ interactive.handleEvent(core.EvPlanUpdate{Update: core.PlanUpdate{Plan: []core.PlanStep{
12+ {Step: "Inspect", Status: core.PlanCompleted},
13+ {Step: "Implement", Status: core.PlanInProgress},
14+ {Step: "Verify", Status: core.PlanPending},
15+ }}})
16+
17+ if interactive.planCurrent != 2 || interactive.planTotal != 3 {
18+ t.Fatalf("plan progress = %d/%d, want 2/3", interactive.planCurrent, interactive.planTotal)
19+ }
20+
21+ interactive.handleEvent(core.EvPlanUpdate{Update: core.PlanUpdate{Plan: []core.PlanStep{
22+ {Step: "Inspect", Status: core.PlanCompleted},
23+ }}})
24+ if interactive.planCurrent != 0 || interactive.planTotal != 1 {
25+ t.Fatalf("completed plan progress = %d/%d, want hidden current with total 1", interactive.planCurrent, interactive.planTotal)
26+ }
27+}
@@ -0,0 +1,27 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/core"
7+)
8+
9+func TestInteractiveTracksCurrentPlanStep(t *testing.T) {
10+ interactive := &Interactive{}
11+ interactive.handleEvent(core.EvPlanUpdate{Update: core.PlanUpdate{Plan: []core.PlanStep{
12+ {Step: "Inspect", Status: core.PlanCompleted},
13+ {Step: "Implement", Status: core.PlanInProgress},
14+ {Step: "Verify", Status: core.PlanPending},
15+ }}})
16+
17+ if interactive.planCurrent != 2 || interactive.planTotal != 3 {
18+ t.Fatalf("plan progress = %d/%d, want 2/3", interactive.planCurrent, interactive.planTotal)
19+ }
20+
21+ interactive.handleEvent(core.EvPlanUpdate{Update: core.PlanUpdate{Plan: []core.PlanStep{
22+ {Step: "Inspect", Status: core.PlanCompleted},
23+ }}})
24+ if interactive.planCurrent != 0 || interactive.planTotal != 1 {
25+ t.Fatalf("completed plan progress = %d/%d, want hidden current with total 1", interactive.planCurrent, interactive.planTotal)
26+ }
27+}
M packages/agent/modes/interactive_rendering.go +2 -0
@@ -680,6 +680,8 @@ func (i *Interactive) redraw() {
680680 Locked: i.cfg.Sandbox.Locked(),
681681 NoYolo: i.cfg.NoYolo,
682682 GoalStatus: goalStatus,
683+ PlanCurrent: i.planCurrent,
684+ PlanTotal: i.planTotal,
683685 Usage: i.cumUsage,
684686 Subscription: i.cfg.AuthMethod == "oauth",
685687 WeeklyUsage: i.codexWeeklyLabelLocked(time.Now()),
@@ -680,6 +680,8 @@ func (i *Interactive) redraw() {
680 Locked: i.cfg.Sandbox.Locked(),680 Locked: i.cfg.Sandbox.Locked(),
681 NoYolo: i.cfg.NoYolo,681 NoYolo: i.cfg.NoYolo,
682 GoalStatus: goalStatus,682 GoalStatus: goalStatus,
683+ PlanCurrent: i.planCurrent,
684+ PlanTotal: i.planTotal,
683 Usage: i.cumUsage,685 Usage: i.cumUsage,
684 Subscription: i.cfg.AuthMethod == "oauth",686 Subscription: i.cfg.AuthMethod == "oauth",
685 WeeklyUsage: i.codexWeeklyLabelLocked(time.Now()),687 WeeklyUsage: i.codexWeeklyLabelLocked(time.Now()),
M packages/agent/modes/interactive_turn.go +4 -0
@@ -209,6 +209,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
209209 i.toolCalls = map[string]*tui.ToolCallView{}
210210 i.toolOrder = nil
211211 i.toolGate = map[string]int{}
212+ i.planCurrent = 0
213+ i.planTotal = 0
212214 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
213215 i.scrollOffset = 0 // jump back to the bottom on new turn
214216 // Lift the resume tail cap once the user starts interacting. The
@@ -398,6 +400,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
398400 i.resetStreamingStateLocked()
399401 }
400402 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
403+ i.planCurrent = 0
404+ i.planTotal = 0
401405 i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
402406 i.mu.Unlock()
403407 if persistHandoff {
@@ -209,6 +209,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
209 i.toolCalls = map[string]*tui.ToolCallView{}209 i.toolCalls = map[string]*tui.ToolCallView{}
210 i.toolOrder = nil210 i.toolOrder = nil
211 i.toolGate = map[string]int{}211 i.toolGate = map[string]int{}
212+ i.planCurrent = 0
213+ i.planTotal = 0
212 i.extNotes = nil // ext notes are one-shot; a new prompt clears them214 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
213 i.scrollOffset = 0 // jump back to the bottom on new turn215 i.scrollOffset = 0 // jump back to the bottom on new turn
214 // Lift the resume tail cap once the user starts interacting. The216 // Lift the resume tail cap once the user starts interacting. The
@@ -398,6 +400,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
398 i.resetStreamingStateLocked()400 i.resetStreamingStateLocked()
399 }401 }
400 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)402 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
403+ i.planCurrent = 0
404+ i.planTotal = 0
401 i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact405 i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
402 i.mu.Unlock()406 i.mu.Unlock()
403 if persistHandoff {407 if persistHandoff {
M packages/tui/statusbar_test.go +54 -8
@@ -93,11 +93,15 @@ func TestStatusBarVeryNarrowModel(t *testing.T) {
9393 }
9494 }
9595
96-func TestStatusBarShowsActiveGoal(t *testing.T) {
96+func TestStatusBarShowsActiveGoalAndPlan(t *testing.T) {
9797 for _, cols := range []int{100, 20} {
98- lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-test", GoalStatus: "active", CWD: "/tmp/project", Cols: cols})
99- if !strings.Contains(stripANSI(strings.Join(lines, "\n")), "goal:active") {
100- t.Fatalf("status bar = %q, want active goal", lines)
98+ lines := StatusBar(StatusBarParams{
99+ Theme: Dark, Model: "gpt-test", GoalStatus: "active", PlanCurrent: 2, PlanTotal: 3,
100+ CWD: "/tmp/project", Cols: cols,
101+ })
102+ plain := stripANSI(strings.Join(lines, "\n"))
103+ if !strings.Contains(plain, "goal:active") || !strings.Contains(plain, "plan:2/3") {
104+ t.Fatalf("status bar = %q, want active goal and plan", lines)
101105 }
102106 }
103107 }
@@ -121,9 +125,12 @@ func TestStatusBarNoCWD(t *testing.T) {
121125 }
122126
123127 func TestStatusBarShowsFastMode(t *testing.T) {
124- lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.6-luna", FastMode: true, CWD: "/tmp/x", Cols: 200})
125- if len(lines) != 2 || !strings.Contains(stripANSI(lines[0]), "fast mode") {
126- t.Fatalf("fast mode should be visible: %q", lines)
128+ for _, cols := range []int{200, 20} {
129+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.6-luna", FastMode: true, CWD: "/tmp/x", Cols: cols})
130+ status := strings.Join(lines, "\n")
131+ if !strings.Contains(stripANSI(status), "fast mode") || !strings.Contains(status, Dark.FGColor(Dark.Muted, "fast mode")) {
132+ t.Fatalf("fast mode should be visible and muted: %q", lines)
133+ }
127134 }
128135 }
129136
@@ -139,6 +146,45 @@ func TestStatusBarReasoningSuffix(t *testing.T) {
139146 }
140147 }
141148
149+func TestStatusLabelValueOmitsEmptyStyledSegments(t *testing.T) {
150+ want := Dark.FGColor(Dark.Muted, "goal:") + Dark.FGColor(Dark.FG, "paused")
151+ if got := statusLabelValue(Dark, "goal:", "paused", Dark.FG); got != want {
152+ t.Fatalf("status label/value = %q, want %q", got, want)
153+ }
154+}
155+
156+func TestStatusBarHighlightsValuesForDarkAndLightThemes(t *testing.T) {
157+ for _, th := range []Theme{Dark, Light} {
158+ lines := StatusBar(StatusBarParams{
159+ Theme: th, Model: "gpt-5.6-sol", Reasoning: "low", WeeklyUsage: "weekly:16%",
160+ GoalStatus: "paused", PlanCurrent: 2, PlanTotal: 3, ContextUsed: 33, ContextMax: 100, Cols: 200,
161+ })
162+ status := strings.Join(lines, "\n")
163+ for _, want := range []string{
164+ statusLabelValue(th, ":", "low", th.FG),
165+ statusLabelValue(th, "weekly:", "16%", th.FG),
166+ statusLabelValue(th, "goal:", "paused", th.FG),
167+ statusLabelValue(th, "plan:", "2/3", th.FG),
168+ statusLabelValueSuffix(th, "ctx", "33%", "/100", th.FG),
169+ } {
170+ if !strings.Contains(status, want) {
171+ t.Fatalf("status bar omitted highlighted value %q: %q", want, status)
172+ }
173+ }
174+ }
175+}
176+
177+func TestStatusBarPreservesColonBearingModelWhenNarrow(t *testing.T) {
178+ th := Dark
179+ th.ThinkingMax = Color256(201)
180+ lines := StatusBar(StatusBarParams{Theme: th, Model: "anthropic.claude:0", Reasoning: "max", WeeklyUsage: "weekly:16%", Cols: 25})
181+ status := strings.Join(lines, "\n")
182+ if !strings.Contains(status, th.FGColor(th.Muted, "anthropic.claude:0")) ||
183+ !strings.Contains(status, statusLabelValue(th, ":", "max", th.ThinkingMax)) {
184+ t.Fatalf("colon-bearing model or reasoning styling was lost: %q", lines)
185+ }
186+}
187+
142188 func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {
143189 th := Dark
144190 th.ThinkingMax = Color256(201)
@@ -156,7 +202,7 @@ func TestStatusBarContextWarningsAndCompaction(t *testing.T) {
156202 color TerminalColor
157203 }{{75, Dark.Warning}, {95, Dark.Error}} {
158204 lines := StatusBar(StatusBarParams{Theme: Dark, Model: "model", ContextUsed: tc.used, ContextMax: 100, AutoCompacting: true, Cols: 200})
159- want := Dark.FGColor(tc.color, fmt.Sprintf("ctx%d%%/100 (auto)", tc.used))
205+ want := statusLabelValueSuffix(Dark, "ctx", fmt.Sprintf("%d%%", tc.used), "/100 (auto)", tc.color)
160206 if !strings.Contains(lines[0], want) {
161207 t.Fatalf("context warning missing: %q", lines)
162208 }
@@ -93,11 +93,15 @@ func TestStatusBarVeryNarrowModel(t *testing.T) {
93 }93 }
94 }94 }
95 95
96-func TestStatusBarShowsActiveGoal(t *testing.T) {96+func TestStatusBarShowsActiveGoalAndPlan(t *testing.T) {
97 for _, cols := range []int{100, 20} {97 for _, cols := range []int{100, 20} {
98- lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-test", GoalStatus: "active", CWD: "/tmp/project", Cols: cols})98+ lines := StatusBar(StatusBarParams{
99- if !strings.Contains(stripANSI(strings.Join(lines, "\n")), "goal:active") {99+ Theme: Dark, Model: "gpt-test", GoalStatus: "active", PlanCurrent: 2, PlanTotal: 3,
100- t.Fatalf("status bar = %q, want active goal", lines)100+ CWD: "/tmp/project", Cols: cols,
101+ })
102+ plain := stripANSI(strings.Join(lines, "\n"))
103+ if !strings.Contains(plain, "goal:active") || !strings.Contains(plain, "plan:2/3") {
104+ t.Fatalf("status bar = %q, want active goal and plan", lines)
101 }105 }
102 }106 }
103 }107 }
@@ -121,9 +125,12 @@ func TestStatusBarNoCWD(t *testing.T) {
121 }125 }
122 126
123 func TestStatusBarShowsFastMode(t *testing.T) {127 func TestStatusBarShowsFastMode(t *testing.T) {
124- lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.6-luna", FastMode: true, CWD: "/tmp/x", Cols: 200})128+ for _, cols := range []int{200, 20} {
125- if len(lines) != 2 || !strings.Contains(stripANSI(lines[0]), "fast mode") {129+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.6-luna", FastMode: true, CWD: "/tmp/x", Cols: cols})
126- t.Fatalf("fast mode should be visible: %q", lines)130+ status := strings.Join(lines, "\n")
131+ if !strings.Contains(stripANSI(status), "fast mode") || !strings.Contains(status, Dark.FGColor(Dark.Muted, "fast mode")) {
132+ t.Fatalf("fast mode should be visible and muted: %q", lines)
133+ }
127 }134 }
128 }135 }
129 136
@@ -139,6 +146,45 @@ func TestStatusBarReasoningSuffix(t *testing.T) {
139 }146 }
140 }147 }
141 148
149+func TestStatusLabelValueOmitsEmptyStyledSegments(t *testing.T) {
150+ want := Dark.FGColor(Dark.Muted, "goal:") + Dark.FGColor(Dark.FG, "paused")
151+ if got := statusLabelValue(Dark, "goal:", "paused", Dark.FG); got != want {
152+ t.Fatalf("status label/value = %q, want %q", got, want)
153+ }
154+}
155+
156+func TestStatusBarHighlightsValuesForDarkAndLightThemes(t *testing.T) {
157+ for _, th := range []Theme{Dark, Light} {
158+ lines := StatusBar(StatusBarParams{
159+ Theme: th, Model: "gpt-5.6-sol", Reasoning: "low", WeeklyUsage: "weekly:16%",
160+ GoalStatus: "paused", PlanCurrent: 2, PlanTotal: 3, ContextUsed: 33, ContextMax: 100, Cols: 200,
161+ })
162+ status := strings.Join(lines, "\n")
163+ for _, want := range []string{
164+ statusLabelValue(th, ":", "low", th.FG),
165+ statusLabelValue(th, "weekly:", "16%", th.FG),
166+ statusLabelValue(th, "goal:", "paused", th.FG),
167+ statusLabelValue(th, "plan:", "2/3", th.FG),
168+ statusLabelValueSuffix(th, "ctx", "33%", "/100", th.FG),
169+ } {
170+ if !strings.Contains(status, want) {
171+ t.Fatalf("status bar omitted highlighted value %q: %q", want, status)
172+ }
173+ }
174+ }
175+}
176+
177+func TestStatusBarPreservesColonBearingModelWhenNarrow(t *testing.T) {
178+ th := Dark
179+ th.ThinkingMax = Color256(201)
180+ lines := StatusBar(StatusBarParams{Theme: th, Model: "anthropic.claude:0", Reasoning: "max", WeeklyUsage: "weekly:16%", Cols: 25})
181+ status := strings.Join(lines, "\n")
182+ if !strings.Contains(status, th.FGColor(th.Muted, "anthropic.claude:0")) ||
183+ !strings.Contains(status, statusLabelValue(th, ":", "max", th.ThinkingMax)) {
184+ t.Fatalf("colon-bearing model or reasoning styling was lost: %q", lines)
185+ }
186+}
187+
142 func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {188 func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {
143 th := Dark189 th := Dark
144 th.ThinkingMax = Color256(201)190 th.ThinkingMax = Color256(201)
@@ -156,7 +202,7 @@ func TestStatusBarContextWarningsAndCompaction(t *testing.T) {
156 color TerminalColor202 color TerminalColor
157 }{{75, Dark.Warning}, {95, Dark.Error}} {203 }{{75, Dark.Warning}, {95, Dark.Error}} {
158 lines := StatusBar(StatusBarParams{Theme: Dark, Model: "model", ContextUsed: tc.used, ContextMax: 100, AutoCompacting: true, Cols: 200})204 lines := StatusBar(StatusBarParams{Theme: Dark, Model: "model", ContextUsed: tc.used, ContextMax: 100, AutoCompacting: true, Cols: 200})
159- want := Dark.FGColor(tc.color, fmt.Sprintf("ctx%d%%/100 (auto)", tc.used))205+ want := statusLabelValueSuffix(Dark, "ctx", fmt.Sprintf("%d%%", tc.used), "/100 (auto)", tc.color)
160 if !strings.Contains(lines[0], want) {206 if !strings.Contains(lines[0], want) {
161 t.Fatalf("context warning missing: %q", lines)207 t.Fatalf("context warning missing: %q", lines)
162 }208 }
M packages/tui/view.go +78 -41
@@ -2976,17 +2976,19 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
29762976 // StatusBarParams groups the many bits of state the status bar needs.
29772977 // Grew from a flat argument list once we settled on the layout.
29782978 type StatusBarParams struct {
2979- Theme Theme
2980- Provider string
2981- Model string
2982- Reasoning string // "" means thinking off
2983- FastMode bool // show the provider's opt-in fast tier when enabled
2984- Busy bool
2985- BusyPrefix string // spinner + funny line when busy
2986- CWD string
2987- Locked bool // sandbox on?
2988- NoYolo bool // confirmation mode enabled?
2989- GoalStatus string // autonomous goal lifecycle, empty when none
2979+ Theme Theme
2980+ Provider string
2981+ Model string
2982+ Reasoning string // "" means thinking off
2983+ FastMode bool // show the provider's opt-in fast tier when enabled
2984+ Busy bool
2985+ BusyPrefix string // spinner + funny line when busy
2986+ CWD string
2987+ Locked bool // sandbox on?
2988+ NoYolo bool // confirmation mode enabled?
2989+ GoalStatus string // autonomous goal lifecycle, empty when none
2990+ PlanCurrent int // one-based in-progress plan step; 0 hides plan progress
2991+ PlanTotal int
29902992
29912993 // Cumulative session usage and cost.
29922994 Usage provider.Usage
@@ -3032,24 +3034,39 @@ func StatusBar(p StatusBarParams) []string {
30323034 th := p.Theme
30333035
30343036 stats := UsageStatsParts(UsageStatsParams{Usage: p.Usage, Subscription: p.Subscription, Compact: true})
3037+ for idx, stat := range stats {
3038+ stats[idx] = th.FGColor(th.Muted, stat)
3039+ }
30353040 if p.WeeklyUsage != "" {
3036- stats = append(stats, p.WeeklyUsage)
3041+ if label, value, ok := strings.Cut(p.WeeklyUsage, ":"); ok {
3042+ stats = append(stats, statusLabelValue(th, label+":", value, th.FG))
3043+ } else {
3044+ stats = append(stats, th.FGColor(th.Muted, p.WeeklyUsage))
3045+ }
30373046 }
30383047 if p.GoalStatus != "" {
3039- stats = append(stats, "goal:"+p.GoalStatus)
3048+ stats = append(stats, statusLabelValue(th, "goal:", p.GoalStatus, th.FG))
3049+ }
3050+ if p.PlanCurrent > 0 && p.PlanTotal > 0 {
3051+ stats = append(stats, statusLabelValue(th, "plan:", fmt.Sprintf("%d/%d", p.PlanCurrent, p.PlanTotal), th.FG))
30403052 }
30413053
30423054 // Context %. Color-coded: yellow >70, red >90.
30433055 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)
30443056 if ctx != "" {
3057+ if ctxColor == th.Muted {
3058+ ctxColor = th.FG
3059+ }
3060+ value := ctx
3061+ suffix := ""
30453062 if p.ContextMax > 0 {
3046- ctx = fmt.Sprintf("%.0f%%/%s", float64(p.ContextUsed)/float64(p.ContextMax)*100, formatTokens(p.ContextMax))
3063+ value = fmt.Sprintf("%.0f%%", float64(p.ContextUsed)/float64(p.ContextMax)*100)
3064+ suffix = "/" + formatTokens(p.ContextMax)
30473065 }
3048- ctx = "ctx" + ctx
30493066 if p.AutoCompacting {
3050- ctx += " (auto)"
3067+ suffix += " (auto)"
30513068 }
3052- stats = append(stats, th.FGColor(ctxColor, ctx))
3069+ stats = append(stats, statusLabelValueSuffix(th, "ctx", value, suffix, ctxColor))
30533070 }
30543071
30553072 // Layout uses exactly 2 spaces of horizontal padding everywhere:
@@ -3058,14 +3075,11 @@ func StatusBar(p StatusBarParams) []string {
30583075 // vertically with the conversation column.
30593076 const pad = " " // 2 spaces
30603077
3061- left := p.Model
3062- if reasoning := reasoningLevelLabel(p.Reasoning); reasoning != "" {
3063- left += ":" + reasoning
3064- }
3065- modelColor := reasoningStatusColor(th, left)
3078+ reasoning := reasoningLevelLabel(p.Reasoning)
3079+ left := renderStatusModel(th, p.Model, reasoning)
30663080 fastText := ""
30673081 if p.FastMode {
3068- fastText = "fast mode"
3082+ fastText = th.FGColor(th.Muted, "fast mode")
30693083 }
30703084 statsText := strings.Join(stats, " ")
30713085 middleParts := make([]string, 0, 3)
@@ -3095,10 +3109,10 @@ func StatusBar(p StatusBarParams) []string {
30953109 // prefix there's no trailing separator to double-pad.
30963110 leftBuilder.WriteString(pad)
30973111 }
3098- leftBuilder.WriteString(th.FGColor(modelColor, left))
3112+ leftBuilder.WriteString(left)
30993113 if middle != "" {
31003114 leftBuilder.WriteString(pad)
3101- leftBuilder.WriteString(th.FGColor(th.Muted, middle))
3115+ leftBuilder.WriteString(middle)
31023116 }
31033117
31043118 cwd := shortenHome(p.CWD)
@@ -3124,16 +3138,16 @@ func StatusBar(p StatusBarParams) []string {
31243138 // stats on their own rows. This mirrors the idle split below.
31253139 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
31263140 busyLine := pad + p.BusyPrefix
3127- modelLine := pad + th.FGColor(modelColor, left)
3141+ modelLine := pad + left
31283142 lines := []string{busyLine}
31293143 if visibleWidth(modelLine+pad+middle) > p.Cols {
3130- lines = appendWrappedStatusLines(lines, th, pad, left, fastText, stats, p.Cols)
3144+ lines = appendWrappedStatusLines(lines, th, pad, p.Model, reasoning, fastText, stats, p.Cols)
31313145 } else {
31323146 var infoBuilder strings.Builder
31333147 infoBuilder.WriteString(modelLine)
31343148 if middle != "" {
31353149 infoBuilder.WriteString(pad)
3136- infoBuilder.WriteString(th.FGColor(th.Muted, middle))
3150+ infoBuilder.WriteString(middle)
31373151 }
31383152 lines = append(lines, infoBuilder.String())
31393153 }
@@ -3149,7 +3163,7 @@ func StatusBar(p StatusBarParams) []string {
31493163 // into an awkward position on small widths.
31503164 if p.Cols > 0 && p.BusyPrefix == "" && visibleWidth(primary) > p.Cols {
31513165 var lines []string
3152- lines = appendWrappedStatusLines(lines, th, pad, left, fastText, stats, p.Cols)
3166+ lines = appendWrappedStatusLines(lines, th, pad, p.Model, reasoning, fastText, stats, p.Cols)
31533167 if cwd != "" {
31543168 lines = append(lines, pad+th.FGColor(th.Muted, cwd))
31553169 }
@@ -3166,10 +3180,40 @@ func StatusBar(p StatusBarParams) []string {
31663180 return []string{primary, cwdRendered}
31673181 }
31683182
3169-func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText string, stats []string, cols int) []string {
3170- modelLine := pad + th.FGColor(reasoningStatusColor(th, modelText), modelText)
3183+func statusLabelValue(th Theme, label, value string, valueColor TerminalColor) string {
3184+ return statusLabelValueSuffix(th, label, value, "", valueColor)
3185+}
3186+
3187+func statusLabelValueSuffix(th Theme, label, value, suffix string, valueColor TerminalColor) string {
3188+ var rendered strings.Builder
3189+ if label != "" {
3190+ rendered.WriteString(th.FGColor(th.Muted, label))
3191+ }
3192+ if value != "" {
3193+ rendered.WriteString(th.FGColor(valueColor, value))
3194+ }
3195+ if suffix != "" {
3196+ rendered.WriteString(th.FGColor(th.Muted, suffix))
3197+ }
3198+ return rendered.String()
3199+}
3200+
3201+func renderStatusModel(th Theme, model, reasoning string) string {
3202+ rendered := th.FGColor(th.Muted, model)
3203+ if reasoning == "" {
3204+ return rendered
3205+ }
3206+ valueColor := th.FG
3207+ if reasoning == "max" {
3208+ valueColor = th.ThinkingMax
3209+ }
3210+ return rendered + statusLabelValue(th, ":", reasoning, valueColor)
3211+}
3212+
3213+func appendWrappedStatusLines(lines []string, th Theme, pad, model, reasoning, fastText string, stats []string, cols int) []string {
3214+ modelLine := pad + renderStatusModel(th, model, reasoning)
31713215 if fastText != "" && visibleWidth(modelLine+pad+fastText) <= cols {
3172- modelLine += pad + th.FGColor(th.Muted, fastText)
3216+ modelLine += pad + fastText
31733217 } else if fastText != "" {
31743218 stats = append([]string{fastText}, stats...)
31753219 }
@@ -3183,14 +3227,14 @@ func appendWrappedUsageStats(lines []string, th Theme, pad string, stats []strin
31833227 }
31843228 available := cols - visibleWidth(pad)
31853229 if cols <= 0 || available <= 0 {
3186- return append(lines, pad+th.FGColor(th.Muted, strings.Join(stats, " ")))
3230+ return append(lines, pad+strings.Join(stats, " "))
31873231 }
31883232 current := make([]string, 0, len(stats))
31893233 flush := func() {
31903234 if len(current) == 0 {
31913235 return
31923236 }
3193- line := pad + th.FGColor(th.Muted, strings.Join(current, " "))
3237+ line := pad + strings.Join(current, " ")
31943238 lines = append(lines, truncateToWidth(line, cols))
31953239 current = current[:0]
31963240 }
@@ -3205,13 +3249,6 @@ func appendWrappedUsageStats(lines []string, th Theme, pad string, stats []strin
32053249 return lines
32063250 }
32073251
3208-func reasoningStatusColor(th Theme, reasoningText string) TerminalColor {
3209- if strings.HasSuffix(reasoningText, ":max") {
3210- return th.ThinkingMax
3211- }
3212- return th.Muted
3213-}
3214-
32153252 func reasoningLevelLabel(level string) string {
32163253 switch strings.ToLower(strings.TrimSpace(level)) {
32173254 case "", "off", "none", "no", "false", "disabled":
@@ -2976,17 +2976,19 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
2976 // StatusBarParams groups the many bits of state the status bar needs.2976 // StatusBarParams groups the many bits of state the status bar needs.
2977 // Grew from a flat argument list once we settled on the layout.2977 // Grew from a flat argument list once we settled on the layout.
2978 type StatusBarParams struct {2978 type StatusBarParams struct {
2979- Theme Theme2979+ Theme Theme
2980- Provider string2980+ Provider string
2981- Model string2981+ Model string
2982- Reasoning string // "" means thinking off2982+ Reasoning string // "" means thinking off
2983- FastMode bool // show the provider's opt-in fast tier when enabled2983+ FastMode bool // show the provider's opt-in fast tier when enabled
2984- Busy bool2984+ Busy bool
2985- BusyPrefix string // spinner + funny line when busy2985+ BusyPrefix string // spinner + funny line when busy
2986- CWD string2986+ CWD string
2987- Locked bool // sandbox on?2987+ Locked bool // sandbox on?
2988- NoYolo bool // confirmation mode enabled?2988+ NoYolo bool // confirmation mode enabled?
2989- GoalStatus string // autonomous goal lifecycle, empty when none2989+ GoalStatus string // autonomous goal lifecycle, empty when none
2990+ PlanCurrent int // one-based in-progress plan step; 0 hides plan progress
2991+ PlanTotal int
2990 2992
2991 // Cumulative session usage and cost.2993 // Cumulative session usage and cost.
2992 Usage provider.Usage2994 Usage provider.Usage
@@ -3032,24 +3034,39 @@ func StatusBar(p StatusBarParams) []string {
3032 th := p.Theme3034 th := p.Theme
3033 3035
3034 stats := UsageStatsParts(UsageStatsParams{Usage: p.Usage, Subscription: p.Subscription, Compact: true})3036 stats := UsageStatsParts(UsageStatsParams{Usage: p.Usage, Subscription: p.Subscription, Compact: true})
3037+ for idx, stat := range stats {
3038+ stats[idx] = th.FGColor(th.Muted, stat)
3039+ }
3035 if p.WeeklyUsage != "" {3040 if p.WeeklyUsage != "" {
3036- stats = append(stats, p.WeeklyUsage)3041+ if label, value, ok := strings.Cut(p.WeeklyUsage, ":"); ok {
3042+ stats = append(stats, statusLabelValue(th, label+":", value, th.FG))
3043+ } else {
3044+ stats = append(stats, th.FGColor(th.Muted, p.WeeklyUsage))
3045+ }
3037 }3046 }
3038 if p.GoalStatus != "" {3047 if p.GoalStatus != "" {
3039- stats = append(stats, "goal:"+p.GoalStatus)3048+ stats = append(stats, statusLabelValue(th, "goal:", p.GoalStatus, th.FG))
3049+ }
3050+ if p.PlanCurrent > 0 && p.PlanTotal > 0 {
3051+ stats = append(stats, statusLabelValue(th, "plan:", fmt.Sprintf("%d/%d", p.PlanCurrent, p.PlanTotal), th.FG))
3040 }3052 }
3041 3053
3042 // Context %. Color-coded: yellow >70, red >90.3054 // Context %. Color-coded: yellow >70, red >90.
3043 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)3055 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)
3044 if ctx != "" {3056 if ctx != "" {
3057+ if ctxColor == th.Muted {
3058+ ctxColor = th.FG
3059+ }
3060+ value := ctx
3061+ suffix := ""
3045 if p.ContextMax > 0 {3062 if p.ContextMax > 0 {
3046- ctx = fmt.Sprintf("%.0f%%/%s", float64(p.ContextUsed)/float64(p.ContextMax)*100, formatTokens(p.ContextMax))3063+ value = fmt.Sprintf("%.0f%%", float64(p.ContextUsed)/float64(p.ContextMax)*100)
3064+ suffix = "/" + formatTokens(p.ContextMax)
3047 }3065 }
3048- ctx = "ctx" + ctx
3049 if p.AutoCompacting {3066 if p.AutoCompacting {
3050- ctx += " (auto)"3067+ suffix += " (auto)"
3051 }3068 }
3052- stats = append(stats, th.FGColor(ctxColor, ctx))3069+ stats = append(stats, statusLabelValueSuffix(th, "ctx", value, suffix, ctxColor))
3053 }3070 }
3054 3071
3055 // Layout uses exactly 2 spaces of horizontal padding everywhere:3072 // Layout uses exactly 2 spaces of horizontal padding everywhere:
@@ -3058,14 +3075,11 @@ func StatusBar(p StatusBarParams) []string {
3058 // vertically with the conversation column.3075 // vertically with the conversation column.
3059 const pad = " " // 2 spaces3076 const pad = " " // 2 spaces
3060 3077
3061- left := p.Model3078+ reasoning := reasoningLevelLabel(p.Reasoning)
3062- if reasoning := reasoningLevelLabel(p.Reasoning); reasoning != "" {3079+ left := renderStatusModel(th, p.Model, reasoning)
3063- left += ":" + reasoning
3064- }
3065- modelColor := reasoningStatusColor(th, left)
3066 fastText := ""3080 fastText := ""
3067 if p.FastMode {3081 if p.FastMode {
3068- fastText = "fast mode"3082+ fastText = th.FGColor(th.Muted, "fast mode")
3069 }3083 }
3070 statsText := strings.Join(stats, " ")3084 statsText := strings.Join(stats, " ")
3071 middleParts := make([]string, 0, 3)3085 middleParts := make([]string, 0, 3)
@@ -3095,10 +3109,10 @@ func StatusBar(p StatusBarParams) []string {
3095 // prefix there's no trailing separator to double-pad.3109 // prefix there's no trailing separator to double-pad.
3096 leftBuilder.WriteString(pad)3110 leftBuilder.WriteString(pad)
3097 }3111 }
3098- leftBuilder.WriteString(th.FGColor(modelColor, left))3112+ leftBuilder.WriteString(left)
3099 if middle != "" {3113 if middle != "" {
3100 leftBuilder.WriteString(pad)3114 leftBuilder.WriteString(pad)
3101- leftBuilder.WriteString(th.FGColor(th.Muted, middle))3115+ leftBuilder.WriteString(middle)
3102 }3116 }
3103 3117
3104 cwd := shortenHome(p.CWD)3118 cwd := shortenHome(p.CWD)
@@ -3124,16 +3138,16 @@ func StatusBar(p StatusBarParams) []string {
3124 // stats on their own rows. This mirrors the idle split below.3138 // stats on their own rows. This mirrors the idle split below.
3125 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {3139 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
3126 busyLine := pad + p.BusyPrefix3140 busyLine := pad + p.BusyPrefix
3127- modelLine := pad + th.FGColor(modelColor, left)3141+ modelLine := pad + left
3128 lines := []string{busyLine}3142 lines := []string{busyLine}
3129 if visibleWidth(modelLine+pad+middle) > p.Cols {3143 if visibleWidth(modelLine+pad+middle) > p.Cols {
3130- lines = appendWrappedStatusLines(lines, th, pad, left, fastText, stats, p.Cols)3144+ lines = appendWrappedStatusLines(lines, th, pad, p.Model, reasoning, fastText, stats, p.Cols)
3131 } else {3145 } else {
3132 var infoBuilder strings.Builder3146 var infoBuilder strings.Builder
3133 infoBuilder.WriteString(modelLine)3147 infoBuilder.WriteString(modelLine)
3134 if middle != "" {3148 if middle != "" {
3135 infoBuilder.WriteString(pad)3149 infoBuilder.WriteString(pad)
3136- infoBuilder.WriteString(th.FGColor(th.Muted, middle))3150+ infoBuilder.WriteString(middle)
3137 }3151 }
3138 lines = append(lines, infoBuilder.String())3152 lines = append(lines, infoBuilder.String())
3139 }3153 }
@@ -3149,7 +3163,7 @@ func StatusBar(p StatusBarParams) []string {
3149 // into an awkward position on small widths.3163 // into an awkward position on small widths.
3150 if p.Cols > 0 && p.BusyPrefix == "" && visibleWidth(primary) > p.Cols {3164 if p.Cols > 0 && p.BusyPrefix == "" && visibleWidth(primary) > p.Cols {
3151 var lines []string3165 var lines []string
3152- lines = appendWrappedStatusLines(lines, th, pad, left, fastText, stats, p.Cols)3166+ lines = appendWrappedStatusLines(lines, th, pad, p.Model, reasoning, fastText, stats, p.Cols)
3153 if cwd != "" {3167 if cwd != "" {
3154 lines = append(lines, pad+th.FGColor(th.Muted, cwd))3168 lines = append(lines, pad+th.FGColor(th.Muted, cwd))
3155 }3169 }
@@ -3166,10 +3180,40 @@ func StatusBar(p StatusBarParams) []string {
3166 return []string{primary, cwdRendered}3180 return []string{primary, cwdRendered}
3167 }3181 }
3168 3182
3169-func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText string, stats []string, cols int) []string {3183+func statusLabelValue(th Theme, label, value string, valueColor TerminalColor) string {
3170- modelLine := pad + th.FGColor(reasoningStatusColor(th, modelText), modelText)3184+ return statusLabelValueSuffix(th, label, value, "", valueColor)
3185+}
3186+
3187+func statusLabelValueSuffix(th Theme, label, value, suffix string, valueColor TerminalColor) string {
3188+ var rendered strings.Builder
3189+ if label != "" {
3190+ rendered.WriteString(th.FGColor(th.Muted, label))
3191+ }
3192+ if value != "" {
3193+ rendered.WriteString(th.FGColor(valueColor, value))
3194+ }
3195+ if suffix != "" {
3196+ rendered.WriteString(th.FGColor(th.Muted, suffix))
3197+ }
3198+ return rendered.String()
3199+}
3200+
3201+func renderStatusModel(th Theme, model, reasoning string) string {
3202+ rendered := th.FGColor(th.Muted, model)
3203+ if reasoning == "" {
3204+ return rendered
3205+ }
3206+ valueColor := th.FG
3207+ if reasoning == "max" {
3208+ valueColor = th.ThinkingMax
3209+ }
3210+ return rendered + statusLabelValue(th, ":", reasoning, valueColor)
3211+}
3212+
3213+func appendWrappedStatusLines(lines []string, th Theme, pad, model, reasoning, fastText string, stats []string, cols int) []string {
3214+ modelLine := pad + renderStatusModel(th, model, reasoning)
3171 if fastText != "" && visibleWidth(modelLine+pad+fastText) <= cols {3215 if fastText != "" && visibleWidth(modelLine+pad+fastText) <= cols {
3172- modelLine += pad + th.FGColor(th.Muted, fastText)3216+ modelLine += pad + fastText
3173 } else if fastText != "" {3217 } else if fastText != "" {
3174 stats = append([]string{fastText}, stats...)3218 stats = append([]string{fastText}, stats...)
3175 }3219 }
@@ -3183,14 +3227,14 @@ func appendWrappedUsageStats(lines []string, th Theme, pad string, stats []strin
3183 }3227 }
3184 available := cols - visibleWidth(pad)3228 available := cols - visibleWidth(pad)
3185 if cols <= 0 || available <= 0 {3229 if cols <= 0 || available <= 0 {
3186- return append(lines, pad+th.FGColor(th.Muted, strings.Join(stats, " ")))3230+ return append(lines, pad+strings.Join(stats, " "))
3187 }3231 }
3188 current := make([]string, 0, len(stats))3232 current := make([]string, 0, len(stats))
3189 flush := func() {3233 flush := func() {
3190 if len(current) == 0 {3234 if len(current) == 0 {
3191 return3235 return
3192 }3236 }
3193- line := pad + th.FGColor(th.Muted, strings.Join(current, " "))3237+ line := pad + strings.Join(current, " ")
3194 lines = append(lines, truncateToWidth(line, cols))3238 lines = append(lines, truncateToWidth(line, cols))
3195 current = current[:0]3239 current = current[:0]
3196 }3240 }
@@ -3205,13 +3249,6 @@ func appendWrappedUsageStats(lines []string, th Theme, pad string, stats []strin
3205 return lines3249 return lines
3206 }3250 }
3207 3251
3208-func reasoningStatusColor(th Theme, reasoningText string) TerminalColor {
3209- if strings.HasSuffix(reasoningText, ":max") {
3210- return th.ThinkingMax
3211- }
3212- return th.Muted
3213-}
3214-
3215 func reasoningLevelLabel(level string) string {3252 func reasoningLevelLabel(level string) string {
3216 switch strings.ToLower(strings.TrimSpace(level)) {3253 switch strings.ToLower(strings.TrimSpace(level)) {
3217 case "", "off", "none", "no", "false", "disabled":3254 case "", "off", "none", "no", "false", "disabled":

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close