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