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