v0.20.0 29be61c

statusbar_test.go RawHistoryFolder

1package tui
2
3import (
4	"strings"
5	"testing"
6
7	"github.com/bnema/zut/packages/provider"
8)
9
10func TestStatusBarShowsCacheHitRatio(t *testing.T) {
11	lines := StatusBar(StatusBarParams{
12		Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,
13		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000},
14		ContextUsed: 45_152, ContextMax: 272_000,
15	})
16	plain := stripANSI(strings.Join(lines, "\n"))
17	if !strings.Contains(plain, "↑84k ↓1.5k R123k/ C59.4%") {
18		t.Fatalf("status bar = %q, want compact cache hit ratio", plain)
19	}
20}
21
22func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) {
23	const cols = 30
24	lines := StatusBar(StatusBarParams{
25		Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols,
26		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CostUSD: 0.525},
27		ContextUsed: 209_000, ContextMax: 272_000,
28	})
29	plain := stripANSI(strings.Join(lines, "\n"))
30	if !strings.Contains(plain, "C58.9%") {
31		t.Fatalf("narrow status bar = %q, want cache hit ratio", plain)
32	}
33	for _, line := range lines {
34		if width := visibleWidth(line); width > cols {
35			t.Fatalf("narrow status line width = %d, want <= %d: %q", width, cols, stripANSI(line))
36		}
37	}
38}
39
40func TestStatusBarShowsActiveGoal(t *testing.T) {
41	params := StatusBarParams{
42		Theme:      Dark,
43		Provider:   "openai",
44		Model:      "gpt-test",
45		GoalStatus: "active",
46		CWD:        "/tmp/project",
47		Cols:       100,
48	}
49	lines := StatusBar(params)
50	if len(lines) == 0 || !strings.Contains(lines[0], "goal:active") {
51		t.Fatalf("status bar = %q, want active goal", lines)
52	}
53
54	params.Cols = 35
55	lines = StatusBar(params)
56	for _, line := range lines {
57		if strings.Contains(line, "goal:active") {
58			return
59		}
60	}
61	t.Fatalf("narrow status bar = %q, want active goal", lines)
62}
63
64// TestStatusBarAlwaysTwoLines verifies the status bar always emits
65// two lines when a cwd is present, regardless of terminal width, and
66// that the cwd is indented with the 2-space pad so it lines up under
67// the "(provider)" column on line 1.
68func TestStatusBarAlwaysTwoLines(t *testing.T) {
69	// Wide terminal that would previously combine into one line.
70	lines := StatusBar(StatusBarParams{
71		Theme:    Dark,
72		Provider: "anthropic",
73		Model:    "claude-opus-4-7",
74		CWD:      "/tmp/x",
75		Usage: provider.Usage{
76			InputTokens:  476_000,
77			OutputTokens: 3_400,
78			CostUSD:      1.242,
79		},
80		Subscription: true,
81		ContextUsed:  55_000,
82		ContextMax:   1_000_000,
83		Cols:         500, // very wide
84	})
85	if len(lines) != 2 {
86		t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
87	}
88	if !strings.Contains(lines[0], "claude-opus-4-7") {
89		t.Errorf("line 1 should contain model, got %q", lines[0])
90	}
91	// Line 2 must start with 2-space indent.
92	if !strings.HasPrefix(lines[1], "  ") {
93		t.Errorf("line 2 should start with 2-space indent, got %q", lines[1])
94	}
95	if !strings.Contains(lines[1], "/tmp/x") {
96		t.Errorf("line 2 should contain cwd, got %q", lines[1])
97	}
98}
99
100// TestStatusBarNoCWD verifies an empty cwd stays single-line.
101func TestStatusBarNoCWD(t *testing.T) {
102	lines := StatusBar(StatusBarParams{
103		Theme:    Dark,
104		Provider: "openai",
105		Model:    "gpt-5.4",
106		CWD:      "",
107		Cols:     200,
108	})
109	if len(lines) != 1 {
110		t.Fatalf("empty cwd: want 1 line, got %d: %q", len(lines), lines)
111	}
112}
113
114func TestStatusBarShowsFastMode(t *testing.T) {
115	lines := StatusBar(StatusBarParams{
116		Theme:    Dark,
117		Provider: "openai-codex",
118		Model:    "gpt-5.6-luna",
119		FastMode: true,
120		CWD:      "/tmp/x",
121		Cols:     200,
122	})
123	if len(lines) != 2 {
124		t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
125	}
126	plain := stripANSI(lines[0])
127	if !strings.Contains(plain, "fast mode") {
128		t.Fatalf("fast mode should be visible in status bar: %q", plain)
129	}
130}
131
132func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) {
133	lines := StatusBar(StatusBarParams{
134		Theme:     Dark,
135		Provider:  "openai-codex",
136		Model:     "gpt-5.5",
137		Reasoning: "minimum",
138		CWD:       "/tmp/x",
139		Usage: provider.Usage{
140			InputTokens:  4_300_000,
141			OutputTokens: 2,
142		},
143		Cols: 500,
144	})
145	if len(lines) != 2 {
146		t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
147	}
148	plain := stripANSI(lines[0])
149	modelIdx := strings.Index(plain, "(openai-codex) gpt-5.5")
150	reasoningIdx := strings.Index(plain, "reasoning: minimal")
151	statsIdx := strings.Index(plain, "↑4.3M")
152	if modelIdx < 0 || reasoningIdx < 0 || statsIdx < 0 {
153		t.Fatalf("line should contain model, reasoning level, and stats, got %q", plain)
154	}
155	if modelIdx >= reasoningIdx || reasoningIdx >= statsIdx {
156		t.Fatalf("reasoning level should sit between model and stats, got %q", plain)
157	}
158}
159
160func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {
161	th := Dark
162	th.ThinkingMax = Color256(201)
163	lines := StatusBar(StatusBarParams{
164		Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200,
165	})
166	if len(lines) != 1 {
167		t.Fatalf("lines = %d, want 1", len(lines))
168	}
169	if !strings.Contains(lines[0], "\x1b[38;5;201m") || !strings.Contains(stripANSI(lines[0]), "reasoning: max") {
170		t.Fatalf("max reasoning style missing: %q", lines[0])
171	}
172}
173
174func TestStatusBarNarrowKeepsModelAndReasoningTogetherWhenTheyFit(t *testing.T) {
175	lines := StatusBar(StatusBarParams{
176		Theme:     Dark,
177		Provider:  "openai-codex",
178		Model:     "gpt-5.5",
179		Reasoning: "xhigh",
180		CWD:       "/tmp/x",
181		Usage: provider.Usage{
182			CostUSD: 0,
183		},
184		Subscription: true,
185		ContextUsed:  100,
186		ContextMax:   1_000_000,
187		Cols:         64,
188	})
189	if len(lines) != 3 {
190		t.Fatalf("narrow status with model+thinking fit: want 3 lines, got %d: %q", len(lines), lines)
191	}
192	plain := make([]string, len(lines))
193	for i, line := range lines {
194		plain[i] = stripANSI(line)
195	}
196	if !strings.Contains(plain[0], "(openai-codex) gpt-5.5  reasoning: xhigh") {
197		t.Fatalf("line 1 should contain model and reasoning level, got %q", plain[0])
198	}
199	if !strings.Contains(plain[1], "$0.000 (sub)") || strings.Contains(plain[1], "reasoning level") {
200		t.Fatalf("line 2 should contain only stats, got %q", plain[1])
201	}
202	if !strings.Contains(plain[2], "/tmp/x") {
203		t.Fatalf("line 3 should contain cwd, got %q", plain[2])
204	}
205}
206
207func TestStatusBarNarrowSplitsAfterReasoningLevel(t *testing.T) {
208	lines := StatusBar(StatusBarParams{
209		Theme:     Dark,
210		Provider:  "openai-codex",
211		Model:     "gpt-5.5",
212		Reasoning: "minimum",
213		CWD:       "/tmp/x",
214		Usage: provider.Usage{
215			InputTokens:  4_300_000,
216			OutputTokens: 2,
217		},
218		Cols: 40,
219	})
220	if len(lines) != 4 {
221		t.Fatalf("narrow status with reasoning: want 4 lines, got %d: %q", len(lines), lines)
222	}
223	plain := make([]string, len(lines))
224	for i, line := range lines {
225		plain[i] = stripANSI(line)
226	}
227	if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
228		t.Fatalf("line 1 should contain model info, got %q", plain[0])
229	}
230	if !strings.Contains(plain[1], "reasoning: minimal") || strings.Contains(plain[1], "↑4.3M") {
231		t.Fatalf("line 2 should contain only reasoning level, got %q", plain[1])
232	}
233	if !strings.Contains(plain[2], "↑4.3M ↓2") {
234		t.Fatalf("line 3 should contain stats, got %q", plain[2])
235	}
236	if !strings.Contains(plain[3], "/tmp/x") {
237		t.Fatalf("line 4 should contain cwd, got %q", plain[3])
238	}
239}
240
241func TestStatusBarVeryNarrowSplitsAfterReasoningLevel(t *testing.T) {
242	lines := StatusBar(StatusBarParams{
243		Theme:     Dark,
244		Provider:  "openai-codex",
245		Model:     "gpt-5.5",
246		Reasoning: "minimum",
247		CWD:       "/tmp/x",
248		Usage: provider.Usage{
249			InputTokens:  4_300_000,
250			OutputTokens: 2,
251		},
252		Cols: 32,
253	})
254	if len(lines) != 4 {
255		t.Fatalf("narrow status with reasoning: want 4 lines, got %d: %q", len(lines), lines)
256	}
257	plain := make([]string, len(lines))
258	for i, line := range lines {
259		plain[i] = stripANSI(line)
260	}
261	if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
262		t.Fatalf("line 1 should contain model info, got %q", plain[0])
263	}
264	if !strings.Contains(plain[1], "reasoning: minimal") {
265		t.Fatalf("line 2 should contain reasoning level, got %q", plain[1])
266	}
267	if !strings.Contains(plain[2], "↑4.3M ↓2") {
268		t.Fatalf("line 3 should contain stats, got %q", plain[2])
269	}
270	if !strings.Contains(plain[3], "/tmp/x") {
271		t.Fatalf("line 4 should contain cwd, got %q", plain[3])
272	}
273}
274
275func TestStatusBarNoYoloTagPrecedesCWD(t *testing.T) {
276	lines := StatusBar(StatusBarParams{
277		Theme:    Dark,
278		Provider: "openai",
279		Model:    "gpt-5.5",
280		CWD:      "/tmp/x",
281		NoYolo:   true,
282		Cols:     200,
283	})
284	if len(lines) != 2 {
285		t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
286	}
287	plain := stripANSI(lines[1])
288	if !strings.Contains(plain, "yolo mode disabled - /tmp/x") {
289		t.Fatalf("cwd line should include no-yolo tag before cwd, got %q", plain)
290	}
291}

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