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 Unverified
B5690EEEBB952194The 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: | |||
| 126 | 126 | The interactive status bar combines the model and reasoning level and rounds cache/context percentages and costs: | |
| 127 | 127 | ||
| 128 | 128 | ```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 | |
| 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 | 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 | ||
| @@ -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 | ```text | 128 | ```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 |
| 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 { | |||
| 698 | 698 | statusErr string | |
| 699 | 699 | statusOK string | |
| 700 | 700 | goalStatus core.GoalStatus | |
| 701 | + | planCurrent int | |
| 702 | + | planTotal int | |
| 701 | 703 | goalRun *goalContinuationRun | |
| 702 | 704 | reloadStatusSeq uint64 | |
| 703 | 705 | extStatuses map[string]map[string]extensionStatus | |
| @@ -698,6 +698,8 @@ type Interactive struct { | |||||
| 698 | statusErr string | 698 | statusErr string | ||
| 699 | statusOK string | 699 | statusOK string | ||
| 700 | goalStatus core.GoalStatus | 700 | goalStatus core.GoalStatus | ||
| 701 | + | planCurrent int | |||
| 702 | + | planTotal int | |||
| 701 | goalRun *goalContinuationRun | 703 | goalRun *goalContinuationRun | ||
| 702 | reloadStatusSeq uint64 | 704 | reloadStatusSeq uint64 | ||
| 703 | extStatuses map[string]map[string]extensionStatus | 705 | 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 { | |||
| 19 | 19 | 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 | 33 | func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) { | |
| 23 | 34 | i.toolRenderRevision++ | |
| 24 | 35 | if i.toolRenderRevision == 0 { | |
| @@ -172,6 +183,8 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 172 | 183 | i.goalStatus = update.Status | |
| 173 | 184 | } | |
| 174 | 185 | } | |
| 186 | + | case core.EvPlanUpdate: | |
| 187 | + | i.planCurrent, i.planTotal = planProgress(e.Update.Plan) | |
| 175 | 188 | case core.EvUsage: | |
| 176 | 189 | i.cumUsage = e.Cumulative | |
| 177 | 190 | 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 true | 19 | 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.Status | 183 | 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.Cumulative | 189 | 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() { | |||
| 680 | 680 | Locked: i.cfg.Sandbox.Locked(), | |
| 681 | 681 | NoYolo: i.cfg.NoYolo, | |
| 682 | 682 | GoalStatus: goalStatus, | |
| 683 | + | PlanCurrent: i.planCurrent, | |
| 684 | + | PlanTotal: i.planTotal, | |
| 683 | 685 | Usage: i.cumUsage, | |
| 684 | 686 | Subscription: i.cfg.AuthMethod == "oauth", | |
| 685 | 687 | 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 | |||
| 209 | 209 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 210 | 210 | i.toolOrder = nil | |
| 211 | 211 | i.toolGate = map[string]int{} | |
| 212 | + | i.planCurrent = 0 | |
| 213 | + | i.planTotal = 0 | |
| 212 | 214 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | |
| 213 | 215 | i.scrollOffset = 0 // jump back to the bottom on new turn | |
| 214 | 216 | // 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 | 400 | i.resetStreamingStateLocked() | |
| 399 | 401 | } | |
| 400 | 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 | 405 | i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | |
| 402 | 406 | i.mu.Unlock() | |
| 403 | 407 | 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 = nil | 210 | 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 them | 214 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | ||
| 213 | i.scrollOffset = 0 // jump back to the bottom on new turn | 215 | i.scrollOffset = 0 // jump back to the bottom on new turn | ||
| 214 | // Lift the resume tail cap once the user starts interacting. The | 216 | // 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 || shouldAutoCompact | 405 | 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) { | |||
| 93 | 93 | } | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | - | func TestStatusBarShowsActiveGoal(t *testing.T) { | |
| 96 | + | func TestStatusBarShowsActiveGoalAndPlan(t *testing.T) { | |
| 97 | 97 | 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) | |
| 101 | 105 | } | |
| 102 | 106 | } | |
| 103 | 107 | } | |
| @@ -121,9 +125,12 @@ func TestStatusBarNoCWD(t *testing.T) { | |||
| 121 | 125 | } | |
| 122 | 126 | ||
| 123 | 127 | 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 | + | } | |
| 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 | 188 | func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) { | |
| 143 | 189 | th := Dark | |
| 144 | 190 | th.ThinkingMax = Color256(201) | |
| @@ -156,7 +202,7 @@ func TestStatusBarContextWarningsAndCompaction(t *testing.T) { | |||
| 156 | 202 | color TerminalColor | |
| 157 | 203 | }{{75, Dark.Warning}, {95, Dark.Error}} { | |
| 158 | 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 | 206 | if !strings.Contains(lines[0], want) { | |
| 161 | 207 | t.Fatalf("context warning missing: %q", lines) | |
| 162 | 208 | } | |
| @@ -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 := Dark | 189 | th := Dark | ||
| 144 | th.ThinkingMax = Color256(201) | 190 | th.ThinkingMax = Color256(201) | ||
| @@ -156,7 +202,7 @@ func TestStatusBarContextWarningsAndCompaction(t *testing.T) { | |||||
| 156 | color TerminalColor | 202 | 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 { | |||
| 2976 | 2976 | // StatusBarParams groups the many bits of state the status bar needs. | |
| 2977 | 2977 | // Grew from a flat argument list once we settled on the layout. | |
| 2978 | 2978 | 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 | |
| 2990 | 2992 | ||
| 2991 | 2993 | // Cumulative session usage and cost. | |
| 2992 | 2994 | Usage provider.Usage | |
| @@ -3032,24 +3034,39 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3032 | 3034 | th := p.Theme | |
| 3033 | 3035 | ||
| 3034 | 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 | 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 | 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 | 3054 | // Context %. Color-coded: yellow >70, red >90. | |
| 3043 | 3055 | ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax) | |
| 3044 | 3056 | if ctx != "" { | |
| 3057 | + | if ctxColor == th.Muted { | |
| 3058 | + | ctxColor = th.FG | |
| 3059 | + | } | |
| 3060 | + | value := ctx | |
| 3061 | + | suffix := "" | |
| 3045 | 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 | 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 | 3072 | // Layout uses exactly 2 spaces of horizontal padding everywhere: | |
| @@ -3058,14 +3075,11 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3058 | 3075 | // vertically with the conversation column. | |
| 3059 | 3076 | const pad = " " // 2 spaces | |
| 3060 | 3077 | ||
| 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) | |
| 3066 | 3080 | fastText := "" | |
| 3067 | 3081 | if p.FastMode { | |
| 3068 | - | fastText = "fast mode" | |
| 3082 | + | fastText = th.FGColor(th.Muted, "fast mode") | |
| 3069 | 3083 | } | |
| 3070 | 3084 | statsText := strings.Join(stats, " ") | |
| 3071 | 3085 | middleParts := make([]string, 0, 3) | |
| @@ -3095,10 +3109,10 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3095 | 3109 | // prefix there's no trailing separator to double-pad. | |
| 3096 | 3110 | leftBuilder.WriteString(pad) | |
| 3097 | 3111 | } | |
| 3098 | - | leftBuilder.WriteString(th.FGColor(modelColor, left)) | |
| 3112 | + | leftBuilder.WriteString(left) | |
| 3099 | 3113 | if middle != "" { | |
| 3100 | 3114 | leftBuilder.WriteString(pad) | |
| 3101 | - | leftBuilder.WriteString(th.FGColor(th.Muted, middle)) | |
| 3115 | + | leftBuilder.WriteString(middle) | |
| 3102 | 3116 | } | |
| 3103 | 3117 | ||
| 3104 | 3118 | cwd := shortenHome(p.CWD) | |
| @@ -3124,16 +3138,16 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3124 | 3138 | // stats on their own rows. This mirrors the idle split below. | |
| 3125 | 3139 | if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols { | |
| 3126 | 3140 | busyLine := pad + p.BusyPrefix | |
| 3127 | - | modelLine := pad + th.FGColor(modelColor, left) | |
| 3141 | + | modelLine := pad + left | |
| 3128 | 3142 | lines := []string{busyLine} | |
| 3129 | 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 | 3145 | } else { | |
| 3132 | 3146 | var infoBuilder strings.Builder | |
| 3133 | 3147 | infoBuilder.WriteString(modelLine) | |
| 3134 | 3148 | if middle != "" { | |
| 3135 | 3149 | infoBuilder.WriteString(pad) | |
| 3136 | - | infoBuilder.WriteString(th.FGColor(th.Muted, middle)) | |
| 3150 | + | infoBuilder.WriteString(middle) | |
| 3137 | 3151 | } | |
| 3138 | 3152 | lines = append(lines, infoBuilder.String()) | |
| 3139 | 3153 | } | |
| @@ -3149,7 +3163,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3149 | 3163 | // into an awkward position on small widths. | |
| 3150 | 3164 | if p.Cols > 0 && p.BusyPrefix == "" && visibleWidth(primary) > p.Cols { | |
| 3151 | 3165 | 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 | 3167 | if cwd != "" { | |
| 3154 | 3168 | lines = append(lines, pad+th.FGColor(th.Muted, cwd)) | |
| 3155 | 3169 | } | |
| @@ -3166,10 +3180,40 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3166 | 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 { | |
| 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) | |
| 3171 | 3215 | if fastText != "" && visibleWidth(modelLine+pad+fastText) <= cols { | |
| 3172 | - | modelLine += pad + th.FGColor(th.Muted, fastText) | |
| 3216 | + | modelLine += pad + fastText | |
| 3173 | 3217 | } else if fastText != "" { | |
| 3174 | 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 | 3228 | available := cols - visibleWidth(pad) | |
| 3185 | 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 | 3232 | current := make([]string, 0, len(stats)) | |
| 3189 | 3233 | flush := func() { | |
| 3190 | 3234 | if len(current) == 0 { | |
| 3191 | 3235 | return | |
| 3192 | 3236 | } | |
| 3193 | - | line := pad + th.FGColor(th.Muted, strings.Join(current, " ")) | |
| 3237 | + | line := pad + strings.Join(current, " ") | |
| 3194 | 3238 | lines = append(lines, truncateToWidth(line, cols)) | |
| 3195 | 3239 | current = current[:0] | |
| 3196 | 3240 | } | |
| @@ -3205,13 +3249,6 @@ func appendWrappedUsageStats(lines []string, th Theme, pad string, stats []strin | |||
| 3205 | 3249 | 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 | 3252 | func reasoningLevelLabel(level string) string { | |
| 3216 | 3253 | switch strings.ToLower(strings.TrimSpace(level)) { | |
| 3217 | 3254 | 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 Theme | 2979 | + | Theme Theme |
| 2980 | - | Provider string | 2980 | + | Provider string |
| 2981 | - | Model string | 2981 | + | Model string |
| 2982 | - | Reasoning string // "" means thinking off | 2982 | + | Reasoning string // "" means thinking off |
| 2983 | - | FastMode bool // show the provider's opt-in fast tier when enabled | 2983 | + | FastMode bool // show the provider's opt-in fast tier when enabled |
| 2984 | - | Busy bool | 2984 | + | Busy bool |
| 2985 | - | BusyPrefix string // spinner + funny line when busy | 2985 | + | BusyPrefix string // spinner + funny line when busy |
| 2986 | - | CWD string | 2986 | + | 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 none | 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 | |||
| 2990 | 2992 | ||||
| 2991 | // Cumulative session usage and cost. | 2993 | // Cumulative session usage and cost. | ||
| 2992 | Usage provider.Usage | 2994 | Usage provider.Usage | ||
| @@ -3032,24 +3034,39 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 3032 | th := p.Theme | 3034 | 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 spaces | 3076 | const pad = " " // 2 spaces | ||
| 3060 | 3077 | ||||
| 3061 | - | left := p.Model | 3078 | + | 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.BusyPrefix | 3140 | 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.Builder | 3146 | 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 []string | 3165 | 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 | return | 3235 | 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 lines | 3249 | 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": | ||