返回 DeepSeek-Reasonix
chat_tui_test.go
根目录 / internal / cli / chat_tui_test.go
1 package cli
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "reflect"
11 "slices"
12 "strings"
13 "testing"
14
15 "github.com/charmbracelet/colorprofile"
16 "time"
17
18 tea "charm.land/bubbletea/v2"
19 "github.com/charmbracelet/x/ansi"
20
21 "reasonix/internal/agent"
22 "reasonix/internal/checkpoint"
23 "reasonix/internal/command"
24 "reasonix/internal/config"
25 "reasonix/internal/control"
26 "reasonix/internal/event"
27 "reasonix/internal/i18n"
28 "reasonix/internal/provider"
29 "reasonix/internal/secrets"
30 "reasonix/internal/skill"
31 "reasonix/internal/testenv"
32 )
33
34 type blockingTurnRunner struct{ started chan struct{} }
35
36 type stubbornTurnRunner struct {
37 started chan struct{}
38 release chan struct{}
39 }
40
41 const tinyPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
42
43 const (
44 middleClickPasteHelperFlag = "GO_WANT_REASONIX_MIDDLE_CLICK_PASTE_HELPER"
45 middleClickPasteHelperMode = "REASONIX_MIDDLE_CLICK_PASTE_HELPER_MODE"
46 middleClickPasteTestValue = "REASONIX_MIDDLE_CLICK_TEST_VALUE"
47 )
48
49 func TestMiddleClickPasteCommandHelper(t *testing.T) {
50 if os.Getenv(middleClickPasteHelperFlag) != "1" {
51 return
52 }
53 switch os.Getenv(middleClickPasteHelperMode) {
54 case "credential":
55 if value := os.Getenv(middleClickPasteTestValue); value != "" {
56 _, _ = fmt.Fprint(os.Stdout, value)
57 } else {
58 _, _ = fmt.Fprint(os.Stdout, "filtered")
59 }
60 case "newlines":
61 _, _ = fmt.Fprint(os.Stdout, "line\n\n")
62 default:
63 os.Exit(2)
64 }
65 os.Exit(0)
66 }
67
68 func TestMain(m *testing.M) {
69 old := detectTermuxTerminal
70 detectTermuxTerminal = func() bool { return false }
71 cleanupUserState, err := testenv.IsolateUserState()
72 if err != nil {
73 panic(err)
74 }
75
76 // Pin the UI language for the whole cli test binary. Production code
77 // (cli.Run) calls i18n.DetectLanguage("") which resolves the host locale from
78 // the environment (REASONIX_LANG/LC_ALL/LC_MESSAGES/LANG) and installs it as
79 // the global i18n.M. On a non-English dev machine that flips M to e.g.
80 // Chinese, and tests that exercise the CLI entry point (acp_test.go,
81 // cli_test.go) don't restore it — so later tests asserting English UI strings
82 // fail, but only when the whole package runs, not in isolation. Forcing a
83 // deterministic English environment keeps the suite independent of the host
84 // locale (matching CI). Tests that need another language still set it
85 // explicitly via i18n.DetectLanguage(lang) with their own cleanup.
86 os.Unsetenv("REASONIX_LANG")
87 os.Unsetenv("LC_ALL")
88 os.Unsetenv("LC_MESSAGES")
89 os.Setenv("LANG", "en_US.UTF-8")
90 i18n.DetectLanguage("en")
91
92 code := m.Run()
93 detectTermuxTerminal = old
94 cleanupUserState()
95 os.Exit(code)
96 }
97
98 func (r *blockingTurnRunner) Run(ctx context.Context, _ string) error {
99 close(r.started)
100 <-ctx.Done()
101 return ctx.Err()
102 }
103
104 func (r *stubbornTurnRunner) Run(ctx context.Context, _ string) error {
105 close(r.started)
106 <-r.release
107 return ctx.Err()
108 }
109
110 type recordingTurnRunner struct {
111 inputs []string
112 }
113
114 func (r *recordingTurnRunner) Run(ctx context.Context, input string) error {
115 r.inputs = append(r.inputs, input)
116 return nil
117 }
118
119 func waitForCLIEvent(t *testing.T, ch <-chan event.Event, kind event.Kind) {
120 t.Helper()
121 deadline := time.After(2 * time.Second)
122 for {
123 select {
124 case e := <-ch:
125 if e.Kind == kind {
126 return
127 }
128 case <-deadline:
129 t.Fatalf("timed out waiting for event %v", kind)
130 }
131 }
132 }
133
134 func writeTUIImageCapabilityConfig(t *testing.T, root string) {
135 t.Helper()
136 cfg := config.Default()
137 cfg.DefaultModel = "custom/text-only"
138 cfg.Providers = []config.ProviderEntry{{
139 Name: "custom",
140 Kind: "openai",
141 BaseURL: "https://example.invalid/v1",
142 Models: []string{"text-only", "vision-pro"},
143 VisionModels: []string{"vision-pro"},
144 }}
145 if err := cfg.SaveTo(filepath.Join(root, "reasonix.toml")); err != nil {
146 t.Fatalf("save config: %v", err)
147 }
148 approveWorkspace(t, root)
149 }
150
151 func saveTestImageAttachment(t *testing.T, root string) string {
152 t.Helper()
153 t.Chdir(root)
154 path, err := control.SaveImageDataURL("data:image/png;base64," + tinyPNGBase64)
155 if err != nil {
156 t.Fatalf("SaveImageDataURL: %v", err)
157 }
158 return path
159 }
160
161 // TestEscCancelsRunningTurnWithCompletionOpen reproduces the report that Esc
162 // (unlike Ctrl+C) did not stop a running turn: an active completion menu
163 // captured Esc to close itself and returned before reaching the running-turn
164 // cancel branch, while Ctrl+C — not in the completion switch — fell through.
165 func TestEscCancelsRunningTurnWithCompletionOpen(t *testing.T) {
166 r := &blockingTurnRunner{started: make(chan struct{})}
167 ctrl := newOwnedTestController(t, control.Options{Runner: r, Sink: event.Discard, SessionDir: t.TempDir(), Label: "test"})
168 ctrl.Send("hi")
169 <-r.started // the turn is in flight and cancellable
170
171 m := newTestChatTUI()
172 m.ctrl = ctrl
173 m.state = tuiRunning
174 m.completion.active = true // e.g. a "/" typed into the composer while waiting
175
176 _, _ = m.update(tea.KeyPressMsg{Code: tea.KeyEscape})
177
178 deadline := time.Now().Add(2 * time.Second)
179 for ctrl.Running() {
180 if time.Now().After(deadline) {
181 t.Fatal("Esc did not cancel the running turn (completion menu swallowed it)")
182 }
183 time.Sleep(10 * time.Millisecond)
184 }
185 }
186
187 // TestTranscriptMirrorsCommits proves the alt-screen migration's foundation:
188 // every line commitLine sends to native scrollback is also captured in the
189 // transcript buffer (the future viewport's content source), in order.
190 func TestTranscriptMirrorsCommits(t *testing.T) {
191 m := newTestChatTUI()
192 m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{Name: "read_file", Args: `{"path":"x"}`}})
193 m.ingestEvent(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "compacted"})
194
195 if len(m.transcript) != len(*m.pendingCommit) {
196 t.Fatalf("transcript (%d) and pendingCommit (%d) should hold the same lines", len(m.transcript), len(*m.pendingCommit))
197 }
198 for i := range m.transcript {
199 if m.transcript[i] != (*m.pendingCommit)[i] {
200 t.Errorf("line %d mismatch: transcript=%q pendingCommit=%q", i, m.transcript[i], (*m.pendingCommit)[i])
201 }
202 }
203 }
204
205 func TestTermuxNativeScrollbackCommitsFinalAnswer(t *testing.T) {
206 m := newTestChatTUI()
207 m.nativeScrollback = true
208 m.pending.WriteString("first paragraph\n\nsecond paragraph")
209
210 m.streamAnswer()
211 if len(*m.pendingCommit) != 0 {
212 t.Fatalf("Termux native scrollback should not commit rewritten streaming blocks, got %v", *m.pendingCommit)
213 }
214
215 m.commitPending()
216 if got := strings.Join(*m.pendingCommit, "\n"); !strings.Contains(got, "first paragraph") || !strings.Contains(got, "second paragraph") {
217 t.Fatalf("final answer was not committed to native scrollback: %v", *m.pendingCommit)
218 }
219 }
220
221 func TestTermuxNativeScrollbackDefaultsToExpandedReasoning(t *testing.T) {
222 old := detectTermuxTerminal
223 detectTermuxTerminal = func() bool { return true }
224 t.Cleanup(func() { detectTermuxTerminal = old })
225
226 ctrl := newOwnedTestController(t, control.Options{})
227 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
228 if !m.nativeScrollback {
229 t.Fatal("Termux should use native scrollback")
230 }
231 if !m.showReasoning {
232 t.Fatal("Termux should expand reasoning by default because live viewport reasoning is unavailable")
233 }
234 m.width = 80
235
236 m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "reasoning details"})
237 m.ingestEvent(event.Event{Kind: event.Text, Text: "answer"})
238 got := strings.Join(*m.pendingCommit, "\n")
239 if !strings.Contains(got, "reasoning details") {
240 t.Fatalf("Termux reasoning was not expanded into native scrollback: %q", got)
241 }
242 }
243
244 // TestCompletionMenuFixedWidth verifies that the completion menu pads every
245 // line (items + footer) to m.width so delta rendering always writes exactly the
246 // same column count — no trailing characters for \033[K to leave behind.
247 func TestCompletionMenuFixedWidth(t *testing.T) {
248 ctrl := newOwnedTestController(t, control.Options{})
249 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
250 m.width = 80
251 m.completion.active = true
252 m.completion.items = []compItem{
253 {label: "review"},
254 {label: "clear", hint: "start fresh"},
255 }
256 m.completion.sel = 1
257 m.completion.kind = compSlash
258
259 out := m.renderCompletion()
260 lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
261 // items + footer = 3 lines
262 if len(lines) != 3 {
263 t.Fatalf("completion menu should have 3 lines (2 items + footer), got %d:\n%s", len(lines), out)
264 }
265 for i, line := range lines {
266 if got := ansi.StringWidth(line); got != 80 {
267 t.Errorf("line %d visual width = %d, want 80: %q", i, got, line)
268 }
269 }
270 }
271
272 // TestCompletionMenuPadsWithNonBreakingSpaces verifies the fixed-width padding
273 // is not ordinary ASCII space. Ultraviolet treats trailing ASCII spaces as
274 // clearable cells and may emit EL/ECH erase sequences; mintty can leave stale
275 // halves of CJK glyphs when those sequences clear Chinese skill descriptions.
276 func TestCompletionMenuPadsWithNonBreakingSpaces(t *testing.T) {
277 ctrl := newOwnedTestController(t, control.Options{})
278 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
279 m.width = 80
280 m.completion.active = true
281 m.completion.items = []compItem{
282 {label: "/土壤", hint: "分析土壤墒情"},
283 {label: "/巡田", hint: "识别病虫害"},
284 }
285 m.completion.sel = 0
286 m.completion.kind = compSlash
287
288 out := m.renderCompletion()
289 for i, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
290 if got := ansi.StringWidth(line); got != 80 {
291 t.Fatalf("line %d visual width = %d, want 80: %q", i, got, line)
292 }
293 if !strings.HasSuffix(line, "\u00a0") {
294 t.Fatalf("line %d should end with non-breaking padding, got %q", i, line)
295 }
296 if strings.HasSuffix(line, " ") {
297 t.Fatalf("line %d should not end with clearable ASCII space, got %q", i, line)
298 }
299 }
300 }
301
302 // TestTranscriptViewportSizing proves the viewport tracks the terminal size and
303 // gets the rows left over after the pinned bottom region (input box + the one
304 // available information row = 4 with an empty 1-line composer and no Git or
305 // telemetry), and is fed the committed transcript.
306 func TestTranscriptViewportSizing(t *testing.T) {
307 ctrl := newOwnedTestController(t, control.Options{})
308 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
309
310 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
311 m = m0.(chatTUI)
312
313 if got := m.bottomRows(); got != 4 {
314 t.Fatalf("bottomRows with an empty composer = %d, want 4 (input 1 + border 2 + status 1)", got)
315 }
316 if m.viewport.Width() != 79 {
317 t.Errorf("viewport content width = %d, want 79 (terminal 80 - 1 scrollbar column)", m.viewport.Width())
318 }
319 if want := m.transcriptHeight(); m.viewport.Height() != want || want != 20 {
320 t.Errorf("viewport height = %d, transcriptHeight = %d, want 20 (24-4)", m.viewport.Height(), want)
321 }
322 if m.viewport.TotalLineCount() == 0 {
323 t.Errorf("viewport should hold the committed banner after the first resize")
324 }
325 }
326
327 // TestStatusLineWrapAccounting proves that computeStatusLineCount correctly
328 // predicts the rendered row count of the compact status block and that
329 // bottomRows reserves the right height so the viewport fills the screen without
330 // overlap.
331 func TestStatusLineWrapAccounting(t *testing.T) {
332 ctrl := newOwnedTestController(t, control.Options{})
333 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 30)
334
335 // Narrow terminal: mode+state line and data line will both wrap.
336 m0, _ := m.Update(tea.WindowSizeMsg{Width: 30, Height: 12})
337 m = m0.(chatTUI)
338
339 if m.statusLineCount < 1 {
340 t.Fatalf("statusLineCount on a narrow terminal (30 cols) = %d, want at least one row", m.statusLineCount)
341 }
342
343 // Verify the height budget covers the full screen.
344 if got := m.transcriptHeight() + m.bottomRows(); got != m.height {
345 t.Fatalf("transcriptHeight(%d) + bottomRows(%d) = %d, want %d (full screen height)",
346 m.transcriptHeight(), m.bottomRows(), got, m.height)
347 }
348
349 // When running, the working line should increase statusLineCount.
350 idleCount := m.statusLineCount
351 m.state = tuiRunning
352 m.elapsed = 5
353 m.turnTokens = 100
354 // Push a durable inbox item so the working line is longer.
355 m2 := newInboxTestChatTUI(t)
356 m2.state = tuiRunning
357 m2.elapsed = 5
358 m2.turnTokens = 100
359 m2.seedInbox("feedback")
360 m2.width = m.width
361 m2.statusLineCount = m2.computeStatusLineCount(m2.width)
362 runCount := m2.statusLineCount
363 if runCount <= idleCount {
364 t.Fatalf("statusLineCount when running (%d) should be > idle (%d)", runCount, idleCount)
365 }
366
367 // Reset and test that a custom statusline command is also counted.
368 m.state = tuiIdle
369 m.statuslineCmd = "custom"
370 m.statuslineOut = "model: claude-3 · ctx: 45% · tokens: 128K · cache: 87% · rate: 1.2s · jobs: 3 running · balance: ¥152.30"
371 m0, _ = m.Update(tea.WindowSizeMsg{Width: 35, Height: 12})
372 m = m0.(chatTUI)
373 if m.statusLineCount <= 2 {
374 t.Fatalf("statusLineCount with custom statusline on 35 cols = %d, want > 2 (custom output should wrap)", m.statusLineCount)
375 }
376 if got := m.transcriptHeight() + m.bottomRows(); got != m.height {
377 t.Fatalf("with custom statusline: transcriptHeight(%d) + bottomRows(%d) = %d, want %d",
378 m.transcriptHeight(), m.bottomRows(), got, m.height)
379 }
380 }
381
382 // TestStatusLineRenderedHeightMatchesBudget proves that the actual rendered
383 // line count of View()'s bottom area matches what bottomRows() predicts,
384 // specifically at the CJK 2-char-overflow boundary where an off-by-one would
385 // hide the bottom row of the viewport.
386 func TestStatusLineRenderedHeightMatchesBudget(t *testing.T) {
387 ctrl := newOwnedTestController(t, control.Options{})
388 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 46)
389
390 // Manually set a long git repo/branch so the status line contains CJK.
391 m.missing = ""
392 m.gitStatus = gitStatus{Repo: "我的项目名字", Branch: "我的分支"}
393
394 m0, _ := m.Update(tea.WindowSizeMsg{Width: 46, Height: 12})
395 m = m0.(chatTUI)
396
397 if m.statusLineCount <= 2 {
398 t.Fatalf("statusLineCount at width 46 with CJK = %d, want > 2", m.statusLineCount)
399 }
400
401 // Verify that computeStatusLineCount matches the actual rendered line count.
402 // Strip ANSI from the full view, then reconstruct what bottomRows expects.
403 viewStr := ansi.Strip(m.View().Content)
404 allLines := strings.Split(viewStr, "\n")
405 totalLines := len(allLines)
406
407 // The total should be m.height (full terminal height).
408 if totalLines != m.height {
409 t.Fatalf("View() total lines = %d, want %d (terminal height)", totalLines, m.height)
410 }
411
412 // transcriptHeight() lines should be the viewport, the rest is bottom rows.
413 if got, want := m.transcriptHeight()+m.bottomRows(), m.height; got != want {
414 t.Fatalf("transcriptHeight(%d) + bottomRows(%d) = %d, want %d",
415 m.transcriptHeight(), m.bottomRows(), got, want)
416 }
417
418 // Also verify the invariant holds at narrower widths.
419 for _, w := range []int{44, 42, 40, 35, 30, 25, 20} {
420 m0, _ = m.Update(tea.WindowSizeMsg{Width: w, Height: 12})
421 m = m0.(chatTUI)
422 viewStr2 := ansi.Strip(m.View().Content)
423 allLines2 := strings.Split(viewStr2, "\n")
424 if len(allLines2) != m.height {
425 t.Errorf("width=%d: View() total lines = %d, want %d", w, len(allLines2), m.height)
426 }
427 if got, want := m.transcriptHeight()+m.bottomRows(), m.height; got != want {
428 t.Errorf("width=%d: transcriptHeight(%d) + bottomRows(%d) = %d, want %d",
429 w, m.transcriptHeight(), m.bottomRows(), got, want)
430 }
431 }
432 }
433
434 func TestManualNewlineGrowsComposerWithoutHidingFirstLine(t *testing.T) {
435 ctrl := newOwnedTestController(t, control.Options{})
436 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 40)
437
438 m0, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 12})
439 m = m0.(chatTUI)
440 m.input.SetValue("first line")
441
442 m0, _ = m.Update(tea.KeyPressMsg{Code: 'j', Mod: tea.ModCtrl})
443 m = m0.(chatTUI)
444
445 if got := m.input.Height(); got != 2 {
446 t.Fatalf("input height after Ctrl+J = %d, want 2", got)
447 }
448 if got := m.input.ScrollYOffset(); got != 0 {
449 t.Fatalf("input scroll offset after Ctrl+J = %d, want 0 so the first line remains visible", got)
450 }
451 }
452
453 func TestEmptyComposerShowsOnlyPrompt(t *testing.T) {
454 ctrl := newOwnedTestController(t, control.Options{})
455 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 60)
456 m0, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 16})
457 m = m0.(chatTUI)
458
459 firstLine := strings.Split(ansi.Strip(m.renderComposerInput()), "\n")[0]
460 if strings.TrimSpace(firstLine) != "❯" {
461 t.Fatalf("empty composer = %q, want only the prompt", firstLine)
462 }
463 }
464
465 func TestManualNewlineCanExceedVisibleComposerRows(t *testing.T) {
466 ctrl := newOwnedTestController(t, control.Options{})
467 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 40)
468
469 m0, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 12})
470 m = m0.(chatTUI)
471 m.input.SetValue("first line")
472 visibleCap := m.input.MaxHeight
473 if visibleCap >= maxInputRows {
474 t.Fatalf("short terminal input cap = %d, want less than comfort cap %d", visibleCap, maxInputRows)
475 }
476
477 for range maxInputRows + 1 {
478 m0, _ = m.Update(tea.KeyPressMsg{Code: 'j', Mod: tea.ModCtrl})
479 m = m0.(chatTUI)
480 }
481
482 if got, want := strings.Count(m.input.Value(), "\n"), maxInputRows+1; got != want {
483 t.Fatalf("manual newlines preserved = %d, want %d", got, want)
484 }
485 if got := m.input.Height(); got != visibleCap {
486 t.Fatalf("visible input height = %d, want terminal-aware cap %d", got, visibleCap)
487 }
488 if got := m.input.ScrollYOffset(); got == 0 {
489 t.Fatal("overflowing composer should scroll internally to keep the caret visible")
490 }
491 if got := m.transcriptHeight(); got < minTranscriptRows {
492 t.Fatalf("transcript height = %d, want at least %d rows", got, minTranscriptRows)
493 }
494 }
495
496 func TestComposerHeightReflowsWhenTerminalShrinksAndGrows(t *testing.T) {
497 ctrl := newOwnedTestController(t, control.Options{})
498 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
499
500 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
501 m = m0.(chatTUI)
502 m.input.SetValue(strings.Repeat("line\n", maxInputRows+2))
503 // SetValue recalculates the dynamic textarea before the outer model gets a
504 // chance to resize the transcript, so send a harmless resize through Update.
505 m0, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
506 m = m0.(chatTUI)
507 if got := m.input.Height(); got != maxInputRows {
508 t.Fatalf("tall terminal input height = %d, want comfort cap %d", got, maxInputRows)
509 }
510
511 m0, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 12})
512 m = m0.(chatTUI)
513 shortCap := m.input.MaxHeight
514 if got := m.input.Height(); got != shortCap {
515 t.Fatalf("shrunk terminal input height = %d, want cap %d", got, shortCap)
516 }
517 if shortCap >= maxInputRows {
518 t.Fatalf("shrunk terminal cap = %d, want less than %d", shortCap, maxInputRows)
519 }
520 if got := strings.Count(m.input.Value(), "\n"); got != maxInputRows+2 {
521 t.Fatalf("resize changed composer content: newline count = %d, want %d", got, maxInputRows+2)
522 }
523 if got := m.transcriptHeight(); got < minTranscriptRows {
524 t.Fatalf("shrunk transcript height = %d, want at least %d", got, minTranscriptRows)
525 }
526
527 m0, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
528 m = m0.(chatTUI)
529 if got := m.input.Height(); got != maxInputRows {
530 t.Fatalf("regrown terminal input height = %d, want restored cap %d", got, maxInputRows)
531 }
532 }
533
534 func TestTranscriptResizeRerendersCommittedMarkdownAtNewWidth(t *testing.T) {
535 ctrl := newOwnedTestController(t, control.Options{})
536 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 40)
537 m0, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 14})
538 m = m0.(chatTUI)
539
540 raw := "A committed answer with a thematic break.\n\n---\n\n" +
541 strings.Repeat("reflow words across the old terminal width ", 4)
542 m.pending.WriteString(raw)
543 m.commitPending()
544 answer := len(m.transcript) - 1
545 oldRendered := ansi.Strip(m.transcript[answer])
546 oldLines := strings.Count(oldRendered, "\n") + 1
547
548 m0, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 14})
549 m = m0.(chatTUI)
550 newRendered := ansi.Strip(m.transcript[answer])
551 newLines := strings.Count(newRendered, "\n") + 1
552
553 ruleWidth := 0
554 for line := range strings.SplitSeq(newRendered, "\n") {
555 trimmed := strings.TrimSpace(line)
556 if trimmed != "" && strings.Trim(trimmed, "─") == "" {
557 ruleWidth = visibleWidth(trimmed)
558 break
559 }
560 }
561 if got, want := ruleWidth, transcriptContentWidth(80, false)-visibleWidth(assistantTranscriptIndent); got != want {
562 t.Fatalf("resized thematic rule width = %d, want indented assistant body width %d", got, want)
563 }
564 if newLines >= oldLines {
565 t.Fatalf("wider transcript kept old hard wrapping: old lines=%d new lines=%d\n%s", oldLines, newLines, newRendered)
566 }
567 if got := m.transcriptSources[answer]; got.kind != transcriptSourceMarkdown || got.raw != raw {
568 t.Fatalf("committed answer lost markdown source: %+v", got)
569 }
570 }
571
572 func TestTranscriptResizeKeepsScrolledReaderOnSameBlock(t *testing.T) {
573 ctrl := newOwnedTestController(t, control.Options{})
574 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 40)
575 m0, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 12})
576 m = m0.(chatTUI)
577 m.clearTranscriptDisplay()
578
579 for i := range 8 {
580 m.commitTranscriptSource(transcriptSource{
581 kind: transcriptSourceMarkdown,
582 raw: fmt.Sprintf("ANCHOR-%d\n\n%s", i, strings.Repeat("content that wraps at the narrow width ", 4)),
583 })
584 }
585 m.transcriptDirty = true
586 m0, _ = m.Update(tea.WindowSizeMsg{Width: 40, Height: 12})
587 m = m0.(chatTUI)
588
589 contentWidth := transcriptContentWidth(m.width, false)
590 secondBlockStart := transcriptBlockLineCount(m.transcript[0], contentWidth)
591 m.viewport.SetYOffset(secondBlockStart)
592 m.markUserScrolled() // explicit leave-tail; production paths do this via wheel/PgUp
593 if m.viewport.AtBottom() {
594 t.Fatal("test reader anchor must be above the transcript bottom")
595 }
596
597 m0, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 12})
598 m = m0.(chatTUI)
599 newContentWidth := transcriptContentWidth(m.width, false)
600 newSecondBlockStart := transcriptBlockLineCount(m.transcript[0], newContentWidth)
601 newThirdBlockStart := newSecondBlockStart + transcriptBlockLineCount(m.transcript[1], newContentWidth)
602 if offset := m.viewport.YOffset(); offset < newSecondBlockStart || offset >= newThirdBlockStart {
603 t.Fatalf("resize moved reader outside ANCHOR-1 block: offset=%d block=[%d,%d)", offset, newSecondBlockStart, newThirdBlockStart)
604 }
605 }
606
607 func TestSoftWrappedInputGrowsComposerAndShrinksTranscript(t *testing.T) {
608 ctrl := newOwnedTestController(t, control.Options{})
609 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 24)
610
611 m0, _ := m.Update(tea.WindowSizeMsg{Width: 24, Height: 12})
612 m = m0.(chatTUI)
613 initialViewportHeight := m.viewport.Height()
614
615 m0, _ = m.Update(tea.PasteMsg{Content: strings.Repeat("x", 60)})
616 m = m0.(chatTUI)
617
618 if got := m.input.Height(); got <= 1 {
619 t.Fatalf("input height after soft-wrapped paste = %d, want > 1", got)
620 }
621 if got := m.viewport.Height(); got >= initialViewportHeight {
622 t.Fatalf("viewport height after composer growth = %d, want less than initial %d", got, initialViewportHeight)
623 }
624 }
625
626 func TestComposerPromptReservesWidthAndOffsetsCJKCursor(t *testing.T) {
627 ctrl := newOwnedTestController(t, control.Options{})
628 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 40)
629
630 m0, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 12})
631 m = m0.(chatTUI)
632 m.input.SetValue("你好")
633
634 firstLine := strings.Split(ansi.Strip(m.input.View()), "\n")[0]
635 if !strings.HasPrefix(firstLine, "❯ 你好") {
636 t.Fatalf("composer first line = %q, want prompt before CJK input", firstLine)
637 }
638 if got, want := m.input.Width(), 40-4-composerPromptWidth; got != want {
639 t.Fatalf("textarea content width = %d, want %d after prompt gutter", got, want)
640 }
641 cursor := m.input.Cursor()
642 if cursor == nil {
643 t.Fatal("focused composer should expose the real terminal cursor")
644 }
645 if got, want := cursor.X, composerPromptWidth+4; got != want {
646 t.Fatalf("cursor X after two CJK runes = %d, want %d", got, want)
647 }
648 }
649
650 func TestComposerPromptDoesNotRepeatOnWrappedRows(t *testing.T) {
651 ctrl := newOwnedTestController(t, control.Options{})
652 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 16)
653
654 // Give this prompt-gutter test enough vertical space for the responsive
655 // footer; terminal-height prioritization is covered separately.
656 m0, _ := m.Update(tea.WindowSizeMsg{Width: 16, Height: 18})
657 m = m0.(chatTUI)
658 m.input.SetValue(strings.Repeat("x", m.input.Width()+1))
659 lines := strings.Split(ansi.Strip(m.input.View()), "\n")
660 if len(lines) < 2 {
661 t.Fatalf("wrapped composer lines = %d, want at least 2", len(lines))
662 }
663 if !strings.HasPrefix(lines[0], "❯ ") {
664 t.Fatalf("first composer row missing prompt: %q", lines[0])
665 }
666 if strings.HasPrefix(lines[1], "❯ ") || !strings.HasPrefix(lines[1], " ") {
667 t.Fatalf("continuation row should keep a blank prompt gutter: %q", lines[1])
668 }
669 }
670
671 func TestMCPManagerHidesComposerBox(t *testing.T) {
672 ctrl := newOwnedTestController(t, control.Options{})
673 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
674 m.mcp = &mcpManager{stage: mcpStageList, snapshot: mcpSnapshot{servers: []mcpServerView{
675 {Name: "github", Transport: "stdio", Status: "deferred", Configured: true, Tier: "background"},
676 }}}
677
678 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
679 m = m0.(chatTUI)
680
681 footerRows := strings.Count(m.renderMainManagerFooter(), "\n") + 1
682 if got, want := m.bottomRows(), footerRows+m.statusLineCount; got != want {
683 t.Fatalf("bottomRows with MCP manager = %d, want %d (footer + status rows; manager content renders in main area)", got, want)
684 }
685 if !m.hideComposer() {
686 t.Fatal("MCP manager should hide the composer")
687 }
688 content := ansi.Strip(m.View().Content)
689 if !strings.Contains(content, "Manage MCP servers") {
690 t.Fatalf("MCP manager missing from view:\n%s", content)
691 }
692 if !strings.Contains(content, "Enter for details") {
693 t.Fatalf("MCP footer hint missing from view:\n%s", content)
694 }
695 if !strings.Contains(content, "· MCP") {
696 t.Fatalf("MCP status line missing from view:\n%s", content)
697 }
698 }
699
700 func TestClearCommandRequiresConfirmationAndDiscardsSession(t *testing.T) {
701 dir := t.TempDir()
702 sess := agent.NewSession("sys")
703 sess.Add(provider.Message{Role: provider.RoleUser, Content: "old context"})
704 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
705 path := filepath.Join(dir, "session.jsonl")
706 ctrl := newOwnedTestController(t, control.Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test"})
707 if err := ctrl.Snapshot(); err != nil {
708 t.Fatal(err)
709 }
710 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
711
712 if cmd := m.runSlashCommand("/clear"); cmd != nil {
713 t.Fatal("/clear should open a local confirmation without returning a command")
714 }
715 if m.clearConfirm == nil {
716 t.Fatal("/clear should open a confirmation prompt")
717 }
718 if m.clearConfirm.confirm != 1 {
719 t.Fatalf("/clear confirmation should default to cancel, got %d", m.clearConfirm.confirm)
720 }
721 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
722 m = m0.(chatTUI)
723 footerRows := strings.Count(m.renderMainManagerFooter(), "\n") + 1
724 if got, want := m.bottomRows(), footerRows+m.statusLineCount; got != want {
725 t.Fatalf("bottomRows with /clear confirmation = %d, want %d (footer + status rows; confirmation renders in main area)", got, want)
726 }
727 if !m.hideComposer() {
728 t.Fatal("/clear confirmation should hide the composer")
729 }
730 content := ansi.Strip(m.View().Content)
731 if !strings.Contains(content, "Clear current context without saving?") {
732 t.Fatalf("/clear confirmation prompt missing from view:\n%s", content)
733 }
734 if _, err := os.Stat(path); err != nil {
735 t.Fatalf("session should still exist before confirmation: %v", err)
736 }
737 if current := exec.Session().Snapshot(); len(current) != 2 {
738 t.Fatalf("context changed before confirmation: %+v", current)
739 }
740
741 next, _ := m.handleClearConfirmKey(tea.KeyPressMsg{Code: tea.KeyEnter})
742 m = next.(chatTUI)
743 if m.clearConfirm != nil {
744 t.Fatal("Enter on default cancel should close the confirmation")
745 }
746 if ctrl.SessionPath() != path {
747 t.Fatal("cancelled /clear should not rotate the session path")
748 }
749 if _, err := os.Stat(path); err != nil {
750 t.Fatalf("cancelled /clear should keep the session file: %v", err)
751 }
752
753 m.runSlashCommand("/clear")
754 m.shellOutputs["shell-old"] = "old shell output\n"
755 m.shellExpanded["shell-old"] = true
756 m.shellTranscriptIdx["shell-old"] = 2
757 next, cmd := m.handleClearConfirmKey(tea.KeyPressMsg{Code: 'y'})
758 m = next.(chatTUI)
759 if cmd == nil {
760 t.Fatal("confirmed /clear should clear native scrollback after rotating the session")
761 }
762 if ctrl.SessionPath() == path {
763 t.Fatal("confirmed /clear should rotate to a fresh session path")
764 }
765 if _, err := os.Stat(path); !os.IsNotExist(err) {
766 t.Fatalf("confirmed /clear should remove the old transcript, stat err=%v", err)
767 }
768 current := exec.Session().Snapshot()
769 if len(current) != 1 || current[0].Role != provider.RoleSystem || current[0].Content != "sys" {
770 t.Fatalf("cleared context = %+v, want only system prompt", current)
771 }
772 if len(m.transcript) == 0 || strings.Contains(strings.Join(m.transcript, "\n"), "old context") {
773 t.Fatalf("TUI transcript was not reset after /clear: %+v", m.transcript)
774 }
775 if len(m.shellTranscriptIdx) != 0 || len(m.shellOutputs) != 0 || len(m.shellExpanded) != 0 {
776 t.Fatalf("confirmed /clear should reset shell display state: idx=%v outputs=%v expanded=%v",
777 m.shellTranscriptIdx, m.shellOutputs, m.shellExpanded)
778 }
779 }
780
781 // TestClearCommandInYOLOModeSkipsConfirmation guards the non-interactive fast
782 // path: with YOLO already active, /clear must clear the session immediately
783 // instead of opening the confirmation overlay.
784 func TestClearCommandInYOLOModeSkipsConfirmation(t *testing.T) {
785 dir := t.TempDir()
786 sess := agent.NewSession("sys")
787 sess.Add(provider.Message{Role: provider.RoleUser, Content: "old context"})
788 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
789 path := filepath.Join(dir, "session.jsonl")
790 ctrl := newOwnedTestController(t, control.Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test"})
791 ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
792 if err := ctrl.Snapshot(); err != nil {
793 t.Fatal(err)
794 }
795 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
796
797 if cmd := m.runSlashCommand("/clear"); cmd == nil {
798 t.Fatal("/clear in YOLO mode should clear native scrollback after rotating the session")
799 }
800 if m.clearConfirm != nil {
801 t.Fatal("/clear in YOLO mode should not open a confirmation prompt")
802 }
803 if ctrl.SessionPath() == path {
804 t.Fatal("/clear in YOLO mode should rotate to a fresh session path")
805 }
806 if _, err := os.Stat(path); !os.IsNotExist(err) {
807 t.Fatalf("/clear in YOLO mode should remove the old transcript, stat err=%v", err)
808 }
809 current := exec.Session().Snapshot()
810 if len(current) != 1 || current[0].Role != provider.RoleSystem || current[0].Content != "sys" {
811 t.Fatalf("cleared context = %+v, want only system prompt", current)
812 }
813 }
814
815 func TestClearCommandFailureKeepsDisplayAndDoesNotClearScreen(t *testing.T) {
816 dir := t.TempDir()
817 sess := agent.NewSession("sys")
818 sess.Add(provider.Message{Role: provider.RoleUser, Content: "old context"})
819 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
820 path := filepath.Join(dir, "session.jsonl")
821 runner := &blockingTurnRunner{started: make(chan struct{})}
822 ctrl := newOwnedTestController(t, control.Options{
823 Runner: runner, Executor: exec, SystemPrompt: "sys",
824 SessionDir: dir, SessionPath: path, Label: "test", Sink: event.Discard,
825 })
826 if err := ctrl.Snapshot(); err != nil {
827 t.Fatal(err)
828 }
829 ctrl.Send("active turn")
830 <-runner.started
831
832 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
833 m.commitLine("visible transcript sentinel")
834 m.shellOutputs["shell-old"] = "old shell output\n"
835 m.shellExpanded["shell-old"] = true
836 m.shellTranscriptIdx["shell-old"] = len(m.transcript) - 1
837 m.runSlashCommand("/clear")
838
839 next, cmd := m.handleClearConfirmKey(tea.KeyPressMsg{Code: 'y'})
840 m = next.(chatTUI)
841 if cmd != nil {
842 t.Fatal("failed /clear should not clear native scrollback")
843 }
844 if ctrl.SessionPath() != path {
845 t.Fatalf("failed /clear rotated session path to %q, want %q", ctrl.SessionPath(), path)
846 }
847 if _, err := os.Stat(path); err != nil {
848 t.Fatalf("failed /clear should preserve the session file: %v", err)
849 }
850 if !strings.Contains(strings.Join(m.transcript, "\n"), "visible transcript sentinel") {
851 t.Fatalf("failed /clear erased the visible transcript: %+v", m.transcript)
852 }
853 if m.shellOutputs["shell-old"] == "" || !m.shellExpanded["shell-old"] {
854 t.Fatalf("failed /clear reset shell display state: outputs=%v expanded=%v", m.shellOutputs, m.shellExpanded)
855 }
856 }
857
858 func TestClsClearsTranscriptDisplayState(t *testing.T) {
859 m := newTestChatTUI()
860 *m.pendingCommit = append(*m.pendingCommit, "stale pending")
861 m.transcript = []string{"banner", "shell card", "old shell output"}
862 m.wrappedLines = []string{"banner", "shell card", "old shell output"}
863 m.shellOutputs["shell-old"] = strings.Repeat("old shell output\n", shellPreviewLines+1)
864 m.shellExpanded["shell-old"] = false
865 m.shellTranscriptIdx["shell-old"] = 2
866 m.toolLineCountByID["shell-old"] = 3
867 m.toolStreamID = "shell-old"
868 m.toolStreamIdx = 2
869 m.toolTail = []string{"old shell output"}
870 m.toolPartial = "partial"
871 m.toolLineCount = 4
872
873 if cmd := m.runSlashCommand("/cls"); cmd != nil {
874 t.Fatal("/cls should clear locally without returning a command")
875 }
876 if len(*m.pendingCommit) != len(m.transcript) {
877 t.Fatalf("pendingCommit should only contain the fresh cleared-screen transcript, pending=%v transcript=%v",
878 *m.pendingCommit, m.transcript)
879 }
880 if len(m.shellTranscriptIdx) != 0 || len(m.shellOutputs) != 0 || len(m.shellExpanded) != 0 {
881 t.Fatalf("/cls should reset shell display state: idx=%v outputs=%v expanded=%v",
882 m.shellTranscriptIdx, m.shellOutputs, m.shellExpanded)
883 }
884 if len(m.toolLineCountByID) != 0 || m.toolStreamID != "" || m.toolStreamIdx != -1 || len(m.toolTail) != 0 || m.toolPartial != "" || m.toolLineCount != 0 {
885 t.Fatalf("/cls should reset live tool display state: counts=%v id=%q idx=%d tail=%v partial=%q lines=%d",
886 m.toolLineCountByID, m.toolStreamID, m.toolStreamIdx, m.toolTail, m.toolPartial, m.toolLineCount)
887 }
888
889 before := strings.Join(m.transcript, "\n")
890 m.toggleShellOutput()
891 if after := strings.Join(m.transcript, "\n"); after != before {
892 t.Fatalf("Ctrl+B after /cls should not rewrite the cleared transcript:\nbefore=%s\nafter=%s", before, after)
893 }
894 if strings.Contains(before, "old shell output") || strings.Contains(before, "/cls") {
895 t.Fatalf("/cls should keep only the fresh banner/notice, got:\n%s", before)
896 }
897 }
898
899 func TestMainManagerFollowsTranscriptWithoutTopPadding(t *testing.T) {
900 ctrl := newOwnedTestController(t, control.Options{})
901 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
902 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
903 m = m0.(chatTUI)
904 m.wrappedLines = []string{"reasonix", "› /mcp"}
905
906 out := ansi.Strip(m.renderTranscriptWithMainManager("Manage MCP servers\n1 servers"))
907 lines := strings.Split(out, "\n")
908 if len(lines) < 4 {
909 t.Fatalf("rendered manager area too short:\n%s", out)
910 }
911 if !strings.Contains(lines[0], "reasonix") || !strings.Contains(lines[1], "/mcp") {
912 t.Fatalf("transcript lines should stay above manager:\n%s", out)
913 }
914 if strings.TrimSpace(lines[2]) != "" {
915 t.Fatalf("expected one separator line before manager, got %q in:\n%s", lines[2], out)
916 }
917 if !strings.Contains(lines[3], "Manage MCP servers") {
918 t.Fatalf("manager should follow transcript immediately, got line 3 %q in:\n%s", lines[3], out)
919 }
920 }
921
922 func TestMarkdownDividerFitsTranscriptContentWidth(t *testing.T) {
923 ctrl := newOwnedTestController(t, control.Options{})
924 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
925 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
926 m = m0.(chatTUI)
927
928 wantW := transcriptContentWidth(80, false)
929 if m.viewport.Width() != wantW {
930 t.Fatalf("viewport width = %d, want transcript content width %d", m.viewport.Width(), wantW)
931 }
932 rule := strings.TrimRight(newMarkdownRenderer(wantW).Render("---"), "\n")
933 lines := strings.Split(wrapTranscript(rule, m.viewport.Width()), "\n")
934 if len(lines) != 1 {
935 t.Fatalf("markdown divider wrapped into %d lines at width %d: %q", len(lines), m.viewport.Width(), lines)
936 }
937 if w := visibleWidth(lines[0]); w != m.viewport.Width() {
938 t.Fatalf("markdown divider width = %d, want %d: %q", w, m.viewport.Width(), lines[0])
939 }
940 }
941
942 func TestTranscriptContentWidthReservesScrollbarColumn(t *testing.T) {
943 if got := transcriptContentWidth(80, false); got != 79 {
944 t.Fatalf("transcriptContentWidth(80, false) = %d, want 79", got)
945 }
946 if got := transcriptContentWidth(80, true); got != 80 {
947 t.Fatalf("transcriptContentWidth(80, true) = %d, want 80", got)
948 }
949 if got := transcriptContentWidth(0, false); got != 1 {
950 t.Fatalf("transcriptContentWidth(0, false) = %d, want 1", got)
951 }
952 }
953
954 func TestModalPanelsHideComposerBox(t *testing.T) {
955 ask := event.Ask{
956 ID: "ask-1",
957 Questions: []event.AskQuestion{{
958 ID: "q1",
959 Prompt: "Pick one",
960 Options: []event.AskOption{{
961 Label: "Option A",
962 }},
963 }},
964 }
965 tests := []struct {
966 name string
967 setup func(*chatTUI)
968 render func(chatTUI) string
969 }{
970 {
971 name: "resume picker",
972 setup: func(m *chatTUI) {
973 m.resumePick = &resumePicker{entries: []resumeEntry{{session: agent.SessionInfo{
974 Path: "one.jsonl",
975 Preview: "previous task",
976 Turns: 3,
977 }}}, sel: 0, active: -1}
978 },
979 render: func(m chatTUI) string { return m.renderResumePicker() },
980 },
981 {
982 name: "rewind picker",
983 setup: func(m *chatTUI) {
984 m.rewind = &rewindPicker{metas: []checkpoint.Meta{{
985 Turn: 0,
986 Prompt: "fix the parser",
987 }}, sel: 0}
988 },
989 render: func(m chatTUI) string { return m.renderRewind() },
990 },
991 {
992 name: "approval prompt",
993 setup: func(m *chatTUI) {
994 m.pendingApproval = &event.Approval{ID: "approval-1", Tool: "bash", Subject: "echo hi"}
995 },
996 render: func(m chatTUI) string { return m.renderApprovalBanner() },
997 },
998 {
999 name: "ask chooser",
1000 setup: func(m *chatTUI) {
1001 m.chooser = newChooser(ask)
1002 },
1003 render: func(m chatTUI) string { return m.renderChooser() },
1004 },
1005 }
1006
1007 for _, tt := range tests {
1008 t.Run(tt.name, func(t *testing.T) {
1009 ctrl := newOwnedTestController(t, control.Options{})
1010 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
1011 tt.setup(&m)
1012
1013 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
1014 m = m0.(chatTUI)
1015
1016 card := tt.render(m)
1017 if card == "" {
1018 t.Fatalf("%s panel did not render", tt.name)
1019 }
1020 cardRows := strings.Count(card, "\n") + 1
1021 if got, want := m.bottomRows(), cardRows+m.statusLineCount; got != want {
1022 t.Fatalf("bottomRows with %s = %d, want %d (panel + status rows, no composer box)", tt.name, got, want)
1023 }
1024 })
1025 }
1026 }
1027
1028 // TestRewindPickerWindowsLongSession verifies the Esc-Esc turn list windows
1029 // long sessions (one row per turn) so the overlay cannot outgrow the terminal:
1030 // at most quickPickerMaxVisible rows render, with ↑/↓ more markers pointing at
1031 // the hidden turns and the window following the selection.
1032 func TestRewindPickerWindowsLongSession(t *testing.T) {
1033 ctrl := newOwnedTestController(t, control.Options{})
1034 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
1035 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
1036 m = m0.(chatTUI)
1037
1038 metas := make([]checkpoint.Meta, 12)
1039 for i := range metas {
1040 metas[i] = checkpoint.Meta{Turn: i, Prompt: fmt.Sprintf("turn %d", i)}
1041 }
1042
1043 // Newest turn selected (default): window shows rows 4..11.
1044 m.rewind = &rewindPicker{metas: metas, sel: 11}
1045 card := m.renderRewind()
1046 if !strings.Contains(card, "↑ more") {
1047 t.Fatalf("newest selection should show ↑ more: %q", card)
1048 }
1049 if strings.Contains(card, "↓ more") {
1050 t.Fatalf("newest selection must not show ↓ more: %q", card)
1051 }
1052 if !strings.Contains(card, "turn 11") || strings.Contains(card, "turn 0") {
1053 t.Fatalf("window must cover rows 4..11, got: %q", card)
1054 }
1055
1056 // Oldest turn selected: window shows rows 0..7.
1057 m.rewind = &rewindPicker{metas: metas, sel: 0}
1058 card = m.renderRewind()
1059 if !strings.Contains(card, "↓ more") {
1060 t.Fatalf("oldest selection should show ↓ more: %q", card)
1061 }
1062 if strings.Contains(card, "↑ more") {
1063 t.Fatalf("oldest selection must not show ↑ more: %q", card)
1064 }
1065 if !strings.Contains(card, "turn 0") || strings.Contains(card, "turn 11") {
1066 t.Fatalf("window must cover rows 0..7, got: %q", card)
1067 }
1068
1069 // Short session (≤8 turns): every row visible, no markers.
1070 m.rewind = &rewindPicker{metas: metas[:4], sel: 0}
1071 card = m.renderRewind()
1072 if strings.Contains(card, "more") {
1073 t.Fatalf("short session must not show more markers: %q", card)
1074 }
1075 for i := range 4 {
1076 if !strings.Contains(card, fmt.Sprintf("turn %d", i)) {
1077 t.Fatalf("short session row %d missing: %q", i, card)
1078 }
1079 }
1080 }
1081
1082 func TestApprovalChoicesPreserveDecisionSemantics(t *testing.T) {
1083 tests := []struct {
1084 name string
1085 tool string
1086 want []approvalChoice
1087 }{
1088 {
1089 name: "ordinary tool",
1090 tool: "bash",
1091 want: []approvalChoice{
1092 {allow: true},
1093 {allow: true, allowForSession: true},
1094 {},
1095 },
1096 },
1097 {
1098 name: "fresh decision",
1099 tool: "remember",
1100 want: []approvalChoice{{allow: true}, {}},
1101 },
1102 {
1103 name: "fresh session grant",
1104 tool: control.SandboxEscapeApprovalTool,
1105 want: []approvalChoice{{allow: true}, {allow: true, allowForSession: true}, {}},
1106 },
1107 {
1108 name: "plan decision",
1109 tool: planApprovalTool,
1110 want: []approvalChoice{{allow: true}, {}, {exitPlan: true}},
1111 },
1112 }
1113 for _, tt := range tests {
1114 t.Run(tt.name, func(t *testing.T) {
1115 got := approvalChoices(&event.Approval{Tool: tt.tool, Subject: "echo hi"})
1116 if len(got) != len(tt.want) {
1117 t.Fatalf("choices = %d, want %d", len(got), len(tt.want))
1118 }
1119 for i := range got {
1120 got[i].label = ""
1121 if got[i] != tt.want[i] {
1122 t.Errorf("choice %d = %+v, want %+v", i, got[i], tt.want[i])
1123 }
1124 }
1125 })
1126 }
1127
1128 retired := approvalChoices(&event.Approval{
1129 Kind: "recovery", Recovery: &event.RecoveryApproval{CanGrantTask: true},
1130 })
1131 if len(retired) != 0 {
1132 t.Fatalf("retired recovery choices = %+v, want none", retired)
1133 }
1134 labels := approvalChoiceLabels(&event.Approval{Kind: "recovery", Recovery: &event.RecoveryApproval{
1135 CanGrantTask: true, TaskGrantScope: "git push origin → feature",
1136 }})
1137 if len(labels) != 0 {
1138 t.Fatalf("retired recovery labels = %v, want none", labels)
1139 }
1140 planLabels := approvalChoiceLabels(&event.Approval{Kind: "recovery", Recovery: &event.RecoveryApproval{
1141 ChangeKind: "strategy",
1142 }})
1143 if len(planLabels) != 0 {
1144 t.Fatalf("retired plan-change recovery labels = %v, want none", planLabels)
1145 }
1146 planApprovalLabels := approvalChoiceLabels(&event.Approval{Tool: planApprovalTool})
1147 if len(planApprovalLabels) != 3 || planApprovalLabels[0] != "Start execution" ||
1148 planApprovalLabels[1] != "Revise plan (keep planning)" || planApprovalLabels[2] != "Exit without executing" {
1149 t.Fatalf("plan approval labels = %v", planApprovalLabels)
1150 }
1151 }
1152
1153 func TestPlanApprovalActionsSynchronizeTUIAndControllerMode(t *testing.T) {
1154 tests := []struct {
1155 name string
1156 key tea.KeyPressMsg
1157 wantPlan bool
1158 }{
1159 {name: "start execution", key: tea.KeyPressMsg{Code: '1'}},
1160 {name: "revise plan", key: tea.KeyPressMsg{Code: '2'}, wantPlan: true},
1161 {name: "exit without executing", key: tea.KeyPressMsg{Code: '3'}},
1162 {name: "legacy n keeps planning", key: tea.KeyPressMsg{Code: 'n'}, wantPlan: true},
1163 {name: "escape keeps planning", key: tea.KeyPressMsg{Code: tea.KeyEscape}, wantPlan: true},
1164 }
1165 for _, tt := range tests {
1166 t.Run(tt.name, func(t *testing.T) {
1167 ctrl := newOwnedTestController(t, control.Options{})
1168 t.Cleanup(ctrl.Close)
1169 m := newTestChatTUI()
1170 m.ctrl = ctrl
1171 m.planMode = true
1172 m.ctrl.SetPlanMode(true)
1173 m.pendingApproval = &event.Approval{ID: "plan", Tool: planApprovalTool}
1174
1175 next, _ := m.handleApprovalKey(tt.key)
1176 m = next.(chatTUI)
1177 if m.pendingApproval != nil {
1178 t.Fatal("plan approval was not resolved")
1179 }
1180 if m.planMode != tt.wantPlan || m.ctrl.PlanMode() != tt.wantPlan {
1181 t.Fatalf("plan mode = tui %v/controller %v, want %v", m.planMode, m.ctrl.PlanMode(), tt.wantPlan)
1182 }
1183 })
1184 }
1185 }
1186
1187 func TestPlanApprovalBannerShowsThreeExplicitActions(t *testing.T) {
1188 m := newTestChatTUI()
1189 m.width = 120
1190 m.pendingApproval = &event.Approval{ID: "plan", Tool: planApprovalTool}
1191 banner := ansi.Strip(m.renderApprovalBanner())
1192 for _, want := range []string{"Start execution", "Revise plan (keep planning)", "Exit without executing"} {
1193 if !strings.Contains(banner, want) {
1194 t.Fatalf("plan approval banner missing %q:\n%s", want, banner)
1195 }
1196 }
1197 }
1198
1199 func TestRetiredRecoveryApprovalBannerHasNoActions(t *testing.T) {
1200 m := newTestChatTUI()
1201 m.width = 120
1202 m.pendingApproval = &event.Approval{
1203 ID: "plan-change", Tool: "todo_write", Reason: "choose the public API direction", Kind: "recovery",
1204 Recovery: &event.RecoveryApproval{
1205 ChangeKind: "scope", PlanBefore: "1. Keep API [in_progress]", PlanAfter: "1. Replace API [in_progress]",
1206 },
1207 }
1208 banner := ansi.Strip(m.renderApprovalBanner())
1209 for _, want := range []string{"Historical recovery record (retired)", "cannot confirm or replay", "Esc/n dismiss"} {
1210 if !strings.Contains(banner, want) {
1211 t.Fatalf("retired recovery banner missing %q:\n%s", want, banner)
1212 }
1213 }
1214 for _, forbidden := range []string{"Adopt", "continue", "retry", "grant"} {
1215 if strings.Contains(strings.ToLower(banner), strings.ToLower(forbidden)) {
1216 t.Fatalf("retired recovery banner exposes %q action:\n%s", forbidden, banner)
1217 }
1218 }
1219 }
1220
1221 func TestRetiredRecoveryApprovalOnlyDismissesLocally(t *testing.T) {
1222 m := newTestChatTUI()
1223 m.ingestEvent(event.Event{
1224 Kind: event.ApprovalRequest,
1225 Approval: event.Approval{
1226 ID: "plan-change", Tool: "todo_write", Kind: "recovery",
1227 Recovery: &event.RecoveryApproval{ChangeKind: "strategy"},
1228 },
1229 })
1230 banner := ansi.Strip(m.renderApprovalBanner())
1231 if strings.Contains(banner, "1.") || strings.Contains(banner, "2.") {
1232 t.Fatalf("retired recovery banner exposes choices:\n%s", banner)
1233 }
1234
1235 next, _ := m.handleApprovalKey(tea.KeyPressMsg{Code: tea.KeyEnter})
1236 m = next.(chatTUI)
1237 if m.pendingApproval == nil {
1238 t.Fatal("Enter dismissed a retired recovery record")
1239 }
1240 next, _ = m.handleApprovalKey(tea.KeyPressMsg{Code: tea.KeyEscape})
1241 m = next.(chatTUI)
1242 if m.pendingApproval != nil {
1243 t.Fatal("Escape did not dismiss the retired recovery record")
1244 }
1245 }
1246
1247 func TestApprovalArrowKeysMoveVisibleSelection(t *testing.T) {
1248 m := newTestChatTUI()
1249 m.pendingApproval = &event.Approval{ID: "approval", Tool: "bash", Subject: "echo hi"}
1250 next, _ := m.handleApprovalKey(tea.KeyPressMsg{Code: tea.KeyDown})
1251 m = next.(chatTUI)
1252 if m.approvalSelection != 1 {
1253 t.Fatalf("approval selection = %d, want 1", m.approvalSelection)
1254 }
1255 banner := ansi.Strip(m.renderApprovalBanner())
1256 if !strings.Contains(banner, "❯ 2.") {
1257 t.Fatalf("approval banner should highlight second row:\n%s", banner)
1258 }
1259 }
1260
1261 // TestApprovalLegacyFourAlwaysDenies pins the documented contract that the
1262 // legacy numeric 4 rejects an approval even when the current prompt shows fewer
1263 // than four rows (fresh two-choice prompts, plan approval). Before the fix,
1264 // pressing 4 on a short prompt was a no-op.
1265 func TestApprovalLegacyFourAlwaysDenies(t *testing.T) {
1266 for _, tool := range []string{"remember", control.SandboxEscapeApprovalTool, "bash"} {
1267 m := newTestChatTUI()
1268 m.ctrl = newOwnedTestController(t, control.Options{})
1269 m.pendingApproval = &event.Approval{ID: "a", Tool: tool, Subject: "echo hi"}
1270 m.approvalSelection = 0
1271 next, _ := m.handleApprovalKey(tea.KeyPressMsg{Code: '4'})
1272 m = next.(chatTUI)
1273 if m.pendingApproval != nil {
1274 t.Fatalf("%s: pressing 4 must resolve (deny) the approval, still pending", tool)
1275 }
1276 }
1277 }
1278
1279 // TestCompletionMenuCtrlPNMovesSelection covers the Ctrl+P/Ctrl+N contract the
1280 // docs advertise for the slash/@ completion menu.
1281 func TestCompletionMenuCtrlPNMovesSelection(t *testing.T) {
1282 m := newTestChatTUI()
1283 m.completion = completion{active: true, kind: compSlash, items: []compItem{{label: "/mcp"}, {label: "/model"}}, sel: 0}
1284
1285 next, _ := m.update(tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl})
1286 m = next.(chatTUI)
1287 if m.completion.sel != 1 {
1288 t.Fatalf("ctrl+n should move completion selection to 1, got %d", m.completion.sel)
1289 }
1290 next, _ = m.update(tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl})
1291 m = next.(chatTUI)
1292 if m.completion.sel != 0 {
1293 t.Fatalf("ctrl+p should move completion selection back to 0, got %d", m.completion.sel)
1294 }
1295 }
1296
1297 func TestStatusCommandShowsRuntimeDetails(t *testing.T) {
1298 m := newTestChatTUI()
1299 m.modelRef = "provider/model"
1300 m.effortLevel = "max"
1301 m.balance = "$10.00"
1302 m.runSlashCommand("/status")
1303 out := ansi.Strip(strings.Join(m.transcript, "\n"))
1304 for _, want := range []string{"Session status", "provider/model", "effort max", "$10.00"} {
1305 if !strings.Contains(out, want) {
1306 t.Errorf("/status output missing %q:\n%s", want, out)
1307 }
1308 }
1309 }
1310
1311 func TestInputOwnedOverlaysKeepComposerBox(t *testing.T) {
1312 ask := event.Ask{
1313 ID: "ask-1",
1314 Questions: []event.AskQuestion{{
1315 ID: "q1",
1316 Prompt: "Pick one",
1317 Options: []event.AskOption{{
1318 Label: "Option A",
1319 }},
1320 }},
1321 }
1322 tests := []struct {
1323 name string
1324 setup func(*chatTUI)
1325 render func(chatTUI) string
1326 }{
1327 {
1328 name: "ask free text",
1329 setup: func(m *chatTUI) {
1330 m.chooser = newChooser(ask)
1331 m.chooser.typing = true
1332 },
1333 render: func(m chatTUI) string { return m.renderChooser() },
1334 },
1335 {
1336 name: "completion menu",
1337 setup: func(m *chatTUI) {
1338 m.input.SetValue("/")
1339 m.completion = completion{active: true, kind: compSlash, items: []compItem{{label: "/mcp"}}, sel: 0}
1340 },
1341 render: func(m chatTUI) string { return m.renderCompletion() },
1342 },
1343 }
1344
1345 for _, tt := range tests {
1346 t.Run(tt.name, func(t *testing.T) {
1347 ctrl := newOwnedTestController(t, control.Options{})
1348 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
1349 tt.setup(&m)
1350
1351 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
1352 m = m0.(chatTUI)
1353
1354 if m.hideComposer() {
1355 t.Fatalf("%s should keep the composer visible", tt.name)
1356 }
1357 panel := tt.render(m)
1358 if panel == "" {
1359 t.Fatalf("%s panel did not render", tt.name)
1360 }
1361 panelRows := strings.Count(panel, "\n") + 1
1362 if got, want := m.bottomRows(), panelRows+m.input.Height()+2+m.statusLineCount; got != want {
1363 t.Fatalf("bottomRows with %s = %d, want %d (panel + composer box + status rows)", tt.name, got, want)
1364 }
1365 })
1366 }
1367 }
1368
1369 // TestIngestEventRoutesByKind proves each event Kind lands in the right place:
1370 // reasoning shows a live marker with streaming text, while tool dispatch, blocked
1371 // results, usage, notices, and coordinator phases each commit as their own
1372 // scrollback line. Routing is by Kind, not by sniffing line prefixes.
1373 func TestIngestEventRoutesByKind(t *testing.T) {
1374 // Reasoning shows a marker plus the live thinking text streamed below it.
1375 m := newTestChatTUI()
1376 m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "weighing options"})
1377 if len(m.transcript) != 2 || !strings.Contains(m.transcript[0], "thinking") {
1378 t.Errorf("reasoning should show a live marker, transcript=%v", m.transcript)
1379 }
1380 if !strings.Contains(m.transcript[1], "weighing options") {
1381 t.Errorf("reasoning text should stream live, transcript=%v", m.transcript)
1382 }
1383
1384 for _, tc := range []struct {
1385 name string
1386 ev event.Event
1387 want string
1388 }{
1389 {"dispatch", event.Event{Kind: event.ToolDispatch, Tool: event.Tool{Name: "read_file", Args: `{"path":"x"}`}}, "● Read(x)"},
1390 {"blocked", event.Event{Kind: event.ToolResult, Tool: event.Tool{Name: "bash", Err: "blocked by permission policy"}}, "● Bash ⊘ blocked by permission policy"},
1391 {"usage", event.Event{Kind: event.Usage, Usage: &provider.Usage{PromptTokens: 1000, CompletionTokens: 200, TotalTokens: 1200, CacheHitTokens: 900, CacheMissTokens: 100}}, "TURN 1.2K tok"},
1392 {"usage-diagnostics", event.Event{Kind: event.Usage, Usage: &provider.Usage{PromptTokens: 1000, CompletionTokens: 200, TotalTokens: 1200}, CacheDiagnostics: &event.CacheDiagnostics{PrefixChanged: true, PrefixChangeReasons: []string{"tools"}}}, "cache prefix changed: tools"},
1393 {"notice-info", event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "compacted 8 messages → summary"}, " · compacted 8 messages → summary"},
1394 {"notice-warn", event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "response truncated: hit max output tokens"}, " ! response truncated: hit max output tokens"},
1395 {"phase", event.Event{Kind: event.Phase, Text: "planner · planning"}, "[planner · planning]"},
1396 } {
1397 m := newTestChatTUI()
1398 m.ingestEvent(tc.ev)
1399 got := *m.pendingCommit
1400 normalized := ""
1401 if len(got) == 1 {
1402 normalized = strings.Join(strings.Fields(ansi.Strip(got[0])), " ")
1403 }
1404 want := strings.Join(strings.Fields(tc.want), " ")
1405 if len(got) != 1 || !strings.Contains(normalized, want) {
1406 t.Errorf("%s: committed=%v, want a single line containing %q", tc.name, got, tc.want)
1407 }
1408 }
1409
1410 // A successful tool result is silent — it only feeds the model.
1411 m = newTestChatTUI()
1412 m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{Name: "read_file", Output: "contents"}})
1413 if len(*m.pendingCommit) != 0 {
1414 t.Errorf("successful tool result should be silent, committed=%v", *m.pendingCommit)
1415 }
1416 }
1417
1418 func TestIngestEventShowsReasoningInVerboseMode(t *testing.T) {
1419 m := newTestChatTUI()
1420 m.showReasoning = true
1421
1422 m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "weighing options"})
1423 if !strings.Contains(m.reasoning.String(), "weighing options") {
1424 t.Errorf("verbose reasoning should buffer the text, got %q", m.reasoning.String())
1425 }
1426 }
1427
1428 // TestUserBubbleEchoedImmediately proves the user bubble is committed to scrollback
1429 // the moment the turn starts, not deferred to the server's first packet. The first
1430 // real packet only confirms the send (closing the un-send window); a local
1431 // TurnStarted must not, so Esc can still un-send until the server actually replies.
1432 func TestUserBubbleEchoedImmediately(t *testing.T) {
1433 m := newTestChatTUI()
1434 // Stand in for startTurn's immediate echo (no controller in the unit harness).
1435 m.bubbleStartIdx = len(m.transcript)
1436 m.commitLine("")
1437 m.commitLine(renderUserBubble("hello world", m.width, m.planMode))
1438 m.bubblePending = true
1439 m.state = tuiRunning
1440
1441 if !strings.Contains(strings.Join(m.transcript, "\n"), "hello world") {
1442 t.Fatalf("bubble should be echoed to scrollback immediately, got %v", m.transcript)
1443 }
1444
1445 // TurnStarted is emitted locally before the request — it must not confirm.
1446 m.ingestEvent(event.Event{Kind: event.TurnStarted})
1447 if !m.bubblePending {
1448 t.Fatalf("TurnStarted should leave the send un-sendable, pending=%v", m.bubblePending)
1449 }
1450
1451 // The first real packet confirms the send; a reasoning packet also shows its
1452 // live thinking marker.
1453 m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "thinking…"})
1454 if m.bubblePending {
1455 t.Fatalf("first packet should confirm the send")
1456 }
1457 if !strings.Contains(strings.Join(m.transcript, "\n"), "thinking") {
1458 t.Errorf("reasoning packet should show the thinking marker, got %v", m.transcript)
1459 }
1460 }
1461
1462 func TestUserBubbleIsLightweightTranscriptLine(t *testing.T) {
1463 prevColor := activeColorProfile
1464 activeColorProfile = colorprofile.ANSI256
1465 defer func() { activeColorProfile = prevColor }()
1466
1467 got := renderUserBubble("hello world", 80, false)
1468 plain := ansi.Strip(got)
1469 if !strings.Contains(plain, "› hello world") {
1470 t.Fatalf("user bubble missing prompt text: %q", plain)
1471 }
1472 if got == plain {
1473 t.Fatalf("user bubble should use themed foreground color when color is enabled: %q", got)
1474 }
1475 if w := ansi.StringWidth(plain); w > 20 {
1476 t.Fatalf("user bubble should not render as a full-width input-like block, width=%d text=%q", w, plain)
1477 }
1478 }
1479
1480 // TestUnsendDiscardsBufferedEvents proves that after an un-send (Esc before any
1481 // packet) the turn's already-buffered events are swallowed — nothing reaches
1482 // scrollback — and its TurnDone settles the model back to idle.
1483 func TestUnsendDiscardsBufferedEvents(t *testing.T) {
1484 m := newTestChatTUI()
1485 m.state = tuiRunning
1486 m.turnDiscarded = true // the state unsendPending leaves behind
1487
1488 m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "late thinking"})
1489 m.ingestEvent(event.Event{Kind: event.Text, Text: "late answer"})
1490 if len(*m.pendingCommit) != 0 || m.reasoning.Len() != 0 || m.pending.Len() != 0 {
1491 t.Fatalf("a discarded turn should swallow buffered events, committed=%v", *m.pendingCommit)
1492 }
1493
1494 m.ingestEvent(event.Event{Kind: event.TurnDone})
1495 if m.turnDiscarded || m.state != tuiIdle {
1496 t.Fatalf("TurnDone should clear the discard and return to idle, discarded=%v state=%v", m.turnDiscarded, m.state)
1497 }
1498 if len(*m.pendingCommit) != 0 {
1499 t.Errorf("a discarded turn should leave nothing in scrollback, committed=%v", *m.pendingCommit)
1500 }
1501 }
1502
1503 func TestRecoveryPauseTurnDoneIsInformational(t *testing.T) {
1504 t.Cleanup(func() { i18n.DetectLanguage("en") })
1505 const backendFallback = "Automatic retries paused. Reasonix stopped repeated attempts and kept completed work. Send \"continue\" to start a fresh attempt, or add instructions to change direction."
1506 tests := []struct {
1507 lang string
1508 want string
1509 }{
1510 {
1511 lang: "en",
1512 want: "Automatic retries paused. Reasonix stopped repeated attempts and kept completed work. Send “Continue” to start a fresh attempt, or add instructions to change direction.",
1513 },
1514 {
1515 lang: "zh",
1516 want: "已暂停自动重试。Reasonix 已停止重复尝试,并保留已完成的工作。发送“继续”即可开始新一轮,也可以补充要求来调整方向。",
1517 },
1518 {
1519 lang: "zh-TW",
1520 want: "已暫停自動重試。Reasonix 已停止重複嘗試,並保留已完成的工作。傳送「繼續」即可開始新一輪,也可以補充要求來調整方向。",
1521 },
1522 }
1523 for _, tt := range tests {
1524 t.Run(tt.lang, func(t *testing.T) {
1525 i18n.DetectLanguage(tt.lang)
1526 m := newTestChatTUI()
1527 m.width = 240
1528 m.ingestEvent(event.Event{
1529 Kind: event.TurnDone,
1530 Err: &agent.RecoveryPauseError{Message: backendFallback},
1531 Outcome: event.TurnOutcomeRecoveryPaused,
1532 })
1533
1534 got := ansi.Strip(strings.Join(*m.pendingCommit, "\n"))
1535 if !strings.Contains(got, tt.want) {
1536 t.Fatalf("recovery pause transcript = %q, want localized pause message %q", got, tt.want)
1537 }
1538 if tt.lang != "en" && strings.Contains(got, backendFallback) {
1539 t.Fatalf("recovery pause transcript = %q, must not leak English fallback into %s", got, tt.lang)
1540 }
1541 if strings.Contains(got, i18n.M.ErrorPrefix) {
1542 t.Fatalf("recovery pause transcript = %q, must not use error prefix %q", got, i18n.M.ErrorPrefix)
1543 }
1544 })
1545 }
1546 }
1547
1548 // TestAnswerTextStartingWithBracketStaysInAnswer locks in the win of the typed
1549 // event stream: model answer text starting with "[" — a markdown link, a slice
1550 // literal, even a quoted "[… · planning]" — is a Text event, so it can never be
1551 // mistaken for a coordinator phase marker the way prefix-sniffing a flattened
1552 // byte stream once could. It stays in the answer buffer and renders as markdown.
1553 func TestAnswerTextStartingWithBracketStaysInAnswer(t *testing.T) {
1554 for _, txt := range []string{
1555 "[link](https://example.com)",
1556 "[1, 2, 3]",
1557 "[planner · planning] (the model quoting a marker)",
1558 } {
1559 m := newTestChatTUI()
1560 m.ingestEvent(event.Event{Kind: event.Text, Text: txt})
1561 if len(*m.pendingCommit) != 0 {
1562 t.Errorf("answer text %q should stay live, not commit as an event line: %v", txt, *m.pendingCommit)
1563 }
1564 if m.pending.String() != txt {
1565 t.Errorf("answer text should buffer verbatim, got %q want %q", m.pending.String(), txt)
1566 }
1567 }
1568 }
1569
1570 // TestInsertNewlineKeyBinding verifies newChatTUI actually wires shift+enter
1571 // into the textarea's InsertNewline binding (plain Enter submits, so a newline
1572 // needs a modifier). It exercises the real constructor, not a hand-built binding.
1573 func TestInsertNewlineKeyBinding(t *testing.T) {
1574 ctrl := newOwnedTestController(t, control.Options{})
1575 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
1576 keys := m.input.KeyMap.InsertNewline.Keys()
1577 found := slices.Contains(keys, "shift+enter")
1578 if !found {
1579 t.Errorf("newChatTUI InsertNewline should include shift+enter, got %v", keys)
1580 }
1581 }
1582
1583 func TestCtrlHomeEndScrollKeyBindings(t *testing.T) {
1584 ctrl := newOwnedTestController(t, control.Options{})
1585 ch := make(chan event.Event, 1)
1586 notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
1587 adv := func(m chatTUI, msg tea.Msg) chatTUI {
1588 n, _ := m.Update(msg)
1589 return n.(chatTUI)
1590 }
1591
1592 cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8})
1593 for range 12 {
1594 cur = adv(cur, notice)
1595 }
1596 // Viewport should be at the bottom after output.
1597 if !cur.viewport.AtBottom() {
1598 t.Fatal("viewport should start at the bottom after streaming output")
1599 }
1600
1601 // Ctrl+Home should scroll to the top.
1602 cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyHome, Mod: tea.ModCtrl})
1603 if !cur.viewport.AtTop() {
1604 t.Fatalf("ctrl+home should scroll to top, AtTop=%v, YOffset=%d", cur.viewport.AtTop(), cur.viewport.YOffset())
1605 }
1606
1607 // Ctrl+End should scroll back to the bottom.
1608 cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnd, Mod: tea.ModCtrl})
1609 if !cur.viewport.AtBottom() {
1610 t.Fatalf("ctrl+end should scroll to bottom, AtBottom=%v, YOffset=%d", cur.viewport.AtBottom(), cur.viewport.YOffset())
1611 }
1612 }
1613
1614 func TestMouseWheelAndPageKeysScrollTranscript(t *testing.T) {
1615 ctrl := newOwnedTestController(t, control.Options{})
1616 ch := make(chan event.Event, 1)
1617 notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
1618 adv := func(m chatTUI, msg tea.Msg) chatTUI {
1619 n, cmd := m.Update(msg)
1620 _, wheel := msg.(tea.MouseWheelMsg)
1621 _, key := msg.(tea.KeyPressMsg)
1622 if cmd != nil && (wheel || key) {
1623 t.Fatalf("viewport update %T should rely on the renderer diff, got command %T", msg, cmd)
1624 }
1625 return n.(chatTUI)
1626 }
1627 cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 10})
1628 for range 40 {
1629 cur = adv(cur, notice)
1630 }
1631 if !cur.viewport.AtBottom() {
1632 t.Fatal("viewport should start at bottom after overflowing output")
1633 }
1634 bottom := cur.viewport.YOffset()
1635 if bottom <= cur.viewport.Height()+3 {
1636 t.Fatalf("test transcript did not overflow enough: bottom=%d height=%d", bottom, cur.viewport.Height())
1637 }
1638 cur.legacyScrollClear = false
1639 cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
1640 if got, want := cur.viewport.YOffset(), bottom-3; got != want {
1641 t.Fatalf("wheel-up YOffset = %d, want %d", got, want)
1642 }
1643 cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelDown})
1644 if got := cur.viewport.YOffset(); got != bottom {
1645 t.Fatalf("wheel-down should return by one wheel step, YOffset=%d want bottom=%d", got, bottom)
1646 }
1647 cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyPgUp})
1648 pageUp := cur.viewport.YOffset()
1649 if got, want := pageUp, bottom-cur.viewport.Height(); got != want {
1650 t.Fatalf("PageUp YOffset = %d, want %d", got, want)
1651 }
1652 cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyPgDown})
1653 if got := cur.viewport.YOffset(); got != bottom {
1654 t.Fatalf("PageDown should return to bottom from one page up, YOffset=%d want %d", got, bottom)
1655 }
1656 }
1657
1658 func TestRunningStreamPreservesScrolledReadingPosition(t *testing.T) {
1659 ctrl := newOwnedTestController(t, control.Options{})
1660 ch := make(chan event.Event, 1)
1661 notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
1662 adv := func(m chatTUI, msg tea.Msg) chatTUI {
1663 n, _ := m.Update(msg)
1664 return n.(chatTUI)
1665 }
1666
1667 cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 10})
1668 for range 40 {
1669 cur = adv(cur, notice)
1670 }
1671 cur.state = tuiRunning
1672 cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
1673 readOffset := cur.viewport.YOffset()
1674 if cur.viewport.AtBottom() {
1675 t.Fatal("wheel-up should leave the bottom before streaming output arrives")
1676 }
1677
1678 cur = adv(cur, agentEventMsg(event.Event{Kind: event.Text, Text: "streamed paragraph\n\n"}))
1679 if cur.viewport.AtBottom() {
1680 t.Fatal("streaming output must not yank a scrolled-up reader back to bottom")
1681 }
1682 if got := cur.viewport.YOffset(); got != readOffset {
1683 t.Fatalf("streaming output should preserve reading offset, got %d want %d", got, readOffset)
1684 }
1685
1686 cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelDown})
1687 if got, want := cur.viewport.YOffset(), readOffset+3; got != want {
1688 t.Fatalf("wheel-down while running should move one wheel step, got %d want %d", got, want)
1689 }
1690 if cur.viewport.AtBottom() {
1691 t.Fatal("one wheel-down step from the reading position should not jump straight to bottom")
1692 }
1693 }
1694
1695 func TestTranscriptScrollbarClickAndDrag(t *testing.T) {
1696 ctrl := newOwnedTestController(t, control.Options{})
1697 ch := make(chan event.Event, 1)
1698 notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
1699 adv := func(m chatTUI, msg tea.Msg) chatTUI {
1700 n, _ := m.Update(msg)
1701 return n.(chatTUI)
1702 }
1703
1704 cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 10})
1705 for range 40 {
1706 cur = adv(cur, notice)
1707 }
1708 cur.viewport.GotoTop()
1709 barX := cur.viewport.Width()
1710 bottomRow := cur.viewport.Height() - 1
1711
1712 cur = adv(cur, tea.MouseClickMsg{X: barX, Y: 0, Button: tea.MouseLeft})
1713 if cur.sel.active {
1714 t.Fatal("clicking the scrollbar must not start transcript selection")
1715 }
1716 if !cur.scrollbarDrag {
1717 t.Fatal("left-click on scrollbar should start scrollbar drag")
1718 }
1719
1720 cur = adv(cur, tea.MouseMotionMsg{X: barX, Y: bottomRow, Button: tea.MouseLeft})
1721 if !cur.viewport.AtBottom() {
1722 t.Fatalf("dragging scrollbar to bottom should reach bottom, YOffset=%d", cur.viewport.YOffset())
1723 }
1724 if cur.sel.active {
1725 t.Fatal("dragging the scrollbar must not leave a transcript selection")
1726 }
1727
1728 cur = adv(cur, tea.MouseReleaseMsg{X: barX, Y: bottomRow, Button: tea.MouseLeft})
1729 if cur.scrollbarDrag {
1730 t.Fatal("mouse release should end scrollbar drag")
1731 }
1732 if cur.sel.active {
1733 t.Fatal("scrollbar release must not create a text selection")
1734 }
1735
1736 cur.viewport.GotoTop()
1737 cur = adv(cur, tea.MouseClickMsg{X: barX - 1, Y: 0, Button: tea.MouseLeft})
1738 if !cur.sel.active {
1739 t.Fatal("clicking the transcript content column next to the scrollbar should still start selection")
1740 }
1741 }
1742
1743 func clipboardCopyResultFromCmd(t *testing.T, cmd tea.Cmd) clipboardCopyMsg {
1744 t.Helper()
1745 if cmd == nil {
1746 t.Fatal("expected clipboard command")
1747 }
1748 msg := cmd()
1749 switch msg := msg.(type) {
1750 case clipboardCopyMsg:
1751 return msg
1752 case tea.BatchMsg:
1753 for _, child := range msg {
1754 if child == nil {
1755 continue
1756 }
1757 childMsg := child()
1758 if result, ok := childMsg.(clipboardCopyMsg); ok {
1759 return result
1760 }
1761 }
1762 }
1763 t.Fatalf("clipboard command returned %T, want clipboardCopyMsg", msg)
1764 return clipboardCopyMsg{}
1765 }
1766
1767 func clipboardTextPasteResultFromCmd(t *testing.T, cmd tea.Cmd) clipboardTextPasteMsg {
1768 t.Helper()
1769 if cmd == nil {
1770 t.Fatal("expected clipboard paste command")
1771 }
1772 msg := cmd()
1773 switch msg := msg.(type) {
1774 case clipboardTextPasteMsg:
1775 return msg
1776 case tea.BatchMsg:
1777 for _, child := range msg {
1778 if child == nil {
1779 continue
1780 }
1781 childMsg := child()
1782 if result, ok := childMsg.(clipboardTextPasteMsg); ok {
1783 return result
1784 }
1785 }
1786 }
1787 t.Fatalf("clipboard paste command returned %T, want clipboardTextPasteMsg", msg)
1788 return clipboardTextPasteMsg{}
1789 }
1790
1791 func middleClickPasteResultFromCmd(t *testing.T, cmd tea.Cmd) tea.PasteMsg {
1792 t.Helper()
1793 if cmd == nil {
1794 t.Fatal("expected middle-click paste command")
1795 }
1796 msg := cmd()
1797 switch msg := msg.(type) {
1798 case tea.PasteMsg:
1799 return msg
1800 case tea.BatchMsg:
1801 for _, child := range msg {
1802 if child == nil {
1803 continue
1804 }
1805 if result, ok := child().(tea.PasteMsg); ok {
1806 return result
1807 }
1808 }
1809 }
1810 t.Fatalf("middle-click command returned %T, want tea.PasteMsg", msg)
1811 return tea.PasteMsg{}
1812 }
1813
1814 func setLocalClipboardSession(t *testing.T) {
1815 t.Helper()
1816 t.Setenv("SSH_CONNECTION", "")
1817 t.Setenv("SSH_CLIENT", "")
1818 t.Setenv("SSH_TTY", "")
1819 }
1820
1821 func TestShiftInsertPastesClipboardText(t *testing.T) {
1822 setLocalClipboardSession(t)
1823 m := newComposerMouseTestTUI(t, 60, 16)
1824 m.input.SetValue("before ")
1825
1826 previous := readNativeClipboardText
1827 t.Cleanup(func() { readNativeClipboardText = previous })
1828 readNativeClipboardText = func() (string, error) { return "pasted text", nil }
1829
1830 next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyInsert, Mod: tea.ModShift})
1831 m = next.(chatTUI)
1832 if got := m.input.Value(); got != "before " {
1833 t.Fatalf("Shift+Insert changed the composer before the async read: %q", got)
1834 }
1835 result := clipboardTextPasteResultFromCmd(t, cmd)
1836 next, _ = m.Update(result)
1837 m = next.(chatTUI)
1838
1839 if got := m.input.Value(); got != "before pasted text" {
1840 t.Fatalf("Shift+Insert paste produced %q, want %q", got, "before pasted text")
1841 }
1842 }
1843
1844 func TestShiftInsertPasteOverSSHDoesNotReadRemoteClipboard(t *testing.T) {
1845 t.Setenv("SSH_CONNECTION", "host 22 client 1234")
1846 t.Setenv("SSH_CLIENT", "")
1847 t.Setenv("SSH_TTY", "")
1848
1849 m := newComposerMouseTestTUI(t, 60, 16)
1850 m.input.SetValue("before ")
1851
1852 previous := readNativeClipboardText
1853 t.Cleanup(func() { readNativeClipboardText = previous })
1854 readNativeClipboardText = func() (string, error) {
1855 t.Fatal("SSH Shift+Insert paste must not read the remote host clipboard")
1856 return "", nil
1857 }
1858
1859 next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyInsert, Mod: tea.ModShift})
1860 m = next.(chatTUI)
1861 result := clipboardTextPasteResultFromCmd(t, cmd)
1862 if !result.remote {
1863 t.Fatalf("SSH Shift+Insert paste result = %+v, want remote hint", result)
1864 }
1865
1866 next, _ = m.Update(result)
1867 m = next.(chatTUI)
1868 if got := m.input.Value(); got != "before " {
1869 t.Fatalf("SSH Shift+Insert paste changed composer to %q", got)
1870 }
1871 }
1872
1873 func TestMouseRightClickWithoutSelectionPastesClipboardText(t *testing.T) {
1874 setLocalClipboardSession(t)
1875 m := newComposerMouseTestTUI(t, 60, 16)
1876 m.input.SetValue("before ")
1877
1878 previous := readNativeClipboardText
1879 t.Cleanup(func() { readNativeClipboardText = previous })
1880 readNativeClipboardText = func() (string, error) { return "pasted text", nil }
1881
1882 next, cmd := m.Update(tea.MouseClickMsg{Button: tea.MouseRight})
1883 m = next.(chatTUI)
1884 result := clipboardTextPasteResultFromCmd(t, cmd)
1885 next, _ = m.Update(result)
1886 m = next.(chatTUI)
1887
1888 if got := m.input.Value(); got != "before pasted text" {
1889 t.Fatalf("right-click paste produced %q, want %q", got, "before pasted text")
1890 }
1891 }
1892
1893 func TestMouseRightClickPasteOverSSHDoesNotReadRemoteClipboard(t *testing.T) {
1894 t.Setenv("SSH_CONNECTION", "host 22 client 1234")
1895 t.Setenv("SSH_CLIENT", "")
1896 t.Setenv("SSH_TTY", "")
1897
1898 m := newComposerMouseTestTUI(t, 60, 16)
1899 m.input.SetValue("before ")
1900
1901 previous := readNativeClipboardText
1902 t.Cleanup(func() { readNativeClipboardText = previous })
1903 readNativeClipboardText = func() (string, error) {
1904 t.Fatal("SSH right-click paste must not read the remote host clipboard")
1905 return "", nil
1906 }
1907
1908 next, cmd := m.Update(tea.MouseClickMsg{Button: tea.MouseRight})
1909 m = next.(chatTUI)
1910 result := clipboardTextPasteResultFromCmd(t, cmd)
1911 if !result.remote {
1912 t.Fatalf("SSH right-click paste result = %+v, want remote hint", result)
1913 }
1914
1915 next, _ = m.Update(result)
1916 m = next.(chatTUI)
1917 if got := m.input.Value(); got != "before " {
1918 t.Fatalf("SSH right-click paste changed composer to %q", got)
1919 }
1920 if got := strings.Join(m.transcript, "\n"); !strings.Contains(got, i18n.M.ClipboardTextPasteRemoteHint) {
1921 t.Fatalf("SSH right-click paste notice = %q, want %q", got, i18n.M.ClipboardTextPasteRemoteHint)
1922 }
1923 }
1924
1925 func TestMouseRightClickPasteUsesCanonicalFoldedPastePath(t *testing.T) {
1926 setLocalClipboardSession(t)
1927 m := newComposerMouseTestTUI(t, 60, 16)
1928 pasted := "one\ntwo\nthree\nfour\nfive"
1929
1930 previous := readNativeClipboardText
1931 t.Cleanup(func() { readNativeClipboardText = previous })
1932 readNativeClipboardText = func() (string, error) { return pasted, nil }
1933
1934 next, cmd := m.Update(tea.MouseClickMsg{Button: tea.MouseRight})
1935 m = next.(chatTUI)
1936 result := clipboardTextPasteResultFromCmd(t, cmd)
1937 next, _ = m.Update(result)
1938 m = next.(chatTUI)
1939
1940 if got := m.input.Value(); got != "[Pasted text #1 · 5 lines] " {
1941 t.Fatalf("right-click folded paste display = %q", got)
1942 }
1943 if len(m.pastedBlocks) != 1 || m.pastedBlocks[0].text != pasted {
1944 t.Fatalf("right-click folded paste block = %+v", m.pastedBlocks)
1945 }
1946 }
1947
1948 func TestMiddleClickUsesTmuxPasteBufferInsideTmux(t *testing.T) {
1949 t.Setenv("TMUX", "/tmp/tmux-1000/default,1,0")
1950 previousTmux := readTmuxPasteBuffer
1951 previousPrimary := readPrimaryPasteSelection
1952 t.Cleanup(func() {
1953 readTmuxPasteBuffer = previousTmux
1954 readPrimaryPasteSelection = previousPrimary
1955 })
1956 readTmuxPasteBuffer = func() (string, error) { return "tmux buffer", nil }
1957 readPrimaryPasteSelection = func() (string, error) {
1958 t.Fatal("middle-click inside tmux must not read the desktop PRIMARY selection")
1959 return "", nil
1960 }
1961
1962 msg := pasteMiddleClick()()
1963 paste, ok := msg.(tea.PasteMsg)
1964 if !ok || paste.Content != "tmux buffer" {
1965 t.Fatalf("middle-click result = %#v, want tmux-buffer PasteMsg", msg)
1966 }
1967 }
1968
1969 func TestMouseMiddleClickPastesPrimarySelectionThroughCanonicalPath(t *testing.T) {
1970 setLocalClipboardSession(t)
1971 t.Setenv("TMUX", "")
1972 previous := readPrimaryPasteSelection
1973 t.Cleanup(func() { readPrimaryPasteSelection = previous })
1974 readPrimaryPasteSelection = func() (string, error) { return "primary selection", nil }
1975
1976 m := newComposerMouseTestTUI(t, 60, 16)
1977 m.input.SetValue("before ")
1978 next, cmd := m.Update(tea.MouseClickMsg{Button: tea.MouseMiddle})
1979 m = next.(chatTUI)
1980 paste := middleClickPasteResultFromCmd(t, cmd)
1981 next, _ = m.Update(paste)
1982 m = next.(chatTUI)
1983
1984 if got := m.input.Value(); got != "before primary selection" {
1985 t.Fatalf("middle-click paste produced %q, want %q", got, "before primary selection")
1986 }
1987 }
1988
1989 func TestMouseMiddleClickDoesNotMutateHiddenComposer(t *testing.T) {
1990 setLocalClipboardSession(t)
1991 t.Setenv("TMUX", "")
1992 previous := readPrimaryPasteSelection
1993 t.Cleanup(func() { readPrimaryPasteSelection = previous })
1994 readPrimaryPasteSelection = func() (string, error) {
1995 t.Fatal("middle-click with a hidden composer must not read PRIMARY")
1996 return "", nil
1997 }
1998
1999 m := newComposerMouseTestTUI(t, 60, 16)
2000 m.input.SetValue("before")
2001 m.pendingApproval = &event.Approval{ID: "approval", Tool: "bash", Subject: "echo hi"}
2002 if !m.hideComposer() {
2003 t.Fatal("test setup did not hide composer")
2004 }
2005
2006 next, cmd := m.Update(tea.MouseClickMsg{Button: tea.MouseMiddle})
2007 m = next.(chatTUI)
2008 if cmd != nil {
2009 t.Fatalf("hidden-composer middle-click returned command with message %#v", cmd())
2010 }
2011 if got := m.input.Value(); got != "before" {
2012 t.Fatalf("hidden composer changed to %q", got)
2013 }
2014 }
2015
2016 func TestMouseMiddleClickPasteOverSSHDoesNotReadRemotePrimary(t *testing.T) {
2017 t.Setenv("SSH_CONNECTION", "host 22 client 1234")
2018 t.Setenv("SSH_CLIENT", "")
2019 t.Setenv("SSH_TTY", "")
2020 t.Setenv("TMUX", "")
2021 previous := readPrimaryPasteSelection
2022 t.Cleanup(func() { readPrimaryPasteSelection = previous })
2023 readPrimaryPasteSelection = func() (string, error) {
2024 t.Fatal("SSH middle-click must not read PRIMARY on the remote host")
2025 return "", nil
2026 }
2027
2028 m := newComposerMouseTestTUI(t, 60, 16)
2029 next, cmd := m.Update(tea.MouseClickMsg{Button: tea.MouseMiddle})
2030 m = next.(chatTUI)
2031 result := clipboardTextPasteResultFromCmd(t, cmd)
2032 if !result.remote {
2033 t.Fatalf("SSH middle-click paste result = %+v, want remote hint", result)
2034 }
2035
2036 next, _ = m.Update(result)
2037 m = next.(chatTUI)
2038 if got := strings.Join(m.transcript, "\n"); !strings.Contains(got, i18n.M.ClipboardTextPasteRemoteHint) {
2039 t.Fatalf("SSH middle-click paste notice = %q, want %q", got, i18n.M.ClipboardTextPasteRemoteHint)
2040 }
2041 }
2042
2043 func TestMiddleClickUsesPrimarySelectionOutsideTmux(t *testing.T) {
2044 t.Setenv("TMUX", "")
2045 previousTmux := readTmuxPasteBuffer
2046 previousPrimary := readPrimaryPasteSelection
2047 t.Cleanup(func() {
2048 readTmuxPasteBuffer = previousTmux
2049 readPrimaryPasteSelection = previousPrimary
2050 })
2051 readTmuxPasteBuffer = func() (string, error) {
2052 t.Fatal("middle-click outside tmux must not read a tmux buffer")
2053 return "", nil
2054 }
2055 readPrimaryPasteSelection = func() (string, error) { return "primary selection", nil }
2056
2057 msg := pasteMiddleClick()()
2058 paste, ok := msg.(tea.PasteMsg)
2059 if !ok || paste.Content != "primary selection" {
2060 t.Fatalf("middle-click result = %#v, want PRIMARY-selection PasteMsg", msg)
2061 }
2062 }
2063
2064 func TestMiddleClickTmuxReadFailureIsSilent(t *testing.T) {
2065 t.Setenv("TMUX", "/tmp/tmux-1000/default,1,0")
2066 previous := readTmuxPasteBuffer
2067 t.Cleanup(func() { readTmuxPasteBuffer = previous })
2068 readTmuxPasteBuffer = func() (string, error) { return "", errors.New("no buffers") }
2069
2070 if msg := pasteMiddleClick()(); msg != nil {
2071 t.Fatalf("failed tmux-buffer read returned %#v, want silent no-op", msg)
2072 }
2073 }
2074
2075 func TestMiddleClickPasteCommandsFilterRegisteredCredentials(t *testing.T) {
2076 t.Setenv(middleClickPasteHelperFlag, "1")
2077 t.Setenv(middleClickPasteHelperMode, "credential")
2078 t.Setenv(middleClickPasteTestValue, "credential-leaked")
2079 secrets.RegisterCredentialEnvKeys([]string{middleClickPasteTestValue})
2080
2081 previous := newPasteCommand
2082 t.Cleanup(func() { newPasteCommand = previous })
2083 newPasteCommand = func(_ string, _ ...string) *exec.Cmd {
2084 return exec.Command(os.Args[0], "-test.run=^TestMiddleClickPasteCommandHelper$")
2085 }
2086
2087 for name, read := range map[string]func() (string, error){
2088 "tmux": readTmuxBuffer,
2089 "primary": readPrimarySelection,
2090 } {
2091 t.Run(name, func(t *testing.T) {
2092 text, err := read()
2093 if err != nil {
2094 t.Fatal(err)
2095 }
2096 if text != "filtered" {
2097 t.Fatalf("paste helper inherited registered credential: %q", text)
2098 }
2099 })
2100 }
2101 }
2102
2103 func TestReadPrimarySelectionRequestsTextAndPreservesNewlines(t *testing.T) {
2104 t.Setenv(middleClickPasteHelperFlag, "1")
2105 t.Setenv(middleClickPasteHelperMode, "newlines")
2106
2107 previous := newPasteCommand
2108 t.Cleanup(func() { newPasteCommand = previous })
2109 called := false
2110 newPasteCommand = func(name string, args ...string) *exec.Cmd {
2111 called = true
2112 if name != "wl-paste" {
2113 t.Fatalf("first PRIMARY helper = %q, want wl-paste", name)
2114 }
2115 want := []string{"--primary", "--type", "text", "--no-newline"}
2116 if !reflect.DeepEqual(args, want) {
2117 t.Fatalf("wl-paste args = %q, want %q", args, want)
2118 }
2119 return exec.Command(os.Args[0], "-test.run=^TestMiddleClickPasteCommandHelper$")
2120 }
2121
2122 text, err := readPrimarySelection()
2123 if err != nil {
2124 t.Fatal(err)
2125 }
2126 if !called {
2127 t.Fatal("PRIMARY helper was not invoked")
2128 }
2129 if text != "line\n\n" {
2130 t.Fatalf("PRIMARY selection = %q, want trailing newlines preserved", text)
2131 }
2132 }
2133
2134 // TestMouseDragReleaseAutoCopies verifies that releasing the mouse after a
2135 // left-drag over the transcript copies the selection to the clipboard
2136 // automatically (native terminal convention), keeps the selection highlighted
2137 // so a follow-up right-click can still re-copy it, and arms the transient
2138 // "copied to clipboard" status-line notice.
2139 func TestMouseDragReleaseAutoCopies(t *testing.T) {
2140 setLocalClipboardSession(t)
2141 m := newTestChatTUI()
2142 m.transcript = []string{"hello world"}
2143 m.wrappedLines = []string{"hello world"}
2144 m.sel = selection{active: true, anchor: selPos{line: 0, col: 0}, head: selPos{line: 0, col: 5}}
2145
2146 out, cmd := m.Update(tea.MouseReleaseMsg{Button: tea.MouseLeft})
2147 m2, ok := out.(chatTUI)
2148 if !ok {
2149 t.Fatalf("Update returned %T, want chatTUI", out)
2150 }
2151
2152 if cmd == nil {
2153 t.Fatal("release after a real drag should return a cmd (clipboard copy + notice)")
2154 }
2155 if !m2.sel.active {
2156 t.Error("selection should stay highlighted after auto-copy so right-click can re-copy it")
2157 }
2158 if m2.copyNoticeText != "" {
2159 t.Error("copy must not claim success before the native clipboard write completes")
2160 }
2161
2162 previous := writeNativeClipboardText
2163 t.Cleanup(func() { writeNativeClipboardText = previous })
2164 writeNativeClipboardText = func(text string) error {
2165 if text != "hello" {
2166 t.Fatalf("native clipboard text = %q, want hello", text)
2167 }
2168 return nil
2169 }
2170 result := clipboardCopyResultFromCmd(t, cmd)
2171 out, _ = m2.Update(result)
2172 m3 := out.(chatTUI)
2173 if m3.copyNoticeText != i18n.M.MouseCopiedHint {
2174 t.Errorf("completed native copy notice = %q, want %q", m3.copyNoticeText, i18n.M.MouseCopiedHint)
2175 }
2176 }
2177
2178 // TestCtrlInsertCopiesTranscriptSelection verifies the terminal-convention
2179 // Ctrl+Insert copy key copies an active transcript selection to the clipboard
2180 // and arms the copied notice, without Ctrl+C's destructive side effects.
2181 func TestCtrlInsertCopiesTranscriptSelection(t *testing.T) {
2182 setLocalClipboardSession(t)
2183 m := newTestChatTUI()
2184 m.transcript = []string{"hello world"}
2185 m.wrappedLines = []string{"hello world"}
2186 m.sel = selection{active: true, anchor: selPos{line: 0, col: 0}, head: selPos{line: 0, col: 5}}
2187
2188 previous := writeNativeClipboardText
2189 t.Cleanup(func() { writeNativeClipboardText = previous })
2190 writeNativeClipboardText = func(text string) error {
2191 if text != "hello" {
2192 t.Fatalf("native clipboard text = %q, want hello", text)
2193 }
2194 return nil
2195 }
2196
2197 out, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyInsert, Mod: tea.ModCtrl})
2198 m2 := out.(chatTUI)
2199 if m2.input.Value() != "" {
2200 t.Fatalf("Ctrl+Insert must not touch the composer, got %q", m2.input.Value())
2201 }
2202 result := clipboardCopyResultFromCmd(t, cmd)
2203 out, _ = m2.Update(result)
2204 m3 := out.(chatTUI)
2205 if m3.copyNoticeText != i18n.M.MouseCopiedHint {
2206 t.Errorf("completed native copy notice = %q, want %q", m3.copyNoticeText, i18n.M.MouseCopiedHint)
2207 }
2208 }
2209
2210 // TestCtrlInsertCopiesComposerSelection verifies Ctrl+Insert also copies an
2211 // active selection inside the composer, mirroring the Ctrl+C handling.
2212 func TestCtrlInsertCopiesComposerSelection(t *testing.T) {
2213 setLocalClipboardSession(t)
2214 m := newComposerMouseTestTUI(t, 60, 16)
2215 m.input.SetValue("hello world")
2216 m.composerSel = composerSelection{active: true, anchor: 0, head: 5, value: m.input.Value()}
2217
2218 previous := writeNativeClipboardText
2219 t.Cleanup(func() { writeNativeClipboardText = previous })
2220 writeNativeClipboardText = func(text string) error {
2221 if text != "hello" {
2222 t.Fatalf("native clipboard text = %q, want hello", text)
2223 }
2224 return nil
2225 }
2226
2227 out, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyInsert, Mod: tea.ModCtrl})
2228 m2 := out.(chatTUI)
2229 result := clipboardCopyResultFromCmd(t, cmd)
2230 out, _ = m2.Update(result)
2231 m3 := out.(chatTUI)
2232 if m3.copyNoticeText != i18n.M.MouseCopiedHint {
2233 t.Errorf("completed native copy notice = %q, want %q", m3.copyNoticeText, i18n.M.MouseCopiedHint)
2234 }
2235 }
2236
2237 // TestCtrlInsertWithoutSelectionIsNoOp verifies Ctrl+Insert with no active
2238 // selection leaves the composer and the session state untouched — unlike
2239 // Ctrl+C, it must never clear input or quit.
2240 func TestCtrlInsertWithoutSelectionIsNoOp(t *testing.T) {
2241 m := newTestChatTUI()
2242 m.input.SetValue("draft text")
2243
2244 out, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyInsert, Mod: tea.ModCtrl})
2245 m2 := out.(chatTUI)
2246 if cmd != nil {
2247 t.Fatalf("Ctrl+Insert without a selection should be a no-op, got cmd %T", cmd)
2248 }
2249 if got := m2.input.Value(); got != "draft text" {
2250 t.Fatalf("Ctrl+Insert without a selection changed the composer to %q", got)
2251 }
2252 if m2.state != tuiIdle {
2253 t.Fatalf("Ctrl+Insert without a selection changed state to %v, want idle", m2.state)
2254 }
2255 }
2256
2257 // TestMousePlainClickReleaseDoesNotCopy verifies that a plain click (no drag,
2258 // empty selection) does not copy an empty string to the clipboard or show the
2259 // copied notice — only clears the zero-width selection, as before.
2260 func TestMousePlainClickReleaseDoesNotCopy(t *testing.T) {
2261 m := newTestChatTUI()
2262 m.transcript = []string{"hello world"}
2263 m.wrappedLines = []string{"hello world"}
2264 at := selPos{line: 0, col: 3}
2265 m.sel = selection{active: true, anchor: at, head: at} // empty: anchor == head
2266
2267 out, _ := m.Update(tea.MouseReleaseMsg{Button: tea.MouseLeft})
2268 m2, ok := out.(chatTUI)
2269 if !ok {
2270 t.Fatalf("Update returned %T, want chatTUI", out)
2271 }
2272
2273 if m2.sel.active {
2274 t.Error("a plain click (empty selection) should be cleared on release")
2275 }
2276 if m2.copyNoticeText != "" {
2277 t.Error("a plain click (empty selection) must not arm the copied-to-clipboard notice")
2278 }
2279 }
2280
2281 // TestCopyNoticeExpires verifies the copied-to-clipboard notice clears itself
2282 // once its own expiry tick fires, and that a stale tick from an earlier copy
2283 // (superseded by a newer one) does not clear the newer notice.
2284 func TestCopyNoticeExpires(t *testing.T) {
2285 m := newTestChatTUI()
2286 m.copyNoticeText = i18n.M.MouseCopiedHint
2287 m.copyNoticeSeq = 2
2288
2289 // A stale tick from a prior (superseded) copy must not clear the current notice.
2290 out, _ := m.Update(copyNoticeExpireMsg{seq: 1})
2291 m2 := out.(chatTUI)
2292 if m2.copyNoticeText == "" {
2293 t.Fatal("a stale expiry tick must not clear a newer notice")
2294 }
2295
2296 // The current tick clears it.
2297 out, _ = m2.Update(copyNoticeExpireMsg{seq: 2})
2298 m3 := out.(chatTUI)
2299 if m3.copyNoticeText != "" {
2300 t.Fatal("the matching expiry tick should clear the notice")
2301 }
2302 }
2303
2304 func TestClipboardCopyFallbackDoesNotClaimNativeSuccess(t *testing.T) {
2305 m := newTestChatTUI()
2306 m.copyNoticeSeq = 7
2307
2308 out, cmd := m.Update(clipboardCopyMsg{
2309 text: "selected text",
2310 err: errors.New("pbcopy unavailable"),
2311 statusHint: true,
2312 seq: 7,
2313 })
2314 m = out.(chatTUI)
2315 if got := m.copyNoticeText; got != i18n.M.ClipboardCopyFallbackHint {
2316 t.Fatalf("fallback copy notice = %q, want %q", got, i18n.M.ClipboardCopyFallbackHint)
2317 }
2318 if cmd == nil {
2319 t.Fatal("fallback copy should emit an OSC 52 clipboard command")
2320 }
2321 }
2322
2323 func TestImagePastePendingAppearsInFooter(t *testing.T) {
2324 ctrl := newOwnedTestController(t, control.Options{})
2325 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
2326 next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 14})
2327 m = next.(chatTUI)
2328 m.clipboardImagePending = true
2329 view := ansi.Strip(m.View().Content)
2330 if !strings.Contains(view, i18n.M.ClipboardImagePastingHint) {
2331 t.Fatalf("pending image paste missing from footer:\n%s", view)
2332 }
2333 }
2334
2335 // TestToggleMouseCaptureFlipsModeAndClearsGestures proves "/mouse" flips
2336 // mouseCaptureOff, shows the matching on/off notice, and drops any in-flight
2337 // selection/scrollbar drag so a stale gesture can't be found mid-drag once the
2338 // terminal starts intercepting the events that would have finished it.
2339 func TestToggleMouseCaptureFlipsModeAndClearsGestures(t *testing.T) {
2340 m := newTestChatTUI()
2341 m.transcript = []string{"hello world"}
2342 m.wrappedLines = []string{"hello world"}
2343 m.sel = selection{active: true, anchor: selPos{line: 0, col: 0}, head: selPos{line: 0, col: 5}}
2344 m.scrollbarDrag = true
2345 m.autoScroll = 1
2346
2347 m.toggleMouseCapture()
2348 if !m.mouseCaptureOff {
2349 t.Fatal("first toggle should turn mouse capture off")
2350 }
2351 if m.sel.active || m.scrollbarDrag || m.autoScroll != 0 {
2352 t.Fatal("toggling mouse capture should clear any in-flight selection/drag")
2353 }
2354 if got := (*m.pendingCommit)[len(*m.pendingCommit)-1]; !strings.Contains(got, i18n.M.MouseCaptureOffHint) {
2355 t.Fatalf("notice = %q, want it to contain %q", got, i18n.M.MouseCaptureOffHint)
2356 }
2357
2358 m.toggleMouseCapture()
2359 if m.mouseCaptureOff {
2360 t.Fatal("second toggle should turn mouse capture back on")
2361 }
2362 if got := (*m.pendingCommit)[len(*m.pendingCommit)-1]; !strings.Contains(got, i18n.M.MouseCaptureOnHint) {
2363 t.Fatalf("notice = %q, want it to contain %q", got, i18n.M.MouseCaptureOnHint)
2364 }
2365 }
2366
2367 // TestViewMouseModeFollowsCapture proves View() requests MouseModeNone (so
2368 // the terminal's native right-click menu and click-drag selection work) while
2369 // mouseCaptureOff is set, and MouseModeCellMotion (in-app selection/scrollbar/
2370 // wheel-scroll) otherwise.
2371 func TestViewMouseModeFollowsCapture(t *testing.T) {
2372 ctrl := newOwnedTestController(t, control.Options{})
2373 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 60)
2374 m0, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 20})
2375 m = m0.(chatTUI)
2376
2377 if got := m.View().MouseMode; got != tea.MouseModeCellMotion {
2378 t.Fatalf("MouseMode with capture on = %v, want MouseModeCellMotion", got)
2379 }
2380
2381 m.mouseCaptureOff = true
2382 if got := m.View().MouseMode; got != tea.MouseModeNone {
2383 t.Fatalf("MouseMode with capture off = %v, want MouseModeNone", got)
2384 }
2385 // Status line wraps at this width, so check the unwrapped tag rather than
2386 // the rendered (possibly line-broken) View() content.
2387 if got := m.mouseTag(); !strings.Contains(ansi.Strip(got), i18n.M.MouseCaptureTag) {
2388 t.Fatalf("mouseTag() = %q, want it to contain %q", got, i18n.M.MouseCaptureTag)
2389 }
2390 }
2391
2392 func TestEchoLocalCommandAddsTranscriptMarker(t *testing.T) {
2393 m := newTestChatTUI()
2394 m.echoLocalCommand(" /tree ")
2395 if len(*m.pendingCommit) != 1 {
2396 t.Fatalf("pending commits = %d, want 1", len(*m.pendingCommit))
2397 }
2398 if got := (*m.pendingCommit)[0]; !strings.Contains(got, "› /tree") {
2399 t.Fatalf("command echo = %q, want /tree marker", got)
2400 }
2401 }
2402
2403 func isolateUserConfig(t *testing.T) {
2404 t.Helper()
2405 root := t.TempDir()
2406 t.Setenv("HOME", root)
2407 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
2408 t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config"))
2409 t.Setenv("AppData", filepath.Join(root, "AppData")) // os.UserConfigDir reads AppData on Windows
2410 t.Chdir(root)
2411 }
2412
2413 func TestEffortCommandWritesCurrentDeepSeekProvider(t *testing.T) {
2414 isolateUserConfig(t)
2415
2416 m := newTestChatTUI()
2417 m.ctrl = newOwnedTestController(t, control.Options{Label: "deepseek-flash"})
2418 m.modelRef = "deepseek-flash/deepseek-v4-flash"
2419 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
2420 return newOwnedTestController(t, control.Options{Label: "deepseek-flash"}), nil
2421 }
2422
2423 cmd := m.runEffortCommand("/effort max")
2424 if cmd == nil {
2425 t.Fatal("/effort max should return a rebuild command")
2426 }
2427
2428 configPath := config.UserConfigPath()
2429 body, err := os.ReadFile(configPath)
2430 if err != nil {
2431 t.Fatalf("read saved config: %v", err)
2432 }
2433 if !strings.Contains(string(body), `effort = "max"`) {
2434 t.Fatalf("saved config missing effort=max:\n%s", body)
2435 }
2436 }
2437
2438 func TestEffortCommandRejectsUnsupportedProvider(t *testing.T) {
2439 isolateUserConfig(t)
2440
2441 m := newTestChatTUI()
2442 m.ctrl = newOwnedTestController(t, control.Options{Label: "mimo-pro"})
2443 m.modelRef = "mimo-pro/mimo-v2.5-pro"
2444 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
2445 return newOwnedTestController(t, control.Options{Label: "mimo-pro"}), nil
2446 }
2447
2448 if cmd := m.runEffortCommand("/effort max"); cmd != nil {
2449 t.Fatal("unsupported provider should not rebuild")
2450 }
2451 if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
2452 t.Fatalf("unsupported provider should not write config, stat err=%v", err)
2453 }
2454 }
2455
2456 func TestEffortCommandAutoClearsProviderEffort(t *testing.T) {
2457 isolateUserConfig(t)
2458
2459 m := newTestChatTUI()
2460 m.ctrl = newOwnedTestController(t, control.Options{Label: "deepseek-flash"})
2461 m.modelRef = "deepseek-flash/deepseek-v4-flash"
2462 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
2463 return newOwnedTestController(t, control.Options{Label: "deepseek-flash"}), nil
2464 }
2465
2466 cmd := m.runEffortCommand("/effort max")
2467 if cmd == nil {
2468 t.Fatal("/effort max should return a rebuild command")
2469 }
2470 next, _ := m.Update(cmd())
2471 m = next.(chatTUI)
2472 if cmd := m.runEffortCommand("/effort auto"); cmd == nil {
2473 t.Fatal("/effort auto should return a rebuild command")
2474 }
2475 body, err := os.ReadFile(config.UserConfigPath())
2476 if err != nil {
2477 t.Fatalf("read saved config: %v", err)
2478 }
2479 section := providerSection(string(body), "deepseek-flash")
2480 if strings.Contains(section, `effort = "`) {
2481 t.Fatalf("auto should clear saved deepseek-flash effort:\n%s", section)
2482 }
2483 }
2484
2485 func TestReasoningLanguageCommandPersistsAndUpdatesController(t *testing.T) {
2486 isolateUserConfig(t)
2487
2488 ctrl := newOwnedTestController(t, control.Options{ReasoningLanguage: "auto"})
2489 m := newTestChatTUI()
2490 m.ctrl = ctrl
2491
2492 m.runReasoningLanguageCommand("/reasoning-language zh")
2493
2494 body, err := os.ReadFile(config.UserConfigPath())
2495 if err != nil {
2496 t.Fatalf("read saved config: %v", err)
2497 }
2498 if !strings.Contains(string(body), `reasoning_language = "zh"`) {
2499 t.Fatalf("saved config missing reasoning_language=zh:\n%s", body)
2500 }
2501 composed := ctrl.Compose("hello")
2502 if !strings.HasPrefix(composed, "<reasoning-language>") || !strings.Contains(composed, "简体中文") {
2503 t.Fatalf("/reasoning-language zh should affect current controller, got %q", composed)
2504 }
2505 }
2506
2507 func TestReasoningLanguageCommandWritesUserConfigNotProjectConfig(t *testing.T) {
2508 isolateUserConfig(t)
2509 projectPath := filepath.Join(mustGetwd(t), "reasonix.toml")
2510 if err := os.WriteFile(projectPath, []byte("[agent]\nreasoning_language = \"en\"\n"), 0o644); err != nil {
2511 t.Fatalf("write project config: %v", err)
2512 }
2513
2514 m := newTestChatTUI()
2515 m.ctrl = newOwnedTestController(t, control.Options{ReasoningLanguage: "en"})
2516 m.runReasoningLanguageCommand("/reasoning-language zh")
2517
2518 userBody, err := os.ReadFile(config.UserConfigPath())
2519 if err != nil {
2520 t.Fatalf("read user config: %v", err)
2521 }
2522 if !strings.Contains(string(userBody), `reasoning_language = "zh"`) {
2523 t.Fatalf("user config missing reasoning_language=zh:\n%s", userBody)
2524 }
2525 projectBody, err := os.ReadFile(projectPath)
2526 if err != nil {
2527 t.Fatalf("read project config: %v", err)
2528 }
2529 if string(projectBody) != "[agent]\nreasoning_language = \"en\"\n" {
2530 t.Fatalf("/reasoning-language should not rewrite project config:\n%s", projectBody)
2531 }
2532 }
2533
2534 func TestLanguageCommandSwitchesImmediatelyAndPersists(t *testing.T) {
2535 isolateUserConfig(t)
2536 i18n.DetectLanguage("en")
2537 t.Cleanup(func() { i18n.DetectLanguage("en") })
2538
2539 m := newTestChatTUI()
2540 m.runLanguageSubcommand("/language zh")
2541
2542 if i18n.M.ChatStatusIdle != "就绪" {
2543 t.Fatalf("/language zh did not switch active catalogue, idle=%q", i18n.M.ChatStatusIdle)
2544 }
2545 if got := m.input.Placeholder; got != "" {
2546 t.Fatalf("/language zh introduced an idle composer placeholder: %q", got)
2547 }
2548 body, err := os.ReadFile(config.UserConfigPath())
2549 if err != nil {
2550 t.Fatalf("read saved config: %v", err)
2551 }
2552 if !strings.Contains(string(body), `language = "zh"`) {
2553 t.Fatalf("saved config missing language=zh:\n%s", body)
2554 }
2555 }
2556
2557 func TestLanguageCommandRefreshesCurrentController(t *testing.T) {
2558 isolateUserConfig(t)
2559 i18n.DetectLanguage("en")
2560 t.Cleanup(func() { i18n.DetectLanguage("en") })
2561
2562 oldCtrl := newOwnedTestController(t, control.Options{Label: "deepseek-flash"})
2563 t.Cleanup(oldCtrl.Close)
2564 m := newTestChatTUI()
2565 m.ctrl = oldCtrl
2566 m.modelRef = "deepseek-flash/deepseek-v4-flash"
2567 var gotSpec controllerBuildSpec
2568 m.buildController = func(spec controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
2569 gotSpec = spec
2570 return newOwnedTestController(t, control.Options{Label: "deepseek-flash"}), nil
2571 }
2572
2573 cmd := m.runSlashCommand("/language zh")
2574 if cmd == nil {
2575 t.Fatal("/language should queue a controller refresh")
2576 }
2577 next, _ := m.Update(cmd())
2578 m = next.(chatTUI)
2579 t.Cleanup(m.ctrl.Close)
2580 if m.ctrl == oldCtrl {
2581 t.Fatal("/language kept the stale controller after a successful refresh")
2582 }
2583 if gotSpec.ModelRef != m.modelRef {
2584 t.Fatalf("language refresh spec = %+v", gotSpec)
2585 }
2586 }
2587
2588 func TestCurrencyCommandPersistsAndRefreshesCurrentController(t *testing.T) {
2589 isolateUserConfig(t)
2590 i18n.DetectLanguage("en")
2591 t.Cleanup(func() { i18n.DetectLanguage("en") })
2592
2593 oldCtrl := newOwnedTestController(t, control.Options{Label: "deepseek-flash"})
2594 t.Cleanup(oldCtrl.Close)
2595 m := newTestChatTUI()
2596 m.ctrl = oldCtrl
2597 m.modelRef = "deepseek-flash/deepseek-v4-flash"
2598 var gotSpec controllerBuildSpec
2599 m.buildController = func(spec controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
2600 gotSpec = spec
2601 return newOwnedTestController(t, control.Options{Label: "deepseek-flash"}), nil
2602 }
2603
2604 cmd := m.runSlashCommand("/currency CNY")
2605 if cmd == nil {
2606 t.Fatal("/currency should queue a controller refresh")
2607 }
2608 cfg := config.LoadForEdit(config.UserConfigPath())
2609 if got := cfg.DesktopCurrency(); got != "CNY" {
2610 t.Fatalf("saved currency = %q, want CNY", got)
2611 }
2612 next, _ := m.Update(cmd())
2613 m = next.(chatTUI)
2614 t.Cleanup(m.ctrl.Close)
2615 if m.ctrl == oldCtrl {
2616 t.Fatal("/currency kept the stale controller after a successful refresh")
2617 }
2618 if gotSpec.ModelRef != m.modelRef {
2619 t.Fatalf("currency refresh spec = %+v", gotSpec)
2620 }
2621 }
2622
2623 func TestCurrencyRefreshFailureKeepsCurrentController(t *testing.T) {
2624 isolateUserConfig(t)
2625 oldCtrl := newOwnedTestController(t, control.Options{Label: "deepseek-flash"})
2626 t.Cleanup(oldCtrl.Close)
2627 m := newTestChatTUI()
2628 m.ctrl = oldCtrl
2629 m.modelRef = "deepseek-flash/deepseek-v4-flash"
2630 m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) {
2631 return nil, errors.New("build failed")
2632 }
2633
2634 cmd := m.runCurrencySubcommand("/currency CNY")
2635 if cmd == nil {
2636 t.Fatal("/currency should queue a controller refresh")
2637 }
2638 next, _ := m.Update(cmd())
2639 m = next.(chatTUI)
2640 if m.ctrl != oldCtrl {
2641 t.Fatal("failed currency refresh replaced the usable controller")
2642 }
2643 if m.modelSwitchPending || m.pendingModelSwitch != nil {
2644 t.Fatal("failed currency refresh left the runtime switch pending")
2645 }
2646 if got := config.LoadForEdit(config.UserConfigPath()).DesktopCurrency(); got != "CNY" {
2647 t.Fatalf("failed refresh should retain the persisted preference, got %q", got)
2648 }
2649 }
2650
2651 func TestLanguageCommandAutoClearsPinnedLanguage(t *testing.T) {
2652 isolateUserConfig(t)
2653 i18n.DetectLanguage("en")
2654 t.Cleanup(func() { i18n.DetectLanguage("en") })
2655
2656 m := newTestChatTUI()
2657 m.runLanguageSubcommand("/language zh")
2658 m.runLanguageSubcommand("/language auto")
2659
2660 cfg := config.LoadForEdit(config.UserConfigPath())
2661 if cfg.Language != "" {
2662 t.Fatalf("auto should clear saved language override, got %q", cfg.Language)
2663 }
2664 }
2665
2666 func TestLanguageCommandAutoClearsLowerPriorityUserOverride(t *testing.T) {
2667 isolateUserConfig(t)
2668 t.Setenv("REASONIX_LANG", "")
2669 t.Setenv("LC_ALL", "")
2670 t.Setenv("LC_MESSAGES", "")
2671 t.Setenv("LANG", "")
2672 i18n.DetectLanguage("en")
2673 t.Cleanup(func() { i18n.DetectLanguage("en") })
2674
2675 userPath := config.UserConfigPath()
2676 userCfg := config.LoadForEdit(userPath)
2677 if err := userCfg.SetLanguage("zh"); err != nil {
2678 t.Fatalf("set user language: %v", err)
2679 }
2680 if err := userCfg.SaveTo(userPath); err != nil {
2681 t.Fatalf("save user config: %v", err)
2682 }
2683 projectCfg := config.Default()
2684 if err := projectCfg.SaveTo("reasonix.toml"); err != nil {
2685 t.Fatalf("save project config: %v", err)
2686 }
2687
2688 m := newTestChatTUI()
2689 m.runLanguageSubcommand("/language auto")
2690
2691 userCfg = config.LoadForEdit(userPath)
2692 if userCfg.Language != "" {
2693 t.Fatalf("/language auto should clear lower-priority user override, got %q", userCfg.Language)
2694 }
2695 loaded, err := config.Load()
2696 if err != nil {
2697 t.Fatalf("load merged config: %v", err)
2698 }
2699 if loaded.Language != "" {
2700 t.Fatalf("merged config should be auto-detect after clearing overrides, got %q", loaded.Language)
2701 }
2702 }
2703
2704 func providerSection(body, name string) string {
2705 needle := `name = "` + name + `"`
2706 start := strings.Index(body, needle)
2707 if start < 0 {
2708 return ""
2709 }
2710 end := strings.Index(body[start+len(needle):], "\n[[providers]]")
2711 if end < 0 {
2712 return body[start:]
2713 }
2714 return body[start : start+len(needle)+end]
2715 }
2716
2717 func TestSubmittedInputRecallWithArrowKeys(t *testing.T) {
2718 m := newTestChatTUI()
2719 m.rememberSubmittedInput("first")
2720 m.rememberSubmittedInput("second")
2721 m.input.SetValue("draft")
2722
2723 up := tea.KeyPressMsg{Code: tea.KeyUp}
2724 down := tea.KeyPressMsg{Code: tea.KeyDown}
2725
2726 model, _ := m.Update(up)
2727 m = model.(chatTUI)
2728 if got := m.input.Value(); got != "second" {
2729 t.Fatalf("first up should recall latest input, got %q", got)
2730 }
2731
2732 model, _ = m.Update(up)
2733 m = model.(chatTUI)
2734 if got := m.input.Value(); got != "first" {
2735 t.Fatalf("second up should recall older input, got %q", got)
2736 }
2737
2738 model, _ = m.Update(down)
2739 m = model.(chatTUI)
2740 if got := m.input.Value(); got != "second" {
2741 t.Fatalf("down should move toward newer input, got %q", got)
2742 }
2743
2744 model, _ = m.Update(down)
2745 m = model.(chatTUI)
2746 if got := m.input.Value(); got != "draft" {
2747 t.Fatalf("down past newest should restore draft, got %q", got)
2748 }
2749 }
2750
2751 func TestQueueNavigationWithArrowKeys(t *testing.T) {
2752 m := newInboxTestChatTUI(t)
2753 m.state = tuiRunning
2754 m.seedInbox("queued one", "queued two", "queued three")
2755 m.input.SetValue("my draft")
2756
2757 up := tea.KeyPressMsg{Code: tea.KeyUp}
2758 down := tea.KeyPressMsg{Code: tea.KeyDown}
2759
2760 // First ↑ should save draft and jump to last queued item.
2761 model, _ := m.Update(up)
2762 m = model.(chatTUI)
2763 if got := m.input.Value(); got != "queued three" {
2764 t.Fatalf("first up: want %q, got %q", "queued three", got)
2765 }
2766 if m.queueEditCursor != 2 {
2767 t.Fatalf("first up: cursor should be 2, got %d", m.queueEditCursor)
2768 }
2769
2770 // Second ↑ should move to "queued two".
2771 model, _ = m.Update(up)
2772 m = model.(chatTUI)
2773 if got := m.input.Value(); got != "queued two" {
2774 t.Fatalf("second up: want %q, got %q", "queued two", got)
2775 }
2776
2777 // ↓ should move back to "queued three".
2778 model, _ = m.Update(down)
2779 m = model.(chatTUI)
2780 if got := m.input.Value(); got != "queued three" {
2781 t.Fatalf("down: want %q, got %q", "queued three", got)
2782 }
2783
2784 // ↓ past the end should restore the draft.
2785 model, _ = m.Update(down)
2786 m = model.(chatTUI)
2787 if got := m.input.Value(); got != "my draft" {
2788 t.Fatalf("down past end: want %q, got %q", "my draft", got)
2789 }
2790 if m.queueEditCursor != -1 {
2791 t.Fatalf("down past end: cursor should be -1, got %d", m.queueEditCursor)
2792 }
2793 }
2794
2795 func TestQueueNavigationClampAtStart(t *testing.T) {
2796 m := newInboxTestChatTUI(t)
2797 m.state = tuiRunning
2798 m.seedInbox("only item")
2799 m.input.SetValue("draft")
2800
2801 up := tea.KeyPressMsg{Code: tea.KeyUp}
2802 // First ↑ jumps to the only item.
2803 model, _ := m.Update(up)
2804 m = model.(chatTUI)
2805 if got := m.input.Value(); got != "only item" {
2806 t.Fatalf("first up: want %q, got %q", "only item", got)
2807 }
2808 // Second ↑ should clamp at index 0 (not go negative).
2809 model, _ = m.Update(up)
2810 m = model.(chatTUI)
2811 if m.queueEditCursor != 0 {
2812 t.Fatalf("second up: cursor should clamp at 0, got %d", m.queueEditCursor)
2813 }
2814 if got := m.input.Value(); got != "only item" {
2815 t.Fatalf("second up: value should stay %q, got %q", "only item", got)
2816 }
2817 }
2818
2819 func TestQueueNavigationNoOpWhenEmpty(t *testing.T) {
2820 m := newInboxTestChatTUI(t)
2821 m.state = tuiRunning
2822 m.input.SetValue("hello")
2823
2824 up := tea.KeyPressMsg{Code: tea.KeyUp}
2825 model, _ := m.Update(up)
2826 m = model.(chatTUI)
2827 if got := m.input.Value(); got != "hello" {
2828 t.Fatalf("empty queue: input should be unchanged, got %q", got)
2829 }
2830 }
2831
2832 func TestQueueEditSavesOnEnter(t *testing.T) {
2833 m := newInboxTestChatTUI(t)
2834 m.state = tuiRunning
2835 m.seedInbox("original one", "original two")
2836
2837 up := tea.KeyPressMsg{Code: tea.KeyUp}
2838 model, _ := m.Update(up)
2839 m = model.(chatTUI)
2840 if m.queueEditCursor != 1 {
2841 t.Fatalf("cursor should be 1 after up, got %d", m.queueEditCursor)
2842 }
2843
2844 // Edit the queued message.
2845 m.input.SetValue("edited two")
2846 enter := tea.KeyPressMsg{Code: tea.KeyEnter}
2847 model, _ = m.Update(enter)
2848 m = model.(chatTUI)
2849
2850 bodies := m.inboxBodies()
2851 if bodies[1] != "edited two" {
2852 t.Fatalf("queue[1] should be %q, got %q", "edited two", bodies[1])
2853 }
2854 if bodies[0] != "original one" {
2855 t.Fatalf("queue[0] should be unchanged, got %q", bodies[0])
2856 }
2857 if m.queueEditCursor != -1 {
2858 t.Fatalf("cursor should reset after enter, got %d", m.queueEditCursor)
2859 }
2860 }
2861
2862 func TestQueueNewMessageOnEnterDuringRunning(t *testing.T) {
2863 m := newInboxTestChatTUI(t)
2864 m.state = tuiRunning
2865 m.seedInbox("existing")
2866
2867 m.input.SetValue("new message")
2868 enter := tea.KeyPressMsg{Code: tea.KeyEnter}
2869 model, _ := m.Update(enter)
2870 m = model.(chatTUI)
2871
2872 bodies := m.inboxBodies()
2873 if len(bodies) != 2 {
2874 t.Fatalf("queue should have 2 items, got %d", len(bodies))
2875 }
2876 if bodies[1] != "new message" {
2877 t.Fatalf("queue[1] should be %q, got %q", "new message", bodies[1])
2878 }
2879 }
2880
2881 func TestQueueNavigationResetOnNonUpDownKey(t *testing.T) {
2882 m := newInboxTestChatTUI(t)
2883 m.state = tuiRunning
2884 m.seedInbox("queued")
2885
2886 up := tea.KeyPressMsg{Code: tea.KeyUp}
2887 model, _ := m.Update(up)
2888 m = model.(chatTUI)
2889 if m.queueEditCursor != 0 {
2890 t.Fatalf("cursor should be 0 after up, got %d", m.queueEditCursor)
2891 }
2892
2893 // A regular key while editing a queued item should preserve the cursor
2894 // so the user can type replacement text. (#4877)
2895 letter := tea.KeyPressMsg{Code: 'a'}
2896 model, _ = m.Update(letter)
2897 m = model.(chatTUI)
2898 if m.queueEditCursor != 0 {
2899 t.Fatalf("cursor should stay at 0 while editing queued item, got %d", m.queueEditCursor)
2900 }
2901 }
2902
2903 func TestQueueEditTypingDoesNotResetCursor(t *testing.T) {
2904 m := newInboxTestChatTUI(t)
2905 m.state = tuiRunning
2906 m.seedInbox("first", "second")
2907
2908 // Navigate up to select the last item.
2909 up := tea.KeyPressMsg{Code: tea.KeyUp}
2910 model, _ := m.Update(up)
2911 m = model.(chatTUI)
2912 if m.queueEditCursor != 1 {
2913 t.Fatalf("cursor should be 1 after up, got %d", m.queueEditCursor)
2914 }
2915
2916 // Type several characters — cursor must survive each keystroke.
2917 for _, c := range "hello" {
2918 letter := tea.KeyPressMsg{Code: c}
2919 model, _ = m.Update(letter)
2920 m = model.(chatTUI)
2921 }
2922 if m.queueEditCursor != 1 {
2923 t.Fatalf("cursor should stay at 1 after typing, got %d", m.queueEditCursor)
2924 }
2925 }
2926
2927 func TestQueueEditReplaceOnEnter(t *testing.T) {
2928 m := newInboxTestChatTUI(t)
2929 m.state = tuiRunning
2930 m.seedInbox("hello")
2931
2932 // Navigate up to select the item.
2933 up := tea.KeyPressMsg{Code: tea.KeyUp}
2934 model, _ := m.Update(up)
2935 m = model.(chatTUI)
2936 if m.queueEditCursor != 0 {
2937 t.Fatalf("cursor should be 0 after up, got %d", m.queueEditCursor)
2938 }
2939
2940 // Simulate real typing: clear input, send key presses through Update.
2941 m.input.SetValue("")
2942 m.input.SetValue("world")
2943 enter := tea.KeyPressMsg{Code: tea.KeyEnter}
2944 model, _ = m.Update(enter)
2945 m = model.(chatTUI)
2946
2947 bodies := m.inboxBodies()
2948 if len(bodies) != 1 {
2949 t.Fatalf("queue should still have 1 item, got %d", len(bodies))
2950 }
2951 if bodies[0] != "world" {
2952 t.Fatalf("queue[0] should be %q, got %q", "world", bodies[0])
2953 }
2954 if m.queueEditCursor != -1 {
2955 t.Fatalf("cursor should reset after enter, got %d", m.queueEditCursor)
2956 }
2957 }
2958
2959 func TestQueueIndicatorRendering(t *testing.T) {
2960 m := newInboxTestChatTUI(t)
2961 m.state = tuiRunning
2962 m.seedInbox("first msg", "second msg")
2963
2964 qi := m.renderQueueIndicator()
2965 if qi == "" {
2966 t.Fatal("queue indicator should not be empty when queue has items and running")
2967 }
2968 if !strings.Contains(qi, "[1]") || !strings.Contains(qi, "[2]") {
2969 t.Fatalf("queue indicator should contain [1] and [2], got %q", qi)
2970 }
2971 if !strings.Contains(qi, "first msg") || !strings.Contains(qi, "second msg") {
2972 t.Fatalf("queue indicator should show message previews, got %q", qi)
2973 }
2974
2975 // Highlight marker should appear for the browsed item.
2976 m.queueEditCursor = 1
2977 qi = m.renderQueueIndicator()
2978 if !strings.Contains(qi, "▸") {
2979 t.Fatalf("queue indicator should show ▸ for browsed item, got %q", qi)
2980 }
2981 }
2982
2983 func TestQueueIndicatorHiddenWhenIdle(t *testing.T) {
2984 m := newInboxTestChatTUI(t)
2985 m.state = tuiIdle
2986 // Idle sessions with a recovered/paused inbox still show the shelf so the
2987 // user can inspect it; empty inboxes stay hidden.
2988 if qi := m.renderQueueIndicator(); qi != "" {
2989 t.Fatalf("queue indicator should be empty when inbox empty, got %q", qi)
2990 }
2991 m.seedInbox("queued")
2992 if qi := m.renderQueueIndicator(); qi == "" {
2993 t.Fatal("queue indicator should show durable items even when idle")
2994 }
2995 }
2996
2997 // TestViewAltScreenFillsHeight proves the switch to alt-screen: View requests
2998 // the alt buffer with mouse reporting for wheel scrolling and in-app text
2999 // selection, and the frame is exactly the terminal height (the transcript
3000 // viewport pads to fill above the pinned bottom region).
3001 func TestViewAltScreenFillsHeight(t *testing.T) {
3002 ctrl := newOwnedTestController(t, control.Options{})
3003 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
3004 m.nativeScrollback = false
3005 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
3006 v := m0.(chatTUI).View()
3007
3008 if !v.AltScreen {
3009 t.Error("View must request alt-screen so resize repaints the whole grid")
3010 }
3011 if v.MouseMode != tea.MouseModeCellMotion {
3012 t.Error("View must enable mouse so the wheel scrolls the transcript")
3013 }
3014 if lines := strings.Count(v.Content, "\n") + 1; lines != 24 {
3015 t.Errorf("alt-screen frame = %d lines, want 24 (full terminal height)", lines)
3016 }
3017 }
3018
3019 func TestViewTermuxUsesNativeScrollback(t *testing.T) {
3020 ctrl := newOwnedTestController(t, control.Options{})
3021 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
3022 m.nativeScrollback = true
3023 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
3024 v := m0.(chatTUI).View()
3025
3026 if v.AltScreen {
3027 t.Error("Termux view must stay in the normal screen so native touch scrollback works")
3028 }
3029 if v.MouseMode != tea.MouseModeNone {
3030 t.Error("Termux view must not enable mouse mode because it prevents soft-keyboard focus")
3031 }
3032 if lines := strings.Count(v.Content, "\n") + 1; lines >= 24 {
3033 t.Errorf("Termux view should render only the pinned bottom frame, got %d full-screen lines", lines)
3034 }
3035 }
3036
3037 // TestTranscriptTailFollow proves the viewport pins to newest output while the
3038 // user is at the bottom, and stops yanking once the user scrolls up.
3039 func TestTranscriptTailFollow(t *testing.T) {
3040 ctrl := newOwnedTestController(t, control.Options{})
3041 adv := func(m chatTUI, msg tea.Msg) chatTUI {
3042 n, _ := m.Update(msg)
3043 return n.(chatTUI)
3044 }
3045 notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
3046
3047 cur := adv(newChatTUI(ctrl, "", make(chan event.Event, 1), 80), tea.WindowSizeMsg{Width: 80, Height: 8})
3048 for range 12 { // overflow the short viewport so there's room to scroll
3049 cur = adv(cur, notice)
3050 }
3051 if !cur.viewport.AtBottom() {
3052 t.Fatal("new output while pinned should keep the viewport at the bottom")
3053 }
3054
3055 cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
3056 if cur.viewport.AtBottom() {
3057 t.Fatal("wheel-up should break the bottom pin")
3058 }
3059
3060 cur = adv(cur, notice)
3061 if cur.viewport.AtBottom() {
3062 t.Error("new output while scrolled up must preserve the reading position")
3063 }
3064 }
3065
3066 // TestEmptyEnterScrollsToBottom proves that pressing Enter with an empty composer
3067 // scrolls the viewport to the bottom in both idle and running states, so the user
3068 // can quickly tail-follow after scrolling up to read history.
3069 func TestEmptyEnterScrollsToBottom(t *testing.T) {
3070 ctrl := newOwnedTestController(t, control.Options{})
3071 ch := make(chan event.Event, 1)
3072 notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
3073 adv := func(m chatTUI, msg tea.Msg) chatTUI {
3074 n, _ := m.Update(msg)
3075 return n.(chatTUI)
3076 }
3077
3078 // idle state
3079 t.Run("idle", func(t *testing.T) {
3080 cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8})
3081 for range 12 {
3082 cur = adv(cur, notice)
3083 }
3084 // Scroll up to leave the bottom.
3085 cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
3086 if cur.viewport.AtBottom() {
3087 t.Fatal("wheel-up should break the bottom pin")
3088 }
3089 // Empty enter → should snap back to bottom.
3090 cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter})
3091 if !cur.viewport.AtBottom() {
3092 t.Error("empty enter while idle should scroll viewport to bottom")
3093 }
3094 })
3095
3096 // running state
3097 t.Run("running", func(t *testing.T) {
3098 cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8})
3099 for range 12 {
3100 cur = adv(cur, notice)
3101 }
3102 cur.state = tuiRunning
3103 cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
3104 if cur.viewport.AtBottom() {
3105 t.Fatal("wheel-up should break the bottom pin")
3106 }
3107 cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter})
3108 if !cur.viewport.AtBottom() {
3109 t.Error("empty enter while running should scroll viewport to bottom")
3110 }
3111 })
3112 }
3113
3114 // TestForceGotoBottomScrollsWithoutTranscriptChange keeps the force-bottom
3115 // contract independent from transcript length, width, or dirty-state changes.
3116 func TestForceGotoBottomScrollsWithoutTranscriptChange(t *testing.T) {
3117 ctrl := newOwnedTestController(t, control.Options{})
3118 ch := make(chan event.Event, 1)
3119 notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
3120 adv := func(m chatTUI, msg tea.Msg) (chatTUI, tea.Cmd) {
3121 n, cmd := m.Update(msg)
3122 return n.(chatTUI), cmd
3123 }
3124 next := func(m chatTUI, msg tea.Msg) chatTUI {
3125 n, _ := adv(m, msg)
3126 return n
3127 }
3128 cur := next(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8})
3129 for range 12 {
3130 cur = next(cur, notice)
3131 }
3132 if !cur.viewport.AtBottom() {
3133 t.Fatal("new output while pinned should keep the viewport at the bottom")
3134 }
3135
3136 cur = next(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
3137 if cur.viewport.AtBottom() {
3138 t.Fatal("wheel-up should break the bottom pin")
3139 }
3140
3141 cur.forceGotoBottom = true
3142 cur.transcriptDirty = false
3143 cur.legacyScrollClear = false
3144 cur, cmd := adv(cur, tea.WindowSizeMsg{Width: 80, Height: 8})
3145
3146 if !cur.viewport.AtBottom() {
3147 t.Fatalf("forceGotoBottom should scroll without transcript changes, YOffset=%d", cur.viewport.YOffset())
3148 }
3149 if cur.forceGotoBottom {
3150 t.Fatal("forceGotoBottom should be cleared after scrolling")
3151 }
3152 assertLegacyViewportClearCmd(t, cmd, false)
3153 }
3154
3155 func TestSessionSwitchSuppressesOneWarpClearScreen(t *testing.T) {
3156 ctrl := newOwnedTestController(t, control.Options{})
3157 ch := make(chan event.Event, 1)
3158 notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
3159 adv := func(m chatTUI, msg tea.Msg) (chatTUI, tea.Cmd) {
3160 n, cmd := m.Update(msg)
3161 return n.(chatTUI), cmd
3162 }
3163 next := func(m chatTUI, msg tea.Msg) chatTUI {
3164 n, _ := adv(m, msg)
3165 return n
3166 }
3167
3168 cur := next(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8})
3169 for range 12 {
3170 cur = next(cur, notice)
3171 }
3172 cur = next(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
3173 if cur.viewport.AtBottom() {
3174 t.Fatal("wheel-up should break the bottom pin")
3175 }
3176 cur.legacyScrollClear = true
3177 cur.sessionSwitch = true
3178 cur.forceGotoBottom = true
3179 cur.transcriptDirty = false
3180 cur, cmd := adv(cur, tea.WindowSizeMsg{Width: 80, Height: 8})
3181 if cmd != nil {
3182 t.Fatal("session switch rebuild should suppress the Warp ClearScreen workaround once")
3183 }
3184 if cur.sessionSwitch {
3185 t.Fatal("sessionSwitch should be cleared after one Update")
3186 }
3187 if !cur.viewport.AtBottom() {
3188 t.Fatalf("session switch should still land at bottom, YOffset=%d", cur.viewport.YOffset())
3189 }
3190
3191 cur = next(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
3192 cur.forceGotoBottom = true
3193 cur, cmd = adv(cur, tea.WindowSizeMsg{Width: 80, Height: 8})
3194 assertLegacyViewportClearCmd(t, cmd, true)
3195 if cur.sessionSwitch {
3196 t.Fatal("sessionSwitch should remain false after the suppressed cycle")
3197 }
3198 }
3199
3200 func TestWideInputChangeRequestsClearScreen(t *testing.T) {
3201 prev := clearWideInputChanges
3202 clearWideInputChanges = true
3203 defer func() { clearWideInputChanges = prev }()
3204
3205 m := newTestChatTUI()
3206 m.input.SetValue("天安a")
3207 m.input.SetCursorColumn(len([]rune("天安a")))
3208
3209 next, cmd := m.update(tea.KeyPressMsg{Code: '门', Text: "门"})
3210 got := next.(chatTUI)
3211 if got.input.Value() != "天安a门" {
3212 t.Fatalf("wide-char insert should preserve the textarea value, got %q", got.input.Value())
3213 }
3214 if cmd == nil {
3215 t.Fatal("wide-char input changes should request a full redraw")
3216 }
3217 if shouldClearWideInputChange("ascii", "ascii!") {
3218 t.Fatal("single-width ASCII input should not request the wide-input redraw")
3219 }
3220 if !shouldClearWideInputChange("门", "") {
3221 t.Fatal("removing the last wide character should request a full redraw")
3222 }
3223 if !shouldClearWideInputChange("a门", "a") {
3224 t.Fatal("removing a wide character from mixed input should request a full redraw")
3225 }
3226 }
3227
3228 func TestChooserFreeTextWideInputChangeRequestsClearScreen(t *testing.T) {
3229 prev := clearWideInputChanges
3230 clearWideInputChanges = true
3231 defer func() { clearWideInputChanges = prev }()
3232
3233 m := newTestChatTUI()
3234 m.chooser = newChooser(event.Ask{
3235 ID: "ask-1",
3236 Questions: []event.AskQuestion{{
3237 ID: "q1",
3238 Prompt: "Pick one",
3239 Options: []event.AskOption{{
3240 Label: "Option A",
3241 }},
3242 }},
3243 })
3244 m.chooser.typing = true
3245 m.input.SetValue("天安a")
3246 m.input.SetCursorColumn(len([]rune("天安a")))
3247
3248 next, cmd := m.update(tea.KeyPressMsg{Code: '门', Text: "门"})
3249 got := next.(chatTUI)
3250 if got.input.Value() != "天安a门" {
3251 t.Fatalf("chooser free-text input should preserve the textarea value, got %q", got.input.Value())
3252 }
3253 if cmd == nil {
3254 t.Fatal("chooser free-text wide-char input changes should request a full redraw")
3255 }
3256 }
3257
3258 func TestReplayActiveBranchClearsPlanModeAndMarksSessionSwitch(t *testing.T) {
3259 m := newTestChatTUI()
3260 m.ctrl = newOwnedTestController(t, control.Options{})
3261 m.planMode = true
3262 m.ctrl.SetPlanMode(true)
3263 m.sessionSwitch = false
3264
3265 m.replayActiveBranch("switched branch")
3266
3267 if m.planMode || m.ctrl.PlanMode() {
3268 t.Fatalf("replay should clear plan mode on both TUI and controller, tui=%v controller=%v", m.planMode, m.ctrl.PlanMode())
3269 }
3270 if !m.sessionSwitch {
3271 t.Fatal("replay should mark the next Update as a session switch")
3272 }
3273 }
3274
3275 func TestFoldedPasteUsesPlaceholderAndExpandsOnSend(t *testing.T) {
3276 m := newTestChatTUI()
3277 pasted := "{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3,\n \"d\": 4\n}"
3278 if !shouldFoldPastedText(pasted) {
3279 t.Fatal("five-line paste should fold")
3280 }
3281
3282 m.insertFoldedPaste(pasted)
3283 display := m.input.Value()
3284 if display != "[Pasted text #1 · 6 lines] " {
3285 t.Fatalf("display = %q", display)
3286 }
3287
3288 sent := m.expandPastedBlocks(display)
3289 for _, want := range []string{
3290 "--- Begin [Pasted text #1 · 6 lines] ---",
3291 `"d": 4`,
3292 "--- End [Pasted text #1 · 6 lines] ---",
3293 } {
3294 if !strings.Contains(sent, want) {
3295 t.Fatalf("expanded paste missing %q in:\n%s", want, sent)
3296 }
3297 }
3298 }
3299
3300 func TestTextOnlyModelSendsPastedImageRefsForToolUse(t *testing.T) {
3301 workspace := t.TempDir()
3302 writeTUIImageCapabilityConfig(t, workspace)
3303 path := saveTestImageAttachment(t, workspace)
3304
3305 runner := &recordingTurnRunner{}
3306 events := make(chan event.Event, 8)
3307 m := newTestChatTUI()
3308 m.ctrl = newOwnedTestController(t, control.Options{
3309 Runner: runner,
3310 Sink: event.FuncSink(func(e event.Event) {
3311 events <- e
3312 }),
3313 WorkspaceRoot: workspace,
3314 ModelRef: "custom/text-only",
3315 })
3316 m.pastedBlocks = []pastedBlock{{label: "[image #1]", text: "@" + path, image: true}}
3317 m.input.SetValue("describe [image #1] please")
3318
3319 model, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
3320 m = model.(chatTUI)
3321 if cmd == nil {
3322 t.Fatal("text-only image ref send should resolve refs before starting the turn")
3323 }
3324 msg := cmd()
3325 if _, ok := msg.(refsResolvedMsg); !ok {
3326 t.Fatalf("enter cmd = %T, want refsResolvedMsg", msg)
3327 }
3328 model, _ = m.Update(msg)
3329 m = model.(chatTUI)
3330 waitForCLIEvent(t, events, event.TurnDone)
3331
3332 if len(runner.inputs) != 1 {
3333 t.Fatalf("text-only model should send the image ref for tool use, inputs=%q", runner.inputs)
3334 }
3335 if !strings.Contains(runner.inputs[0], "@"+path) {
3336 t.Fatalf("runner input should retain the image ref context, got %q", runner.inputs[0])
3337 }
3338 if !strings.Contains(runner.inputs[0], "OCR/image/vision tool") {
3339 t.Fatalf("runner input should mention tool-based image handling, got %q", runner.inputs[0])
3340 }
3341 if got := strings.Join(m.transcript, "\n"); strings.Contains(got, "will not receive images directly") {
3342 t.Fatalf("text-only model should not block image refs that tools can read, transcript=%q", got)
3343 }
3344 }
3345
3346 func TestVisionModelAllowsSendingPastedImageRefs(t *testing.T) {
3347 workspace := t.TempDir()
3348 writeTUIImageCapabilityConfig(t, workspace)
3349 path := saveTestImageAttachment(t, workspace)
3350
3351 runner := &recordingTurnRunner{}
3352 events := make(chan event.Event, 8)
3353 m := newTestChatTUI()
3354 m.ctrl = newOwnedTestController(t, control.Options{
3355 Runner: runner,
3356 Sink: event.FuncSink(func(e event.Event) {
3357 events <- e
3358 }),
3359 WorkspaceRoot: workspace,
3360 ModelRef: "custom/vision-pro",
3361 })
3362 m.pastedBlocks = []pastedBlock{{label: "[image #1]", text: "@" + path, image: true}}
3363 m.input.SetValue("describe [image #1] please")
3364
3365 model, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
3366 m = model.(chatTUI)
3367 if cmd == nil {
3368 t.Fatal("vision model send should resolve refs before starting the turn")
3369 }
3370 msg := cmd()
3371 if _, ok := msg.(refsResolvedMsg); !ok {
3372 t.Fatalf("enter cmd = %T, want refsResolvedMsg", msg)
3373 }
3374 model, _ = m.Update(msg)
3375 m = model.(chatTUI)
3376 waitForCLIEvent(t, events, event.TurnDone)
3377
3378 if len(runner.inputs) != 1 {
3379 t.Fatalf("vision model should send exactly one turn, inputs=%q", runner.inputs)
3380 }
3381 if !strings.Contains(runner.inputs[0], "@"+path) {
3382 t.Fatalf("runner input should retain the image ref context, got %q", runner.inputs[0])
3383 }
3384 if got := strings.Join(m.transcript, "\n"); strings.Contains(got, "will not receive images directly") {
3385 t.Fatalf("vision-capable model should not warn about image input, transcript=%q", got)
3386 }
3387 }
3388
3389 // TestPasteFoldExpandOnSubmit verifies that a folded paste is fully expanded
3390 // before being sent to the controller (the LLM sees the actual content, not just
3391 // the placeholder label).
3392 func TestPasteFoldExpandOnSubmit(t *testing.T) {
3393 r := &recordingTurnRunner{}
3394 events := make(chan event.Event, 64)
3395 ctrl := newOwnedTestController(t, control.Options{
3396 Runner: r,
3397 Sink: event.FuncSink(func(e event.Event) { events <- e }),
3398 SessionDir: t.TempDir(),
3399 Label: "test",
3400 })
3401
3402 m := newTestChatTUI()
3403 m.ctrl = ctrl
3404 m.eventCh = make(chan event.Event, 64)
3405
3406 // Simulate a multi-line paste that meets the fold threshold (≥5 lines).
3407 pasted := strings.Repeat("line of pasted content\n", 10)
3408 model, _ := m.Update(tea.PasteMsg{Content: pasted})
3409 m = model.(chatTUI)
3410
3411 display := m.input.Value()
3412 if !strings.Contains(display, "[Pasted text #1") {
3413 t.Fatalf("paste should be folded, got: %q", display)
3414 }
3415 if len(m.pastedBlocks) != 1 {
3416 t.Fatalf("expected 1 pastedBlock, got %d", len(m.pastedBlocks))
3417 }
3418
3419 // Simulate pressing Enter to submit.
3420 // NOTE: in a real terminal KeyEnter has empty Text, so String() returns "enter".
3421 model, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
3422 m = model.(chatTUI)
3423
3424 waitForCLIEvent(t, events, event.TurnDone)
3425
3426 if len(r.inputs) == 0 {
3427 t.Fatal("runner.Run was not called — the paste was never submitted")
3428 }
3429 sentToRunner := r.inputs[0]
3430 t.Logf("sent to runner (%d bytes):\n%s", len(sentToRunner), sentToRunner)
3431
3432 // The runner must receive the FULL expanded paste, not just the label.
3433 if !strings.Contains(sentToRunner, "line of pasted content") {
3434 t.Fatalf("runner received only the placeholder label, not the expanded paste content.\nGot: %q", sentToRunner)
3435 }
3436 // Verify the expanded markers are present.
3437 if !strings.Contains(sentToRunner, "--- Begin [Pasted text #1") {
3438 t.Fatalf("missing Begin marker in runner input.\nGot: %q", sentToRunner)
3439 }
3440 if !strings.Contains(sentToRunner, "--- End [Pasted text #1") {
3441 t.Fatalf("missing End marker in runner input.\nGot: %q", sentToRunner)
3442 }
3443 }
3444
3445 func TestStrongResearchPromptStaysInOrdinaryMode(t *testing.T) {
3446 r := &recordingTurnRunner{}
3447 events := make(chan event.Event, 8)
3448 ctrl := newOwnedTestController(t, control.Options{
3449 Runner: r,
3450 Sink: event.FuncSink(func(e event.Event) { events <- e }),
3451 })
3452 m := newTestChatTUI()
3453 m.ctrl = ctrl
3454 input := "持续排查这个线上卡顿直到根因明确,并验证修复"
3455 m.input.SetValue(input)
3456
3457 model, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
3458 m = model.(chatTUI)
3459 waitForCLIEvent(t, events, event.TurnDone)
3460
3461 if len(r.inputs) != 1 || !strings.HasSuffix(r.inputs[0], input) {
3462 t.Fatalf("ordinary prompt was not sent unchanged at the user boundary: %q", r.inputs)
3463 }
3464 if strings.Contains(r.inputs[0], "<active-goal>") || strings.Contains(r.inputs[0], "AutoResearch protocol") {
3465 t.Fatalf("ordinary TUI prompt should not enter Goal or AutoResearch:\n%s", r.inputs[0])
3466 }
3467 if ctrl.GoalStatus() != control.GoalStatusStopped {
3468 t.Fatalf("GoalStatus() = %q, want stopped", ctrl.GoalStatus())
3469 }
3470 }
3471
3472 func TestSlashCodeCommentSubmitStartsTurn(t *testing.T) {
3473 for _, input := range []string{
3474 "// explain this",
3475 "/**\n * 阿明\n */",
3476 } {
3477 t.Run(input, func(t *testing.T) {
3478 r := &recordingTurnRunner{}
3479 events := make(chan event.Event, 8)
3480 ctrl := newOwnedTestController(t, control.Options{
3481 Runner: r,
3482 Sink: event.FuncSink(func(e event.Event) { events <- e }),
3483 })
3484 m := newTestChatTUI()
3485 m.ctrl = ctrl
3486 m.input.SetValue(input)
3487
3488 model, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
3489 m = model.(chatTUI)
3490 waitForCLIEvent(t, events, event.TurnDone)
3491
3492 if len(r.inputs) != 1 || r.inputs[0] != input {
3493 t.Fatalf("slash code comment should start a model turn, inputs=%q", r.inputs)
3494 }
3495 })
3496 }
3497 }
3498
3499 func TestUnknownSlashCommandStartsOrdinaryTurnWithNotice(t *testing.T) {
3500 r := &recordingTurnRunner{}
3501 events := make(chan event.Event, 8)
3502 ctrl := newOwnedTestController(t, control.Options{
3503 Runner: r,
3504 Sink: event.FuncSink(func(e event.Event) { events <- e }),
3505 })
3506 m := newTestChatTUI()
3507 m.ctrl = ctrl
3508 input := "/definitely-not-a-command"
3509 m.input.SetValue(input)
3510
3511 model, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
3512 m = model.(chatTUI)
3513 waitForCLIEvent(t, events, event.TurnDone)
3514
3515 if len(r.inputs) != 1 || r.inputs[0] != input {
3516 t.Fatalf("unknown slash command should start one ordinary turn, inputs=%q", r.inputs)
3517 }
3518 if got := strings.Join(m.transcript, "\n"); !strings.Contains(got, "unknown command") {
3519 t.Fatalf("unknown slash command should be reported in transcript, got:\n%s", got)
3520 }
3521 }
3522
3523 func TestSlashDocsShowsLocalOverviewWithoutStartingTurn(t *testing.T) {
3524 r := &recordingTurnRunner{}
3525 ctrl := newOwnedTestController(t, control.Options{
3526 Runner: r,
3527 Sink: event.FuncSink(func(event.Event) {}),
3528 })
3529 m := newTestChatTUI()
3530 m.ctrl = ctrl
3531
3532 if cmd := m.runSlashCommand("/docs"); cmd != nil {
3533 t.Fatal("bare /docs should complete locally")
3534 }
3535 if len(r.inputs) != 0 {
3536 t.Fatalf("bare /docs should not start a model turn, inputs=%q", r.inputs)
3537 }
3538 transcript := strings.Join(m.transcript, "\n")
3539 if !strings.Contains(transcript, "digest=sha256:") || !strings.Contains(transcript, "/docs") {
3540 t.Fatalf("bare /docs transcript missing corpus identity or usage:\n%s", transcript)
3541 }
3542 }
3543
3544 func TestQualifiedSlashDocsBypassesConflictingCustomCommand(t *testing.T) {
3545 r := &recordingTurnRunner{}
3546 commands := []command.Command{
3547 {Name: "docs", Body: "legacy docs"},
3548 }
3549 ctrl := newOwnedTestController(t, control.Options{
3550 Runner: r,
3551 Commands: commands,
3552 Sink: event.FuncSink(func(event.Event) {}),
3553 })
3554 m := newTestChatTUI()
3555 m.ctrl = ctrl
3556 m.commands = commands
3557
3558 if cmd := m.runSlashCommand("/reasonix:docs"); cmd != nil {
3559 t.Fatal("bare /reasonix:docs should complete locally")
3560 }
3561 if len(r.inputs) != 0 {
3562 t.Fatalf("bare /reasonix:docs should not start a model turn, inputs=%q", r.inputs)
3563 }
3564 transcript := strings.Join(m.transcript, "\n")
3565 if !strings.Contains(transcript, "digest=sha256:") || !strings.Contains(transcript, "Usage: /reasonix:docs <question>") || strings.Contains(transcript, "legacy docs") {
3566 t.Fatalf("qualified built-in docs was shadowed:\n%s", transcript)
3567 }
3568 }
3569
3570 func TestPasteMsgFoldsBeforeTextareaConsumesNewlines(t *testing.T) {
3571 m := newTestChatTUI()
3572 model, _ := m.Update(tea.PasteMsg{Content: "1\n2\n3\n4\n5"})
3573 got := model.(chatTUI)
3574 if got.input.Value() != "[Pasted text #1 · 5 lines] " {
3575 t.Fatalf("input = %q", got.input.Value())
3576 }
3577 if got.input.Height() != 1 {
3578 t.Fatalf("folded paste should keep one input row, got %d", got.input.Height())
3579 }
3580 }
3581
3582 func TestUnsendRestoresFoldedPastePlaceholder(t *testing.T) {
3583 m := newTestChatTUI()
3584 m.ctrl = newOwnedTestController(t, control.Options{})
3585 m.bubbleStartIdx = len(m.transcript)
3586 m.commitLine("")
3587 m.commitLine(renderUserBubble("expanded JSON", m.width, m.planMode))
3588 m.pendingRestore = "[Pasted text #1 · 5 lines] 这是什么?"
3589 m.bubblePending = true
3590 m.state = tuiRunning
3591
3592 m.unsendPending()
3593
3594 if got := m.input.Value(); got != "[Pasted text #1 · 5 lines] 这是什么?" {
3595 t.Fatalf("restored input = %q", got)
3596 }
3597 if len(m.transcript) != m.bubbleStartIdx {
3598 t.Fatalf("un-send should pop the echoed bubble, transcript=%v", m.transcript)
3599 }
3600 if m.pendingRestore != "" || m.bubblePending {
3601 t.Fatalf("pending state not cleared: restore=%q pending=%v", m.pendingRestore, m.bubblePending)
3602 }
3603 }
3604
3605 func TestApprovalToolDetailsShortensMCPNames(t *testing.T) {
3606 name, detail := approvalToolDetails("mcp__minimax-coding-plan-mcp__understand_image")
3607 if name != "understand_image" {
3608 t.Fatalf("name = %q, want understand_image", name)
3609 }
3610 for _, want := range []string{"provided image input", "minimax-coding-plan-mcp"} {
3611 if !strings.Contains(detail, want) {
3612 t.Errorf("detail = %q, want it to contain %q", detail, want)
3613 }
3614 }
3615
3616 name, detail = approvalToolDetails("bash")
3617 if name != "bash" || !strings.Contains(detail, "built-in") {
3618 t.Errorf("built-in details = (%q, %q), want bash + built-in source", name, detail)
3619 }
3620 }
3621
3622 func TestSandboxEscapeApprovalBannerUsesRealEnvironmentChoice(t *testing.T) {
3623 i18n.DetectLanguage("zh")
3624 t.Cleanup(func() { i18n.DetectLanguage("en") })
3625
3626 m := newTestChatTUI()
3627 m.width = 120
3628 m.pendingApproval = &event.Approval{
3629 ID: "approval-1",
3630 Tool: control.SandboxEscapeApprovalTool,
3631 Subject: "仅本次不进沙箱运行:go test ./...",
3632 Reason: "Windows 沙箱启动这条命令时失败。",
3633 }
3634 banner := m.renderApprovalBanner()
3635 if !strings.Contains(banner, "本会话使用真实环境") {
3636 t.Fatalf("approval banner = %q, want real-environment session choice", banner)
3637 }
3638 if !strings.Contains(banner, "允许一次") {
3639 t.Fatalf("approval banner = %q, want desktop-matching allow-once choice", banner)
3640 }
3641 if !strings.Contains(banner, "3. 拒绝") || strings.Contains(banner, "4. 拒绝") {
3642 t.Fatalf("approval banner = %q, want conventional 1/2/3 sandbox choices", banner)
3643 }
3644 if strings.Contains(banner, "sandbox_escape") {
3645 t.Fatalf("approval banner leaked raw tool grant: %q", banner)
3646 }
3647 }
3648
3649 func TestFreshApprovalBannerUsesConventionalDenyChoice(t *testing.T) {
3650 i18n.DetectLanguage("zh")
3651 t.Cleanup(func() { i18n.DetectLanguage("en") })
3652
3653 m := newTestChatTUI()
3654 m.width = 120
3655 m.pendingApproval = &event.Approval{
3656 ID: "approval-1",
3657 Tool: "remember",
3658 Subject: "保存/更新记忆",
3659 }
3660 banner := m.renderApprovalBanner()
3661 if !strings.Contains(banner, "1. 本次允许") || !strings.Contains(banner, "2. 拒绝") {
3662 t.Fatalf("approval banner = %q, want conventional 1/2 fresh choices", banner)
3663 }
3664 if strings.Contains(banner, "4. 拒绝") {
3665 t.Fatalf("approval banner = %q, must not show non-consecutive deny choice", banner)
3666 }
3667 }
3668
3669 func TestDynamicMCPFreshApprovalHidesRememberedChoices(t *testing.T) {
3670 i18n.DetectLanguage("en")
3671 m := newTestChatTUI()
3672 m.width = 120
3673 m.pendingApproval = &event.Approval{
3674 ID: "approval-mcp-1",
3675 Tool: "mcp__srv__wipe",
3676 Subject: "MCP srv/wipe declares destructive side effects",
3677 Fresh: true,
3678 }
3679 banner := m.renderApprovalBanner()
3680 if !strings.Contains(banner, "1. Allow once") || !strings.Contains(banner, "2. Deny") {
3681 t.Fatalf("approval banner = %q, want fresh two-choice prompt", banner)
3682 }
3683 if strings.Contains(banner, "for this session") || strings.Contains(banner, "Always allow") {
3684 t.Fatalf("approval banner offers remembered grant for destructive MCP: %q", banner)
3685 }
3686 }
3687
3688 func TestDynamicBashApprovalChoicesUseExactLiteralRules(t *testing.T) {
3689 const command = "git status $(touch /tmp/reasonix-dynamic-approval)"
3690 approval := &event.Approval{Tool: "bash", Subject: command}
3691 choices := approvalChoices(approval)
3692 if len(choices) != 3 {
3693 t.Fatalf("dynamic Bash choices = %+v, want once/session/deny", choices)
3694 }
3695 want := "Bash=" + command
3696 if !strings.Contains(choices[1].label, want) {
3697 t.Fatalf("session choice = %q, want exact rule %q", choices[1].label, want)
3698 }
3699 }
3700
3701 func TestFreshApprovalSessionChoiceIsLimitedToSandboxEscape(t *testing.T) {
3702 if !freshApprovalAllowsSession(control.SandboxEscapeApprovalTool) {
3703 t.Fatal("sandbox escape should allow an explicit session choice")
3704 }
3705 for _, toolName := range []string{"remember", "forget", planApprovalTool, agent.PlanModeReadOnlyCommandApprovalTool} {
3706 if freshApprovalAllowsSession(toolName) {
3707 t.Fatalf("%s should not allow the sandbox escape session choice", toolName)
3708 }
3709 }
3710 }
3711
3712 // TestSlashQuitExit verifies that /quit and /exit slash commands quit through
3713 // the shutdown path (tuiShutdownMsg → snapshot → tea.Quit, #5879), providing an
3714 // alternative to Ctrl+D and the bare "quit"/"exit" text commands.
3715 func TestSlashQuitExit(t *testing.T) {
3716 m := newTestChatTUI()
3717 for _, cmd := range []string{"/quit", "/exit"} {
3718 got := m.runSlashCommand(cmd)
3719 if got == nil {
3720 t.Errorf("%s should return a quit cmd, got nil", cmd)
3721 continue
3722 }
3723 msg := got()
3724 if _, ok := msg.(tuiShutdownMsg); !ok {
3725 t.Errorf("%s cmd should produce tuiShutdownMsg, got %T", cmd, msg)
3726 }
3727 }
3728 }
3729
3730 func TestSlashSubagentWithoutTaskStaysIdleWithUsageHint(t *testing.T) {
3731 ctrl := newOwnedTestController(t, control.Options{Skills: []skill.Skill{{
3732 Name: "helper", RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal,
3733 }}})
3734 m := newTestChatTUI()
3735 m.ctrl = ctrl
3736
3737 if cmd := m.runSlashCommand("/helper"); cmd != nil {
3738 t.Fatal("taskless subagent slash should be handled locally")
3739 }
3740 if m.state != tuiIdle {
3741 t.Fatalf("taskless subagent slash left TUI state=%v, want idle", m.state)
3742 }
3743 if out := strings.Join(m.transcript, "\n"); !strings.Contains(out, "usage: /helper <task>") {
3744 t.Fatalf("missing task usage hint:\n%s", out)
3745 }
3746 }
3747
3748 func TestSlashMigrateShowsProgress(t *testing.T) {
3749 isolateCLIConfigHome(t)
3750 m := newTestChatTUI()
3751
3752 if cmd := m.runSlashCommand("/migrate"); cmd != nil {
3753 t.Fatal("/migrate should run locally without returning a command")
3754 }
3755 out := strings.Join(m.transcript, "\n")
3756 for _, want := range []string{
3757 "/migrate",
3758 "migration rescue: checking legacy config and credentials",
3759 "migration rescue: scanning legacy memory",
3760 "migration rescue: scanning legacy sessions",
3761 "migration rescue complete:",
3762 } {
3763 if !strings.Contains(out, want) {
3764 t.Fatalf("missing %q in transcript:\n%s", want, out)
3765 }
3766 }
3767 }
3768
3769 func TestSlashMigrateFromImportsExplicitSessions(t *testing.T) {
3770 home := isolateCLIConfigHome(t)
3771 legacySessions := filepath.Join(home, "Old Reasonix", "sessions")
3772 if err := os.MkdirAll(legacySessions, 0o755); err != nil {
3773 t.Fatal(err)
3774 }
3775 if err := os.WriteFile(filepath.Join(legacySessions, "old-chat.jsonl"), []byte(`{"role":"user","content":"hello from old install"}`+"\n"), 0o644); err != nil {
3776 t.Fatal(err)
3777 }
3778 m := newTestChatTUI()
3779
3780 input := `/migrate --from "` + filepath.Dir(legacySessions) + `"`
3781 if cmd := m.runSlashCommand(input); cmd != nil {
3782 t.Fatal("/migrate --from should run locally without returning a command")
3783 }
3784 out := strings.Join(m.transcript, "\n")
3785 for _, want := range []string{
3786 input,
3787 "migration rescue: scanning explicit legacy sessions from " + filepath.Dir(legacySessions),
3788 "imported 1 past session(s) from " + legacySessions,
3789 } {
3790 if !strings.Contains(out, want) {
3791 t.Fatalf("missing %q in transcript:\n%s", want, out)
3792 }
3793 }
3794 }
3795
3796 // TestDoubleCtrlCQuit verifies that Ctrl+C while idle requires a double-press
3797 // within the 1.5s window to actually quit. A single press shows a hint; a
3798 // second press within the window returns tea.Quit.
3799 func TestDoubleCtrlCQuit(t *testing.T) {
3800 ctrl := newOwnedTestController(t, control.Options{})
3801 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
3802 ctrlC := tea.KeyPressMsg{Code: 'c', Mod: 4} // 4 = ModCtrl
3803
3804 // First Ctrl+C while idle arms quit and adds the hint to model state. A
3805 // renderer command is not required for Bubble Tea to paint that state.
3806 out, _ := m.Update(ctrlC)
3807 m2, ok := out.(chatTUI)
3808 if !ok {
3809 t.Fatalf("Update returned %T, want chatTUI", out)
3810 }
3811 if m2.lastCtrlCAt.IsZero() {
3812 t.Error("first Ctrl+C should set lastCtrlCAt")
3813 }
3814
3815 // Second Ctrl+C within window: returns tea.Quit.
3816 out2, cmd2 := m2.Update(ctrlC)
3817 if cmd2 == nil {
3818 t.Error("second Ctrl+C within window should return a quit cmd")
3819 }
3820 _ = out2
3821
3822 // Window expired: re-arms instead of quitting.
3823 m3 := m2
3824 m3.lastCtrlCAt = time.Now().Add(-2 * time.Second)
3825 out4, _ := m3.Update(ctrlC)
3826 m4, ok := out4.(chatTUI)
3827 if !ok {
3828 t.Fatalf("Update returned %T, want chatTUI", out4)
3829 }
3830 // lastCtrlCAt should be refreshed to now.
3831 if time.Since(m4.lastCtrlCAt) > time.Second {
3832 t.Error("expired Ctrl+C should refresh lastCtrlCAt")
3833 }
3834 }
3835
3836 func TestSecondCtrlCQuitsAfterCancelIsAlreadyRequested(t *testing.T) {
3837 r := &stubbornTurnRunner{started: make(chan struct{}), release: make(chan struct{})}
3838 ctrl := newOwnedTestController(t, control.Options{Runner: r, Sink: event.Discard, SessionDir: t.TempDir(), Label: "test"})
3839 ctrl.Send("hi")
3840 <-r.started
3841 defer close(r.release)
3842
3843 m := newTestChatTUI()
3844 m.ctrl = ctrl
3845 m.state = tuiRunning
3846 ctrlC := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
3847
3848 _, firstCmd := m.Update(ctrlC)
3849 if firstCmd != nil {
3850 t.Fatal("first Ctrl+C while running should request cancel, not quit")
3851 }
3852 if st := ctrl.RuntimeStatus(); !st.Running || !st.CancelRequested {
3853 t.Fatalf("first Ctrl+C status = %+v, want running cancel requested", st)
3854 }
3855
3856 _, secondCmd := m.Update(ctrlC)
3857 if secondCmd == nil {
3858 t.Fatal("second Ctrl+C after cancel request should quit")
3859 }
3860 if msg := secondCmd(); msg != (tuiShutdownMsg{userInitiated: true}) {
3861 t.Fatalf("second Ctrl+C command = %T, want tuiShutdownMsg (snapshot-before-quit, #5879)", msg)
3862 }
3863 }
3864
3865 func TestRunningStatusShowsCancelRequested(t *testing.T) {
3866 r := &stubbornTurnRunner{started: make(chan struct{}), release: make(chan struct{})}
3867 ctrl := newOwnedTestController(t, control.Options{Runner: r, Sink: event.Discard, SessionDir: t.TempDir(), Label: "test"})
3868 ctrl.Send("hi")
3869 <-r.started
3870 defer close(r.release)
3871
3872 m := newTestChatTUI()
3873 m.ctrl = ctrl
3874 m.state = tuiRunning
3875 m.width = 80
3876 m.height = 24
3877 ctrl.Cancel()
3878
3879 view := ansi.Strip(m.View().Content)
3880 if !strings.Contains(view, "stopping") {
3881 t.Fatalf("running status after cancel should show stopping feedback:\n%s", view)
3882 }
3883 }
3884
3885 func TestCtrlZResetsMouseTrackingBeforeSuspend(t *testing.T) {
3886 m := newTestChatTUI()
3887 ctrlZ := tea.KeyPressMsg{Code: 'z', Mod: tea.ModCtrl}
3888
3889 _, cmd := m.Update(ctrlZ)
3890 if cmd == nil {
3891 t.Fatal("expected Ctrl+Z to return a suspend sequence")
3892 }
3893 msg := cmd()
3894 seq := reflect.ValueOf(msg)
3895 if seq.Kind() != reflect.Slice || seq.Len() != 2 {
3896 t.Fatalf("expected Ctrl+Z to return a two-command sequence, got %T", msg)
3897 }
3898 first, ok := seq.Index(0).Interface().(tea.Cmd)
3899 if !ok {
3900 t.Fatalf("first sequence item is %T, want tea.Cmd", seq.Index(0).Interface())
3901 }
3902 raw, ok := first().(tea.RawMsg)
3903 if !ok {
3904 t.Fatalf("first Ctrl+Z command = %T, want tea.RawMsg", first())
3905 }
3906 if got := fmt.Sprint(raw.Msg); got != resetMouseTracking {
3907 t.Fatalf("Ctrl+Z mouse reset = %q, want %q", got, resetMouseTracking)
3908 }
3909 second, ok := seq.Index(1).Interface().(tea.Cmd)
3910 if !ok {
3911 t.Fatalf("second sequence item is %T, want tea.Cmd", seq.Index(1).Interface())
3912 }
3913 if msg := second(); msg != (tea.SuspendMsg{}) {
3914 t.Fatalf("second Ctrl+Z command = %T, want tea.SuspendMsg", msg)
3915 }
3916 }
3917
3918 // TestCtrlCClearsInput verifies that a single Ctrl+C while idle with non-empty
3919 // input clears the composer without arming the double-press quit gesture.
3920 func TestCtrlCClearsInput(t *testing.T) {
3921 m := newTestChatTUI()
3922 m.input.SetValue("hello world")
3923 ctrlC := tea.KeyPressMsg{Code: 'c', Mod: 4}
3924
3925 out, _ := m.Update(ctrlC)
3926 m2 := out.(chatTUI)
3927
3928 if strings.TrimSpace(m2.input.Value()) != "" {
3929 t.Errorf("Ctrl+C should clear non-empty input, got %q", m2.input.Value())
3930 }
3931 if !m2.lastCtrlCAt.IsZero() {
3932 t.Error("Ctrl+C on non-empty input should not arm the quit gesture")
3933 }
3934 }
3935
3936 // TestCtrlCClearsThenDoublePressQuits verifies the full user flow: Ctrl+C on
3937 // non-empty input clears it, then two more presses on the empty composer quit.
3938 func TestCtrlCClearsThenDoublePressQuits(t *testing.T) {
3939 m := newTestChatTUI()
3940 m.input.SetValue("draft text")
3941 ctrlC := tea.KeyPressMsg{Code: 'c', Mod: 4}
3942
3943 // First press: clear input.
3944 out, _ := m.Update(ctrlC)
3945 m2 := out.(chatTUI)
3946 if strings.TrimSpace(m2.input.Value()) != "" {
3947 t.Fatal("first Ctrl+C should clear input")
3948 }
3949
3950 // Second press (on empty): arm quit.
3951 out2, _ := m2.Update(ctrlC)
3952 m3 := out2.(chatTUI)
3953 if m3.lastCtrlCAt.IsZero() {
3954 t.Error("Ctrl+C on empty input should arm quit")
3955 }
3956
3957 // Third press (within window): quit.
3958 out3, cmd := m3.Update(ctrlC)
3959 if cmd == nil {
3960 t.Error("double Ctrl+C on empty input should quit")
3961 }
3962 _ = out3
3963 }
3964
3965 // TestCtrlCCopySelection verifies that Ctrl+C while idle on an empty composer
3966 // with an active text selection copies the selected text to clipboard instead
3967 // of arming the double-press quit gesture.
3968 func TestCtrlCCopySelection(t *testing.T) {
3969 m := newTestChatTUI()
3970 ctrlC := tea.KeyPressMsg{Code: 'c', Mod: 4}
3971
3972 // Set up an active selection: anchor < head so there's something to copy.
3973 // selection uses content-line coordinates; transcript needs at least one line.
3974 m.transcript = []string{"hello world"}
3975 m.wrappedLines = []string{"hello world"}
3976 m.sel = selection{active: true, anchor: selPos{line: 0, col: 0}, head: selPos{line: 0, col: 5}}
3977
3978 out, cmd := m.Update(ctrlC)
3979 m2, ok := out.(chatTUI)
3980 if !ok {
3981 t.Fatalf("Update returned %T, want chatTUI", out)
3982 }
3983
3984 // Selection should be cleared after copy.
3985 if m2.sel.active {
3986 t.Error("selection should be cleared after Ctrl+C copy")
3987 }
3988
3989 // Should NOT arm the quit gesture.
3990 if !m2.lastCtrlCAt.IsZero() {
3991 t.Error("Ctrl+C on active selection should not arm the quit gesture")
3992 }
3993
3994 // Should return a command (clipboard copy + finalize).
3995 if cmd == nil {
3996 t.Fatal("Ctrl+C on selection should return a cmd (clipboard + finalize)")
3997 }
3998
3999 // Execute the command (copyToClipboard → OSC 52).
4000 cmd()
4001
4002 // Second Ctrl+C should now arm quit (selection is gone). Rendering the
4003 // changed model does not require a command.
4004 out2, _ := m2.Update(ctrlC)
4005 if out2.(chatTUI).lastCtrlCAt.IsZero() {
4006 t.Error("Ctrl+C after copy should arm quit")
4007 }
4008 }
4009
4010 // TestAgentEventCoalescesBurst proves one update drains the buffered event burst
4011 // behind the delivered event, so a flood collapses into a single re-render.
4012 func TestAgentEventCoalescesBurst(t *testing.T) {
4013 m := newTestChatTUI()
4014 m.eventCh = make(chan event.Event, 16)
4015 m.eventCh <- event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "b1", Output: "l1\n"}}
4016 m.eventCh <- event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "b1", Output: "l2\n"}}
4017 m.eventCh <- event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "b1", Output: "l3\n"}}
4018
4019 next, _ := m.update(agentEventMsg(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "b1", Name: "bash", Args: `{"command":"x"}`}}))
4020 cm := next.(chatTUI)
4021
4022 if cm.toolLineCount != 3 {
4023 t.Fatalf("burst not coalesced into one update: toolLineCount=%d, want 3", cm.toolLineCount)
4024 }
4025 if len(m.eventCh) != 0 {
4026 t.Errorf("channel should be fully drained, %d left", len(m.eventCh))
4027 }
4028 }
4029
4030 func TestShortTokens(t *testing.T) {
4031 cases := []struct {
4032 n int
4033 want string
4034 }{
4035 {0, "0"},
4036 {999, "999"},
4037 {1000, "1.0K"},
4038 {1500, "1.5K"},
4039 {1999, "2.0K"},
4040 {9999, "10.0K"},
4041 {142000, "142.0K"},
4042 {999999, "1.0M"},
4043 {1000000, "1.0M"},
4044 {1500000, "1.5M"},
4045 }
4046 for _, tc := range cases {
4047 t.Run(fmt.Sprintf("n=%d", tc.n), func(t *testing.T) {
4048 got := shortTokens(tc.n)
4049 if got != tc.want {
4050 t.Errorf("shortTokens(%d) = %q, want %q", tc.n, got, tc.want)
4051 }
4052 })
4053 }
4054 }
4055
4056 func TestTruncateSubject(t *testing.T) {
4057 cases := []struct {
4058 name string
4059 input string
4060 width int
4061 }{
4062 {"short ASCII", "rm file", 60},
4063 {"long ASCII", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 60},
4064 {"CJK at 60", "日本語の文章は通常、表示幅が広いため、端末の横幅を超えてしまうことがあります。", 60},
4065 {"CJK at 30", "日本語の文章は通常、表示幅が広いため、端末の横幅を超えてしまうことがあります。", 30},
4066 }
4067 for _, tc := range cases {
4068 t.Run(tc.name, func(t *testing.T) {
4069 got := truncateSubject(tc.input, tc.width)
4070 wantMax := max(tc.width-28, 16)
4071 w := ansi.StringWidth(got)
4072 if w > wantMax {
4073 t.Errorf("truncateSubject(%q, %d) = %q (width %d), want visible width <= %d", tc.input, tc.width, got, w, wantMax)
4074 }
4075 })
4076 }
4077 }
4078
4079 // TestCtrlCCopyBeatsClearInput — regression for the bug where an active
4080 // selection AND a non-empty composer both existed: Ctrl+C used to wipe the
4081 // draft text and discard the selection. The fix hoists the selection-copy
4082 // branch above the clear-input branch so the user's draft survives. After
4083 // the copy the user can still press Ctrl+C again to clear the composer.
4084 func TestCtrlCCopyBeatsClearInput(t *testing.T) {
4085 m := newTestChatTUI()
4086 m.input.SetValue("draft I'm typing") // non-empty composer
4087 m.transcript = []string{"selected text"}
4088 m.wrappedLines = []string{"selected text"}
4089 m.sel = selection{active: true, anchor: selPos{line: 0, col: 0}, head: selPos{line: 0, col: 8}}
4090
4091 ctrlC := tea.KeyPressMsg{Code: 'c', Mod: 4}
4092 out, cmd := m.Update(ctrlC)
4093 m2 := out.(chatTUI)
4094
4095 // Draft text must survive the selection copy.
4096 if got := m2.input.Value(); got != "draft I'm typing" {
4097 t.Errorf("composer draft wiped by Ctrl+C copy; got %q, want preserved", got)
4098 }
4099 if cmd == nil {
4100 t.Fatal("expected clipboard cmd")
4101 }
4102 // Second Ctrl+C (no selection, non-empty composer) clears the draft.
4103 out2, _ := m2.Update(ctrlC)
4104 m3 := out2.(chatTUI)
4105 if got := m3.input.Value(); got != "" {
4106 t.Errorf("second Ctrl+C should clear composer; got %q", got)
4107 }
4108 }
4109
4110 // TestEscInPlanModeDoesNotExitPlan — regression for the part of PR #3051 that
4111 // was missed: Esc was still falling into the case m.planMode branch. The
4112 // Shift+Tab cycle is the only path that flips plan mode; Esc must only
4113 // rewind / clear input. PR #3051 already removed the equivalent YOLO branch;
4114 // the m.ctrl.SetBypass path is exercised end-to-end in control/yolo_test.go
4115 // and intentionally not duplicated here.
4116 func TestEscInPlanModeDoesNotExitPlan(t *testing.T) {
4117 m := newTestChatTUI()
4118 m.planMode = true
4119
4120 esc := tea.KeyPressMsg{Code: tea.KeyEsc}
4121 out, _ := m.Update(esc)
4122 m2 := out.(chatTUI)
4123
4124 if !m2.planMode {
4125 t.Error("Esc must not exit plan mode; only Shift+Tab should")
4126 }
4127 }
4128
4129 func TestDesktopShortcutLayoutCtrlYTogglesYoloFromReadOnly(t *testing.T) {
4130 m := newTestChatTUI()
4131 m.ctrl = newOwnedTestController(t, control.Options{})
4132 m.cfg = config.Default()
4133 if err := m.cfg.SetUIShortcutLayout("desktop"); err != nil {
4134 t.Fatal(err)
4135 }
4136
4137 ctrlY := tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}
4138 out, _ := m.Update(ctrlY)
4139 m = out.(chatTUI)
4140 if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalDangerFullAccess {
4141 t.Fatalf("Ctrl+Y permission mode = %q, want danger-full-access", got)
4142 }
4143 if got := m.modeTagText(); got != "YOLO" {
4144 t.Fatalf("Ctrl+Y mode tag = %q, want YOLO", got)
4145 }
4146
4147 out, _ = m.Update(ctrlY)
4148 m = out.(chatTUI)
4149 if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalReadOnly {
4150 t.Fatalf("second Ctrl+Y permission mode = %q, want read-only", got)
4151 }
4152 }
4153
4154 func TestDesktopShortcutLayoutCtrlYRestoresWorkspacePermission(t *testing.T) {
4155 m := newTestChatTUI()
4156 m.ctrl = newOwnedTestController(t, control.Options{})
4157 m.ctrl.SetToolApprovalMode(control.ToolApprovalAuto)
4158 m.cfg = config.Default()
4159 if err := m.cfg.SetUIShortcutLayout("desktop"); err != nil {
4160 t.Fatal(err)
4161 }
4162
4163 ctrlY := tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}
4164 out, _ := m.Update(ctrlY)
4165 m = out.(chatTUI)
4166 if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalDangerFullAccess {
4167 t.Fatalf("Ctrl+Y permission mode = %q, want danger-full-access", got)
4168 }
4169 out, _ = m.Update(ctrlY)
4170 m = out.(chatTUI)
4171 if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalWorkspaceWrite {
4172 t.Fatalf("second Ctrl+Y permission mode = %q, want workspace-write", got)
4173 }
4174 }
4175
4176 func TestClassicShortcutLayoutCtrlYTogglesYolo(t *testing.T) {
4177 m := newTestChatTUI()
4178 m.ctrl = newOwnedTestController(t, control.Options{})
4179 m.cfg = config.Default()
4180 if err := m.cfg.SetUIShortcutLayout("classic"); err != nil {
4181 t.Fatal(err)
4182 }
4183
4184 ctrlY := tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}
4185 out, _ := m.Update(ctrlY)
4186 m = out.(chatTUI)
4187 if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalDangerFullAccess {
4188 t.Fatalf("Ctrl+Y permission mode = %q, want danger-full-access", got)
4189 }
4190 }
4191
4192 func TestPrimaryYShortcutPreservesWorkspacePermission(t *testing.T) {
4193 m := newTestChatTUI()
4194 m.ctrl = newOwnedTestController(t, control.Options{})
4195 m.ctrl.SetToolApprovalMode(control.ToolApprovalAuto)
4196 m.cfg = config.Default()
4197 if err := m.cfg.SetUIShortcutLayout("classic"); err != nil {
4198 t.Fatal(err)
4199 }
4200
4201 cmdY := tea.KeyPressMsg{Code: 'y', Mod: tea.ModSuper}
4202 out, _ := m.Update(cmdY)
4203 m = out.(chatTUI)
4204 if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalAuto {
4205 t.Fatalf("Cmd/Super+Y changed workspace permission mode to %q", got)
4206 }
4207 }
4208
4209 func TestDesktopShortcutLayoutDoesNotStealCompletionTab(t *testing.T) {
4210 m := newTestChatTUI()
4211 m.ctrl = newOwnedTestController(t, control.Options{})
4212 m.cfg = config.Default()
4213 if err := m.cfg.SetUIShortcutLayout("desktop"); err != nil {
4214 t.Fatal(err)
4215 }
4216 m.input.SetValue("/")
4217 m.completion = completion{
4218 active: true,
4219 kind: compSlash,
4220 items: []compItem{{label: "/mcp", insert: "/mcp ", descend: true}},
4221 replaceFrom: 0,
4222 replaceTo: len("/"),
4223 }
4224
4225 out, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyTab})
4226 m = out.(chatTUI)
4227 if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalAsk {
4228 t.Fatalf("completion Tab changed approval mode to %q", got)
4229 }
4230 if got := m.input.Value(); got != "/mcp " {
4231 t.Fatalf("completion Tab input = %q, want /mcp ", got)
4232 }
4233 }
4234
4235 func TestShiftTabCyclesPermissionModesUnderClassicShortcutLayout(t *testing.T) {
4236 m := newTestChatTUI()
4237 m.ctrl = newOwnedTestController(t, control.Options{})
4238 m.cfg = config.Default()
4239 if err := m.cfg.SetUIShortcutLayout("classic"); err != nil {
4240 t.Fatal(err)
4241 }
4242
4243 out, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift})
4244 m = out.(chatTUI)
4245 if m.planMode || m.ctrl.PlanMode() || m.ctrl.ToolApprovalMode() != control.ToolApprovalAuto {
4246 t.Fatalf("first Shift+Tab should enter auto, plan=%v approval=%q", m.planMode, m.ctrl.ToolApprovalMode())
4247 }
4248 out, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift})
4249 m = out.(chatTUI)
4250 if m.planMode || m.ctrl.PlanMode() || m.ctrl.ToolApprovalMode() != control.ToolApprovalDangerFullAccess {
4251 t.Fatalf("second Shift+Tab should enter YOLO, plan=%v approval=%q", m.planMode, m.ctrl.ToolApprovalMode())
4252 }
4253 out, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift})
4254 m = out.(chatTUI)
4255 if !m.planMode || !m.ctrl.PlanMode() || m.ctrl.ToolApprovalMode() != control.ToolApprovalReadOnly {
4256 t.Fatalf("third Shift+Tab should enter plan, plan=%v approval=%q", m.planMode, m.ctrl.ToolApprovalMode())
4257 }
4258 }
4259
4260 func TestLegacyDontAskDisplaysAndCyclesAsReadOnly(t *testing.T) {
4261 m := newTestChatTUI()
4262 m.ctrl = newOwnedTestController(t, control.Options{})
4263 m.ctrl.SetToolApprovalMode(control.ToolApprovalDontAsk)
4264 m.cfg = config.Default()
4265 if err := m.cfg.SetUIShortcutLayout("desktop"); err != nil {
4266 t.Fatal(err)
4267 }
4268 if got := m.modeTagText(); got != "Read only" {
4269 t.Fatalf("dontAsk mode tag = %q", got)
4270 }
4271
4272 m.cycleMode()
4273 if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalAsk {
4274 t.Fatalf("Shift+Tab from legacy dontAsk = %q, want read-only", got)
4275 }
4276 }
4277
4278 // TestQuitGesturesRouteThroughShutdown guards #5879: every in-TUI quit gesture
4279 // must emit tuiShutdownMsg (whose handler snapshots the session) rather than
4280 // tea.Quit directly, which would drop everything past the last snapshot.
4281 func TestQuitGesturesRouteThroughShutdown(t *testing.T) {
4282 ctrlC := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
4283
4284 // Double Ctrl+C on an idle, empty composer.
4285 m := newTestChatTUI()
4286 model, cmd := m.Update(ctrlC)
4287 m = model.(chatTUI)
4288 if cmd != nil {
4289 if msg := cmd(); msg == (tea.QuitMsg{}) {
4290 t.Fatal("first Ctrl+C must not quit")
4291 }
4292 }
4293 _, cmd = m.Update(ctrlC)
4294 if cmd == nil {
4295 t.Fatal("second Ctrl+C should return a command")
4296 }
4297 if msg := cmd(); msg != (tuiShutdownMsg{userInitiated: true}) {
4298 t.Fatalf("double Ctrl+C emitted %T, want tuiShutdownMsg", msg)
4299 }
4300
4301 // Ctrl+D.
4302 m = newTestChatTUI()
4303 _, cmd = m.Update(tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl})
4304 if cmd == nil {
4305 t.Fatal("Ctrl+D should return a command")
4306 }
4307 if msg := cmd(); msg != (tuiShutdownMsg{userInitiated: true}) {
4308 t.Fatalf("Ctrl+D emitted %T, want tuiShutdownMsg", msg)
4309 }
4310 }
4311
4312 // TestMessageEventReplacesStreamedAnswer guards #6665 on the TUI side: the
4313 // final Message event carries the canonical display text (protocol blocks
4314 // stripped at emission), and it must replace the raw streamed accumulation.
4315 func TestMessageEventReplacesStreamedAnswer(t *testing.T) {
4316 m := newTestChatTUI()
4317 m.ingestEvent(event.Event{Kind: event.Text, Text: "answer <autoresearch-evidence>{\"id\":\"e1\"}</autoresearch-evidence> tail"})
4318 m.ingestEvent(event.Event{Kind: event.Message, Text: "answer tail"})
4319
4320 joined := strings.Join(m.transcript, "\n")
4321 if strings.Contains(joined, "autoresearch-evidence") {
4322 t.Fatalf("committed transcript still contains evidence block:\n%s", joined)
4323 }
4324 if !strings.Contains(joined, "answer") {
4325 t.Fatalf("committed transcript lost the answer text:\n%s", joined)
4326 }
4327 }
4328
4328 lines GO