package tui import ( "fmt" "strings" "testing" "time" tea "charm.land/bubbletea/v2" "github.com/smallnest/pigo/internal/cli/ui" ) // TestModelQuitKeys verifies the root model returns tea.Quit on the standard // exit keys (Ctrl+C / Ctrl+D), which is how Bubble Tea tears down the program // and restores the terminal from the alt-screen. func TestModelQuitKeys(t *testing.T) { for _, key := range []string{"ctrl+c", "ctrl+d"} { m := NewModel(Options{}) got, cmd := m.Update(keyPress(key)) if cmd == nil { t.Fatalf("%s: expected a quit command, got nil", key) } if msg := cmd(); msg != (tea.QuitMsg{}) { t.Errorf("%s: cmd produced %T, want tea.QuitMsg", key, msg) } if !got.(Model).quitting { t.Errorf("%s: model should be marked quitting", key) } } } // TestModelViewShell verifies the empty shell renders on the alt-screen and, // once a size is known, occupies the full terminal height (empty transcript rows // + status bar + input line), with the real status bar (#386) painting its // fields. func TestModelViewShell(t *testing.T) { m := NewModel(Options{Model: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 10}) view := next.View() if !view.AltScreen { t.Error("View should request the alt-screen") } if got := strings.Count(view.Content, "\n"); got != 9 { t.Errorf("newline count = %d, want 9 (10 rows)", got) } if !strings.Contains(view.Content, "test-model") { t.Errorf("status bar model field missing from view: %q", view.Content) } } // TestModelNewlineKeys verifies that Shift+Enter inserts a line break at the // cursor and preserves the already-typed text, rather than submitting. Plain // Enter still submits, so it does not leave a newline in the buffer. Shift+Enter // is the primary newline key (reported distinctly by terminals speaking the // Kitty disambiguate protocol, which Bubble Tea enables by default); Ctrl+J and // Alt+Enter are fallbacks for terminals that collapse Shift+Enter to a bare CR. func TestModelNewlineKeys(t *testing.T) { var mm tea.Model = NewModel(Options{}) mm, _ = mm.Update(tea.WindowSizeMsg{Width: 60, Height: 10}) for _, r := range "abc" { mm, _ = mm.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) } mm, _ = mm.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift}) for _, r := range "def" { mm, _ = mm.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) } if got := mm.(Model).input.Value(); got != "abc\ndef" { t.Errorf("input = %q, want %q", got, "abc\ndef") } } // TestModelSelectionCopy drives a mouse selection over a transcript line and // asserts Ctrl+C copies the selected text (over OSC52) and clears the selection. func TestModelSelectionCopy(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) m.transcript.addUser("hello world") // Locate the rendered screen cell where the text begins so the test does not // hard-code the transcript's bottom-stick row. rows := strings.Split(m.renderContent(), "\n") y, x := -1, -1 for i, r := range rows { plain := stripANSI(r) if idx := strings.Index(plain, "hello world"); idx >= 0 { y = i x = ui.Width(plain[:idx]) break } } if y < 0 { t.Fatal("rendered screen did not contain the transcript text") } // Select exactly "hello world" (11 display cells) on that row. m.sel = selection{active: true, anchor: point{x, y}, cursor: point{x + 11, y}} next, cmd := m.Update(keyPress("ctrl+c")) if cmd == nil { t.Fatal("ctrl+c with a selection should emit a clipboard command") } if got := fmt.Sprintf("%s", cmd()); got != "hello world" { t.Errorf("copied %q, want %q", got, "hello world") } if !next.(Model).sel.empty() { t.Error("selection should be cleared after Ctrl+C copies it") } } // TestModelCtrlCFallsBackToQuit verifies Ctrl+C with no selection keeps its // interrupt/quit role (idle → quit). func TestModelCtrlCFallsBackToQuit(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) next, cmd := m.Update(keyPress("ctrl+c")) if cmd == nil || cmd() != (tea.QuitMsg{}) { t.Fatal("ctrl+c without a selection should quit when idle") } if !next.(Model).quitting { t.Error("model should be marked quitting") } } // TestModelImagePasteInsertsPlaceholder verifies a clipboard image (already saved // to a temp file) is stashed and shown in the composer as a compact "[Image #N]" // placeholder, and that expandImages swaps it for an "@image:" reference at // submit so BuildUserContent attaches it as multimodal content. func TestModelImagePasteInsertsPlaceholder(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) next, _ := m.Update(clipboardImageMsg{path: "/tmp/pigo-clip-1.png", ok: true}) m = next.(Model) if got, want := m.input.Value(), "[Image #1]"; got != want { t.Errorf("composer showed %q, want placeholder %q", got, want) } if got := m.expandImages(m.input.Value()); got != "@image:/tmp/pigo-clip-1.png" { t.Errorf("expandImages = %q, want the @image reference", got) } } // TestModelImagePasteFallsBackToText verifies an empty clipboard image reply // (ok=false) falls back to an OSC52 text read rather than inserting anything. func TestModelImagePasteFallsBackToText(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) next, cmd := m.Update(clipboardImageMsg{ok: false}) if cmd == nil { t.Fatal("no image on the clipboard should fall back to a text read command") } if got := next.(Model).input.Value(); got != "" { t.Errorf("composer should stay empty on fallback, got %q", got) } } // TestModelExpandImagesUnknownID verifies an unknown image id is left untouched. func TestModelExpandImagesUnknownID(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) m = apply(t, m, clipboardImageMsg{path: "/tmp/a.png", ok: true}) got := m.expandImages("see [Image #1] and [Image #7]") want := "see @image:/tmp/a.png and [Image #7]" if got != want { t.Errorf("expandImages = %q, want %q", got, want) } } // keyPress builds a KeyPressMsg matching String()==s for the simple keys used // in these tests (ctrl+). func keyPress(s string) tea.KeyPressMsg { switch s { case "ctrl+c": return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} case "ctrl+d": return tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl} case "ctrl+y": return tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl} case "super+c": return tea.KeyPressMsg{Code: 'c', Mod: tea.ModSuper} case "super+v": return tea.KeyPressMsg{Code: 'v', Mod: tea.ModSuper} default: return tea.KeyPressMsg{} } } // TestModelPasteSingleLineInsertsVerbatim verifies a single-line bracketed paste // is inserted into the editor as-is (no placeholder collapsing). func TestModelPasteSingleLineInsertsVerbatim(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) m = apply(t, m, tea.PasteMsg{Content: "hello world"}) if got := m.input.Value(); got != "hello world" { t.Errorf("input after paste = %q, want %q", got, "hello world") } } // TestModelPasteMultilineCollapses verifies a multi-line paste is collapsed to a // compact "[Pasted text #N +M lines]" placeholder in the composer (Claude Code // style) while the full body is stashed and expanded back at submit. func TestModelPasteMultilineCollapses(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) m = apply(t, m, tea.PasteMsg{Content: "line1\nline2\nline3"}) if got, want := m.input.Value(), "[Pasted text #1 +3 lines]"; got != want { t.Errorf("composer showed %q, want placeholder %q", got, want) } if got := m.expandPastes(m.input.Value()); got != "line1\nline2\nline3" { t.Errorf("expandPastes = %q, want the original body", got) } } // TestModelExpandPastesMultiple verifies several collapsed pastes each expand // back to their own body, and an unknown id is left untouched. func TestModelExpandPastesMultiple(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) m = apply(t, m, tea.PasteMsg{Content: "aaa\nbbb"}) m = apply(t, m, tea.PasteMsg{Content: "ccc\nddd"}) got := m.expandPastes("x [Pasted text #1 +2 lines] y [Pasted text #2 +2 lines] [Pasted text #9 +9 lines]") want := "x aaa\nbbb y ccc\nddd [Pasted text #9 +9 lines]" if got != want { t.Errorf("expandPastes = %q, want %q", got, want) } } // TestModelClipboardReadInsertsIntoInput verifies an OSC52 clipboard read reply // (tea.ClipboardMsg, the response to Ctrl+V) is inserted into the editor. func TestModelClipboardReadInsertsIntoInput(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) m = apply(t, m, tea.ClipboardMsg{Content: "pasted"}) if got := m.input.Value(); got != "pasted" { t.Errorf("input after clipboard read = %q, want %q", got, "pasted") } } // TestModelCopyToClipboard verifies Ctrl+Y emits an OSC52 SetClipboard command // carrying the current buffer, and is a no-op on an empty buffer. func TestModelCopyToClipboard(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) // Empty buffer: no command. if _, cmd := m.Update(keyPress("ctrl+y")); cmd != nil { t.Errorf("ctrl+y on empty buffer should be a no-op, got a command") } m = apply(t, m, tea.PasteMsg{Content: "copy me"}) _, cmd := m.Update(keyPress("ctrl+y")) if cmd == nil { t.Fatal("ctrl+y with content should emit a clipboard command") } // SetClipboard yields an unexported string-underlying message; format it to // read its payload without depending on the tea-internal type. if got := fmt.Sprintf("%s", cmd()); got != "copy me" { t.Errorf("clipboard command carried %q, want %q", got, "copy me") } } // TestModelSuperCCopiesSelection verifies Cmd+C (super+c) copies the mouse // selection just like Ctrl+C, but with an empty buffer and no selection it is a // no-op rather than quitting — Cmd+C is "copy" on macOS, never interrupt/quit. func TestModelSuperCCopiesSelection(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) // No selection, empty buffer: no-op, and never a quit command. if next, cmd := m.Update(keyPress("super+c")); cmd != nil { t.Errorf("super+c with nothing to copy should be a no-op, got a command") } else if next.(Model).quitting { t.Error("super+c must never quit") } // No selection, non-empty buffer: copies the whole buffer. m = apply(t, m, tea.PasteMsg{Content: "buffer text"}) if _, cmd := m.Update(keyPress("super+c")); cmd == nil { t.Fatal("super+c with buffer content should emit a clipboard command") } else if got := fmt.Sprintf("%s", cmd()); got != "buffer text" { t.Errorf("super+c copied %q, want %q", got, "buffer text") } } // TestModelSuperVPastes verifies Cmd+V (super+v) requests the clipboard over // OSC52 when idle, like Ctrl+V. func TestModelSuperVPastes(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) if _, cmd := m.Update(keyPress("super+v")); cmd == nil { t.Fatal("super+v should emit a clipboard read command when idle") } } // TestModelSubagentPanelLifecycle drives the sub-agent status panel through a // task tool's lifecycle on the running model: a toolStartMsg(name=="task") opens // a row, subagentProgressMsg refreshes it and it appears in the rendered View // above the input, and the task's toolEndMsg retires it (empty panel → no rows). func TestModelSubagentPanelLifecycle(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 20}) m.running = true // the panel only renders while a run is in flight m.spinner.begin(time.Now(), "") m = apply(t, m, toolStartMsg{id: "task-1", name: "task", input: map[string]any{"description": "build parser"}}) if got := m.subagents.active(); got != 1 { t.Fatalf("active after task start = %d, want 1", got) } m = apply(t, m, subagentProgressMsg{id: "task-1", desc: "build parser", activity: "Editing", tokens: 64}) if row := m.subagents.byID["task-1"]; row == nil || row.activity != "Editing" { t.Fatalf("row after progress = %+v, want activity=Editing", row) } // The panel line is identified by its ⏺ glyph (distinct from the tool card, // which also mentions the description) plus the live activity. if view := m.View().Content; !strings.Contains(view, "⏺") || !strings.Contains(view, "Editing") { t.Errorf("view missing panel line: %q", view) } // A non-task tool must not open a panel row. m = apply(t, m, toolStartMsg{id: "read-1", name: "read_file", input: map[string]any{"path": "/x"}}) if got := m.subagents.active(); got != 1 { t.Errorf("active after non-task start = %d, want 1", got) } m = apply(t, m, toolEndMsg{id: "task-1", ok: true, result: "done"}) if got := m.subagents.active(); got != 0 { t.Errorf("active after task end = %d, want 0", got) } if view := m.View().Content; strings.Contains(view, "⏺") { t.Errorf("view still shows retired panel line: %q", view) } } // TestModelCompactionIndicator verifies compactionStartMsg pins the spinner to // "Compacting conversation…" while summarization runs, and compactionMsg clears // the label and records the "(context compacted)" system note. func TestModelCompactionIndicator(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 20}) m.running = true m.spinner.begin(time.Now(), "") m = apply(t, m, compactionStartMsg{}) if view := stripANSI(m.spinner.view(120)); !strings.Contains(view, "Compacting conversation…") { t.Errorf("spinner view %q should show the compaction label", view) } m = apply(t, m, compactionMsg{}) if m.spinner.pinned != "" { t.Errorf("compactionMsg should unpin the spinner, got %q", m.spinner.pinned) } if joined := strings.Join(blockTexts(m.transcript), "\n"); !strings.Contains(joined, "(context compacted)") { t.Errorf("transcript should note the compaction, got:\n%s", joined) } } // TestModelSubagentPanelHeightReservation verifies the panel's rows are reserved // out of the transcript height so the total shell height is unchanged whether or // not sub-agents are active. func TestModelSubagentPanelHeightReservation(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 20}) m.running = true m.spinner.begin(time.Now(), "") m.relayout() base := m.transcript.viewportHeight() m = apply(t, m, toolStartMsg{id: "t1", name: "task", input: map[string]any{"description": "a"}}) m = apply(t, m, toolStartMsg{id: "t2", name: "task", input: map[string]any{"description": "b"}}) if got := m.transcript.viewportHeight(); got != base-2 { t.Errorf("transcript height with 2 panel rows = %d, want %d (base %d - 2)", got, base-2, base) } // Every rendered frame stays exactly Height rows tall regardless of the panel. if got := strings.Count(m.View().Content, "\n"); got != 19 { t.Errorf("newline count = %d, want 19 (20 rows)", got) } } // TestModelSubagentPanelNavigation verifies that while a run streams and the // composer is empty, ↓/↑ move the panel cursor, Enter expands the selected row's // accumulated output inline (fed by tool-update deltas), and Esc collapses/clears // the selection rather than interrupting the run. func TestModelSubagentPanelNavigation(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 24}) m.running = true m.spinner.begin(time.Now(), "") m = apply(t, m, toolStartMsg{id: "a", name: "task", input: map[string]any{"description": "task A"}}) m = apply(t, m, toolStartMsg{id: "b", name: "task", input: map[string]any{"description": "task B"}}) // A sub-agent's forwarded text arrives as an incremental tool-update delta. m = apply(t, m, toolUpdateMsg{id: "b", partial: "output of B"}) // ↓ selects the top row, a second ↓ moves to row b. m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) if !m.subagents.hasSelection() || m.subagents.selected != 0 { t.Fatalf("after down: selected=%d hasSel=%v, want 0/true", m.subagents.selected, m.subagents.hasSelection()) } m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) // Enter expands the selected row; its accumulated output shows in the render. m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) if got := m.subagents.expandedID(); got != "b" { t.Fatalf("expandedID after enter = %q, want b", got) } if !strings.Contains(m.renderContent(), "output of B") { t.Errorf("expanded render missing sub-agent output:\n%s", m.renderContent()) } // Esc collapses/clears the selection and does NOT quit (no quit command). next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) m = next.(Model) if m.subagents.hasSelection() { t.Error("esc should clear the panel selection") } if cmd != nil { if _, isQuit := cmd().(tea.QuitMsg); isQuit { t.Error("esc with an active selection should not quit the program") } } } // TestModelSubagentEscReturnsToInput verifies the one-key escape ("escape hatch: one key back to the input box"): // while a sub-agent runs the composer is blurred (no typing), and after arrowing // into the panel a single Esc both clears the selection and re-focuses the input // box — so returning to the composer never requires more than one press and never // interrupts the run. func TestModelSubagentEscReturnsToInput(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 24}) m.running = true m.spinner.begin(time.Now(), "") m.input.Blur() // the composer is blurred for the duration of a run (startPrompt) m = apply(t, m, toolStartMsg{id: "a", name: "task", input: map[string]any{"description": "task A"}}) // Arrow into the panel: a selection is now active while the input stays blurred. m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) if !m.subagents.hasSelection() { t.Fatal("down should select a sub-agent row") } if m.input.Focused() { t.Fatal("the composer should be blurred while a sub-agent run streams") } // One Esc escapes: selection cleared AND the input box re-focused, in a single // press, without interrupting the run. next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) m = next.(Model) if m.subagents.hasSelection() { t.Error("one Esc should clear the panel selection") } if !m.input.Focused() { t.Error("one Esc should re-focus the input box (return to the composer)") } if !m.running { t.Error("escaping the panel selection must not interrupt the run") } } // TestModelPromptHistoryNavigation verifies shell-like prompt history: after // submitting two prompts, ↑ from an empty composer recalls the most recent, a // second ↑ walks further back, ↓ walks forward again, and a final ↓ restores the // (empty) live draft. With no run starter wired, submit records history and // leaves the composer idle/focused, so the arrow keys route to historyPrev / // historyNext. func TestModelPromptHistoryNavigation(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 12}) submit := func(s string) { for _, r := range s { m = apply(t, m, runeKey(r)) } m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) } submit("first prompt") submit("second prompt") if m.input.Value() != "" { t.Fatalf("composer should be empty after submit, got %q", m.input.Value()) } // ↑ recalls the newest entry, a second ↑ the older one. m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyUp}) if got := m.input.Value(); got != "second prompt" { t.Errorf("first ↑ recalled %q, want %q", got, "second prompt") } m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyUp}) if got := m.input.Value(); got != "first prompt" { t.Errorf("second ↑ recalled %q, want %q", got, "first prompt") } // ↓ walks forward to the newer entry, then past it to restore the live draft. m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) if got := m.input.Value(); got != "second prompt" { t.Errorf("↓ walked to %q, want %q", got, "second prompt") } m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) if got := m.input.Value(); got != "" { t.Errorf("↓ past newest should restore the empty draft, got %q", got) } } // TestModelPromptHistoryDedupsAndStashesDraft verifies two shell-like behaviors: // a consecutive-duplicate submit is not stored twice, and an in-progress draft is // stashed when browsing begins so ↓ past the newest entry brings it back. func TestModelPromptHistoryDedupsAndStashesDraft(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 12}) submit := func(s string) { for _, r := range s { m = apply(t, m, runeKey(r)) } m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) } submit("same") submit("same") // consecutive duplicate — must not be stored twice if len(m.history) != 1 { t.Fatalf("history = %v, want a single deduped entry", m.history) } // Type a fresh draft, then browse: ↑ stashes the draft and recalls history, // ↓ past the newest entry restores the stashed draft verbatim. for _, r := range "draft" { m = apply(t, m, runeKey(r)) } m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyUp}) if got := m.input.Value(); got != "same" { t.Errorf("↑ recalled %q, want %q", got, "same") } m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) if got := m.input.Value(); got != "draft" { t.Errorf("↓ should restore the stashed draft %q, got %q", "draft", got) } }