first commit
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user