返回 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 := newOwnedTestController(t, control.Options{Label: "old-model"})
83 newCtrl := newOwnedTestController(t, 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, "Workspace") || !strings.Contains(plain, "ready") {
127 t.Fatalf("idle status line missing mode status:\n%s", plain)
128 }
129 if !strings.Contains(plain, "Shift+Tab read-only/workspace/YOLO/plan") || !strings.Contains(plain, "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, "[Workspace]") {
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("Workspace 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, "Shift+Tab read-only/workspace/YOLO/plan") || !strings.Contains(plain, "Ctrl+Y YOLO") {
153 t.Fatalf("YOLO status line missing mode or shortcut text:\n%s", plain)
154 }
155 if strings.Contains(plain, "full access") {
156 t.Fatalf("YOLO status line should display only the YOLO mode label:\n%s", plain)
157 }
158 if strings.Contains(plain, "[YOLO]") {
159 t.Fatalf("YOLO status line should use a pill label, not bracketed tag:\n%s", plain)
160 }
161 if !strings.Contains(content, "\x1b[48;2;229;72;77m") {
162 t.Fatalf("full-access status line should use danger pill background, got:\n%q", content)
163 }
164 }
165
166 func TestPlanStatuslineUsesBluePill(t *testing.T) {
167 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
168 activeColorProfile = colorprofile.TrueColor
169 i18n.DetectLanguage("en")
170
171 content := renderPlanStatuslineView(t)
172 plain := bottomStatusPlain(content)
173 if !strings.Contains(plain, "Plan") || !strings.Contains(plain, "ready") || !strings.Contains(plain, "Shift+Tab read-only/workspace/YOLO/plan") || !strings.Contains(plain, "Ctrl+Y YOLO") {
174 t.Fatalf("plan status line missing mode status:\n%s", plain)
175 }
176 if !strings.Contains(content, "\x1b[48;2;37;99;235m") {
177 t.Fatalf("Plan status line should use blue pill background, got:\n%q", content)
178 }
179 }
180
181 func TestStatuslineCycleHintFollowsLanguage(t *testing.T) {
182 i18n.DetectLanguage("zh")
183 t.Cleanup(func() { i18n.DetectLanguage("en") })
184
185 content := renderStatuslineView(t, false)
186 plain := bottomStatusPlain(content)
187 if !strings.Contains(plain, "Workspace") || !strings.Contains(plain, "就绪") || !strings.Contains(plain, "Shift+Tab 仅可查看/工作区内修改/YOLO/计划") || !strings.Contains(plain, "Ctrl+Y YOLO") {
188 t.Fatalf("localized plan-toggle hint missing:\n%s", plain)
189 }
190 if strings.Contains(plain, "ready") || strings.Contains(plain, "Shift+Tab read-only/workspace/YOLO/plan") {
191 t.Fatalf("localized status line should not fall back to English:\n%s", plain)
192 }
193 }
194
195 func TestDesktopShortcutStatuslineUsesPlanToggleHint(t *testing.T) {
196 i18n.DetectLanguage("en")
197
198 content := renderStatuslineViewWithShortcutLayout(t, "desktop")
199 plain := bottomStatusPlain(content)
200 if !strings.Contains(plain, "Read only") || !strings.Contains(plain, "Shift+Tab read-only/workspace/YOLO/plan") || !strings.Contains(plain, "Ctrl+Y YOLO") {
201 t.Fatalf("desktop shortcut status line missing unified plan-toggle hint:\n%s", plain)
202 }
203 }
204
205 func TestStatuslineShowsEffortInPersistentFooter(t *testing.T) {
206 i18n.DetectLanguage("en")
207
208 content := renderStatuslineViewWithEffort(t, "auto")
209 lines := strings.Split(ansi.Strip(content), "\n")
210 statusLine := lines[len(lines)-1]
211 if !strings.Contains(statusLine, "MODEL deepseek-v4-flash EFFORT auto") {
212 t.Fatalf("session row should keep effort beside the model:\n%s", statusLine)
213 }
214 }
215
216 func TestStatuslineShowsCacheRatesInPersistentFooter(t *testing.T) {
217 i18n.DetectLanguage("en")
218
219 content := renderStatuslineViewWithCache(t)
220 lines := bottomStatusPlainLines(content)
221 if len(lines) != 3 {
222 t.Fatalf("status block lines = %d, want 3:\n%s", len(lines), strings.Join(lines, "\n"))
223 }
224 if !strings.Contains(lines[0], "MODEL deepseek-v4-flash") {
225 t.Fatalf("mode row should show model:\n%s", strings.Join(lines, "\n"))
226 }
227 if !strings.Contains(lines[2], "CACHE turn hit 90.00% · avg 90.00%") {
228 t.Fatalf("telemetry row should show cache rates:\n%s", strings.Join(lines, "\n"))
229 }
230 }
231
232 func TestStatuslineShowsGitAndEffortInPersistentFooter(t *testing.T) {
233 i18n.DetectLanguage("en")
234
235 content := renderStatuslineViewWithGitAndEffort(t)
236 lines := bottomStatusPlainLines(content)
237 if len(lines) != 3 {
238 t.Fatalf("status block lines = %d, want 3:\n%s", len(lines), strings.Join(lines, "\n"))
239 }
240 if !strings.Contains(lines[0], "MODEL deepseek-v4-flash EFFORT auto") {
241 t.Fatalf("session row should keep effort beside the model:\n%s", strings.Join(lines, "\n"))
242 }
243 if !strings.Contains(lines[2], "Reasonix@codex/demo +3 -1 ?2") {
244 t.Fatalf("telemetry row should start with git identity:\n%s", strings.Join(lines, "\n"))
245 }
246 }
247
248 func TestStatuslineShowsModelAndBalanceInPersistentFooter(t *testing.T) {
249 i18n.DetectLanguage("en")
250
251 ctrl := newOwnedTestController(t, control.Options{})
252 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 120)
253 m.label = "deepseek-v4-flash"
254 m.balance = "¥12.34"
255 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 24})
256 lines := bottomStatusPlainLines(next.(chatTUI).View().Content)
257 if len(lines) != 3 {
258 t.Fatalf("status block lines = %d, want 3:\n%s", len(lines), strings.Join(lines, "\n"))
259 }
260 if !strings.Contains(lines[0], "MODEL deepseek-v4-flash") {
261 t.Fatalf("session row should show model:\n%s", strings.Join(lines, "\n"))
262 }
263 if strings.Contains(strings.Join(lines, "\n"), "WORK") {
264 t.Fatalf("status line should not show execution-mode labels:\n%s", strings.Join(lines, "\n"))
265 }
266 if !strings.Contains(lines[2], "BAL ¥12.34") {
267 t.Fatalf("telemetry row should show balance:\n%s", strings.Join(lines, "\n"))
268 }
269 }
270
271 func TestEffortTagExplicitValueUsesThemeInfo(t *testing.T) {
272 i18n.DetectLanguage("en")
273 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
274 activeColorProfile = colorprofile.ANSI256
275
276 for _, tt := range []struct {
277 mode, infoSGR string
278 }{
279 {mode: "dark", infoSGR: "\033[1;38;5;80m"},
280 {mode: "light", infoSGR: "\033[1;38;5;25m"},
281 } {
282 t.Run(tt.mode, func(t *testing.T) {
283 configureCLITheme(tt.mode)
284 m := newTestChatTUI()
285 m.effortLevel = "max"
286 content := m.effortTag()
287 if !strings.Contains(ansi.Strip(content), "EFFORT max") {
288 t.Fatalf("status data line should show explicit effort:\n%s", ansi.Strip(content))
289 }
290 if !strings.Contains(content, tt.infoSGR+"max") {
291 t.Fatalf("%s explicit effort should use theme info colour, got:\n%q", tt.mode, content)
292 }
293 })
294 }
295 }
296
297 func TestRefreshEffortStatusUsesCurrentModel(t *testing.T) {
298 isolateUserConfig(t)
299
300 ctrl := newOwnedTestController(t, control.Options{})
301 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
302 m.modelRef = "deepseek-flash/deepseek-v4-flash"
303 m.refreshEffortStatus()
304 if m.effortLevel != "auto" {
305 t.Fatalf("effortLevel = %q, want auto", m.effortLevel)
306 }
307 }
308
309 func renderStatuslineView(t *testing.T, yolo bool) string {
310 t.Helper()
311
312 ctrl := newOwnedTestController(t, control.Options{})
313 if yolo {
314 ctrl.SetToolApprovalMode(control.ToolApprovalDangerFullAccess)
315 } else {
316 ctrl.SetToolApprovalMode(control.ToolApprovalWorkspaceWrite)
317 }
318 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
319 next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
320 return next.(chatTUI).View().Content
321 }
322
323 func renderStatuslineViewWithShortcutLayout(t *testing.T, layout string) string {
324 t.Helper()
325
326 ctrl := newOwnedTestController(t, control.Options{})
327 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
328 m.cfg = config.Default()
329 if err := m.cfg.SetUIShortcutLayout(layout); err != nil {
330 t.Fatal(err)
331 }
332 next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
333 return next.(chatTUI).View().Content
334 }
335
336 func renderStatuslineViewWithEffort(t *testing.T, effort string) string {
337 t.Helper()
338
339 ctrl := newOwnedTestController(t, control.Options{})
340 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 120)
341 m.label = "deepseek-v4-flash"
342 m.effortLevel = effort
343 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 24})
344 return next.(chatTUI).View().Content
345 }
346
347 func renderStatuslineViewWithGitAndEffort(t *testing.T) string {
348 t.Helper()
349
350 ctrl := newOwnedTestController(t, control.Options{})
351 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 120)
352 m.label = "deepseek-v4-flash"
353 m.effortLevel = "auto"
354 m.gitStatus = gitStatus{
355 Repo: "Reasonix",
356 Branch: "codex/demo",
357 Added: 3,
358 Removed: 1,
359 Untracked: 2,
360 }
361 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 24})
362 return next.(chatTUI).View().Content
363 }
364
365 func renderStatuslineViewWithCache(t *testing.T) string {
366 t.Helper()
367
368 prov := testutil.NewMock("deepseek-v4-flash", testutil.Turn{
369 Text: "ok",
370 Usage: &provider.Usage{
371 CacheHitTokens: 900,
372 CacheMissTokens: 100,
373 CompletionTokens: 50,
374 PromptTokens: 1000,
375 TotalTokens: 1050,
376 },
377 })
378 exec := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{MaxSteps: 1, ContextWindow: 200_000}, event.Discard)
379 if err := exec.Run(context.Background(), "hello"); err != nil {
380 t.Fatalf("seed agent usage: %v", err)
381 }
382 ctrl := newOwnedTestController(t, control.Options{Executor: exec})
383 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 160)
384 m.label = "deepseek-v4-flash"
385 m.effortLevel = "auto"
386 next, _ := m.Update(tea.WindowSizeMsg{Width: 160, Height: 24})
387 return next.(chatTUI).View().Content
388 }
389
390 func renderPlanStatuslineView(t *testing.T) string {
391 t.Helper()
392
393 ctrl := newOwnedTestController(t, control.Options{})
394 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
395 m.planMode = true
396 next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
397 return next.(chatTUI).View().Content
398 }
399
400 func bottomStatusPlain(content string) string {
401 return strings.Join(bottomStatusPlainLines(content), "\n")
402 }
403
404 func bottomStatusPlainLines(content string) []string {
405 lines := strings.Split(ansi.Strip(content), "\n")
406 if len(lines) < 3 {
407 return lines
408 }
409 return lines[len(lines)-3:]
410 }
411
411 lines GO