package cli import ( "context" "fmt" "os" "path/filepath" "reflect" "strings" "testing" "time" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" "reasonix/internal/agent" "reasonix/internal/checkpoint" "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/event" "reasonix/internal/i18n" "reasonix/internal/provider" "reasonix/internal/skill" ) type blockingTurnRunner struct{ started chan struct{} } type stubbornTurnRunner struct { started chan struct{} release chan struct{} } const tinyPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" func TestMain(m *testing.M) { old := detectTermuxTerminal detectTermuxTerminal = func() bool { return false } // Pin the UI language for the whole cli test binary. Production code // (cli.Run) calls i18n.DetectLanguage("") which resolves the host locale from // the environment (REASONIX_LANG/LC_ALL/LC_MESSAGES/LANG) and installs it as // the global i18n.M. On a non-English dev machine that flips M to e.g. // Chinese, and tests that exercise the CLI entry point (acp_test.go, // cli_test.go) don't restore it — so later tests asserting English UI strings // fail, but only when the whole package runs, not in isolation. Forcing a // deterministic English environment keeps the suite independent of the host // locale (matching CI). Tests that need another language still set it // explicitly via i18n.DetectLanguage(lang) with their own cleanup. os.Unsetenv("REASONIX_LANG") os.Unsetenv("LC_ALL") os.Unsetenv("LC_MESSAGES") os.Setenv("LANG", "en_US.UTF-8") i18n.DetectLanguage("en") code := m.Run() detectTermuxTerminal = old os.Exit(code) } func (r *blockingTurnRunner) Run(ctx context.Context, _ string) error { close(r.started) <-ctx.Done() return ctx.Err() } func (r *stubbornTurnRunner) Run(ctx context.Context, _ string) error { close(r.started) <-r.release return ctx.Err() } type recordingTurnRunner struct { inputs []string memoryCompilerInputs []string } func (r *recordingTurnRunner) Run(ctx context.Context, input string) error { r.inputs = append(r.inputs, input) // The memory compiler's source_event is set by the orchestrator from the // controller's `raw` value. Capture it so we can prove the CLI passes the // EXPANDED paste (not the folded label) — the label would starve the model // of the pasted content once the compiler's contract replaces the user turn. if source, ok := agent.MemoryCompilerSourceInputFromContext(ctx); ok { r.memoryCompilerInputs = append(r.memoryCompilerInputs, source) } return nil } func waitForCLIEvent(t *testing.T, ch <-chan event.Event, kind event.Kind) { t.Helper() deadline := time.After(2 * time.Second) for { select { case e := <-ch: if e.Kind == kind { return } case <-deadline: t.Fatalf("timed out waiting for event %v", kind) } } } func writeTUIImageCapabilityConfig(t *testing.T, root string) { t.Helper() cfg := config.Default() cfg.DefaultModel = "custom/text-only" cfg.Providers = []config.ProviderEntry{{ Name: "custom", Kind: "openai", BaseURL: "https://example.invalid/v1", Models: []string{"text-only", "vision-pro"}, VisionModels: []string{"vision-pro"}, }} if err := cfg.SaveTo(filepath.Join(root, "reasonix.toml")); err != nil { t.Fatalf("save config: %v", err) } } func saveTestImageAttachment(t *testing.T, root string) string { t.Helper() t.Chdir(root) path, err := control.SaveImageDataURL("data:image/png;base64," + tinyPNGBase64) if err != nil { t.Fatalf("SaveImageDataURL: %v", err) } return path } // TestEscCancelsRunningTurnWithCompletionOpen reproduces the report that Esc // (unlike Ctrl+C) did not stop a running turn: an active completion menu // captured Esc to close itself and returned before reaching the running-turn // cancel branch, while Ctrl+C — not in the completion switch — fell through. func TestEscCancelsRunningTurnWithCompletionOpen(t *testing.T) { r := &blockingTurnRunner{started: make(chan struct{})} ctrl := control.New(control.Options{Runner: r, Sink: event.Discard, SessionDir: t.TempDir(), Label: "test"}) ctrl.Send("hi") <-r.started // the turn is in flight and cancellable m := newTestChatTUI() m.ctrl = ctrl m.state = tuiRunning m.completion.active = true // e.g. a "/" typed into the composer while waiting _, _ = m.update(tea.KeyPressMsg{Code: tea.KeyEscape}) deadline := time.Now().Add(2 * time.Second) for ctrl.Running() { if time.Now().After(deadline) { t.Fatal("Esc did not cancel the running turn (completion menu swallowed it)") } time.Sleep(10 * time.Millisecond) } } // TestTranscriptMirrorsCommits proves the alt-screen migration's foundation: // every line commitLine sends to native scrollback is also captured in the // transcript buffer (the future viewport's content source), in order. func TestTranscriptMirrorsCommits(t *testing.T) { m := newTestChatTUI() m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{Name: "read_file", Args: `{"path":"x"}`}}) m.ingestEvent(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "compacted"}) if len(m.transcript) != len(*m.pendingCommit) { t.Fatalf("transcript (%d) and pendingCommit (%d) should hold the same lines", len(m.transcript), len(*m.pendingCommit)) } for i := range m.transcript { if m.transcript[i] != (*m.pendingCommit)[i] { t.Errorf("line %d mismatch: transcript=%q pendingCommit=%q", i, m.transcript[i], (*m.pendingCommit)[i]) } } } func TestTermuxNativeScrollbackCommitsFinalAnswer(t *testing.T) { m := newTestChatTUI() m.nativeScrollback = true m.pending.WriteString("first paragraph\n\nsecond paragraph") m.streamAnswer() if len(*m.pendingCommit) != 0 { t.Fatalf("Termux native scrollback should not commit rewritten streaming blocks, got %v", *m.pendingCommit) } m.commitPending() if got := strings.Join(*m.pendingCommit, "\n"); !strings.Contains(got, "first paragraph") || !strings.Contains(got, "second paragraph") { t.Fatalf("final answer was not committed to native scrollback: %v", *m.pendingCommit) } } func TestTermuxNativeScrollbackDefaultsToExpandedReasoning(t *testing.T) { old := detectTermuxTerminal detectTermuxTerminal = func() bool { return true } t.Cleanup(func() { detectTermuxTerminal = old }) ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) if !m.nativeScrollback { t.Fatal("Termux should use native scrollback") } if !m.showReasoning { t.Fatal("Termux should expand reasoning by default because live viewport reasoning is unavailable") } m.width = 80 m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "reasoning details"}) m.ingestEvent(event.Event{Kind: event.Text, Text: "answer"}) got := strings.Join(*m.pendingCommit, "\n") if !strings.Contains(got, "reasoning details") { t.Fatalf("Termux reasoning was not expanded into native scrollback: %q", got) } } // TestCompletionMenuFixedWidth verifies that the completion menu pads every // line (items + footer) to m.width so delta rendering always writes exactly the // same column count — no trailing characters for \033[K to leave behind. func TestCompletionMenuFixedWidth(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) m.width = 80 m.completion.active = true m.completion.items = []compItem{ {label: "review"}, {label: "clear", hint: "start fresh"}, } m.completion.sel = 1 m.completion.kind = compSlash out := m.renderCompletion() lines := strings.Split(strings.TrimRight(out, "\n"), "\n") // items + footer = 3 lines if len(lines) != 3 { t.Fatalf("completion menu should have 3 lines (2 items + footer), got %d:\n%s", len(lines), out) } for i, line := range lines { if got := ansi.StringWidth(line); got != 80 { t.Errorf("line %d visual width = %d, want 80: %q", i, got, line) } } } // TestCompletionMenuPadsWithNonBreakingSpaces verifies the fixed-width padding // is not ordinary ASCII space. Ultraviolet treats trailing ASCII spaces as // clearable cells and may emit EL/ECH erase sequences; mintty can leave stale // halves of CJK glyphs when those sequences clear Chinese skill descriptions. func TestCompletionMenuPadsWithNonBreakingSpaces(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) m.width = 80 m.completion.active = true m.completion.items = []compItem{ {label: "/土壤", hint: "分析土壤墒情"}, {label: "/巡田", hint: "识别病虫害"}, } m.completion.sel = 0 m.completion.kind = compSlash out := m.renderCompletion() for i, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") { if got := ansi.StringWidth(line); got != 80 { t.Fatalf("line %d visual width = %d, want 80: %q", i, got, line) } if !strings.HasSuffix(line, "\u00a0") { t.Fatalf("line %d should end with non-breaking padding, got %q", i, line) } if strings.HasSuffix(line, " ") { t.Fatalf("line %d should not end with clearable ASCII space, got %q", i, line) } } } // TestTranscriptViewportSizing proves the viewport tracks the terminal size and // gets the rows left over after the pinned bottom region (input box + 2 status // rows = 5 with an empty 1-line composer), and is fed the committed transcript. func TestTranscriptViewportSizing(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) m = m0.(chatTUI) if got := m.bottomRows(); got != 5 { t.Fatalf("bottomRows with an empty composer = %d, want 5 (input 1 + border 2 + status 2)", got) } if m.viewport.Width() != 79 { t.Errorf("viewport content width = %d, want 79 (terminal 80 - 1 scrollbar column)", m.viewport.Width()) } if want := m.transcriptHeight(); m.viewport.Height() != want || want != 19 { t.Errorf("viewport height = %d, transcriptHeight = %d, want 19 (24-5)", m.viewport.Height(), want) } if m.viewport.TotalLineCount() == 0 { t.Errorf("viewport should hold the committed banner after the first resize") } } // TestStatusLineWrapAccounting proves that computeStatusLineCount correctly // predicts the rendered row count of the status block (working + mode/state line // + data line) when wrapping is triggered on a narrow terminal, and that // bottomRows reserves the right height so the viewport fills the screen without // overlap. func TestStatusLineWrapAccounting(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 30) // Narrow terminal: mode+state line and data line will both wrap. m0, _ := m.Update(tea.WindowSizeMsg{Width: 30, Height: 12}) m = m0.(chatTUI) // At width 30 the status block should be detectably wrapped. if m.statusLineCount <= 2 { t.Fatalf("statusLineCount on a narrow terminal (30 cols) = %d, want > 2 (wrapping should be detected)", m.statusLineCount) } // Verify the height budget covers the full screen. if got := m.transcriptHeight() + m.bottomRows(); got != m.height { t.Fatalf("transcriptHeight(%d) + bottomRows(%d) = %d, want %d (full screen height)", m.transcriptHeight(), m.bottomRows(), got, m.height) } // When running, the working line should increase statusLineCount. idleCount := m.statusLineCount m.state = tuiRunning m.elapsed = 5 m.turnTokens = 100 // Push an interject so the working line is longer. m.pendingInterject = []string{"feedback"} m.statusLineCount = m.computeStatusLineCount(m.width) runCount := m.statusLineCount if runCount <= idleCount { t.Fatalf("statusLineCount when running (%d) should be > idle (%d)", runCount, idleCount) } // Reset and test that a custom statusline command is also counted. m.state = tuiIdle m.pendingInterject = nil m.statuslineCmd = "custom" m.statuslineOut = "model: claude-3 · ctx: 45% · tokens: 128K · cache: 87% · rate: 1.2s · jobs: 3 running · balance: ¥152.30" m0, _ = m.Update(tea.WindowSizeMsg{Width: 35, Height: 12}) m = m0.(chatTUI) if m.statusLineCount <= 2 { t.Fatalf("statusLineCount with custom statusline on 35 cols = %d, want > 2 (custom output should wrap)", m.statusLineCount) } if got := m.transcriptHeight() + m.bottomRows(); got != m.height { t.Fatalf("with custom statusline: transcriptHeight(%d) + bottomRows(%d) = %d, want %d", m.transcriptHeight(), m.bottomRows(), got, m.height) } } // TestStatusLineRenderedHeightMatchesBudget proves that the actual rendered // line count of View()'s bottom area matches what bottomRows() predicts, // specifically at the CJK 2-char-overflow boundary where an off-by-one would // hide the bottom row of the viewport. func TestStatusLineRenderedHeightMatchesBudget(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 46) // Manually set a long git repo/branch so the status line contains CJK. m.missing = "" m.gitStatus = gitStatus{Repo: "我的项目名字", Branch: "我的分支"} m0, _ := m.Update(tea.WindowSizeMsg{Width: 46, Height: 12}) m = m0.(chatTUI) if m.statusLineCount <= 2 { t.Fatalf("statusLineCount at width 46 with CJK = %d, want > 2", m.statusLineCount) } // Verify that computeStatusLineCount matches the actual rendered line count. // Strip ANSI from the full view, then reconstruct what bottomRows expects. viewStr := ansi.Strip(m.View().Content) allLines := strings.Split(viewStr, "\n") totalLines := len(allLines) // The total should be m.height (full terminal height). if totalLines != m.height { t.Fatalf("View() total lines = %d, want %d (terminal height)", totalLines, m.height) } // transcriptHeight() lines should be the viewport, the rest is bottom rows. if got, want := m.transcriptHeight()+m.bottomRows(), m.height; got != want { t.Fatalf("transcriptHeight(%d) + bottomRows(%d) = %d, want %d", m.transcriptHeight(), m.bottomRows(), got, want) } // Also verify the invariant holds at narrower widths. for _, w := range []int{44, 42, 40, 35, 30, 25, 20} { m0, _ = m.Update(tea.WindowSizeMsg{Width: w, Height: 12}) m = m0.(chatTUI) viewStr2 := ansi.Strip(m.View().Content) allLines2 := strings.Split(viewStr2, "\n") if len(allLines2) != m.height { t.Errorf("width=%d: View() total lines = %d, want %d", w, len(allLines2), m.height) } if got, want := m.transcriptHeight()+m.bottomRows(), m.height; got != want { t.Errorf("width=%d: transcriptHeight(%d) + bottomRows(%d) = %d, want %d", w, m.transcriptHeight(), m.bottomRows(), got, want) } } } func TestManualNewlineGrowsComposerWithoutHidingFirstLine(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 40) m0, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 12}) m = m0.(chatTUI) m.input.SetValue("first line") m0, _ = m.Update(tea.KeyPressMsg{Code: 'j', Mod: tea.ModCtrl}) m = m0.(chatTUI) if got := m.input.Height(); got != 2 { t.Fatalf("input height after Ctrl+J = %d, want 2", got) } if got := m.input.ScrollYOffset(); got != 0 { t.Fatalf("input scroll offset after Ctrl+J = %d, want 0 so the first line remains visible", got) } } func TestManualNewlineCanExceedVisibleComposerRows(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 40) m0, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 12}) m = m0.(chatTUI) m.input.SetValue("first line") for range maxInputRows + 1 { m0, _ = m.Update(tea.KeyPressMsg{Code: 'j', Mod: tea.ModCtrl}) m = m0.(chatTUI) } if got, want := strings.Count(m.input.Value(), "\n"), maxInputRows+1; got != want { t.Fatalf("manual newlines preserved = %d, want %d", got, want) } if got := m.input.Height(); got != maxInputRows { t.Fatalf("visible input height = %d, want capped at %d", got, maxInputRows) } } func TestSoftWrappedInputGrowsComposerAndShrinksTranscript(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 24) m0, _ := m.Update(tea.WindowSizeMsg{Width: 24, Height: 12}) m = m0.(chatTUI) initialViewportHeight := m.viewport.Height() m0, _ = m.Update(tea.PasteMsg{Content: strings.Repeat("x", 60)}) m = m0.(chatTUI) if got := m.input.Height(); got <= 1 { t.Fatalf("input height after soft-wrapped paste = %d, want > 1", got) } if got := m.viewport.Height(); got >= initialViewportHeight { t.Fatalf("viewport height after composer growth = %d, want less than initial %d", got, initialViewportHeight) } } func TestMCPManagerHidesComposerBox(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) m.mcp = &mcpManager{stage: mcpStageList, snapshot: mcpSnapshot{servers: []mcpServerView{ {Name: "github", Transport: "stdio", Status: "deferred", Configured: true, Tier: "background"}, }}} m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) m = m0.(chatTUI) footerRows := strings.Count(m.renderMainManagerFooter(), "\n") + 1 if got, want := m.bottomRows(), footerRows+m.statusLineCount; got != want { t.Fatalf("bottomRows with MCP manager = %d, want %d (footer + status rows; manager content renders in main area)", got, want) } if !m.hideComposer() { t.Fatal("MCP manager should hide the composer") } content := ansi.Strip(m.View().Content) if !strings.Contains(content, "Manage MCP servers") { t.Fatalf("MCP manager missing from view:\n%s", content) } if !strings.Contains(content, "Enter for details") { t.Fatalf("MCP footer hint missing from view:\n%s", content) } if !strings.Contains(content, "· MCP") { t.Fatalf("MCP status line missing from view:\n%s", content) } } func TestClearCommandRequiresConfirmationAndDiscardsSession(t *testing.T) { dir := t.TempDir() sess := agent.NewSession("sys") sess.Add(provider.Message{Role: provider.RoleUser, Content: "old context"}) exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) path := filepath.Join(dir, "session.jsonl") ctrl := control.New(control.Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test"}) if err := ctrl.Snapshot(); err != nil { t.Fatal(err) } m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) if cmd := m.runSlashCommand("/clear"); cmd != nil { t.Fatal("/clear should open a local confirmation without returning a command") } if m.clearConfirm == nil { t.Fatal("/clear should open a confirmation prompt") } if m.clearConfirm.confirm != 1 { t.Fatalf("/clear confirmation should default to cancel, got %d", m.clearConfirm.confirm) } m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) m = m0.(chatTUI) footerRows := strings.Count(m.renderMainManagerFooter(), "\n") + 1 if got, want := m.bottomRows(), footerRows+m.statusLineCount; got != want { t.Fatalf("bottomRows with /clear confirmation = %d, want %d (footer + status rows; confirmation renders in main area)", got, want) } if !m.hideComposer() { t.Fatal("/clear confirmation should hide the composer") } content := ansi.Strip(m.View().Content) if !strings.Contains(content, "Clear current context without saving?") { t.Fatalf("/clear confirmation prompt missing from view:\n%s", content) } if _, err := os.Stat(path); err != nil { t.Fatalf("session should still exist before confirmation: %v", err) } if current := exec.Session().Snapshot(); len(current) != 2 { t.Fatalf("context changed before confirmation: %+v", current) } next, _ := m.handleClearConfirmKey(tea.KeyPressMsg{Code: tea.KeyEnter}) m = next.(chatTUI) if m.clearConfirm != nil { t.Fatal("Enter on default cancel should close the confirmation") } if ctrl.SessionPath() != path { t.Fatal("cancelled /clear should not rotate the session path") } if _, err := os.Stat(path); err != nil { t.Fatalf("cancelled /clear should keep the session file: %v", err) } m.runSlashCommand("/clear") m.shellOutputs["shell-old"] = "old shell output\n" m.shellExpanded["shell-old"] = true m.shellTranscriptIdx["shell-old"] = 2 next, _ = m.handleClearConfirmKey(tea.KeyPressMsg{Code: 'y'}) m = next.(chatTUI) if ctrl.SessionPath() == path { t.Fatal("confirmed /clear should rotate to a fresh session path") } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("confirmed /clear should remove the old transcript, stat err=%v", err) } current := exec.Session().Snapshot() if len(current) != 1 || current[0].Role != provider.RoleSystem || current[0].Content != "sys" { t.Fatalf("cleared context = %+v, want only system prompt", current) } if len(m.transcript) == 0 || strings.Contains(strings.Join(m.transcript, "\n"), "old context") { t.Fatalf("TUI transcript was not reset after /clear: %+v", m.transcript) } if len(m.shellTranscriptIdx) != 0 || len(m.shellOutputs) != 0 || len(m.shellExpanded) != 0 { t.Fatalf("confirmed /clear should reset shell display state: idx=%v outputs=%v expanded=%v", m.shellTranscriptIdx, m.shellOutputs, m.shellExpanded) } } func TestClsClearsTranscriptDisplayState(t *testing.T) { m := newTestChatTUI() *m.pendingCommit = append(*m.pendingCommit, "stale pending") m.transcript = []string{"banner", "shell card", "old shell output"} m.wrappedLines = []string{"banner", "shell card", "old shell output"} m.shellOutputs["shell-old"] = strings.Repeat("old shell output\n", shellPreviewLines+1) m.shellExpanded["shell-old"] = false m.shellTranscriptIdx["shell-old"] = 2 m.toolLineCountByID["shell-old"] = 3 m.toolStreamID = "shell-old" m.toolStreamIdx = 2 m.toolTail = []string{"old shell output"} m.toolPartial = "partial" m.toolLineCount = 4 if cmd := m.runSlashCommand("/cls"); cmd != nil { t.Fatal("/cls should clear locally without returning a command") } if len(*m.pendingCommit) != len(m.transcript) { t.Fatalf("pendingCommit should only contain the fresh cleared-screen transcript, pending=%v transcript=%v", *m.pendingCommit, m.transcript) } if len(m.shellTranscriptIdx) != 0 || len(m.shellOutputs) != 0 || len(m.shellExpanded) != 0 { t.Fatalf("/cls should reset shell display state: idx=%v outputs=%v expanded=%v", m.shellTranscriptIdx, m.shellOutputs, m.shellExpanded) } if len(m.toolLineCountByID) != 0 || m.toolStreamID != "" || m.toolStreamIdx != -1 || len(m.toolTail) != 0 || m.toolPartial != "" || m.toolLineCount != 0 { t.Fatalf("/cls should reset live tool display state: counts=%v id=%q idx=%d tail=%v partial=%q lines=%d", m.toolLineCountByID, m.toolStreamID, m.toolStreamIdx, m.toolTail, m.toolPartial, m.toolLineCount) } before := strings.Join(m.transcript, "\n") m.toggleShellOutput() if after := strings.Join(m.transcript, "\n"); after != before { t.Fatalf("Ctrl+B after /cls should not rewrite the cleared transcript:\nbefore=%s\nafter=%s", before, after) } if strings.Contains(before, "old shell output") || strings.Contains(before, "/cls") { t.Fatalf("/cls should keep only the fresh banner/notice, got:\n%s", before) } } func TestMainManagerFollowsTranscriptWithoutTopPadding(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) m = m0.(chatTUI) m.wrappedLines = []string{"reasonix", "› /mcp"} out := ansi.Strip(m.renderTranscriptWithMainManager("Manage MCP servers\n1 servers")) lines := strings.Split(out, "\n") if len(lines) < 4 { t.Fatalf("rendered manager area too short:\n%s", out) } if !strings.Contains(lines[0], "reasonix") || !strings.Contains(lines[1], "/mcp") { t.Fatalf("transcript lines should stay above manager:\n%s", out) } if strings.TrimSpace(lines[2]) != "" { t.Fatalf("expected one separator line before manager, got %q in:\n%s", lines[2], out) } if !strings.Contains(lines[3], "Manage MCP servers") { t.Fatalf("manager should follow transcript immediately, got line 3 %q in:\n%s", lines[3], out) } } func TestMarkdownDividerFitsTranscriptContentWidth(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) m = m0.(chatTUI) wantW := transcriptContentWidth(80, false) if m.viewport.Width() != wantW { t.Fatalf("viewport width = %d, want transcript content width %d", m.viewport.Width(), wantW) } rule := strings.TrimRight(m.renderer.Render("---"), "\n") lines := strings.Split(wrapTranscript(rule, m.viewport.Width()), "\n") if len(lines) != 1 { t.Fatalf("markdown divider wrapped into %d lines at width %d: %q", len(lines), m.viewport.Width(), lines) } if w := visibleWidth(lines[0]); w != m.viewport.Width() { t.Fatalf("markdown divider width = %d, want %d: %q", w, m.viewport.Width(), lines[0]) } } func TestTranscriptContentWidthReservesScrollbarColumn(t *testing.T) { if got := transcriptContentWidth(80, false); got != 79 { t.Fatalf("transcriptContentWidth(80, false) = %d, want 79", got) } if got := transcriptContentWidth(80, true); got != 80 { t.Fatalf("transcriptContentWidth(80, true) = %d, want 80", got) } if got := transcriptContentWidth(0, false); got != 1 { t.Fatalf("transcriptContentWidth(0, false) = %d, want 1", got) } } func TestModalPanelsHideComposerBox(t *testing.T) { ask := event.Ask{ ID: "ask-1", Questions: []event.AskQuestion{{ ID: "q1", Prompt: "Pick one", Options: []event.AskOption{{ Label: "Option A", }}, }}, } tests := []struct { name string setup func(*chatTUI) render func(chatTUI) string }{ { name: "resume picker", setup: func(m *chatTUI) { m.resumePick = &resumePicker{sessions: []agent.SessionInfo{{ Path: "one.jsonl", Preview: "previous task", Turns: 3, }}, sel: 0, active: -1} }, render: func(m chatTUI) string { return m.renderResumePicker() }, }, { name: "rewind picker", setup: func(m *chatTUI) { m.rewind = &rewindPicker{metas: []checkpoint.Meta{{ Turn: 0, Prompt: "fix the parser", }}, sel: 0} }, render: func(m chatTUI) string { return m.renderRewind() }, }, { name: "approval prompt", setup: func(m *chatTUI) { m.pendingApproval = &event.Approval{ID: "approval-1", Tool: "bash", Subject: "echo hi"} }, render: func(m chatTUI) string { return m.renderApprovalBanner() }, }, { name: "ask chooser", setup: func(m *chatTUI) { m.chooser = newChooser(ask) }, render: func(m chatTUI) string { return m.renderChooser() }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) tt.setup(&m) m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) m = m0.(chatTUI) card := tt.render(m) if card == "" { t.Fatalf("%s panel did not render", tt.name) } cardRows := strings.Count(card, "\n") + 1 if got, want := m.bottomRows(), cardRows+m.statusLineCount; got != want { t.Fatalf("bottomRows with %s = %d, want %d (panel + status rows, no composer box)", tt.name, got, want) } }) } } func TestInputOwnedOverlaysKeepComposerBox(t *testing.T) { ask := event.Ask{ ID: "ask-1", Questions: []event.AskQuestion{{ ID: "q1", Prompt: "Pick one", Options: []event.AskOption{{ Label: "Option A", }}, }}, } tests := []struct { name string setup func(*chatTUI) render func(chatTUI) string }{ { name: "ask free text", setup: func(m *chatTUI) { m.chooser = newChooser(ask) m.chooser.typing = true }, render: func(m chatTUI) string { return m.renderChooser() }, }, { name: "completion menu", setup: func(m *chatTUI) { m.input.SetValue("/") m.completion = completion{active: true, kind: compSlash, items: []compItem{{label: "/mcp"}}, sel: 0} }, render: func(m chatTUI) string { return m.renderCompletion() }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) tt.setup(&m) m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) m = m0.(chatTUI) if m.hideComposer() { t.Fatalf("%s should keep the composer visible", tt.name) } panel := tt.render(m) if panel == "" { t.Fatalf("%s panel did not render", tt.name) } panelRows := strings.Count(panel, "\n") + 1 if got, want := m.bottomRows(), panelRows+m.input.Height()+2+m.statusLineCount; got != want { t.Fatalf("bottomRows with %s = %d, want %d (panel + composer box + status rows)", tt.name, got, want) } }) } } // TestIngestEventRoutesByKind proves each event Kind lands in the right place: // reasoning shows a live marker with streaming text, while tool dispatch, blocked // results, usage, notices, and coordinator phases each commit as their own // scrollback line. Routing is by Kind, not by sniffing line prefixes. func TestIngestEventRoutesByKind(t *testing.T) { // Reasoning shows a marker plus the live thinking text streamed below it. m := newTestChatTUI() m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "weighing options"}) if len(m.transcript) != 2 || !strings.Contains(m.transcript[0], "thinking") { t.Errorf("reasoning should show a live marker, transcript=%v", m.transcript) } if !strings.Contains(m.transcript[1], "weighing options") { t.Errorf("reasoning text should stream live, transcript=%v", m.transcript) } for _, tc := range []struct { name string ev event.Event want string }{ {"dispatch", event.Event{Kind: event.ToolDispatch, Tool: event.Tool{Name: "read_file", Args: `{"path":"x"}`}}, "● Read(x)"}, {"blocked", event.Event{Kind: event.ToolResult, Tool: event.Tool{Name: "bash", Err: "blocked by permission policy"}}, "● Bash ⊘ blocked by permission policy"}, {"usage", event.Event{Kind: event.Usage, Usage: &provider.Usage{PromptTokens: 1000, CompletionTokens: 200, TotalTokens: 1200, CacheHitTokens: 900, CacheMissTokens: 100}}, " · 1200 tok"}, {"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"}, {"notice-info", event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "compacted 8 messages → summary"}, " · compacted 8 messages → summary"}, {"notice-warn", event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "response truncated: hit max output tokens"}, " ! response truncated: hit max output tokens"}, {"phase", event.Event{Kind: event.Phase, Text: "planner · planning"}, "[planner · planning]"}, } { m := newTestChatTUI() m.ingestEvent(tc.ev) got := *m.pendingCommit if len(got) != 1 || !strings.Contains(got[0], tc.want) { t.Errorf("%s: committed=%v, want a single line containing %q", tc.name, got, tc.want) } } // A successful tool result is silent — it only feeds the model. m = newTestChatTUI() m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{Name: "read_file", Output: "contents"}}) if len(*m.pendingCommit) != 0 { t.Errorf("successful tool result should be silent, committed=%v", *m.pendingCommit) } } func TestIngestEventShowsReasoningInVerboseMode(t *testing.T) { m := newTestChatTUI() m.showReasoning = true m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "weighing options"}) if !strings.Contains(m.reasoning.String(), "weighing options") { t.Errorf("verbose reasoning should buffer the text, got %q", m.reasoning.String()) } } // TestUserBubbleEchoedImmediately proves the user bubble is committed to scrollback // the moment the turn starts, not deferred to the server's first packet. The first // real packet only confirms the send (closing the un-send window); a local // TurnStarted must not, so Esc can still un-send until the server actually replies. func TestUserBubbleEchoedImmediately(t *testing.T) { m := newTestChatTUI() // Stand in for startTurn's immediate echo (no controller in the unit harness). m.bubbleStartIdx = len(m.transcript) m.commitLine("") m.commitLine(renderUserBubble("hello world", m.width, m.planMode)) m.bubblePending = true m.state = tuiRunning if !strings.Contains(strings.Join(m.transcript, "\n"), "hello world") { t.Fatalf("bubble should be echoed to scrollback immediately, got %v", m.transcript) } // TurnStarted is emitted locally before the request — it must not confirm. m.ingestEvent(event.Event{Kind: event.TurnStarted}) if !m.bubblePending { t.Fatalf("TurnStarted should leave the send un-sendable, pending=%v", m.bubblePending) } // The first real packet confirms the send; a reasoning packet also shows its // live thinking marker. m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "thinking…"}) if m.bubblePending { t.Fatalf("first packet should confirm the send") } if !strings.Contains(strings.Join(m.transcript, "\n"), "thinking") { t.Errorf("reasoning packet should show the thinking marker, got %v", m.transcript) } } func TestUserBubbleIsLightweightTranscriptLine(t *testing.T) { prevColor := colorEnabled colorEnabled = true defer func() { colorEnabled = prevColor }() got := renderUserBubble("hello world", 80, false) plain := ansi.Strip(got) if !strings.Contains(plain, "› hello world") { t.Fatalf("user bubble missing prompt text: %q", plain) } if got == plain { t.Fatalf("user bubble should use themed foreground color when color is enabled: %q", got) } if w := ansi.StringWidth(plain); w > 20 { t.Fatalf("user bubble should not render as a full-width input-like block, width=%d text=%q", w, plain) } } // TestUnsendDiscardsBufferedEvents proves that after an un-send (Esc before any // packet) the turn's already-buffered events are swallowed — nothing reaches // scrollback — and its TurnDone settles the model back to idle. func TestUnsendDiscardsBufferedEvents(t *testing.T) { m := newTestChatTUI() m.state = tuiRunning m.turnDiscarded = true // the state unsendPending leaves behind m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "late thinking"}) m.ingestEvent(event.Event{Kind: event.Text, Text: "late answer"}) if len(*m.pendingCommit) != 0 || m.reasoning.Len() != 0 || m.pending.Len() != 0 { t.Fatalf("a discarded turn should swallow buffered events, committed=%v", *m.pendingCommit) } m.ingestEvent(event.Event{Kind: event.TurnDone}) if m.turnDiscarded || m.state != tuiIdle { t.Fatalf("TurnDone should clear the discard and return to idle, discarded=%v state=%v", m.turnDiscarded, m.state) } if len(*m.pendingCommit) != 0 { t.Errorf("a discarded turn should leave nothing in scrollback, committed=%v", *m.pendingCommit) } } // TestAnswerTextStartingWithBracketStaysInAnswer locks in the win of the typed // event stream: model answer text starting with "[" — a markdown link, a slice // literal, even a quoted "[… · planning]" — is a Text event, so it can never be // mistaken for a coordinator phase marker the way prefix-sniffing a flattened // byte stream once could. It stays in the answer buffer and renders as markdown. func TestAnswerTextStartingWithBracketStaysInAnswer(t *testing.T) { for _, txt := range []string{ "[link](https://example.com)", "[1, 2, 3]", "[planner · planning] (the model quoting a marker)", } { m := newTestChatTUI() m.ingestEvent(event.Event{Kind: event.Text, Text: txt}) if len(*m.pendingCommit) != 0 { t.Errorf("answer text %q should stay live, not commit as an event line: %v", txt, *m.pendingCommit) } if m.pending.String() != txt { t.Errorf("answer text should buffer verbatim, got %q want %q", m.pending.String(), txt) } } } // TestInsertNewlineKeyBinding verifies newChatTUI actually wires shift+enter // into the textarea's InsertNewline binding (plain Enter submits, so a newline // needs a modifier). It exercises the real constructor, not a hand-built binding. func TestInsertNewlineKeyBinding(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) keys := m.input.KeyMap.InsertNewline.Keys() found := false for _, k := range keys { if k == "shift+enter" { found = true break } } if !found { t.Errorf("newChatTUI InsertNewline should include shift+enter, got %v", keys) } } func TestCtrlHomeEndScrollKeyBindings(t *testing.T) { ctrl := control.New(control.Options{}) ch := make(chan event.Event, 1) notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"}) adv := func(m chatTUI, msg tea.Msg) chatTUI { n, _ := m.Update(msg) return n.(chatTUI) } cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8}) for i := 0; i < 12; i++ { cur = adv(cur, notice) } // Viewport should be at the bottom after output. if !cur.viewport.AtBottom() { t.Fatal("viewport should start at the bottom after streaming output") } // Ctrl+Home should scroll to the top. cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyHome, Mod: tea.ModCtrl}) if !cur.viewport.AtTop() { t.Fatalf("ctrl+home should scroll to top, AtTop=%v, YOffset=%d", cur.viewport.AtTop(), cur.viewport.YOffset()) } // Ctrl+End should scroll back to the bottom. cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnd, Mod: tea.ModCtrl}) if !cur.viewport.AtBottom() { t.Fatalf("ctrl+end should scroll to bottom, AtBottom=%v, YOffset=%d", cur.viewport.AtBottom(), cur.viewport.YOffset()) } } func TestMouseWheelAndPageKeysScrollTranscript(t *testing.T) { ctrl := control.New(control.Options{}) ch := make(chan event.Event, 1) notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"}) adv := func(m chatTUI, msg tea.Msg) chatTUI { n, _ := m.Update(msg) return n.(chatTUI) } cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 10}) for i := 0; i < 40; i++ { cur = adv(cur, notice) } if !cur.viewport.AtBottom() { t.Fatal("viewport should start at bottom after overflowing output") } bottom := cur.viewport.YOffset() if bottom <= cur.viewport.Height()+3 { t.Fatalf("test transcript did not overflow enough: bottom=%d height=%d", bottom, cur.viewport.Height()) } cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp}) if got, want := cur.viewport.YOffset(), bottom-3; got != want { t.Fatalf("wheel-up YOffset = %d, want %d", got, want) } cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelDown}) if got := cur.viewport.YOffset(); got != bottom { t.Fatalf("wheel-down should return by one wheel step, YOffset=%d want bottom=%d", got, bottom) } cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyPgUp}) pageUp := cur.viewport.YOffset() if got, want := pageUp, bottom-cur.viewport.Height(); got != want { t.Fatalf("PageUp YOffset = %d, want %d", got, want) } cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyPgDown}) if got := cur.viewport.YOffset(); got != bottom { t.Fatalf("PageDown should return to bottom from one page up, YOffset=%d want %d", got, bottom) } } func TestRunningStreamPreservesScrolledReadingPosition(t *testing.T) { ctrl := control.New(control.Options{}) ch := make(chan event.Event, 1) notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"}) adv := func(m chatTUI, msg tea.Msg) chatTUI { n, _ := m.Update(msg) return n.(chatTUI) } cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 10}) for i := 0; i < 40; i++ { cur = adv(cur, notice) } cur.state = tuiRunning cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp}) readOffset := cur.viewport.YOffset() if cur.viewport.AtBottom() { t.Fatal("wheel-up should leave the bottom before streaming output arrives") } cur = adv(cur, agentEventMsg(event.Event{Kind: event.Text, Text: "streamed paragraph\n\n"})) if cur.viewport.AtBottom() { t.Fatal("streaming output must not yank a scrolled-up reader back to bottom") } if got := cur.viewport.YOffset(); got != readOffset { t.Fatalf("streaming output should preserve reading offset, got %d want %d", got, readOffset) } cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelDown}) if got, want := cur.viewport.YOffset(), readOffset+3; got != want { t.Fatalf("wheel-down while running should move one wheel step, got %d want %d", got, want) } if cur.viewport.AtBottom() { t.Fatal("one wheel-down step from the reading position should not jump straight to bottom") } } func TestTranscriptScrollbarClickAndDrag(t *testing.T) { ctrl := control.New(control.Options{}) ch := make(chan event.Event, 1) notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"}) adv := func(m chatTUI, msg tea.Msg) chatTUI { n, _ := m.Update(msg) return n.(chatTUI) } cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 10}) for i := 0; i < 40; i++ { cur = adv(cur, notice) } cur.viewport.GotoTop() barX := cur.viewport.Width() bottomRow := cur.viewport.Height() - 1 cur = adv(cur, tea.MouseClickMsg{X: barX, Y: 0, Button: tea.MouseLeft}) if cur.sel.active { t.Fatal("clicking the scrollbar must not start transcript selection") } if !cur.scrollbarDrag { t.Fatal("left-click on scrollbar should start scrollbar drag") } cur = adv(cur, tea.MouseMotionMsg{X: barX, Y: bottomRow, Button: tea.MouseLeft}) if !cur.viewport.AtBottom() { t.Fatalf("dragging scrollbar to bottom should reach bottom, YOffset=%d", cur.viewport.YOffset()) } if cur.sel.active { t.Fatal("dragging the scrollbar must not leave a transcript selection") } cur = adv(cur, tea.MouseReleaseMsg{X: barX, Y: bottomRow, Button: tea.MouseLeft}) if cur.scrollbarDrag { t.Fatal("mouse release should end scrollbar drag") } if cur.sel.active { t.Fatal("scrollbar release must not create a text selection") } cur.viewport.GotoTop() cur = adv(cur, tea.MouseClickMsg{X: barX - 1, Y: 0, Button: tea.MouseLeft}) if !cur.sel.active { t.Fatal("clicking the transcript content column next to the scrollbar should still start selection") } } // TestMouseDragReleaseAutoCopies verifies that releasing the mouse after a // left-drag over the transcript copies the selection to the clipboard // automatically (native terminal convention), keeps the selection highlighted // so a follow-up right-click can still re-copy it, and arms the transient // "copied to clipboard" status-line notice. func TestMouseDragReleaseAutoCopies(t *testing.T) { m := newTestChatTUI() m.transcript = []string{"hello world"} m.wrappedLines = []string{"hello world"} m.sel = selection{active: true, anchor: selPos{line: 0, col: 0}, head: selPos{line: 0, col: 5}} out, cmd := m.Update(tea.MouseReleaseMsg{Button: tea.MouseLeft}) m2, ok := out.(chatTUI) if !ok { t.Fatalf("Update returned %T, want chatTUI", out) } if cmd == nil { t.Fatal("release after a real drag should return a cmd (clipboard copy + notice)") } if !m2.sel.active { t.Error("selection should stay highlighted after auto-copy so right-click can re-copy it") } if m2.copyNoticeText == "" { t.Error("release after a real drag should arm the copied-to-clipboard notice") } } // TestMousePlainClickReleaseDoesNotCopy verifies that a plain click (no drag, // empty selection) does not copy an empty string to the clipboard or show the // copied notice — only clears the zero-width selection, as before. func TestMousePlainClickReleaseDoesNotCopy(t *testing.T) { m := newTestChatTUI() m.transcript = []string{"hello world"} m.wrappedLines = []string{"hello world"} at := selPos{line: 0, col: 3} m.sel = selection{active: true, anchor: at, head: at} // empty: anchor == head out, _ := m.Update(tea.MouseReleaseMsg{Button: tea.MouseLeft}) m2, ok := out.(chatTUI) if !ok { t.Fatalf("Update returned %T, want chatTUI", out) } if m2.sel.active { t.Error("a plain click (empty selection) should be cleared on release") } if m2.copyNoticeText != "" { t.Error("a plain click (empty selection) must not arm the copied-to-clipboard notice") } } // TestCopyNoticeExpires verifies the copied-to-clipboard notice clears itself // once its own expiry tick fires, and that a stale tick from an earlier copy // (superseded by a newer one) does not clear the newer notice. func TestCopyNoticeExpires(t *testing.T) { m := newTestChatTUI() m.copyNoticeText = i18n.M.MouseCopiedHint m.copyNoticeSeq = 2 // A stale tick from a prior (superseded) copy must not clear the current notice. out, _ := m.Update(copyNoticeExpireMsg{seq: 1}) m2 := out.(chatTUI) if m2.copyNoticeText == "" { t.Fatal("a stale expiry tick must not clear a newer notice") } // The current tick clears it. out, _ = m2.Update(copyNoticeExpireMsg{seq: 2}) m3 := out.(chatTUI) if m3.copyNoticeText != "" { t.Fatal("the matching expiry tick should clear the notice") } } // TestToggleMouseCaptureFlipsModeAndClearsGestures proves "/mouse" flips // mouseCaptureOff, shows the matching on/off notice, and drops any in-flight // selection/scrollbar drag so a stale gesture can't be found mid-drag once the // terminal starts intercepting the events that would have finished it. func TestToggleMouseCaptureFlipsModeAndClearsGestures(t *testing.T) { m := newTestChatTUI() m.transcript = []string{"hello world"} m.wrappedLines = []string{"hello world"} m.sel = selection{active: true, anchor: selPos{line: 0, col: 0}, head: selPos{line: 0, col: 5}} m.scrollbarDrag = true m.autoScroll = 1 m.toggleMouseCapture() if !m.mouseCaptureOff { t.Fatal("first toggle should turn mouse capture off") } if m.sel.active || m.scrollbarDrag || m.autoScroll != 0 { t.Fatal("toggling mouse capture should clear any in-flight selection/drag") } if got := (*m.pendingCommit)[len(*m.pendingCommit)-1]; !strings.Contains(got, i18n.M.MouseCaptureOffHint) { t.Fatalf("notice = %q, want it to contain %q", got, i18n.M.MouseCaptureOffHint) } m.toggleMouseCapture() if m.mouseCaptureOff { t.Fatal("second toggle should turn mouse capture back on") } if got := (*m.pendingCommit)[len(*m.pendingCommit)-1]; !strings.Contains(got, i18n.M.MouseCaptureOnHint) { t.Fatalf("notice = %q, want it to contain %q", got, i18n.M.MouseCaptureOnHint) } } // TestViewMouseModeFollowsCapture proves View() requests MouseModeNone (so // the terminal's native right-click menu and click-drag selection work) while // mouseCaptureOff is set, and MouseModeCellMotion (in-app selection/scrollbar/ // wheel-scroll) otherwise. func TestViewMouseModeFollowsCapture(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 60) m0, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 20}) m = m0.(chatTUI) if got := m.View().MouseMode; got != tea.MouseModeCellMotion { t.Fatalf("MouseMode with capture on = %v, want MouseModeCellMotion", got) } m.mouseCaptureOff = true if got := m.View().MouseMode; got != tea.MouseModeNone { t.Fatalf("MouseMode with capture off = %v, want MouseModeNone", got) } // Status line wraps at this width, so check the unwrapped tag rather than // the rendered (possibly line-broken) View() content. if got := m.mouseTag(); !strings.Contains(ansi.Strip(got), i18n.M.MouseCaptureTag) { t.Fatalf("mouseTag() = %q, want it to contain %q", got, i18n.M.MouseCaptureTag) } } func TestEchoLocalCommandAddsTranscriptMarker(t *testing.T) { m := newTestChatTUI() m.echoLocalCommand(" /tree ") if len(*m.pendingCommit) != 1 { t.Fatalf("pending commits = %d, want 1", len(*m.pendingCommit)) } if got := (*m.pendingCommit)[0]; !strings.Contains(got, "› /tree") { t.Fatalf("command echo = %q, want /tree marker", got) } } func isolateUserConfig(t *testing.T) { t.Helper() root := t.TempDir() t.Setenv("HOME", root) t.Setenv("REASONIX_CREDENTIALS_STORE", "file") t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config")) t.Setenv("AppData", filepath.Join(root, "AppData")) // os.UserConfigDir reads AppData on Windows t.Chdir(root) } func TestEffortCommandWritesCurrentDeepSeekProvider(t *testing.T) { isolateUserConfig(t) m := newTestChatTUI() m.ctrl = control.New(control.Options{Label: "deepseek-flash"}) m.modelRef = "deepseek-flash/deepseek-v4-flash" m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string) (*control.Controller, error) { return control.New(control.Options{Label: "deepseek-flash"}), nil } cmd := m.runEffortCommand("/effort max") if cmd == nil { t.Fatal("/effort max should return a rebuild command") } configPath := config.UserConfigPath() body, err := os.ReadFile(configPath) if err != nil { t.Fatalf("read saved config: %v", err) } if !strings.Contains(string(body), `effort = "max"`) { t.Fatalf("saved config missing effort=max:\n%s", body) } } func TestEffortCommandRejectsUnsupportedProvider(t *testing.T) { isolateUserConfig(t) m := newTestChatTUI() m.ctrl = control.New(control.Options{Label: "mimo-pro"}) m.modelRef = "mimo-pro/mimo-v2.5-pro" m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string) (*control.Controller, error) { return control.New(control.Options{Label: "mimo-pro"}), nil } if cmd := m.runEffortCommand("/effort max"); cmd != nil { t.Fatal("unsupported provider should not rebuild") } if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) { t.Fatalf("unsupported provider should not write config, stat err=%v", err) } } func TestEffortCommandAutoClearsProviderEffort(t *testing.T) { isolateUserConfig(t) m := newTestChatTUI() m.ctrl = control.New(control.Options{Label: "deepseek-flash"}) m.modelRef = "deepseek-flash/deepseek-v4-flash" m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string) (*control.Controller, error) { return control.New(control.Options{Label: "deepseek-flash"}), nil } cmd := m.runEffortCommand("/effort max") if cmd == nil { t.Fatal("/effort max should return a rebuild command") } next, _ := m.Update(cmd()) m = next.(chatTUI) if cmd := m.runEffortCommand("/effort auto"); cmd == nil { t.Fatal("/effort auto should return a rebuild command") } body, err := os.ReadFile(config.UserConfigPath()) if err != nil { t.Fatalf("read saved config: %v", err) } section := providerSection(string(body), "deepseek-flash") if strings.Contains(section, `effort = "`) { t.Fatalf("auto should clear saved deepseek-flash effort:\n%s", section) } } func TestAutoPlanCommandPersistsAndUpdatesController(t *testing.T) { isolateUserConfig(t) runner := &recordingTurnRunner{} events := make(chan event.Event, 4) ctrl := control.New(control.Options{ AutoPlan: "off", Runner: runner, Sink: event.FuncSink(func(e event.Event) { events <- e }), }) m := newTestChatTUI() m.ctrl = ctrl m.runAutoPlanCommand("/auto-plan on") body, err := os.ReadFile(config.UserConfigPath()) if err != nil { t.Fatalf("read saved config: %v", err) } if !strings.Contains(string(body), `auto_plan = "on"`) { t.Fatalf("saved config missing auto_plan=on:\n%s", body) } input := "实现 GitHub issue #2395:\n- 新增配置项\n- 自动判断复杂任务\n- 补测试和文档" ctrl.Send(input) waitForCLIEvent(t, events, event.TurnDone) if len(runner.inputs) != 1 || !strings.HasPrefix(agent.StripTransientUserBlocks(runner.inputs[0]), control.PlanModeMarker) { t.Fatalf("/auto-plan on should affect current controller, inputs=%q", runner.inputs) } } func TestAutoPlanCommandWritesUserConfigNotProjectConfig(t *testing.T) { isolateUserConfig(t) projectPath := filepath.Join(mustGetwd(t), "reasonix.toml") if err := os.WriteFile(projectPath, []byte("[agent]\nauto_plan = \"off\"\n"), 0o644); err != nil { t.Fatalf("write project config: %v", err) } m := newTestChatTUI() m.ctrl = control.New(control.Options{AutoPlan: "off"}) m.runAutoPlanCommand("/auto-plan on") userBody, err := os.ReadFile(config.UserConfigPath()) if err != nil { t.Fatalf("read user config: %v", err) } if !strings.Contains(string(userBody), `auto_plan = "on"`) { t.Fatalf("user config missing auto_plan=on:\n%s", userBody) } projectBody, err := os.ReadFile(projectPath) if err != nil { t.Fatalf("read project config: %v", err) } if string(projectBody) != "[agent]\nauto_plan = \"off\"\n" { t.Fatalf("/auto-plan should not rewrite project config:\n%s", projectBody) } } func TestReasoningLanguageCommandPersistsAndUpdatesController(t *testing.T) { isolateUserConfig(t) ctrl := control.New(control.Options{ReasoningLanguage: "auto"}) m := newTestChatTUI() m.ctrl = ctrl m.runReasoningLanguageCommand("/reasoning-language zh") body, err := os.ReadFile(config.UserConfigPath()) if err != nil { t.Fatalf("read saved config: %v", err) } if !strings.Contains(string(body), `reasoning_language = "zh"`) { t.Fatalf("saved config missing reasoning_language=zh:\n%s", body) } composed := ctrl.Compose("hello") if !strings.HasPrefix(composed, "") || !strings.Contains(composed, "简体中文") { t.Fatalf("/reasoning-language zh should affect current controller, got %q", composed) } } func TestReasoningLanguageCommandWritesUserConfigNotProjectConfig(t *testing.T) { isolateUserConfig(t) projectPath := filepath.Join(mustGetwd(t), "reasonix.toml") if err := os.WriteFile(projectPath, []byte("[agent]\nreasoning_language = \"en\"\n"), 0o644); err != nil { t.Fatalf("write project config: %v", err) } m := newTestChatTUI() m.ctrl = control.New(control.Options{ReasoningLanguage: "en"}) m.runReasoningLanguageCommand("/reasoning-language zh") userBody, err := os.ReadFile(config.UserConfigPath()) if err != nil { t.Fatalf("read user config: %v", err) } if !strings.Contains(string(userBody), `reasoning_language = "zh"`) { t.Fatalf("user config missing reasoning_language=zh:\n%s", userBody) } projectBody, err := os.ReadFile(projectPath) if err != nil { t.Fatalf("read project config: %v", err) } if string(projectBody) != "[agent]\nreasoning_language = \"en\"\n" { t.Fatalf("/reasoning-language should not rewrite project config:\n%s", projectBody) } } func TestMemoryV5CommandWritesUserConfigNotProjectConfig(t *testing.T) { isolateUserConfig(t) projectPath := filepath.Join(mustGetwd(t), "reasonix.toml") if err := os.WriteFile(projectPath, []byte("[agent]\nmemory_compiler = { enabled = true }\n"), 0o644); err != nil { t.Fatalf("write project config: %v", err) } m := newTestChatTUI() m.ctrl = control.New(control.Options{}) m.runMemoryV5Command("/memory-v5 off") userBody, err := os.ReadFile(config.UserConfigPath()) if err != nil { t.Fatalf("read user config: %v", err) } if !strings.Contains(string(userBody), `memory_compiler = { enabled = false, verbosity = "observe" }`) { t.Fatalf("user config missing memory_compiler off:\n%s", userBody) } projectBody, err := os.ReadFile(projectPath) if err != nil { t.Fatalf("read project config: %v", err) } if string(projectBody) != "[agent]\nmemory_compiler = { enabled = true }\n" { t.Fatalf("/memory-v5 should not rewrite project config:\n%s", projectBody) } } func TestLanguageCommandSwitchesImmediatelyAndPersists(t *testing.T) { isolateUserConfig(t) i18n.DetectLanguage("en") t.Cleanup(func() { i18n.DetectLanguage("en") }) m := newTestChatTUI() m.runLanguageSubcommand("/language zh") if i18n.M.ChatStatusIdle != "就绪" { t.Fatalf("/language zh did not switch active catalogue, idle=%q", i18n.M.ChatStatusIdle) } body, err := os.ReadFile(config.UserConfigPath()) if err != nil { t.Fatalf("read saved config: %v", err) } if !strings.Contains(string(body), `language = "zh"`) { t.Fatalf("saved config missing language=zh:\n%s", body) } } func TestLanguageCommandAutoClearsPinnedLanguage(t *testing.T) { isolateUserConfig(t) i18n.DetectLanguage("en") t.Cleanup(func() { i18n.DetectLanguage("en") }) m := newTestChatTUI() m.runLanguageSubcommand("/language zh") m.runLanguageSubcommand("/language auto") cfg := config.LoadForEdit(config.UserConfigPath()) if cfg.Language != "" { t.Fatalf("auto should clear saved language override, got %q", cfg.Language) } } func TestLanguageCommandAutoClearsLowerPriorityUserOverride(t *testing.T) { isolateUserConfig(t) t.Setenv("REASONIX_LANG", "") t.Setenv("LC_ALL", "") t.Setenv("LC_MESSAGES", "") t.Setenv("LANG", "") i18n.DetectLanguage("en") t.Cleanup(func() { i18n.DetectLanguage("en") }) userPath := config.UserConfigPath() userCfg := config.LoadForEdit(userPath) if err := userCfg.SetLanguage("zh"); err != nil { t.Fatalf("set user language: %v", err) } if err := userCfg.SaveTo(userPath); err != nil { t.Fatalf("save user config: %v", err) } projectCfg := config.Default() if err := projectCfg.SaveTo("reasonix.toml"); err != nil { t.Fatalf("save project config: %v", err) } m := newTestChatTUI() m.runLanguageSubcommand("/language auto") userCfg = config.LoadForEdit(userPath) if userCfg.Language != "" { t.Fatalf("/language auto should clear lower-priority user override, got %q", userCfg.Language) } loaded, err := config.Load() if err != nil { t.Fatalf("load merged config: %v", err) } if loaded.Language != "" { t.Fatalf("merged config should be auto-detect after clearing overrides, got %q", loaded.Language) } } func providerSection(body, name string) string { needle := `name = "` + name + `"` start := strings.Index(body, needle) if start < 0 { return "" } end := strings.Index(body[start+len(needle):], "\n[[providers]]") if end < 0 { return body[start:] } return body[start : start+len(needle)+end] } func TestSubmittedInputRecallWithArrowKeys(t *testing.T) { m := newTestChatTUI() m.rememberSubmittedInput("first") m.rememberSubmittedInput("second") m.input.SetValue("draft") up := tea.KeyPressMsg{Code: tea.KeyUp} down := tea.KeyPressMsg{Code: tea.KeyDown} model, _ := m.Update(up) m = model.(chatTUI) if got := m.input.Value(); got != "second" { t.Fatalf("first up should recall latest input, got %q", got) } model, _ = m.Update(up) m = model.(chatTUI) if got := m.input.Value(); got != "first" { t.Fatalf("second up should recall older input, got %q", got) } model, _ = m.Update(down) m = model.(chatTUI) if got := m.input.Value(); got != "second" { t.Fatalf("down should move toward newer input, got %q", got) } model, _ = m.Update(down) m = model.(chatTUI) if got := m.input.Value(); got != "draft" { t.Fatalf("down past newest should restore draft, got %q", got) } } func TestQueueNavigationWithArrowKeys(t *testing.T) { m := newTestChatTUI() m.state = tuiRunning m.pendingInterject = []string{"queued one", "queued two", "queued three"} m.input.SetValue("my draft") up := tea.KeyPressMsg{Code: tea.KeyUp} down := tea.KeyPressMsg{Code: tea.KeyDown} // First ↑ should save draft and jump to last queued item. model, _ := m.Update(up) m = model.(chatTUI) if got := m.input.Value(); got != "queued three" { t.Fatalf("first up: want %q, got %q", "queued three", got) } if m.queueEditCursor != 2 { t.Fatalf("first up: cursor should be 2, got %d", m.queueEditCursor) } // Second ↑ should move to "queued two". model, _ = m.Update(up) m = model.(chatTUI) if got := m.input.Value(); got != "queued two" { t.Fatalf("second up: want %q, got %q", "queued two", got) } // ↓ should move back to "queued three". model, _ = m.Update(down) m = model.(chatTUI) if got := m.input.Value(); got != "queued three" { t.Fatalf("down: want %q, got %q", "queued three", got) } // ↓ past the end should restore the draft. model, _ = m.Update(down) m = model.(chatTUI) if got := m.input.Value(); got != "my draft" { t.Fatalf("down past end: want %q, got %q", "my draft", got) } if m.queueEditCursor != -1 { t.Fatalf("down past end: cursor should be -1, got %d", m.queueEditCursor) } } func TestQueueNavigationClampAtStart(t *testing.T) { m := newTestChatTUI() m.state = tuiRunning m.pendingInterject = []string{"only item"} m.input.SetValue("draft") up := tea.KeyPressMsg{Code: tea.KeyUp} // First ↑ jumps to the only item. model, _ := m.Update(up) m = model.(chatTUI) if got := m.input.Value(); got != "only item" { t.Fatalf("first up: want %q, got %q", "only item", got) } // Second ↑ should clamp at index 0 (not go negative). model, _ = m.Update(up) m = model.(chatTUI) if m.queueEditCursor != 0 { t.Fatalf("second up: cursor should clamp at 0, got %d", m.queueEditCursor) } if got := m.input.Value(); got != "only item" { t.Fatalf("second up: value should stay %q, got %q", "only item", got) } } func TestQueueNavigationNoOpWhenEmpty(t *testing.T) { m := newTestChatTUI() m.state = tuiRunning m.input.SetValue("hello") up := tea.KeyPressMsg{Code: tea.KeyUp} model, _ := m.Update(up) m = model.(chatTUI) if got := m.input.Value(); got != "hello" { t.Fatalf("empty queue: input should be unchanged, got %q", got) } } func TestQueueEditSavesOnEnter(t *testing.T) { m := newTestChatTUI() m.state = tuiRunning m.pendingInterject = []string{"original one", "original two"} up := tea.KeyPressMsg{Code: tea.KeyUp} model, _ := m.Update(up) m = model.(chatTUI) if m.queueEditCursor != 1 { t.Fatalf("cursor should be 1 after up, got %d", m.queueEditCursor) } // Edit the queued message. m.input.SetValue("edited two") enter := tea.KeyPressMsg{Code: tea.KeyEnter} model, _ = m.Update(enter) m = model.(chatTUI) if m.pendingInterject[1] != "edited two" { t.Fatalf("queue[1] should be %q, got %q", "edited two", m.pendingInterject[1]) } if m.pendingInterject[0] != "original one" { t.Fatalf("queue[0] should be unchanged, got %q", m.pendingInterject[0]) } if m.queueEditCursor != -1 { t.Fatalf("cursor should reset after enter, got %d", m.queueEditCursor) } } func TestQueueNewMessageOnEnterDuringRunning(t *testing.T) { m := newTestChatTUI() m.state = tuiRunning m.pendingInterject = []string{"existing"} m.input.SetValue("new message") enter := tea.KeyPressMsg{Code: tea.KeyEnter} model, _ := m.Update(enter) m = model.(chatTUI) if len(m.pendingInterject) != 2 { t.Fatalf("queue should have 2 items, got %d", len(m.pendingInterject)) } if m.pendingInterject[1] != "new message" { t.Fatalf("queue[1] should be %q, got %q", "new message", m.pendingInterject[1]) } } func TestQueuedFoldedPasteExpandsBeforeInterjectSend(t *testing.T) { runner := &recordingTurnRunner{} events := make(chan event.Event, 8) ctrl := control.New(control.Options{ Runner: runner, Sink: event.FuncSink(func(e event.Event) { events <- e }), SessionDir: t.TempDir(), Label: "test", }) m := newTestChatTUI() m.ctrl = ctrl m.eventCh = make(chan event.Event, 8) m.state = tuiRunning pasted := strings.Repeat("queued pasted content\n", 10) model, _ := m.Update(tea.PasteMsg{Content: pasted}) m = model.(chatTUI) display := strings.TrimSpace(m.input.Value()) if !strings.Contains(display, "[Pasted text #1") { t.Fatalf("paste should be folded, got %q", display) } model, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = model.(chatTUI) if len(m.pendingInterject) != 1 { t.Fatalf("queue should have 1 item, got %d", len(m.pendingInterject)) } queued := m.pendingInterject[0] if queued == display { t.Fatalf("queued interject kept the folded placeholder: %q", queued) } for _, want := range []string{ "queued pasted content", "--- Begin [Pasted text #1", "--- End [Pasted text #1", } { if !strings.Contains(queued, want) { t.Fatalf("queued interject missing %q in:\n%s", want, queued) } } model, _ = m.Update(agentEventMsg(event.Event{Kind: event.TurnDone})) m = model.(chatTUI) waitForCLIEvent(t, events, event.TurnDone) if len(runner.inputs) != 1 { t.Fatalf("runner should receive queued interject, inputs=%q", runner.inputs) } sent := runner.inputs[0] if sent == display { t.Fatalf("runner received the folded placeholder: %q", sent) } if !strings.Contains(sent, "queued pasted content") { t.Fatalf("runner input missing pasted content:\n%s", sent) } } func TestQueueNavigationResetOnNonUpDownKey(t *testing.T) { m := newTestChatTUI() m.state = tuiRunning m.pendingInterject = []string{"queued"} up := tea.KeyPressMsg{Code: tea.KeyUp} model, _ := m.Update(up) m = model.(chatTUI) if m.queueEditCursor != 0 { t.Fatalf("cursor should be 0 after up, got %d", m.queueEditCursor) } // A regular key should reset the queue navigation cursor. letter := tea.KeyPressMsg{Code: 'a'} model, _ = m.Update(letter) m = model.(chatTUI) if m.queueEditCursor != -1 { t.Fatalf("cursor should reset on non-up/down key, got %d", m.queueEditCursor) } } func TestQueueIndicatorRendering(t *testing.T) { m := newTestChatTUI() m.state = tuiRunning m.pendingInterject = []string{"first msg", "second msg"} qi := m.renderQueueIndicator() if qi == "" { t.Fatal("queue indicator should not be empty when queue has items and running") } if !strings.Contains(qi, "[1]") || !strings.Contains(qi, "[2]") { t.Fatalf("queue indicator should contain [1] and [2], got %q", qi) } if !strings.Contains(qi, "first msg") || !strings.Contains(qi, "second msg") { t.Fatalf("queue indicator should show message previews, got %q", qi) } // Highlight marker should appear for the browsed item. m.queueEditCursor = 1 qi = m.renderQueueIndicator() if !strings.Contains(qi, "▸") { t.Fatalf("queue indicator should show ▸ for browsed item, got %q", qi) } } func TestQueueIndicatorHiddenWhenIdle(t *testing.T) { m := newTestChatTUI() m.state = tuiIdle m.pendingInterject = []string{"queued"} if qi := m.renderQueueIndicator(); qi != "" { t.Fatalf("queue indicator should be empty when idle, got %q", qi) } } // TestViewAltScreenFillsHeight proves the switch to alt-screen: View requests // the alt buffer with mouse reporting for wheel scrolling and in-app text // selection, and the frame is exactly the terminal height (the transcript // viewport pads to fill above the pinned bottom region). func TestViewAltScreenFillsHeight(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) m.nativeScrollback = false m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) v := m0.(chatTUI).View() if !v.AltScreen { t.Error("View must request alt-screen so resize repaints the whole grid") } if v.MouseMode != tea.MouseModeCellMotion { t.Error("View must enable mouse so the wheel scrolls the transcript") } if lines := strings.Count(v.Content, "\n") + 1; lines != 24 { t.Errorf("alt-screen frame = %d lines, want 24 (full terminal height)", lines) } } func TestViewTermuxUsesNativeScrollback(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) m.nativeScrollback = true m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) v := m0.(chatTUI).View() if v.AltScreen { t.Error("Termux view must stay in the normal screen so native touch scrollback works") } if v.MouseMode != tea.MouseModeNone { t.Error("Termux view must not enable mouse mode because it prevents soft-keyboard focus") } if lines := strings.Count(v.Content, "\n") + 1; lines >= 24 { t.Errorf("Termux view should render only the pinned bottom frame, got %d full-screen lines", lines) } } // TestTranscriptTailFollow proves the viewport pins to newest output while the // user is at the bottom, and stops yanking once the user scrolls up. func TestTranscriptTailFollow(t *testing.T) { ctrl := control.New(control.Options{}) adv := func(m chatTUI, msg tea.Msg) chatTUI { n, _ := m.Update(msg) return n.(chatTUI) } notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"}) cur := adv(newChatTUI(ctrl, "", make(chan event.Event, 1), 80), tea.WindowSizeMsg{Width: 80, Height: 8}) for i := 0; i < 12; i++ { // overflow the short viewport so there's room to scroll cur = adv(cur, notice) } if !cur.viewport.AtBottom() { t.Fatal("new output while pinned should keep the viewport at the bottom") } cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp}) if cur.viewport.AtBottom() { t.Fatal("wheel-up should break the bottom pin") } cur = adv(cur, notice) if cur.viewport.AtBottom() { t.Error("new output while scrolled up must preserve the reading position") } } // TestEmptyEnterScrollsToBottom proves that pressing Enter with an empty composer // scrolls the viewport to the bottom in both idle and running states, so the user // can quickly tail-follow after scrolling up to read history. func TestEmptyEnterScrollsToBottom(t *testing.T) { ctrl := control.New(control.Options{}) ch := make(chan event.Event, 1) notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"}) adv := func(m chatTUI, msg tea.Msg) chatTUI { n, _ := m.Update(msg) return n.(chatTUI) } // --- idle state --- t.Run("idle", func(t *testing.T) { cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8}) for i := 0; i < 12; i++ { cur = adv(cur, notice) } // Scroll up to leave the bottom. cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp}) if cur.viewport.AtBottom() { t.Fatal("wheel-up should break the bottom pin") } // Empty enter → should snap back to bottom. cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter}) if !cur.viewport.AtBottom() { t.Error("empty enter while idle should scroll viewport to bottom") } }) // --- running state --- t.Run("running", func(t *testing.T) { cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8}) for i := 0; i < 12; i++ { cur = adv(cur, notice) } cur.state = tuiRunning cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp}) if cur.viewport.AtBottom() { t.Fatal("wheel-up should break the bottom pin") } cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter}) if !cur.viewport.AtBottom() { t.Error("empty enter while running should scroll viewport to bottom") } }) } // TestForceGotoBottomScrollsWithoutTranscriptChange keeps the force-bottom // contract independent from transcript length, width, or dirty-state changes. func TestForceGotoBottomScrollsWithoutTranscriptChange(t *testing.T) { ctrl := control.New(control.Options{}) ch := make(chan event.Event, 1) notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"}) adv := func(m chatTUI, msg tea.Msg) (chatTUI, tea.Cmd) { n, cmd := m.Update(msg) return n.(chatTUI), cmd } next := func(m chatTUI, msg tea.Msg) chatTUI { n, _ := adv(m, msg) return n } cur := next(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8}) for i := 0; i < 12; i++ { cur = next(cur, notice) } if !cur.viewport.AtBottom() { t.Fatal("new output while pinned should keep the viewport at the bottom") } cur = next(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp}) if cur.viewport.AtBottom() { t.Fatal("wheel-up should break the bottom pin") } cur.forceGotoBottom = true cur.transcriptDirty = false cur, cmd := adv(cur, tea.WindowSizeMsg{Width: 80, Height: 8}) if !cur.viewport.AtBottom() { t.Fatalf("forceGotoBottom should scroll without transcript changes, YOffset=%d", cur.viewport.YOffset()) } if cur.forceGotoBottom { t.Fatal("forceGotoBottom should be cleared after scrolling") } if cmd == nil { t.Fatal("regular forceGotoBottom scroll jump should request ClearScreen") } } func TestSessionSwitchSuppressesOneClearScreen(t *testing.T) { ctrl := control.New(control.Options{}) ch := make(chan event.Event, 1) notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"}) adv := func(m chatTUI, msg tea.Msg) (chatTUI, tea.Cmd) { n, cmd := m.Update(msg) return n.(chatTUI), cmd } next := func(m chatTUI, msg tea.Msg) chatTUI { n, _ := adv(m, msg) return n } cur := next(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8}) for i := 0; i < 12; i++ { cur = next(cur, notice) } cur = next(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp}) if cur.viewport.AtBottom() { t.Fatal("wheel-up should break the bottom pin") } cur.sessionSwitch = true cur.forceGotoBottom = true cur.transcriptDirty = false cur, cmd := adv(cur, tea.WindowSizeMsg{Width: 80, Height: 8}) if cmd != nil { t.Fatal("session switch rebuild should suppress the ClearScreen scroll-jump workaround once") } if cur.sessionSwitch { t.Fatal("sessionSwitch should be cleared after one Update") } if !cur.viewport.AtBottom() { t.Fatalf("session switch should still land at bottom, YOffset=%d", cur.viewport.YOffset()) } cur = next(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp}) cur.forceGotoBottom = true cur, cmd = adv(cur, tea.WindowSizeMsg{Width: 80, Height: 8}) if cmd == nil { t.Fatal("later scroll jumps must still request ClearScreen") } if cur.sessionSwitch { t.Fatal("sessionSwitch should remain false after the suppressed cycle") } } func TestWideInputChangeRequestsClearScreen(t *testing.T) { prev := clearWideInputChanges clearWideInputChanges = true defer func() { clearWideInputChanges = prev }() m := newTestChatTUI() m.input.SetValue("天安a") m.input.SetCursorColumn(len([]rune("天安a"))) next, cmd := m.update(tea.KeyPressMsg{Code: '门', Text: "门"}) got := next.(chatTUI) if got.input.Value() != "天安a门" { t.Fatalf("wide-char insert should preserve the textarea value, got %q", got.input.Value()) } if cmd == nil { t.Fatal("wide-char input changes should request a full redraw") } if shouldClearWideInputChange("ascii", "ascii!") { t.Fatal("single-width ASCII input should not request the wide-input redraw") } if !shouldClearWideInputChange("门", "") { t.Fatal("removing the last wide character should request a full redraw") } if !shouldClearWideInputChange("a门", "a") { t.Fatal("removing a wide character from mixed input should request a full redraw") } } func TestReplayActiveBranchClearsPlanModeAndMarksSessionSwitch(t *testing.T) { m := newTestChatTUI() m.ctrl = control.New(control.Options{}) m.planMode = true m.ctrl.SetPlanMode(true) m.sessionSwitch = false m.replayActiveBranch("switched branch") if m.planMode || m.ctrl.PlanMode() { t.Fatalf("replay should clear plan mode on both TUI and controller, tui=%v controller=%v", m.planMode, m.ctrl.PlanMode()) } if !m.sessionSwitch { t.Fatal("replay should mark the next Update as a session switch") } } func TestFoldedPasteUsesPlaceholderAndExpandsOnSend(t *testing.T) { m := newTestChatTUI() pasted := "{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3,\n \"d\": 4\n}" if !shouldFoldPastedText(pasted) { t.Fatal("five-line paste should fold") } m.insertFoldedPaste(pasted) display := m.input.Value() if display != "[Pasted text #1 · 6 lines] " { t.Fatalf("display = %q", display) } sent := m.expandPastedBlocks(display) for _, want := range []string{ "--- Begin [Pasted text #1 · 6 lines] ---", `"d": 4`, "--- End [Pasted text #1 · 6 lines] ---", } { if !strings.Contains(sent, want) { t.Fatalf("expanded paste missing %q in:\n%s", want, sent) } } } func TestTextOnlyModelSendsPastedImageRefsForToolUse(t *testing.T) { workspace := t.TempDir() writeTUIImageCapabilityConfig(t, workspace) path := saveTestImageAttachment(t, workspace) runner := &recordingTurnRunner{} events := make(chan event.Event, 8) m := newTestChatTUI() m.ctrl = control.New(control.Options{ Runner: runner, Sink: event.FuncSink(func(e event.Event) { events <- e }), WorkspaceRoot: workspace, ModelRef: "custom/text-only", }) m.pastedBlocks = []pastedBlock{{label: "[image #1]", text: "@" + path, image: true}} m.input.SetValue("describe [image #1] please") model, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = model.(chatTUI) if cmd == nil { t.Fatal("text-only image ref send should resolve refs before starting the turn") } msg := cmd() if _, ok := msg.(refsResolvedMsg); !ok { t.Fatalf("enter cmd = %T, want refsResolvedMsg", msg) } model, _ = m.Update(msg) m = model.(chatTUI) waitForCLIEvent(t, events, event.TurnDone) if len(runner.inputs) != 1 { t.Fatalf("text-only model should send the image ref for tool use, inputs=%q", runner.inputs) } if !strings.Contains(runner.inputs[0], "@"+path) { t.Fatalf("runner input should retain the image ref context, got %q", runner.inputs[0]) } if !strings.Contains(runner.inputs[0], "OCR/image/vision tool") { t.Fatalf("runner input should mention tool-based image handling, got %q", runner.inputs[0]) } if got := strings.Join(m.transcript, "\n"); strings.Contains(got, "will not receive images directly") { t.Fatalf("text-only model should not block image refs that tools can read, transcript=%q", got) } } func TestVisionModelAllowsSendingPastedImageRefs(t *testing.T) { workspace := t.TempDir() writeTUIImageCapabilityConfig(t, workspace) path := saveTestImageAttachment(t, workspace) runner := &recordingTurnRunner{} events := make(chan event.Event, 8) m := newTestChatTUI() m.ctrl = control.New(control.Options{ Runner: runner, Sink: event.FuncSink(func(e event.Event) { events <- e }), WorkspaceRoot: workspace, ModelRef: "custom/vision-pro", }) m.pastedBlocks = []pastedBlock{{label: "[image #1]", text: "@" + path, image: true}} m.input.SetValue("describe [image #1] please") model, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = model.(chatTUI) if cmd == nil { t.Fatal("vision model send should resolve refs before starting the turn") } msg := cmd() if _, ok := msg.(refsResolvedMsg); !ok { t.Fatalf("enter cmd = %T, want refsResolvedMsg", msg) } model, _ = m.Update(msg) m = model.(chatTUI) waitForCLIEvent(t, events, event.TurnDone) if len(runner.inputs) != 1 { t.Fatalf("vision model should send exactly one turn, inputs=%q", runner.inputs) } if !strings.Contains(runner.inputs[0], "@"+path) { t.Fatalf("runner input should retain the image ref context, got %q", runner.inputs[0]) } if got := strings.Join(m.transcript, "\n"); strings.Contains(got, "will not receive images directly") { t.Fatalf("vision-capable model should not warn about image input, transcript=%q", got) } } // TestPasteFoldExpandOnSubmit verifies that a folded paste is fully expanded // before being sent to the controller (the LLM sees the actual content, not just // the placeholder label). func TestPasteFoldExpandOnSubmit(t *testing.T) { r := &recordingTurnRunner{} events := make(chan event.Event, 64) ctrl := control.New(control.Options{ Runner: r, Sink: event.FuncSink(func(e event.Event) { events <- e }), SessionDir: t.TempDir(), Label: "test", }) m := newTestChatTUI() m.ctrl = ctrl m.eventCh = make(chan event.Event, 64) // Simulate a multi-line paste that meets the fold threshold (≥5 lines). pasted := strings.Repeat("line of pasted content\n", 10) model, _ := m.Update(tea.PasteMsg{Content: pasted}) m = model.(chatTUI) display := m.input.Value() if !strings.Contains(display, "[Pasted text #1") { t.Fatalf("paste should be folded, got: %q", display) } if len(m.pastedBlocks) != 1 { t.Fatalf("expected 1 pastedBlock, got %d", len(m.pastedBlocks)) } // Simulate pressing Enter to submit. // NOTE: in a real terminal KeyEnter has empty Text, so String() returns "enter". model, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = model.(chatTUI) waitForCLIEvent(t, events, event.TurnDone) if len(r.inputs) == 0 { t.Fatal("runner.Run was not called — the paste was never submitted") } sentToRunner := r.inputs[0] t.Logf("sent to runner (%d bytes):\n%s", len(sentToRunner), sentToRunner) // The runner must receive the FULL expanded paste, not just the label. if !strings.Contains(sentToRunner, "line of pasted content") { t.Fatalf("runner received only the placeholder label, not the expanded paste content.\nGot: %q", sentToRunner) } // Verify the expanded markers are present. if !strings.Contains(sentToRunner, "--- Begin [Pasted text #1") { t.Fatalf("missing Begin marker in runner input.\nGot: %q", sentToRunner) } if !strings.Contains(sentToRunner, "--- End [Pasted text #1") { t.Fatalf("missing End marker in runner input.\nGot: %q", sentToRunner) } // The memory compiler (enabled by default) replaces the user turn with an // execution contract whose source_event is the controller's `raw` value. // If `raw` were the folded label, the model would only ever see // "[Pasted text #1 · N lines]" and never the pasted content. Assert the // source_event carries the EXPANDED content. if len(r.memoryCompilerInputs) == 0 { t.Fatal("memory compiler source input was not set on the context") } mcSource := r.memoryCompilerInputs[0] if strings.Contains(mcSource, "[Pasted text #1") && !strings.Contains(mcSource, "line of pasted content") { t.Fatalf("memory compiler source_event has the folded label but not the expanded content:\n%q", mcSource) } if !strings.Contains(mcSource, "line of pasted content") { t.Fatalf("memory compiler source_event must contain the expanded paste content, got:\n%q", mcSource) } } func TestSlashCodeCommentSubmitStartsTurn(t *testing.T) { for _, input := range []string{ "// explain this", "/**\n * 阿明\n */", } { t.Run(input, func(t *testing.T) { r := &recordingTurnRunner{} events := make(chan event.Event, 8) ctrl := control.New(control.Options{ AutoPlan: "off", Runner: r, Sink: event.FuncSink(func(e event.Event) { events <- e }), }) m := newTestChatTUI() m.ctrl = ctrl m.input.SetValue(input) model, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = model.(chatTUI) waitForCLIEvent(t, events, event.TurnDone) if len(r.inputs) != 1 || r.inputs[0] != input { t.Fatalf("slash code comment should start a model turn, inputs=%q", r.inputs) } }) } } func TestUnknownSlashCommandDoesNotStartTurn(t *testing.T) { r := &recordingTurnRunner{} ctrl := control.New(control.Options{ AutoPlan: "off", Runner: r, Sink: event.FuncSink(func(event.Event) {}), }) m := newTestChatTUI() m.ctrl = ctrl m.input.SetValue("/definitely-not-a-command") model, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = model.(chatTUI) if len(r.inputs) != 0 { t.Fatalf("unknown slash command should not start a model turn, inputs=%q", r.inputs) } if got := strings.Join(m.transcript, "\n"); !strings.Contains(got, "unknown command") { t.Fatalf("unknown slash command should be reported in transcript, got:\n%s", got) } } func TestPasteMsgFoldsBeforeTextareaConsumesNewlines(t *testing.T) { m := newTestChatTUI() model, _ := m.Update(tea.PasteMsg{Content: "1\n2\n3\n4\n5"}) got := model.(chatTUI) if got.input.Value() != "[Pasted text #1 · 5 lines] " { t.Fatalf("input = %q", got.input.Value()) } if got.input.Height() != 1 { t.Fatalf("folded paste should keep one input row, got %d", got.input.Height()) } } func TestUnsendRestoresFoldedPastePlaceholder(t *testing.T) { m := newTestChatTUI() m.ctrl = control.New(control.Options{}) m.bubbleStartIdx = len(m.transcript) m.commitLine("") m.commitLine(renderUserBubble("expanded JSON", m.width, m.planMode)) m.pendingRestore = "[Pasted text #1 · 5 lines] 这是什么?" m.bubblePending = true m.state = tuiRunning m.unsendPending() if got := m.input.Value(); got != "[Pasted text #1 · 5 lines] 这是什么?" { t.Fatalf("restored input = %q", got) } if len(m.transcript) != m.bubbleStartIdx { t.Fatalf("un-send should pop the echoed bubble, transcript=%v", m.transcript) } if m.pendingRestore != "" || m.bubblePending { t.Fatalf("pending state not cleared: restore=%q pending=%v", m.pendingRestore, m.bubblePending) } } func TestApprovalToolDetailsShortensMCPNames(t *testing.T) { name, detail := approvalToolDetails("mcp__minimax-coding-plan-mcp__understand_image") if name != "understand_image" { t.Fatalf("name = %q, want understand_image", name) } for _, want := range []string{"provided image input", "minimax-coding-plan-mcp"} { if !strings.Contains(detail, want) { t.Errorf("detail = %q, want it to contain %q", detail, want) } } name, detail = approvalToolDetails("bash") if name != "bash" || !strings.Contains(detail, "built-in") { t.Errorf("built-in details = (%q, %q), want bash + built-in source", name, detail) } } func TestSandboxEscapeApprovalBannerUsesRealEnvironmentChoice(t *testing.T) { i18n.DetectLanguage("zh") t.Cleanup(func() { i18n.DetectLanguage("en") }) m := newTestChatTUI() m.width = 120 m.pendingApproval = &event.Approval{ ID: "approval-1", Tool: control.SandboxEscapeApprovalTool, Subject: "仅本次不进沙箱运行:go test ./...", Reason: "Windows 沙箱启动这条命令时失败。", } banner := m.renderApprovalBanner() if !strings.Contains(banner, "本会话使用真实环境") { t.Fatalf("approval banner = %q, want real-environment session choice", banner) } if !strings.Contains(banner, "允许一次") { t.Fatalf("approval banner = %q, want desktop-matching allow-once choice", banner) } if !strings.Contains(banner, "3. 拒绝") || strings.Contains(banner, "4. 拒绝") { t.Fatalf("approval banner = %q, want conventional 1/2/3 sandbox choices", banner) } if strings.Contains(banner, "sandbox_escape") { t.Fatalf("approval banner leaked raw tool grant: %q", banner) } } func TestFreshApprovalBannerUsesConventionalDenyChoice(t *testing.T) { i18n.DetectLanguage("zh") t.Cleanup(func() { i18n.DetectLanguage("en") }) m := newTestChatTUI() m.width = 120 m.pendingApproval = &event.Approval{ ID: "approval-1", Tool: "remember", Subject: "保存/更新记忆", } banner := m.renderApprovalBanner() if !strings.Contains(banner, "1. 本次允许") || !strings.Contains(banner, "2. 拒绝") { t.Fatalf("approval banner = %q, want conventional 1/2 fresh choices", banner) } if strings.Contains(banner, "4. 拒绝") { t.Fatalf("approval banner = %q, must not show non-consecutive deny choice", banner) } } func TestFreshApprovalSessionChoiceIsLimitedToSandboxEscape(t *testing.T) { if !freshApprovalAllowsSession(control.SandboxEscapeApprovalTool) { t.Fatal("sandbox escape should allow an explicit session choice") } for _, toolName := range []string{"remember", "forget", planApprovalTool, agent.PlanModeReadOnlyCommandApprovalTool} { if freshApprovalAllowsSession(toolName) { t.Fatalf("%s should not allow the sandbox escape session choice", toolName) } } } // TestSlashQuitExit verifies that /quit and /exit slash commands return tea.Quit, // providing an alternative to Ctrl+D and the bare "quit"/"exit" text commands. func TestSlashQuitExit(t *testing.T) { m := newTestChatTUI() for _, cmd := range []string{"/quit", "/exit"} { got := m.runSlashCommand(cmd) if got == nil { t.Errorf("%s should return a quit cmd, got nil", cmd) continue } msg := got() if _, ok := msg.(tea.QuitMsg); !ok { t.Errorf("%s cmd should produce QuitMsg, got %T", cmd, msg) } } } func TestSlashSubagentWithoutTaskStaysIdleWithUsageHint(t *testing.T) { ctrl := control.New(control.Options{Skills: []skill.Skill{{ Name: "helper", RunAs: skill.RunSubagent, Invocation: "manual", Scope: skill.ScopeGlobal, }}}) m := newTestChatTUI() m.ctrl = ctrl if cmd := m.runSlashCommand("/helper"); cmd != nil { t.Fatal("taskless subagent slash should be handled locally") } if m.state != tuiIdle { t.Fatalf("taskless subagent slash left TUI state=%v, want idle", m.state) } if out := strings.Join(m.transcript, "\n"); !strings.Contains(out, "usage: /helper ") { t.Fatalf("missing task usage hint:\n%s", out) } } func TestSlashMigrateShowsProgress(t *testing.T) { isolateCLIConfigHome(t) m := newTestChatTUI() if cmd := m.runSlashCommand("/migrate"); cmd != nil { t.Fatal("/migrate should run locally without returning a command") } out := strings.Join(m.transcript, "\n") for _, want := range []string{ "/migrate", "migration rescue: checking legacy config and credentials", "migration rescue: scanning legacy memory", "migration rescue: scanning legacy sessions", "migration rescue complete:", } { if !strings.Contains(out, want) { t.Fatalf("missing %q in transcript:\n%s", want, out) } } } func TestSlashMigrateFromImportsExplicitSessions(t *testing.T) { home := isolateCLIConfigHome(t) legacySessions := filepath.Join(home, "Old Reasonix", "sessions") if err := os.MkdirAll(legacySessions, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(legacySessions, "old-chat.jsonl"), []byte(`{"role":"user","content":"hello from old install"}`+"\n"), 0o644); err != nil { t.Fatal(err) } m := newTestChatTUI() input := `/migrate --from "` + filepath.Dir(legacySessions) + `"` if cmd := m.runSlashCommand(input); cmd != nil { t.Fatal("/migrate --from should run locally without returning a command") } out := strings.Join(m.transcript, "\n") for _, want := range []string{ input, "migration rescue: scanning explicit legacy sessions from " + filepath.Dir(legacySessions), "imported 1 past session(s) from " + legacySessions, } { if !strings.Contains(out, want) { t.Fatalf("missing %q in transcript:\n%s", want, out) } } } // TestDoubleCtrlCQuit verifies that Ctrl+C while idle requires a double-press // within the 1.5s window to actually quit. A single press shows a hint; a // second press within the window returns tea.Quit. func TestDoubleCtrlCQuit(t *testing.T) { ctrl := control.New(control.Options{}) m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) ctrlC := tea.KeyPressMsg{Code: 'c', Mod: 4} // 4 = ModCtrl // First Ctrl+C while idle: arms quit, flushes hint via finalize cmd. out, cmd := m.Update(ctrlC) if cmd == nil { t.Error("first Ctrl+C should return a finalize cmd to flush the hint") } m2, ok := out.(chatTUI) if !ok { t.Fatalf("Update returned %T, want chatTUI", out) } if m2.lastCtrlCAt.IsZero() { t.Error("first Ctrl+C should set lastCtrlCAt") } // Second Ctrl+C within window: returns tea.Quit. out2, cmd2 := m2.Update(ctrlC) if cmd2 == nil { t.Error("second Ctrl+C within window should return a quit cmd") } _ = out2 // Window expired: re-arms instead of quitting (still flushes hint via finalize). m3 := m2 m3.lastCtrlCAt = time.Now().Add(-2 * time.Second) out4, cmd4 := m3.Update(ctrlC) if cmd4 == nil { t.Error("expired Ctrl+C should return a finalize cmd to flush the re-armed hint") } m4, ok := out4.(chatTUI) if !ok { t.Fatalf("Update returned %T, want chatTUI", out4) } // lastCtrlCAt should be refreshed to now. if time.Since(m4.lastCtrlCAt) > time.Second { t.Error("expired Ctrl+C should refresh lastCtrlCAt") } } func TestSecondCtrlCQuitsAfterCancelIsAlreadyRequested(t *testing.T) { r := &stubbornTurnRunner{started: make(chan struct{}), release: make(chan struct{})} ctrl := control.New(control.Options{Runner: r, Sink: event.Discard, SessionDir: t.TempDir(), Label: "test"}) ctrl.Send("hi") <-r.started defer close(r.release) m := newTestChatTUI() m.ctrl = ctrl m.state = tuiRunning ctrlC := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} _, firstCmd := m.Update(ctrlC) if firstCmd != nil { t.Fatal("first Ctrl+C while running should request cancel, not quit") } if st := ctrl.RuntimeStatus(); !st.Running || !st.CancelRequested { t.Fatalf("first Ctrl+C status = %+v, want running cancel requested", st) } _, secondCmd := m.Update(ctrlC) if secondCmd == nil { t.Fatal("second Ctrl+C after cancel request should quit") } if msg := secondCmd(); msg != (tea.QuitMsg{}) { t.Fatalf("second Ctrl+C command = %T, want tea.QuitMsg", msg) } } func TestRunningStatusShowsCancelRequested(t *testing.T) { r := &stubbornTurnRunner{started: make(chan struct{}), release: make(chan struct{})} ctrl := control.New(control.Options{Runner: r, Sink: event.Discard, SessionDir: t.TempDir(), Label: "test"}) ctrl.Send("hi") <-r.started defer close(r.release) m := newTestChatTUI() m.ctrl = ctrl m.state = tuiRunning m.width = 80 m.height = 24 ctrl.Cancel() view := ansi.Strip(m.View().Content) if !strings.Contains(view, "stopping") { t.Fatalf("running status after cancel should show stopping feedback:\n%s", view) } } func TestCtrlZResetsMouseTrackingBeforeSuspend(t *testing.T) { m := newTestChatTUI() ctrlZ := tea.KeyPressMsg{Code: 'z', Mod: tea.ModCtrl} _, cmd := m.Update(ctrlZ) if cmd == nil { t.Fatal("expected Ctrl+Z to return a suspend sequence") } msg := cmd() seq := reflect.ValueOf(msg) if seq.Kind() != reflect.Slice || seq.Len() != 2 { t.Fatalf("expected Ctrl+Z to return a two-command sequence, got %T", msg) } first, ok := seq.Index(0).Interface().(tea.Cmd) if !ok { t.Fatalf("first sequence item is %T, want tea.Cmd", seq.Index(0).Interface()) } raw, ok := first().(tea.RawMsg) if !ok { t.Fatalf("first Ctrl+Z command = %T, want tea.RawMsg", first()) } if got := fmt.Sprint(raw.Msg); got != resetMouseTracking { t.Fatalf("Ctrl+Z mouse reset = %q, want %q", got, resetMouseTracking) } second, ok := seq.Index(1).Interface().(tea.Cmd) if !ok { t.Fatalf("second sequence item is %T, want tea.Cmd", seq.Index(1).Interface()) } if msg := second(); msg != (tea.SuspendMsg{}) { t.Fatalf("second Ctrl+Z command = %T, want tea.SuspendMsg", msg) } } // TestCtrlCClearsInput verifies that a single Ctrl+C while idle with non-empty // input clears the composer without arming the double-press quit gesture. func TestCtrlCClearsInput(t *testing.T) { m := newTestChatTUI() m.input.SetValue("hello world") ctrlC := tea.KeyPressMsg{Code: 'c', Mod: 4} out, _ := m.Update(ctrlC) m2 := out.(chatTUI) if strings.TrimSpace(m2.input.Value()) != "" { t.Errorf("Ctrl+C should clear non-empty input, got %q", m2.input.Value()) } if !m2.lastCtrlCAt.IsZero() { t.Error("Ctrl+C on non-empty input should not arm the quit gesture") } } // TestCtrlCClearsThenDoublePressQuits verifies the full user flow: Ctrl+C on // non-empty input clears it, then two more presses on the empty composer quit. func TestCtrlCClearsThenDoublePressQuits(t *testing.T) { m := newTestChatTUI() m.input.SetValue("draft text") ctrlC := tea.KeyPressMsg{Code: 'c', Mod: 4} // First press: clear input. out, _ := m.Update(ctrlC) m2 := out.(chatTUI) if strings.TrimSpace(m2.input.Value()) != "" { t.Fatal("first Ctrl+C should clear input") } // Second press (on empty): arm quit. out2, _ := m2.Update(ctrlC) m3 := out2.(chatTUI) if m3.lastCtrlCAt.IsZero() { t.Error("Ctrl+C on empty input should arm quit") } // Third press (within window): quit. out3, cmd := m3.Update(ctrlC) if cmd == nil { t.Error("double Ctrl+C on empty input should quit") } _ = out3 } // TestCtrlCCopySelection verifies that Ctrl+C while idle on an empty composer // with an active text selection copies the selected text to clipboard instead // of arming the double-press quit gesture. func TestCtrlCCopySelection(t *testing.T) { m := newTestChatTUI() ctrlC := tea.KeyPressMsg{Code: 'c', Mod: 4} // Set up an active selection: anchor < head so there's something to copy. // selection uses content-line coordinates; transcript needs at least one line. m.transcript = []string{"hello world"} m.wrappedLines = []string{"hello world"} m.sel = selection{active: true, anchor: selPos{line: 0, col: 0}, head: selPos{line: 0, col: 5}} out, cmd := m.Update(ctrlC) m2, ok := out.(chatTUI) if !ok { t.Fatalf("Update returned %T, want chatTUI", out) } // Selection should be cleared after copy. if m2.sel.active { t.Error("selection should be cleared after Ctrl+C copy") } // Should NOT arm the quit gesture. if !m2.lastCtrlCAt.IsZero() { t.Error("Ctrl+C on active selection should not arm the quit gesture") } // Should return a command (clipboard copy + finalize). if cmd == nil { t.Fatal("Ctrl+C on selection should return a cmd (clipboard + finalize)") } // Execute the command (copyToClipboard → OSC 52). cmd() // Second Ctrl+C should now arm quit (selection is gone). _, cmd2 := m2.Update(ctrlC) if cmd2 == nil { t.Error("Ctrl+C after copy should arm quit (return a finalize cmd)") } } // TestAgentEventCoalescesBurst proves one update drains the buffered event burst // behind the delivered event, so a flood collapses into a single re-render. func TestAgentEventCoalescesBurst(t *testing.T) { m := newTestChatTUI() m.eventCh = make(chan event.Event, 16) m.eventCh <- event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "b1", Output: "l1\n"}} m.eventCh <- event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "b1", Output: "l2\n"}} m.eventCh <- event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "b1", Output: "l3\n"}} next, _ := m.update(agentEventMsg(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "b1", Name: "bash", Args: `{"command":"x"}`}})) cm := next.(chatTUI) if cm.toolLineCount != 3 { t.Fatalf("burst not coalesced into one update: toolLineCount=%d, want 3", cm.toolLineCount) } if len(m.eventCh) != 0 { t.Errorf("channel should be fully drained, %d left", len(m.eventCh)) } } func TestShortTokens(t *testing.T) { cases := []struct { n int want string }{ {0, "0"}, {999, "999"}, {1000, "1.0K"}, {1500, "1.5K"}, {1999, "2.0K"}, {9999, "10.0K"}, {142000, "142.0K"}, {999999, "1.0M"}, {1000000, "1.0M"}, {1500000, "1.5M"}, } for _, tc := range cases { t.Run(fmt.Sprintf("n=%d", tc.n), func(t *testing.T) { got := shortTokens(tc.n) if got != tc.want { t.Errorf("shortTokens(%d) = %q, want %q", tc.n, got, tc.want) } }) } } func TestTruncateSubject(t *testing.T) { cases := []struct { name string input string width int }{ {"short ASCII", "rm file", 60}, {"long ASCII", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 60}, {"CJK at 60", "日本語の文章は通常、表示幅が広いため、端末の横幅を超えてしまうことがあります。", 60}, {"CJK at 30", "日本語の文章は通常、表示幅が広いため、端末の横幅を超えてしまうことがあります。", 30}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := truncateSubject(tc.input, tc.width) wantMax := tc.width - 28 if wantMax < 16 { wantMax = 16 } w := ansi.StringWidth(got) if w > wantMax { t.Errorf("truncateSubject(%q, %d) = %q (width %d), want visible width <= %d", tc.input, tc.width, got, w, wantMax) } }) } } // TestCtrlCCopyBeatsClearInput — regression for the bug where an active // selection AND a non-empty composer both existed: Ctrl+C used to wipe the // draft text and discard the selection. The fix hoists the selection-copy // branch above the clear-input branch so the user's draft survives. After // the copy the user can still press Ctrl+C again to clear the composer. func TestCtrlCCopyBeatsClearInput(t *testing.T) { m := newTestChatTUI() m.input.SetValue("draft I'm typing") // non-empty composer m.transcript = []string{"selected text"} m.wrappedLines = []string{"selected text"} m.sel = selection{active: true, anchor: selPos{line: 0, col: 0}, head: selPos{line: 0, col: 8}} ctrlC := tea.KeyPressMsg{Code: 'c', Mod: 4} out, cmd := m.Update(ctrlC) m2 := out.(chatTUI) // Draft text must survive the selection copy. if got := m2.input.Value(); got != "draft I'm typing" { t.Errorf("composer draft wiped by Ctrl+C copy; got %q, want preserved", got) } if cmd == nil { t.Fatal("expected clipboard cmd") } if batch, ok := cmd().(tea.BatchMsg); ok { for _, c := range batch { c() } } // Second Ctrl+C (no selection, non-empty composer) clears the draft. out2, _ := m2.Update(ctrlC) m3 := out2.(chatTUI) if got := m3.input.Value(); got != "" { t.Errorf("second Ctrl+C should clear composer; got %q", got) } } // TestEscInPlanModeDoesNotExitPlan — regression for the part of PR #3051 that // was missed: Esc was still falling into the case m.planMode branch. The // Shift+Tab cycle is the only path that flips plan mode; Esc must only // rewind / clear input. PR #3051 already removed the equivalent YOLO branch; // the m.ctrl.SetBypass path is exercised end-to-end in control/yolo_test.go // and intentionally not duplicated here. func TestEscInPlanModeDoesNotExitPlan(t *testing.T) { m := newTestChatTUI() m.planMode = true esc := tea.KeyPressMsg{Code: tea.KeyEsc} out, _ := m.Update(esc) m2 := out.(chatTUI) if !m2.planMode { t.Error("Esc must not exit plan mode; only Shift+Tab should") } } func TestDesktopShortcutLayoutShiftTabTogglesPlanOnly(t *testing.T) { m := newTestChatTUI() m.ctrl = control.New(control.Options{}) m.ctrl.SetToolApprovalMode(control.ToolApprovalAuto) m.cfg = config.Default() if err := m.cfg.SetUIShortcutLayout("desktop"); err != nil { t.Fatal(err) } shiftTab := tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} out, _ := m.Update(shiftTab) m = out.(chatTUI) if !m.planMode || !m.ctrl.PlanMode() { t.Fatalf("first Shift+Tab should enter plan mode, tui=%v controller=%v", m.planMode, m.ctrl.PlanMode()) } if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalAuto { t.Fatalf("Shift+Tab changed approval mode to %q, want auto", got) } out, _ = m.Update(shiftTab) m = out.(chatTUI) if m.planMode || m.ctrl.PlanMode() { t.Fatalf("second Shift+Tab should leave plan mode, tui=%v controller=%v", m.planMode, m.ctrl.PlanMode()) } if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalAuto { t.Fatalf("second Shift+Tab changed approval mode to %q, want auto", got) } } func TestDesktopShortcutLayoutShiftTabClearsGoalWhenEnteringPlan(t *testing.T) { m := newTestChatTUI() m.ctrl = control.New(control.Options{}) m.ctrl.SetGoal("ship the shortcut redesign") m.cfg = config.Default() if err := m.cfg.SetUIShortcutLayout("desktop"); err != nil { t.Fatal(err) } out, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) m = out.(chatTUI) if !m.planMode || !m.ctrl.PlanMode() { t.Fatalf("Shift+Tab should enter plan mode, tui=%v controller=%v", m.planMode, m.ctrl.PlanMode()) } if got := m.ctrl.Goal(); got != "" { t.Fatalf("Shift+Tab entering plan should clear goal, got %q", got) } } func TestDesktopShortcutLayoutCtrlYTogglesYolo(t *testing.T) { m := newTestChatTUI() m.ctrl = control.New(control.Options{}) m.cfg = config.Default() if err := m.cfg.SetUIShortcutLayout("desktop"); err != nil { t.Fatal(err) } ctrlY := tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl} out, _ := m.Update(ctrlY) m = out.(chatTUI) if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalYolo { t.Fatalf("Ctrl+Y approval mode = %q, want yolo", got) } out, _ = m.Update(ctrlY) m = out.(chatTUI) if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalAsk { t.Fatalf("second Ctrl+Y approval mode = %q, want ask", got) } } func TestDesktopShortcutLayoutCtrlYRestoresAutoAfterYolo(t *testing.T) { m := newTestChatTUI() m.ctrl = control.New(control.Options{}) m.ctrl.SetToolApprovalMode(control.ToolApprovalAuto) m.cfg = config.Default() if err := m.cfg.SetUIShortcutLayout("desktop"); err != nil { t.Fatal(err) } ctrlY := tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl} out, _ := m.Update(ctrlY) m = out.(chatTUI) if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalYolo { t.Fatalf("Ctrl+Y approval mode = %q, want yolo", got) } out, _ = m.Update(ctrlY) m = out.(chatTUI) if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalAuto { t.Fatalf("second Ctrl+Y approval mode = %q, want restored auto", got) } } func TestClassicShortcutLayoutCtrlYTogglesYolo(t *testing.T) { m := newTestChatTUI() m.ctrl = control.New(control.Options{}) m.cfg = config.Default() if err := m.cfg.SetUIShortcutLayout("classic"); err != nil { t.Fatal(err) } ctrlY := tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl} out, cmd := m.Update(ctrlY) if cmd != nil { t.Fatal("Ctrl+Y should toggle YOLO directly, not return a paste command") } m = out.(chatTUI) if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalYolo { t.Fatalf("Ctrl+Y approval mode = %q, want yolo", got) } out, _ = m.Update(ctrlY) m = out.(chatTUI) if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalAsk { t.Fatalf("second Ctrl+Y approval mode = %q, want ask", got) } } func TestPrimaryYShortcutRestoresAutoUnderClassicShortcutLayout(t *testing.T) { m := newTestChatTUI() m.ctrl = control.New(control.Options{}) m.ctrl.SetToolApprovalMode(control.ToolApprovalAuto) m.cfg = config.Default() if err := m.cfg.SetUIShortcutLayout("classic"); err != nil { t.Fatal(err) } cmdY := tea.KeyPressMsg{Code: 'y', Mod: tea.ModSuper} out, _ := m.Update(cmdY) m = out.(chatTUI) if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalYolo { t.Fatalf("Cmd/Super+Y approval mode = %q, want yolo", got) } out, _ = m.Update(cmdY) m = out.(chatTUI) if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalAuto { t.Fatalf("second Cmd/Super+Y approval mode = %q, want restored auto", got) } } func TestDesktopShortcutLayoutDoesNotStealCompletionTab(t *testing.T) { m := newTestChatTUI() m.ctrl = control.New(control.Options{}) m.cfg = config.Default() if err := m.cfg.SetUIShortcutLayout("desktop"); err != nil { t.Fatal(err) } m.input.SetValue("/") m.completion = completion{ active: true, kind: compSlash, items: []compItem{{label: "/mcp", insert: "/mcp ", descend: true}}, replaceFrom: 0, } out, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) m = out.(chatTUI) if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalAsk { t.Fatalf("completion Tab changed approval mode to %q", got) } if got := m.input.Value(); got != "/mcp " { t.Fatalf("completion Tab input = %q, want /mcp ", got) } } func TestShiftTabStillTogglesPlanUnderClassicShortcutLayout(t *testing.T) { m := newTestChatTUI() m.ctrl = control.New(control.Options{}) m.cfg = config.Default() if err := m.cfg.SetUIShortcutLayout("classic"); err != nil { t.Fatal(err) } out, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) m = out.(chatTUI) if !m.planMode || !m.ctrl.PlanMode() { t.Fatalf("Shift+Tab should toggle plan mode, tui=%v controller=%v", m.planMode, m.ctrl.PlanMode()) } if got := m.ctrl.ToolApprovalMode(); got != control.ToolApprovalAsk { t.Fatalf("Shift+Tab changed approval mode to %q", got) } }