520 lines
21 KiB
Go
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)
|
|
}
|
|
}
|