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