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