返回 DeepSeek-Reasonix
statusline_test.go
根目录 / internal / cli / statusline_test.go
1 package cli
2
3 import (
4 "context"
5 "os/exec"
6 "runtime"
7 "strings"
8 "testing"
9
10 "github.com/charmbracelet/colorprofile"
11 "time"
12
13 tea "charm.land/bubbletea/v2"
14 "github.com/charmbracelet/x/ansi"
15
16 "reasonix/internal/agent"
17 "reasonix/internal/agent/testutil"
18 "reasonix/internal/config"
19 "reasonix/internal/control"
20 "reasonix/internal/event"
21 "reasonix/internal/i18n"
22 "reasonix/internal/provider"
23 "reasonix/internal/tool"
24 )
25
26 // TestRunStatuslineCmd checks the custom status-line runner: it returns the
27 // first stdout line and forwards the JSON payload on stdin.
28 func TestRunStatuslineCmd(t *testing.T) {
29 firstLineCmd := "printf 'row-one\\nrow-two\\n'"
30 stdinCmd := "cat"
31 failCmd := "exit 3"
32 if runtime.GOOS == "windows" {
33 firstLineCmd = "echo row-one & echo row-two"
34 stdinCmd = "more"
35 failCmd = "exit /b 3"
36 }
37
38 // Multi-line output collapses to the first row.
39 if got := runStatuslineCmd(firstLineCmd, "{}"); got != "row-one" {
40 t.Errorf("multi-line output should collapse to the first row, got %q", got)
41 }
42 // The JSON payload is delivered on stdin.
43 if got := runStatuslineCmd(stdinCmd, `{"model":"deepseek"}`); got != `{"model":"deepseek"}` {
44 t.Errorf("stdin payload not forwarded, got %q", got)
45 }
46 // A failing command yields an empty line, not an error.
47 if got := runStatuslineCmd(failCmd, "{}"); got != "" {
48 t.Errorf("failed command should yield empty, got %q", got)
49 }
50 }
51
52 func TestRunStatuslineCmdNormalizesQuotedNodeEval(t *testing.T) {
53 if _, err := exec.LookPath("node"); err != nil {
54 t.Skip("node not available")
55 }
56 script := "let input = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { const payload = JSON.parse(input); console.log(payload.model) })"
57 cmd := `node -e "\"` + script + `\""`
58 timeout := statuslineCommandTimeout
59 if runtime.GOOS == "windows" {
60 // Windows CI cold-starts node.exe through Defender scanning while the
61 // rest of the module compiles and tests in parallel; a fresh toolchain
62 // (empty setup-go cache) pushes that past 10s. The production timeout
63 // is not under test here — only the quoted-eval normalization is.
64 timeout = 30 * time.Second
65 }
66
67 if got := runStatuslineCmdWithTimeout(cmd, `{"model":"deepseek"}`, timeout); got != "deepseek" {
68 t.Fatalf("normalized statusline node -e output = %q, want deepseek", got)
69 }
70 }
71
72 // TestRunStatuslineDisabled confirms no command means no work (nil cmd), without
73 // touching the controller.
74 func TestRunStatuslineDisabled(t *testing.T) {
75 m := chatTUI{} // no statuslineCmd, nil ctrl
76 if cmd := m.runStatusline(); cmd != nil {
77 t.Error("an unconfigured status line must return a nil tea.Cmd")
78 }
79 }
80
81 func TestModelSwitchRefreshesCustomStatusline(t *testing.T) {
82 oldCtrl := control.New(control.Options{Label: "old-model"})
83 newCtrl := control.New(control.Options{Label: "new-model"})
84 m := newChatTUI(oldCtrl, "", make(chan event.Event, 1), 80)
85 m.statuslineCmd = "cat"
86 m.statuslineOut = `{"model":"old-model"}`
87
88 _, cmd := m.Update(modelSwitchMsg{
89 ref: "provider/new-model",
90 ctrl: newCtrl,
91 label: "new-model",
92 })
93 if cmd == nil {
94 t.Fatal("model switch should schedule commands")
95 }
96 if !statuslineCommandHasModel(cmd, "new-model") {
97 t.Fatal("model switch did not refresh custom statusline with the new model")
98 }
99 }
100
101 func statuslineCommandHasModel(cmd tea.Cmd, model string) bool {
102 msg := cmd()
103 switch msg := msg.(type) {
104 case statuslineMsg:
105 return strings.Contains(msg.out, `"model":"`+model+`"`)
106 case tea.BatchMsg:
107 for _, child := range msg {
108 if child == nil {
109 continue
110 }
111 if statuslineCommandHasModel(child, model) {
112 return true
113 }
114 }
115 }
116 return false
117 }
118
119 func TestIdleStatuslineIsCompact(t *testing.T) {
120 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
121 activeColorProfile = colorprofile.TrueColor
122 i18n.DetectLanguage("en")
123
124 content := renderStatuslineView(t, false)
125 plain := bottomStatusPlain(content)
126 if !strings.Contains(plain, "Auto") || !strings.Contains(plain, "ready") {
127 t.Fatalf("idle status line missing mode status:\n%s", plain)
128 }
129 if !strings.Contains(plain, "Shift+Tab ask/auto/plan · Ctrl+Y YOLO") {
130 t.Fatalf("idle status line missing plan-toggle hint:\n%s", plain)
131 }
132 for _, old := range []string{"Shift-Tab", "Ctrl-O", "Ctrl-D", "Enter sends", "Esc clears/exits state", "PgUp/PgDn"} {
133 if strings.Contains(plain, old) {
134 t.Fatalf("idle status line should not contain %q:\n%s", old, plain)
135 }
136 }
137 if strings.Contains(plain, "[auto]") {
138 t.Fatalf("idle status line should use pill label, not bracketed tag:\n%s", plain)
139 }
140 if !strings.Contains(content, "\x1b[48;2;245;158;11m") {
141 t.Fatalf("Auto status line should use amber pill background, got:\n%q", content)
142 }
143 }
144
145 func TestYoloStatuslineUsesDangerPill(t *testing.T) {
146 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
147 activeColorProfile = colorprofile.TrueColor
148 i18n.DetectLanguage("en")
149
150 content := renderStatuslineView(t, true)
151 plain := bottomStatusPlain(content)
152 if !strings.Contains(plain, "YOLO") || !strings.Contains(plain, "approvals skipped") || !strings.Contains(plain, "Shift+Tab ask/auto/plan · Ctrl+Y YOLO") {
153 t.Fatalf("YOLO status line missing warning text:\n%s", plain)
154 }
155 if strings.Contains(plain, "[YOLO]") {
156 t.Fatalf("YOLO status line should use a pill label, not bracketed tag:\n%s", plain)
157 }
158 if !strings.Contains(content, "\x1b[48;2;229;72;77m") {
159 t.Fatalf("YOLO status line should use danger pill background, got:\n%q", content)
160 }
161 }
162
163 func TestPlanStatuslineUsesBluePill(t *testing.T) {
164 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
165 activeColorProfile = colorprofile.TrueColor
166 i18n.DetectLanguage("en")
167
168 content := renderPlanStatuslineView(t)
169 plain := bottomStatusPlain(content)
170 if !strings.Contains(plain, "Plan") || !strings.Contains(plain, "ready") || !strings.Contains(plain, "Shift+Tab ask/auto/plan · Ctrl+Y YOLO") {
171 t.Fatalf("plan status line missing mode status:\n%s", plain)
172 }
173 if !strings.Contains(content, "\x1b[48;2;37;99;235m") {
174 t.Fatalf("Plan status line should use blue pill background, got:\n%q", content)
175 }
176 }
177
178 func TestStatuslineCycleHintFollowsLanguage(t *testing.T) {
179 i18n.DetectLanguage("zh")
180 t.Cleanup(func() { i18n.DetectLanguage("en") })
181
182 content := renderStatuslineView(t, false)
183 plain := bottomStatusPlain(content)
184 if !strings.Contains(plain, "Auto") || !strings.Contains(plain, "就绪") || !strings.Contains(plain, "Shift+Tab 询问/自动/计划 · Ctrl+Y YOLO") {
185 t.Fatalf("localized plan-toggle hint missing:\n%s", plain)
186 }
187 if strings.Contains(plain, "ready") || strings.Contains(plain, "Shift+Tab ask/auto/plan · Ctrl+Y YOLO") {
188 t.Fatalf("localized status line should not fall back to English:\n%s", plain)
189 }
190 }
191
192 func TestDesktopShortcutStatuslineUsesPlanToggleHint(t *testing.T) {
193 i18n.DetectLanguage("en")
194
195 content := renderStatuslineViewWithShortcutLayout(t, "desktop")
196 plain := bottomStatusPlain(content)
197 if !strings.Contains(plain, "Ask") || !strings.Contains(plain, "Shift+Tab ask/auto/plan · Ctrl+Y YOLO") {
198 t.Fatalf("desktop shortcut status line missing unified plan-toggle hint:\n%s", plain)
199 }
200 }
201
202 func TestStatuslineShowsEffortInPersistentFooter(t *testing.T) {
203 i18n.DetectLanguage("en")
204
205 content := renderStatuslineViewWithEffort(t, "auto")
206 lines := strings.Split(ansi.Strip(content), "\n")
207 statusLine := lines[len(lines)-1]
208 if !strings.Contains(statusLine, "MODEL deepseek-v4-flash EFFORT auto") {
209 t.Fatalf("session row should keep effort beside the model:\n%s", statusLine)
210 }
211 }
212
213 func TestStatuslineShowsCacheRatesInPersistentFooter(t *testing.T) {
214 i18n.DetectLanguage("en")
215
216 content := renderStatuslineViewWithCache(t)
217 lines := bottomStatusPlainLines(content)
218 if len(lines) != 3 {
219 t.Fatalf("status block lines = %d, want 3:\n%s", len(lines), strings.Join(lines, "\n"))
220 }
221 if !strings.Contains(lines[0], "MODEL deepseek-v4-flash") {
222 t.Fatalf("mode row should show model:\n%s", strings.Join(lines, "\n"))
223 }
224 if !strings.Contains(lines[2], "CACHE turn hit 90.00% · avg 90.00%") {
225 t.Fatalf("telemetry row should show cache rates:\n%s", strings.Join(lines, "\n"))
226 }
227 }
228
229 func TestStatuslineShowsGitAndEffortInPersistentFooter(t *testing.T) {
230 i18n.DetectLanguage("en")
231
232 content := renderStatuslineViewWithGitAndEffort(t)
233 lines := bottomStatusPlainLines(content)
234 if len(lines) != 3 {
235 t.Fatalf("status block lines = %d, want 3:\n%s", len(lines), strings.Join(lines, "\n"))
236 }
237 if !strings.Contains(lines[0], "MODEL deepseek-v4-flash EFFORT auto") {
238 t.Fatalf("session row should keep effort beside the model:\n%s", strings.Join(lines, "\n"))
239 }
240 if !strings.Contains(lines[2], "Reasonix@codex/demo +3 -1 ?2") {
241 t.Fatalf("telemetry row should start with git identity:\n%s", strings.Join(lines, "\n"))
242 }
243 }
244
245 func TestStatuslineShowsWorkModeAndBalanceInPersistentFooter(t *testing.T) {
246 i18n.DetectLanguage("en")
247
248 ctrl := control.New(control.Options{})
249 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 120)
250 m.label = "deepseek-v4-flash"
251 m.runtimeProfile = "delivery"
252 m.balance = "¥12.34"
253 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 24})
254 lines := bottomStatusPlainLines(next.(chatTUI).View().Content)
255 if len(lines) != 3 {
256 t.Fatalf("status block lines = %d, want 3:\n%s", len(lines), strings.Join(lines, "\n"))
257 }
258 if !strings.Contains(lines[0], "MODEL deepseek-v4-flash WORK delivery") {
259 t.Fatalf("mode row should show model and work mode:\n%s", strings.Join(lines, "\n"))
260 }
261 if !strings.Contains(lines[2], "BAL ¥12.34") {
262 t.Fatalf("telemetry row should show balance:\n%s", strings.Join(lines, "\n"))
263 }
264 }
265
266 func TestEffortTagExplicitValueUsesThemeInfo(t *testing.T) {
267 i18n.DetectLanguage("en")
268 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
269 activeColorProfile = colorprofile.ANSI256
270
271 for _, tt := range []struct {
272 mode, infoSGR string
273 }{
274 {mode: "dark", infoSGR: "\033[1;38;5;80m"},
275 {mode: "light", infoSGR: "\033[1;38;5;25m"},
276 } {
277 t.Run(tt.mode, func(t *testing.T) {
278 configureCLITheme(tt.mode)
279 m := newTestChatTUI()
280 m.effortLevel = "max"
281 content := m.effortTag()
282 if !strings.Contains(ansi.Strip(content), "EFFORT max") {
283 t.Fatalf("status data line should show explicit effort:\n%s", ansi.Strip(content))
284 }
285 if !strings.Contains(content, tt.infoSGR+"max") {
286 t.Fatalf("%s explicit effort should use theme info colour, got:\n%q", tt.mode, content)
287 }
288 })
289 }
290 }
291
292 func TestRefreshEffortStatusUsesCurrentModel(t *testing.T) {
293 isolateUserConfig(t)
294
295 ctrl := control.New(control.Options{})
296 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
297 m.modelRef = "deepseek-flash/deepseek-v4-flash"
298 m.refreshEffortStatus()
299 if m.effortLevel != "auto" {
300 t.Fatalf("effortLevel = %q, want auto", m.effortLevel)
301 }
302 }
303
304 func renderStatuslineView(t *testing.T, yolo bool) string {
305 t.Helper()
306
307 ctrl := control.New(control.Options{})
308 ctrl.SetAutoApproveTools(yolo)
309 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
310 next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
311 return next.(chatTUI).View().Content
312 }
313
314 func renderStatuslineViewWithShortcutLayout(t *testing.T, layout string) string {
315 t.Helper()
316
317 ctrl := control.New(control.Options{})
318 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
319 m.cfg = config.Default()
320 if err := m.cfg.SetUIShortcutLayout(layout); err != nil {
321 t.Fatal(err)
322 }
323 next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
324 return next.(chatTUI).View().Content
325 }
326
327 func renderStatuslineViewWithEffort(t *testing.T, effort string) string {
328 t.Helper()
329
330 ctrl := control.New(control.Options{})
331 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 120)
332 m.label = "deepseek-v4-flash"
333 m.effortLevel = effort
334 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 24})
335 return next.(chatTUI).View().Content
336 }
337
338 func renderStatuslineViewWithGitAndEffort(t *testing.T) string {
339 t.Helper()
340
341 ctrl := control.New(control.Options{})
342 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 120)
343 m.label = "deepseek-v4-flash"
344 m.effortLevel = "auto"
345 m.gitStatus = gitStatus{
346 Repo: "Reasonix",
347 Branch: "codex/demo",
348 Added: 3,
349 Removed: 1,
350 Untracked: 2,
351 }
352 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 24})
353 return next.(chatTUI).View().Content
354 }
355
356 func renderStatuslineViewWithCache(t *testing.T) string {
357 t.Helper()
358
359 prov := testutil.NewMock("deepseek-v4-flash", testutil.Turn{
360 Text: "ok",
361 Usage: &provider.Usage{
362 CacheHitTokens: 900,
363 CacheMissTokens: 100,
364 CompletionTokens: 50,
365 PromptTokens: 1000,
366 TotalTokens: 1050,
367 },
368 })
369 exec := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{MaxSteps: 1, ContextWindow: 200_000}, event.Discard)
370 if err := exec.Run(context.Background(), "hello"); err != nil {
371 t.Fatalf("seed agent usage: %v", err)
372 }
373 ctrl := control.New(control.Options{Executor: exec})
374 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 160)
375 m.label = "deepseek-v4-flash"
376 m.effortLevel = "auto"
377 next, _ := m.Update(tea.WindowSizeMsg{Width: 160, Height: 24})
378 return next.(chatTUI).View().Content
379 }
380
381 func renderPlanStatuslineView(t *testing.T) string {
382 t.Helper()
383
384 ctrl := control.New(control.Options{})
385 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
386 m.planMode = true
387 next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
388 return next.(chatTUI).View().Content
389 }
390
391 func bottomStatusPlain(content string) string {
392 return strings.Join(bottomStatusPlainLines(content), "\n")
393 }
394
395 func bottomStatusPlainLines(content string) []string {
396 lines := strings.Split(ansi.Strip(content), "\n")
397 if len(lines) < 3 {
398 return lines
399 }
400 return lines[len(lines)-3:]
401 }
402
402 lines GO