first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+599
View File
@@ -0,0 +1,599 @@
package repl
import (
"bufio"
"bytes"
"errors"
"io"
"strings"
"testing"
"github.com/smallnest/pigo/internal/runtime"
)
func testLineEditor(history ...string) *replLineEditor {
reg := runtime.NewSlashRegistry()
reg.AddBuiltin(runtime.SlashCommand{Name: "model", Action: func(string) string { return "" }})
reg.AddBuiltin(runtime.SlashCommand{Name: "models", Action: func(string) string { return "" }})
return newREPLLineEditor(strings.NewReader(""), bufio.NewReader(strings.NewReader("")), io.Discard, reg, history)
}
func TestLineEditorPrefersMostRecentMatchingInput(t *testing.T) {
e := testLineEditor("explain old", "other", "explain recent")
if got := e.suggestion("exp"); got != "explain recent" {
t.Fatalf("suggestion = %q, want most recent match", got)
}
}
func TestLineEditorCompletesSlashCommands(t *testing.T) {
e := testLineEditor()
if got := e.suggestion("/mod"); got != "/model" {
t.Fatalf("suggestion = %q, want /model", got)
}
}
func TestLineEditorCompletesModelsByRecentUseAndBasename(t *testing.T) {
e := testLineEditor("/model openai/gpt-4o")
if got := e.suggestion("/model "); got != "/model openai/gpt-4o" {
t.Fatalf("empty model suggestion = %q", got)
}
if got := e.suggestion("/model gpt"); got != "/model openai/gpt-4o" {
t.Fatalf("recent model suggestion = %q", got)
}
e = testLineEditor()
got := e.suggestion("/model deepseek")
if got == "" || !strings.HasPrefix(got, "/model ") {
t.Fatalf("catalog model suggestion = %q", got)
}
}
func TestLineEditorSuggestionsAreOrderedAndDeduped(t *testing.T) {
// Two recent inputs plus a slash command all sharing a prefix: the caller
// cycles this list with the arrow keys, so ordering (best first) and
// dedup both matter.
e := testLineEditor("explain old", "explain recent", "explain recent")
cands := e.suggestions("exp")
if len(cands) != 2 {
t.Fatalf("suggestions = %v, want 2 unique candidates", cands)
}
if cands[0] != "explain recent" || cands[1] != "explain old" {
t.Fatalf("suggestions = %v, want most-recent first", cands)
}
// The head of the list must match the single-suggestion helper.
if e.suggestion("exp") != cands[0] {
t.Fatalf("suggestion head %q != suggestions[0] %q", e.suggestion("exp"), cands[0])
}
}
func TestLineEditorSlashCommandsCycleAllMatches(t *testing.T) {
e := testLineEditor()
cands := e.suggestions("/mode")
// Both /model and /models share the prefix; cycling must expose both.
if len(cands) != 2 || cands[0] != "/model" || cands[1] != "/models" {
t.Fatalf("suggestions = %v, want [/model /models]", cands)
}
}
func TestMLBufferEmptyBehavesLikeEmptyString(t *testing.T) {
b := newMLBuffer()
if !b.isEmpty() {
t.Fatalf("fresh buffer should be empty")
}
if !b.single() {
t.Fatalf("fresh buffer should be single-line")
}
if got := b.String(); got != "" {
t.Fatalf("empty buffer String() = %q, want \"\"", got)
}
}
func TestMLBufferSingleLineInsert(t *testing.T) {
b := newMLBuffer()
b.insert("hello")
if got := b.String(); got != "hello" {
t.Fatalf("String() = %q, want %q", got, "hello")
}
if b.col != 5 {
t.Fatalf("col = %d, want 5", b.col)
}
if b.isEmpty() {
t.Fatalf("buffer with text should not be empty")
}
}
func TestMLBufferBackspaceRemovesLastRune(t *testing.T) {
b := newMLBuffer()
b.insert("héllo") // multi-byte rune to exercise UTF-8 handling
b.backspace()
if got := b.String(); got != "héll" {
t.Fatalf("after backspace String() = %q, want %q", got, "héll")
}
// Remove the multi-byte rune specifically.
b.setString("café")
b.backspace()
if got := b.String(); got != "caf" {
t.Fatalf("multi-byte backspace String() = %q, want %q", got, "caf")
}
}
func TestMLBufferBackspaceEmptyIsNoop(t *testing.T) {
b := newMLBuffer()
b.backspace()
if got := b.String(); got != "" {
t.Fatalf("backspace on empty buffer String() = %q, want \"\"", got)
}
if b.col != 0 || b.row != 0 {
t.Fatalf("cursor moved on empty backspace: row=%d col=%d", b.row, b.col)
}
}
func TestMLBufferSetStringJoinsWithNewline(t *testing.T) {
b := newMLBuffer()
b.setString("line1\nline2\nline3")
if got := b.String(); got != "line1\nline2\nline3" {
t.Fatalf("String() = %q, want round-trip", got)
}
if b.single() {
t.Fatalf("multi-line buffer reported single()")
}
// Cursor lands at end of last line.
if b.row != 2 || b.col != 5 {
t.Fatalf("cursor = (%d,%d), want (2,5)", b.row, b.col)
}
// No trailing newline is introduced.
if strings.HasSuffix(b.String(), "\n") {
t.Fatalf("String() has trailing newline: %q", b.String())
}
}
// editorWithOutput is editorWithInput but captures the editor's terminal
// output so raw-mode rendering (escape sequences) can be asserted.
func editorWithOutput(input string, out io.Writer, history ...string) *replLineEditor {
reg := runtime.NewSlashRegistry()
reg.AddBuiltin(runtime.SlashCommand{Name: "model", Action: func(string) string { return "" }})
r := strings.NewReader(input)
return newREPLLineEditor(r, bufio.NewReader(r), out, reg, history)
}
func TestVisibleWidthSkipsANSI(t *testing.T) {
if w := visibleWidth("pigo> "); w != 6 {
t.Fatalf("plain width = %d, want 6", w)
}
if w := visibleWidth("\033[2m·\033[0m "); w != 2 {
t.Fatalf("dim marker width = %d, want 2 (marker + space)", w)
}
if w := visibleWidth("café> "); w != 6 {
t.Fatalf("multi-byte width = %d, want 6 runes", w)
}
}
func TestRuneAndDisplayWidthHandleWideRunes(t *testing.T) {
// ASCII and Latin-1 accents are one cell; CJK ideographs and fullwidth
// forms are two; combining marks are zero.
if w := runeWidth('a'); w != 1 {
t.Fatalf("width('a') = %d, want 1", w)
}
if w := runeWidth('é'); w != 1 {
t.Fatalf("width('é') = %d, want 1", w)
}
if w := runeWidth('中'); w != 2 {
t.Fatalf("width('中') = %d, want 2", w)
}
if w := runeWidth('́'); w != 0 { // combining acute accent
t.Fatalf("width(combining) = %d, want 0", w)
}
// A mixed CJK/ASCII string sums per-cell.
if w := displayWidth("你好a"); w != 5 {
t.Fatalf("displayWidth(\"你好a\") = %d, want 5", w)
}
// visibleWidth (ANSI-stripping) agrees on wide runes.
if w := visibleWidth("\033[2m中\033[0m"); w != 2 {
t.Fatalf("visibleWidth wide = %d, want 2", w)
}
}
func TestEditLoopCursorAccountsForWideRunes(t *testing.T) {
// Typing a CJK char must move the cursor two cells, not one, so the final
// reposition after "中" (prompt width 2 + 2 cells) lands at column 4.
var out bytes.Buffer
e := editorWithOutput("中", &out)
// Drive one render by feeding the rune then EOF (no submit needed).
if _, err := e.editLoop("> "); err == nil {
t.Fatalf("expected EOF error from truncated input")
}
s := out.String()
if !strings.Contains(s, "\033[4C") {
t.Fatalf("cursor not repositioned to column 4 for wide rune:\n%q", s)
}
if strings.Contains(s, "\033[3C") {
t.Fatalf("cursor used rune-count column 3 (wide rune miscounted):\n%q", s)
}
}
func TestEditLoopRendersContinuationAndClears(t *testing.T) {
// "foo" + Shift+Enter + "bar" builds a two-line buffer; the continuation
// line is indented with the dim marker and each redraw clears the block.
var out bytes.Buffer
e := editorWithOutput("foo\x1b[13;2ubar\r", &out)
got, err := e.editLoop("> ")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "foo\nbar" {
t.Fatalf("submitted %q, want %q", got, "foo\nbar")
}
s := out.String()
if !strings.Contains(s, "\033[2m·\033[0m bar") {
t.Fatalf("output missing dim continuation prefix before %q:\n%q", "bar", s)
}
if !strings.Contains(s, "\033[J") {
t.Fatalf("output never clears the block with \\033[J:\n%q", s)
}
}
// editorWithInput builds an editor whose editLoop reads the given byte stream,
// so raw-mode key handling can be exercised without a real terminal.
func editorWithInput(input string, history ...string) *replLineEditor {
reg := runtime.NewSlashRegistry()
reg.AddBuiltin(runtime.SlashCommand{Name: "model", Action: func(string) string { return "" }})
reg.AddBuiltin(runtime.SlashCommand{Name: "models", Action: func(string) string { return "" }})
r := strings.NewReader(input)
return newREPLLineEditor(r, bufio.NewReader(r), io.Discard, reg, history)
}
func TestMLBufferNewlineSplitsAtCursor(t *testing.T) {
b := newMLBuffer()
b.insert("abcdef")
b.col = 3 // cursor between "abc" and "def"
b.newline()
if got := b.String(); got != "abc\ndef" {
t.Fatalf("newline split = %q, want %q", got, "abc\ndef")
}
if b.row != 1 || b.col != 0 {
t.Fatalf("cursor after newline = (%d,%d), want (1,0)", b.row, b.col)
}
}
func TestEditLoopPlainEnterSubmits(t *testing.T) {
e := editorWithInput("abc\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "abc" {
t.Fatalf("submitted %q, want %q", got, "abc")
}
}
func TestEditLoopShiftEnterInsertsNewline(t *testing.T) {
// CSI-u Shift+Enter is \x1b[13;2u; a bare \r then submits.
e := editorWithInput("abc\x1b[13;2udef\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "abc\ndef" {
t.Fatalf("submitted %q, want %q", got, "abc\ndef")
}
}
func TestEditLoopModifyOtherKeysEnterInsertsNewline(t *testing.T) {
// modifyOtherKeys Shift+Enter is \x1b[27;2;13~.
e := editorWithInput("x\x1b[27;2;13~y\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "x\ny" {
t.Fatalf("submitted %q, want %q", got, "x\ny")
}
}
func TestEditLoopCSIuPlainEnterSubmits(t *testing.T) {
// Unmodified Enter reported as CSI-u \x1b[13u must submit, not newline.
e := editorWithInput("hi\x1b[13u")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "hi" {
t.Fatalf("submitted %q, want %q", got, "hi")
}
}
func TestEditLoopCSIuCtrlDEmptyReturnsEOF(t *testing.T) {
// Under the kitty keyboard protocol Ctrl+D on an empty line arrives as the
// CSI-u report \x1b[100;5u (code 'd', ctrl modifier) rather than raw 0x04,
// and must still exit with io.EOF.
e := editorWithInput("\x1b[100;5u")
got, err := e.editLoop("")
if !errors.Is(err, io.EOF) {
t.Fatalf("want io.EOF, got err=%v got=%q", err, got)
}
}
func TestEditLoopCSIuCtrlDNonEmptyIgnored(t *testing.T) {
// Ctrl+D on a non-empty line is a no-op (matches the raw-byte behavior); the
// following Enter submits the typed text.
e := editorWithInput("ab\x1b[100;5u\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "ab" {
t.Fatalf("submitted %q, want %q", got, "ab")
}
}
func TestEditLoopCSIuCtrlCInterrupts(t *testing.T) {
// Ctrl+C reported as CSI-u \x1b[99;5u must interrupt the line.
e := editorWithInput("\x1b[99;5u")
_, err := e.editLoop("")
if !errors.Is(err, errLineInterrupted) {
t.Fatalf("want errLineInterrupted, got %v", err)
}
}
func TestMLBufferLeftRightCrossLines(t *testing.T) {
b := newMLBuffer()
b.setString("ab\ncd") // cursor at end of "cd" → (1,2)
b.left() // (1,1)
b.left() // (1,0)
if b.row != 1 || b.col != 0 {
t.Fatalf("after two lefts = (%d,%d), want (1,0)", b.row, b.col)
}
b.left() // cross to end of "ab" → (0,2)
if b.row != 0 || b.col != 2 {
t.Fatalf("left at line start = (%d,%d), want (0,2)", b.row, b.col)
}
b.right() // cross to start of "cd" → (1,0)
if b.row != 1 || b.col != 0 {
t.Fatalf("right at line end = (%d,%d), want (1,0)", b.row, b.col)
}
// Left at the very origin is a no-op.
b.setString("x")
b.home()
b.left()
if b.row != 0 || b.col != 0 {
t.Fatalf("left at origin moved cursor: (%d,%d)", b.row, b.col)
}
// Right at the very end is a no-op.
b.end()
b.right()
if b.row != 0 || b.col != 1 {
t.Fatalf("right at end moved cursor: (%d,%d)", b.row, b.col)
}
}
func TestMLBufferUpDownClampColumn(t *testing.T) {
b := newMLBuffer()
b.setString("long line\nhi") // cursor at end of "hi" → (1,2)
b.up() // move to "long line", col stays 2
if b.row != 0 || b.col != 2 {
t.Fatalf("up = (%d,%d), want (0,2)", b.row, b.col)
}
b.end() // col = 9
b.down()
// Down to "hi" (len 2) clamps col from 9 to 2.
if b.row != 1 || b.col != 2 {
t.Fatalf("down clamp = (%d,%d), want (1,2)", b.row, b.col)
}
// Up on the first line is a no-op; down on the last line is a no-op.
b.setString("a\nb")
b.up()
b.up()
if b.row != 0 {
t.Fatalf("up past first line: row=%d", b.row)
}
b.down()
b.down()
if b.row != 1 {
t.Fatalf("down past last line: row=%d", b.row)
}
}
func TestMLBufferHomeEnd(t *testing.T) {
b := newMLBuffer()
b.setString("héllo") // multi-byte, 5 runes
b.home()
if b.col != 0 {
t.Fatalf("home col = %d, want 0", b.col)
}
b.end()
if b.col != 5 {
t.Fatalf("end col = %d, want 5", b.col)
}
}
func TestEditLoopLeftArrowThenInsert(t *testing.T) {
// Type "abc", move left once (\x1b[D), insert "X", submit → "abXc".
e := editorWithInput("abc\x1b[DX\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "abXc" {
t.Fatalf("submitted %q, want %q", got, "abXc")
}
}
func TestEditLoopUpArrowEditsPreviousLine(t *testing.T) {
// "a" + Shift+Enter + "b", then up-arrow to line 0, End, insert "Z":
// line 0 becomes "aZ", line 1 stays "b" → "aZ\nb".
e := editorWithInput("a\x1b[13;2ub\x1b[A\x1b[FZ\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "aZ\nb" {
t.Fatalf("submitted %q, want %q", got, "aZ\nb")
}
}
func TestMLBufferInsertMidLine(t *testing.T) {
b := newMLBuffer()
b.setString("abc")
b.home()
b.right() // cursor between "a" and "bc"
b.insert("X")
if got := b.String(); got != "aXbc" {
t.Fatalf("mid-line insert = %q, want %q", got, "aXbc")
}
if b.col != 2 {
t.Fatalf("col after insert = %d, want 2", b.col)
}
}
func TestMLBufferBackspaceMergesLines(t *testing.T) {
b := newMLBuffer()
b.setString("ab\ncd") // cursor at (1,2)
b.home() // cursor at (1,0)
b.backspace() // merge line 1 into line 0
if got := b.String(); got != "abcd" {
t.Fatalf("merge = %q, want %q", got, "abcd")
}
if b.row != 0 || b.col != 2 {
t.Fatalf("cursor after merge = (%d,%d), want (0,2)", b.row, b.col)
}
// Merge with a multi-byte previous line lands the cursor by rune count.
b.setString("café\nx")
b.home()
b.backspace()
if got := b.String(); got != "caféx" {
t.Fatalf("multi-byte merge = %q, want %q", got, "caféx")
}
if b.col != 4 {
t.Fatalf("cursor col after multi-byte merge = %d, want 4", b.col)
}
}
func TestMLBufferBackspaceFirstLineCol0IsNoop(t *testing.T) {
b := newMLBuffer()
b.setString("abc")
b.home()
b.backspace()
if got := b.String(); got != "abc" {
t.Fatalf("col-0 backspace on first line = %q, want unchanged", got)
}
if b.row != 0 || b.col != 0 {
t.Fatalf("cursor moved: (%d,%d)", b.row, b.col)
}
}
func TestEditLoopCrossLineBackspaceMerges(t *testing.T) {
// "ab" + Shift+Enter + "cd" → two lines; Home to line-1 start, backspace
// merges into "abcd", submit.
e := editorWithInput("ab\x1b[13;2ucd\x1b[H\x7f\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "abcd" {
t.Fatalf("submitted %q, want %q", got, "abcd")
}
}
func TestEditLoopBackslashContinues(t *testing.T) {
// A line ending with a single unescaped "\" + Enter continues; a second
// line + Enter submits the two-line block without the continuation "\".
e := editorWithInput("foo\\\rbar\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "foo\nbar" {
t.Fatalf("submitted %q, want %q", got, "foo\nbar")
}
}
func TestEditLoopEscapedBackslashSubmits(t *testing.T) {
// A line ending with "\\" (escaped) + Enter submits, keeping one literal
// backslash — it does not continue.
e := editorWithInput("foo\\\\\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "foo\\" {
t.Fatalf("submitted %q, want %q", got, "foo\\")
}
}
func TestEditLoopBackslashMultipleContinuations(t *testing.T) {
// Three continued lines accumulate into a three-line block.
e := editorWithInput("a\\\rb\\\rc\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "a\nb\nc" {
t.Fatalf("submitted %q, want %q", got, "a\nb\nc")
}
}
func TestMLBufferEnterContinuesTrailingRuns(t *testing.T) {
// Odd run continues and collapses to k/2 backslashes; even run submits and
// halves the run.
b := newMLBuffer()
b.setString("x\\") // one trailing backslash
if !b.enterContinues() {
t.Fatalf("single trailing backslash should continue")
}
if got := b.line(); got != "x" {
t.Fatalf("line after continue = %q, want %q", got, "x")
}
b.setString("y\\\\") // two trailing backslashes
if b.enterContinues() {
t.Fatalf("escaped double backslash should not continue")
}
if got := b.line(); got != "y\\" {
t.Fatalf("line after submit = %q, want %q", got, "y\\")
}
b.setString("z\\\\\\") // three trailing backslashes → continue, keep one
if !b.enterContinues() {
t.Fatalf("triple trailing backslash should continue")
}
if got := b.line(); got != "z\\" {
t.Fatalf("line after triple continue = %q, want %q", got, "z\\")
}
}
func TestRememberPreservesInternalNewlines(t *testing.T) {
// A submitted multi-line block is stored as ONE history record; remember
// trims only the outer whitespace, never the internal newlines.
e := testLineEditor()
e.remember(" foo\nbar ")
if len(e.history) != 1 {
t.Fatalf("history = %v, want a single record", e.history)
}
if e.history[0] != "foo\nbar" {
t.Fatalf("remembered %q, want %q (internal newline kept)", e.history[0], "foo\nbar")
}
}
func TestEditLoopHistoryRestoresMultiLineAndSubmits(t *testing.T) {
// Up-arrow on a blank line restores the newest history entry; a multi-line
// record comes back as a multi-line buffer that submits intact with its
// internal newline (i.e. the block is one message, not two).
e := editorWithInput("\x1b[A\r", "foo\nbar")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "foo\nbar" {
t.Fatalf("restored+submitted %q, want %q", got, "foo\nbar")
}
}
func TestEditLoopMultiLineSubmitJoinsWithNewline(t *testing.T) {
// Three Shift+Enter lines submit as one \n-joined message, so the full
// multi-line string reaches the agent in a single turn.
e := editorWithInput("a\x1b[13;2ub\x1b[13;2uc\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "a\nb\nc" {
t.Fatalf("submitted %q, want %q", got, "a\nb\nc")
}
}