Files
2026-08-14 23:41:57 +08:00

520 lines
21 KiB
Go

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:<path>" 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+<letter>).
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)
}
}