v0.48.1 deaab59

statusbar_test.go RawHistoryFolder

1package tui
2
3import (
4	"fmt"
5	"strings"
6	"testing"
7
8	"github.com/bnema/zut/packages/provider"
9)
10
11func TestStatusBarCompact(t *testing.T) {
12	lines := StatusBar(StatusBarParams{
13		Theme: Dark, Provider: "openai-codex", Model: "gpt-6-astra", Reasoning: "medium", Cols: 120,
14		Usage: provider.Usage{InputTokens: 261_000, OutputTokens: 34_000, CacheReadTokens: 12_000_000,
15			CacheMeasuredPromptTokens: 1000, CacheMeasuredReadTokens: 979, CostUSD: 8.522},
16		Subscription: true, ContextUsed: 155_000, ContextMax: 500_000, WeeklyUsage: "weekly:69%",
17		CWD: "/tmp/x",
18	})
19	if len(lines) != 2 {
20		t.Fatalf("want stats + cwd, got %q", lines)
21	}
22	want := "  gpt-6-astra:medium  ↑261k ↓34k R12M C98% $8.52 weekly:69% ctx31%/500k"
23	if got := stripANSI(lines[0]); got != want {
24		t.Fatalf("status = %q, want %q", got, want)
25	}
26	if got := stripANSI(lines[1]); got != "  /tmp/x" {
27		t.Fatalf("cwd = %q", got)
28	}
29}
30
31func TestStatusBarShowsCacheHitRatio(t *testing.T) {
32	lines := StatusBar(StatusBarParams{
33		Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,
34		Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000,
35			CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000},
36		ContextUsed: 45_152, ContextMax: 272_000,
37	})
38	plain := stripANSI(strings.Join(lines, "\n"))
39	if !strings.Contains(plain, "↑84k ↓1.5k R123k C59%") {
40		t.Fatalf("status bar = %q, want compact cache hit ratio", plain)
41	}
42}
43
44func TestStatusBarOmitsCacheRatioWhenCacheDetailsAreUnavailable(t *testing.T) {
45	lines := StatusBar(StatusBarParams{
46		Theme: Dark, Provider: "compatible", Model: "model", Cols: 200,
47		Usage: provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000},
48	})
49	plain := stripANSI(strings.Join(lines, "\n"))
50	if strings.Contains(plain, "C") {
51		t.Fatalf("status bar = %q, want no cache ratio without measured details", plain)
52	}
53}
54
55func TestStatusBarNarrow(t *testing.T) {
56	for _, cols := range []int{24, 30, 32, 40, 64} {
57		for _, busy := range []string{"", "⠋ Working"} {
58			t.Run(fmt.Sprintf("%d/busy=%t", cols, busy != ""), func(t *testing.T) {
59				lines := StatusBar(StatusBarParams{
60					Theme: Dark, Provider: "openai-codex", Model: "gpt-5.5", Reasoning: "minimum", Cols: cols,
61					BusyPrefix: busy, CWD: "/tmp/x", FastMode: true, WeeklyUsage: "weekly:69%",
62					Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000,
63						CacheWriteTokens: 2_000, CacheMeasuredPromptTokens: 209_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
64					ContextUsed: 209_000, ContextMax: 272_000,
65				})
66				plain := stripANSI(strings.Join(lines, "\n"))
67				for _, want := range []string{"gpt-5.5:minimal", "fast mode", "↑84k", "↓1.5k", "R123k", "C59%", "W2.0k", "$0.53", "weekly:69%", "ctx77%/272k", "/tmp/x"} {
68					if !strings.Contains(plain, want) {
69						t.Fatalf("missing %q in %q", want, plain)
70					}
71				}
72				for _, line := range lines {
73					if width := visibleWidth(line); width > cols {
74						t.Fatalf("line width = %d, want <= %d: %q", width, cols, stripANSI(line))
75					}
76				}
77				if busy != "" && stripANSI(lines[0]) != "  "+busy {
78					t.Fatalf("busy prefix should stay on its own row: %q", lines)
79				}
80			})
81		}
82	}
83}
84
85func TestStatusBarVeryNarrowModel(t *testing.T) {
86	for _, busy := range []string{"", "⠋"} {
87		lines := StatusBar(StatusBarParams{Theme: Dark, Model: "模型-with-long-name", Reasoning: "max", BusyPrefix: busy, Cols: 10})
88		for _, line := range lines {
89			if visibleWidth(line) > 10 {
90				t.Fatalf("model overflows narrow terminal: %q", line)
91			}
92		}
93	}
94}
95
96func TestStatusBarShowsActiveGoalAndPlan(t *testing.T) {
97	for _, cols := range []int{100, 20} {
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)
105		}
106	}
107}
108
109func TestStatusBarAlwaysTwoLines(t *testing.T) {
110	lines := StatusBar(StatusBarParams{
111		Theme: Dark, Provider: "anthropic", Model: "claude-opus-4-7", CWD: "/tmp/x",
112		Usage:        provider.Usage{InputTokens: 476_000, OutputTokens: 3_400, CostUSD: 1.242},
113		Subscription: true, ContextUsed: 55_000, ContextMax: 1_000_000, Cols: 500,
114	})
115	if len(lines) != 2 || !strings.Contains(lines[0], "claude-opus-4-7") || stripANSI(lines[1]) != "  /tmp/x" {
116		t.Fatalf("want model/stats followed by indented cwd: %q", lines)
117	}
118}
119
120func TestStatusBarNoCWD(t *testing.T) {
121	lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.4", Cols: 200})
122	if len(lines) != 1 {
123		t.Fatalf("empty cwd: want 1 line, got %q", lines)
124	}
125}
126
127func TestStatusBarShowsFastMode(t *testing.T) {
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		}
134	}
135}
136
137func TestStatusBarReasoningSuffix(t *testing.T) {
138	for _, tc := range []struct{ level, want string }{
139		{"medium", "gpt-test:medium"}, {"minimum", "gpt-test:minimal"}, {"xhigh", "gpt-test:xhigh"},
140		{"", "gpt-test"}, {"off", "gpt-test"},
141	} {
142		lines := StatusBar(StatusBarParams{Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Reasoning: tc.level, Cols: 200})
143		if got := stripANSI(lines[0]); got != "  "+tc.want {
144			t.Fatalf("reasoning %q: got %q, want %q", tc.level, got, tc.want)
145		}
146	}
147}
148
149func 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
156func 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
177func 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
188func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {
189	th := Dark
190	th.ThinkingMax = Color256(201)
191	for _, cols := range []int{200, 25} {
192		lines := StatusBar(StatusBarParams{Theme: th, Model: "gpt-5.6-sol", Reasoning: "max", Cols: cols, WeeklyUsage: "weekly:0%"})
193		if !strings.Contains(lines[0], "\x1b[38;5;201m") || !strings.Contains(stripANSI(lines[0]), "gpt-5.6-sol:max") {
194			t.Fatalf("max reasoning style missing: %q", lines)
195		}
196	}
197}
198
199func TestStatusBarContextWarningsAndCompaction(t *testing.T) {
200	for _, tc := range []struct {
201		used  int
202		color TerminalColor
203	}{{75, Dark.Warning}, {95, Dark.Error}} {
204		lines := StatusBar(StatusBarParams{Theme: Dark, Model: "model", ContextUsed: tc.used, ContextMax: 100, AutoCompacting: true, Cols: 200})
205		want := statusLabelValueSuffix(Dark, "ctx", fmt.Sprintf("%d%%", tc.used), "/100 (auto)", tc.color)
206		if !strings.Contains(lines[0], want) {
207			t.Fatalf("context warning missing: %q", lines)
208		}
209	}
210}
211
212func TestStatusBarNoYoloTagPrecedesCWD(t *testing.T) {
213	lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.5", CWD: "/tmp/x", NoYolo: true, Cols: 200})
214	if len(lines) != 2 || !strings.Contains(stripANSI(lines[1]), "yolo mode disabled - /tmp/x") {
215		t.Fatalf("cwd line should include no-yolo tag: %q", lines)
216	}
217}

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 476

↑↓ moveenter openesc close