Files
2026-07-13 13:00:08 +08:00

3056 lines
104 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "<reasoning-language>") || !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 <task>") {
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)
}
}