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
+766
View File
@@ -0,0 +1,766 @@
package repl
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"unicode/utf8"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
)
// errLineInterrupted aliases cli.ErrLineInterrupted so the editor's own returns
// and tests keep the short local name while subpackages (e.g. /btw) recognize
// the same sentinel through the exported cli.ErrLineInterrupted.
var errLineInterrupted = cli.ErrLineInterrupted
// mlBuffer models the readLine input as one or more lines with a cursor at
// (row, col), col counted in runes within the current line. A fresh buffer
// holds a single empty line with the cursor at the origin. It is the data
// model the multi-line REPL editor (continuation, Shift+Enter, cross-line
// movement/editing, rendering, history) is built on; single-line input behaves
// exactly as a plain string with the cursor at its end.
type mlBuffer struct {
lines []string
row int
col int
}
func newMLBuffer() *mlBuffer { return &mlBuffer{lines: []string{""}} }
// String joins the lines with "\n" for submission. A single empty line yields
// "" and there is never a trailing newline.
func (b *mlBuffer) String() string { return strings.Join(b.lines, "\n") }
// isEmpty reports whether the buffer is a single empty line.
func (b *mlBuffer) isEmpty() bool { return len(b.lines) == 1 && b.lines[0] == "" }
// single reports whether the buffer holds exactly one line.
func (b *mlBuffer) single() bool { return len(b.lines) == 1 }
// line returns the text of the current cursor line.
func (b *mlBuffer) line() string { return b.lines[b.row] }
// setString replaces the whole buffer with s (which may contain "\n"), placing
// the cursor at the end of the last line. Used when the entire input is swapped
// wholesale — accepting a suggestion, browsing history, or restoring a
// multi-line entry.
func (b *mlBuffer) setString(s string) {
b.lines = strings.Split(s, "\n")
b.row = len(b.lines) - 1
b.col = utf8.RuneCountInString(b.lines[b.row])
}
// insert adds s (which must not contain "\n") at the cursor on the current
// line and advances col past it.
func (b *mlBuffer) insert(s string) {
line := b.lines[b.row]
off := runeOffset(line, b.col)
b.lines[b.row] = line[:off] + s + line[off:]
b.col += utf8.RuneCountInString(s)
}
// backspace deletes the rune immediately left of the cursor on the current
// line. At column 0 it merges the current line into the previous one (the
// cursor landing at the merge point), unless already on the first line, where
// it is a no-op. Multi-byte runes are removed whole.
func (b *mlBuffer) backspace() {
if b.col == 0 {
if b.row == 0 {
return
}
prev := b.lines[b.row-1]
b.col = utf8.RuneCountInString(prev)
b.lines[b.row-1] = prev + b.lines[b.row]
b.lines = append(b.lines[:b.row], b.lines[b.row+1:]...)
b.row--
return
}
line := b.lines[b.row]
start := runeOffset(line, b.col-1)
end := runeOffset(line, b.col)
b.lines[b.row] = line[:start] + line[end:]
b.col--
}
// newline splits the current line at the cursor, moving the text right of the
// cursor onto a fresh line below and placing the cursor at its start. It is how
// Shift+Enter (and, later, backslash continuation) turn one line into two.
func (b *mlBuffer) newline() {
line := b.lines[b.row]
off := runeOffset(line, b.col)
head, tail := line[:off], line[off:]
rest := append([]string{}, b.lines[b.row+1:]...)
b.lines = append(b.lines[:b.row], head, tail)
b.lines = append(b.lines, rest...)
b.row++
b.col = 0
}
// left moves the cursor one rune left, crossing to the end of the previous
// line when already at column 0. It is a no-op at the buffer origin.
func (b *mlBuffer) left() {
if b.col > 0 {
b.col--
} else if b.row > 0 {
b.row--
b.col = utf8.RuneCountInString(b.lines[b.row])
}
}
// right moves the cursor one rune right, crossing to the start of the next
// line when already at the end of the current line. It is a no-op at the end
// of the last line.
func (b *mlBuffer) right() {
if b.col < utf8.RuneCountInString(b.lines[b.row]) {
b.col++
} else if b.row < len(b.lines)-1 {
b.row++
b.col = 0
}
}
// up moves the cursor to the previous line, clamping the column to that line's
// length. It is a no-op on the first line.
func (b *mlBuffer) up() {
if b.row > 0 {
b.row--
if n := utf8.RuneCountInString(b.lines[b.row]); b.col > n {
b.col = n
}
}
}
// down moves the cursor to the next line, clamping the column to that line's
// length. It is a no-op on the last line.
func (b *mlBuffer) down() {
if b.row < len(b.lines)-1 {
b.row++
if n := utf8.RuneCountInString(b.lines[b.row]); b.col > n {
b.col = n
}
}
}
// home moves the cursor to the start of the current line.
func (b *mlBuffer) home() { b.col = 0 }
// end moves the cursor to the end of the current line.
func (b *mlBuffer) end() { b.col = utf8.RuneCountInString(b.lines[b.row]) }
// enterContinues collapses the current line's trailing backslash run and
// reports whether a pressed Enter should continue onto a new line instead of
// submitting. A run of k trailing backslashes pairs up as k/2 literal
// backslashes (each "\\" → one "\"); an odd run has one extra backslash that
// escapes the newline, so the caller inserts a continuation line. The run is
// always collapsed to k/2 backslashes, so the escaping "\" never survives into
// submitted text.
func (b *mlBuffer) enterContinues() bool {
line := b.lines[b.row]
k := 0
for i := len(line) - 1; i >= 0 && line[i] == '\\'; i-- {
k++
}
if k == 0 {
return false
}
b.lines[b.row] = line[:len(line)-k] + strings.Repeat("\\", k/2)
if n := utf8.RuneCountInString(b.lines[b.row]); b.col > n {
b.col = n
}
return k%2 == 1
}
// visibleWidth returns the number of terminal columns s occupies, skipping ANSI
// CSI escape sequences so a colored prompt still aligns its continuation lines.
// Wide runes (CJK ideographs, fullwidth forms, most emoji) count as two columns.
func visibleWidth(s string) int {
w := 0
for i := 0; i < len(s); {
if s[i] == 0x1b {
i++
if i < len(s) && s[i] == '[' {
i++
for i < len(s) && !(s[i] >= 0x40 && s[i] <= 0x7e) {
i++
}
}
if i < len(s) {
i++
}
continue
}
r, size := utf8.DecodeRuneInString(s[i:])
i += size
w += runeWidth(r)
}
return w
}
// displayWidth returns the number of terminal columns the plain string s
// occupies, summing each rune's cell width. Unlike visibleWidth it does not
// strip ANSI escapes — callers pass already-plain buffer text.
func displayWidth(s string) int {
w := 0
for _, r := range s {
w += runeWidth(r)
}
return w
}
// runeWidth reports how many terminal cells a rune occupies: 0 for combining /
// zero-width marks, 2 for East Asian wide and fullwidth characters (and most
// emoji), 1 otherwise. This is what keeps the cursor aligned when the line
// contains CJK text, where one rune spans two columns.
func runeWidth(r rune) int {
switch {
case r == 0:
return 0
case (r >= 0x0300 && r <= 0x036F), // combining diacritical marks
(r >= 0x1AB0 && r <= 0x1AFF), // combining diacritical marks extended
(r >= 0x1DC0 && r <= 0x1DFF), // combining diacritical marks supplement
(r >= 0x20D0 && r <= 0x20FF), // combining marks for symbols
(r >= 0xFE20 && r <= 0xFE2F), // combining half marks
r == 0x200B: // zero width space
return 0
case (r >= 0x1100 && r <= 0x115F), // Hangul Jamo
(r >= 0x2E80 && r <= 0x303E), // CJK radicals, Kangxi, CJK symbols
(r >= 0x3041 && r <= 0x33FF), // Hiragana, Katakana, CJK compat
(r >= 0x3400 && r <= 0x4DBF), // CJK Ext A
(r >= 0x4E00 && r <= 0x9FFF), // CJK Unified Ideographs
(r >= 0xA000 && r <= 0xA4CF), // Yi
(r >= 0xAC00 && r <= 0xD7A3), // Hangul syllables
(r >= 0xF900 && r <= 0xFAFF), // CJK compat ideographs
(r >= 0xFE10 && r <= 0xFE19), // vertical forms
(r >= 0xFE30 && r <= 0xFE6F), // CJK compat forms
(r >= 0xFF00 && r <= 0xFF60), // fullwidth forms
(r >= 0xFFE0 && r <= 0xFFE6), // fullwidth signs
(r >= 0x1F300 && r <= 0x1FAFF), // emoji & pictographs
(r >= 0x20000 && r <= 0x3FFFD): // CJK Ext B and beyond
return 2
default:
return 1
}
}
// runeOffset converts a rune column into a byte offset within s.
func runeOffset(s string, col int) int {
off := 0
for i := 0; i < col && off < len(s); i++ {
_, size := utf8.DecodeRuneInString(s[off:])
off += size
}
return off
}
// replLineEditor adds a small shell-style editing layer without turning the
// line-oriented REPL back into a full-screen TUI. On terminals it shows the
// best completion in dim text as the user types. Pipes and tests keep using the
// ordinary buffered reader.
type replLineEditor struct {
in *bufio.Reader
terminal *os.File
out io.Writer
slash *runtime.SlashRegistry
history []string // oldest to newest
models []string
}
func newREPLLineEditor(in io.Reader, buffered *bufio.Reader, out io.Writer, slash *runtime.SlashRegistry, history []string) *replLineEditor {
e := &replLineEditor{in: buffered, out: out, slash: slash}
if f, ok := in.(*os.File); ok {
if info, err := f.Stat(); err == nil && info.Mode()&os.ModeCharDevice != 0 {
e.terminal = f
}
}
for _, h := range history {
e.remember(h)
}
seen := map[string]bool{}
for _, m := range provider.PresetCatalog {
if !seen[m.ID] {
e.models = append(e.models, m.ID)
seen[m.ID] = true
}
}
return e
}
func (e *replLineEditor) remember(line string) {
line = strings.TrimSpace(line)
if line == "" {
return
}
e.history = append(e.history, line)
if len(e.history) > 200 {
e.history = e.history[len(e.history)-200:]
}
}
// formatSlashAutocompleteLabel renders a slash command for the Tab-completion
// hint as "name <argument-hint> - description" (mirrors pi's autocomplete). The
// argument-hint is shown verbatim (frontmatter supplies its own <angle>/
// [square] brackets); it and the description are omitted when absent, so a
// bare command renders as just its name.
func formatSlashAutocompleteLabel(cmd runtime.SlashCommand) string {
label := cmd.Name
if cmd.ArgumentHint != "" {
label += " " + cmd.ArgumentHint
}
if cmd.Description != "" {
label += " - " + cmd.Description
}
return label
}
// suggestion returns the single best completion for input, or "" when there is
// none. It is the head of the ordered candidate list (see suggestions).
func (e *replLineEditor) suggestion(input string) string {
if cands := e.suggestions(input); len(cands) > 0 {
return cands[0]
}
return ""
}
// suggestions returns every completion candidate for input, best first, so the
// caller can cycle through them with the arrow keys. Candidates are gathered in
// priority order — slash commands, then recent inputs, then the /model catalog
// — deduplicated, with the raw input itself excluded.
func (e *replLineEditor) suggestions(input string) []string {
if input == "" {
return nil
}
lower := strings.ToLower(input)
var out []string
seen := map[string]bool{}
add := func(s string) {
if s == "" || s == input || seen[s] {
return
}
seen[s] = true
out = append(out, s)
}
if strings.HasPrefix(input, "/") && !strings.ContainsAny(input, " \t") {
var commands []string
for _, cmd := range e.slash.List() {
commands = append(commands, "/"+cmd.Name)
}
sort.Strings(commands)
for _, cmd := range commands {
if strings.HasPrefix(strings.ToLower(cmd), lower) {
add(cmd)
}
}
}
for i := len(e.history) - 1; i >= 0; i-- {
if strings.HasPrefix(strings.ToLower(e.history[i]), lower) {
add(e.history[i])
}
}
if strings.HasPrefix(lower, "/model ") {
query := strings.TrimSpace(input[len("/model "):])
for i := len(e.history) - 1; i >= 0; i-- {
h := e.history[i]
if !strings.HasPrefix(h, "/model ") {
continue
}
id := strings.TrimSpace(h[len("/model "):])
if query == "" || modelMatches(id, query) {
add(h)
}
}
for _, id := range e.models {
if query == "" || modelMatches(id, query) {
add("/model " + id)
}
}
}
return out
}
func modelMatches(id, query string) bool {
id, query = strings.ToLower(id), strings.ToLower(query)
if strings.HasPrefix(id, query) {
return true
}
if slash := strings.LastIndexByte(id, '/'); slash >= 0 {
return strings.HasPrefix(id[slash+1:], query)
}
return false
}
// parseCSIParams splits a CSI-u parameter list ("<code>[;<mod>]") into the key
// code and modifier. A missing modifier defaults to 1 (no modifier).
func parseCSIParams(params []byte) (code, mod int) {
parts := strings.Split(string(params), ";")
code = atoiDefault(parts[0], 0)
mod = 1
if len(parts) > 1 {
mod = atoiDefault(parts[1], 1)
}
return code, mod
}
func atoiDefault(s string, def int) int {
if n, err := strconv.Atoi(s); err == nil {
return n
}
return def
}
func (e *replLineEditor) ReadLine(prompt string) (string, error) {
if e.terminal == nil {
fmt.Fprint(e.out, prompt)
return e.in.ReadString('\n')
}
probe := exec.Command("stty", "-g")
probe.Stdin = e.terminal
state, err := probe.CombinedOutput()
if err != nil {
fmt.Fprint(e.out, prompt)
return e.in.ReadString('\n')
}
raw := exec.Command("stty", "raw", "-echo")
raw.Stdin = e.terminal
if err := raw.Run(); err != nil {
fmt.Fprint(e.out, prompt)
return e.in.ReadString('\n')
}
// Ask the terminal to report modified keys so Shift+Enter is distinguishable
// from a bare Enter: enable xterm modifyOtherKeys level 1 and push a CSI-u
// (fixterms/kitty) keyboard mode. Level 1 (not 2) reports only keys without
// a standard encoding — so Shift+Enter is escaped while ordinary Tab/Enter
// stay untouched. Terminals that ignore these simply never send the reports,
// and the user falls back to backslash continuation.
fmt.Fprint(e.out, "\x1b[>4;1m\x1b[>1u")
defer func() {
// Restore the terminal's key reporting before the stty state, so we
// never leave it stuck in CSI-u/modifyOtherKeys mode on any exit path.
fmt.Fprint(e.out, "\x1b[<u\x1b[>4;0m")
restore := exec.Command("stty", strings.TrimSpace(string(state)))
restore.Stdin = e.terminal
_ = restore.Run()
}()
return e.editLoop(prompt)
}
// editLoop runs the raw-mode key-processing loop over e.in, kept separate from
// readLine's terminal setup so it can be driven by a programmable io.Reader in
// tests (no real TTY required). It returns the submitted text (lines joined by
// "\n") or an error.
func (e *replLineEditor) editLoop(prompt string) (string, error) {
// buf models the input as a multi-line buffer with a cursor. For this
// single-line editing layer the cursor stays at the end of the sole line,
// so buf behaves exactly like the former input string; the buffer model is
// what later cross-line editing/rendering is built on.
buf := newMLBuffer()
// selected indexes into the current candidate list. It advances with the
// up/down arrows so the user can cycle through suggestions; it resets to 0
// (the best match) whenever the input text changes, since the candidate list
// is recomputed from scratch.
selected := 0
// histNav tracks the position while browsing prior inputs with the arrow
// keys on a blank line: -1 means not browsing, otherwise it indexes into
// e.history (oldest to newest). It resets to -1 whenever the user edits the
// line, so history browsing is only active while stepping through entries.
histNav := -1
// visible returns the suggestion currently shown/accepted: the candidate at
// the selected index, clamped to the available list.
visible := func() string {
cands := e.suggestions(buf.String())
if len(cands) == 0 {
return ""
}
if selected >= len(cands) {
selected = len(cands) - 1
}
if selected < 0 {
selected = 0
}
return cands[selected]
}
// promptW is the prompt's visible width; continuation lines are indented to
// that column so every line's text starts at the same place, with a dim
// marker standing in for the prompt.
promptW := visibleWidth(prompt)
contPrefix := strings.Repeat(" ", promptW)
if promptW >= 2 {
contPrefix = strings.Repeat(" ", promptW-2) + "\033[2m·\033[0m "
}
// prevCursorRow is the screen row (relative to the block's first line) the
// cursor was left on by the previous render, so the next render can climb
// back to the top of the block before clearing and redrawing it.
prevCursorRow := 0
render := func() {
// Return to the top-left of the block drawn last time and clear it plus
// anything below, so shrinking the buffer leaves no stale rows/chars.
if prevCursorRow > 0 {
fmt.Fprintf(e.out, "\033[%dA", prevCursorRow)
}
fmt.Fprint(e.out, "\r\033[J")
for i, line := range buf.lines {
if i == 0 {
fmt.Fprintf(e.out, "%s%s", prompt, line)
} else {
fmt.Fprintf(e.out, "\r\n%s%s", contPrefix, line)
}
}
// The dim completion hint only fits on a single line with the cursor at
// its end, where it can't collide with continuation rows.
if buf.single() && buf.col == utf8.RuneCountInString(buf.lines[0]) {
if s := visible(); s != "" {
input := buf.lines[0]
if strings.HasPrefix(s, "/") {
// Slash command: render the argument-hint + description label
// (mirrors pi's autocomplete) instead of the bare name suffix.
label := s
if cmd, ok := e.slash.Lookup(s[1:]); ok {
label = formatSlashAutocompleteLabel(cmd)
}
fmt.Fprintf(e.out, "\033[2m -> %s\033[0m", label)
} else if strings.HasPrefix(s, input) {
fmt.Fprintf(e.out, "\033[2m%s\033[0m", s[len(input):])
} else {
fmt.Fprintf(e.out, "\033[2m → %s\033[0m", s)
}
}
}
// Reposition to the logical (row, col): after the draw the cursor sits
// at the end of the last line, so climb to the target row, then step
// right past the prefix and the display width of the runes left of the
// cursor (wide CJK runes span two columns, so count cells, not runes).
if up := len(buf.lines) - 1 - buf.row; up > 0 {
fmt.Fprintf(e.out, "\033[%dA", up)
}
fmt.Fprint(e.out, "\r")
curLine := buf.lines[buf.row]
cursorCells := displayWidth(curLine[:runeOffset(curLine, buf.col)])
if col := promptW + cursorCells; col > 0 {
fmt.Fprintf(e.out, "\033[%dC", col)
}
prevCursorRow = buf.row
}
// tryEnter handles a pressed Enter shared by the raw, CSI-u, and
// modifyOtherKeys report paths: if the current line ends with an unescaped
// backslash it continues onto a new line and reports submitted=false;
// otherwise it emits the newline and reports the block as submitted.
tryEnter := func() (string, bool) {
if buf.enterContinues() {
buf.end()
buf.newline()
selected = 0
histNav = -1
return "", false
}
// Move below the whole rendered block before the newline so the
// submitted lines stay on screen and the next prompt starts clean.
if down := len(buf.lines) - 1 - buf.row; down > 0 {
fmt.Fprintf(e.out, "\033[%dB", down)
}
fmt.Fprint(e.out, "\r\n")
return buf.String(), true
}
render()
for {
b, err := e.in.ReadByte()
if err != nil {
return buf.String(), err
}
switch b {
case '\r', '\n':
if res, done := tryEnter(); done {
return res, nil
}
case 1: // Ctrl+A moves to line start.
buf.home()
case 5: // Ctrl+E moves to line end.
buf.end()
case 3: // Ctrl+C
fmt.Fprint(e.out, "^C\r\n")
return "", errLineInterrupted
case 4: // Ctrl+D
if buf.isEmpty() {
fmt.Fprint(e.out, "\r\n")
return "", io.EOF
}
case 9: // Tab accepts the visible suggestion.
if s := visible(); s != "" {
buf.setString(s)
selected = 0
histNav = -1
}
case 8, 127:
buf.backspace()
selected = 0
histNav = -1
case 27:
// Parse a full CSI sequence so multi-parameter reports (CSI-u key
// events like Shift+Enter's \x1b[13;2u) are handled, not just the
// bare arrow sequences. → accepts the visible suggestion, ↑/↓ cycle
// candidates or browse history on a blank line, and Enter reports
// (code 13) either submit or insert a newline depending on the
// modifier. Any other sequence is consumed and ignored so it never
// leaks into the submitted text.
b2, escErr := e.in.ReadByte()
if escErr != nil {
return buf.String(), escErr
}
if b2 == '[' {
var params []byte
var final byte
for {
c, cErr := e.in.ReadByte()
if cErr != nil {
return buf.String(), cErr
}
if c >= 0x40 && c <= 0x7e {
final = c
break
}
params = append(params, c)
}
switch final {
case 'u': // CSI-u key report: "<code>[;<mod>]u".
code, mod := parseCSIParams(params)
ctrl := (mod-1)&4 != 0
switch {
case code == 13:
if mod >= 2 {
buf.newline()
selected = 0
histNav = -1
} else if res, done := tryEnter(); done {
return res, nil
}
case ctrl && code == 'd':
// Ctrl+D: EOF on an empty line. Under the kitty keyboard
// protocol (enabled via \x1b[>1u) the terminal reports it here
// as a CSI-u event, not the raw 0x04 byte the case-4 arm handles.
if buf.isEmpty() {
fmt.Fprint(e.out, "\r\n")
return "", io.EOF
}
case ctrl && code == 'c':
// Ctrl+C: same story — delivered as a CSI-u report rather than
// the raw 0x03 byte once the kitty keyboard mode is active.
fmt.Fprint(e.out, "^C\r\n")
return "", errLineInterrupted
}
case '~': // modifyOtherKeys ("27;<mod>;<code>~") or Home/End ("1~"/"4~").
parts := strings.Split(string(params), ";")
if len(parts) == 3 && atoiDefault(parts[0], -1) == 27 {
mod := atoiDefault(parts[1], 1)
code := atoiDefault(parts[2], 0)
if code == 13 {
if mod >= 2 {
buf.newline()
selected = 0
histNav = -1
} else if res, done := tryEnter(); done {
return res, nil
}
}
} else if len(parts) == 1 {
switch atoiDefault(parts[0], -1) {
case 1, 7: // Home
buf.home()
case 4, 8: // End
buf.end()
}
}
case 'C': // right arrow: accept a visible suggestion, else move the cursor
if len(params) == 0 {
if s := visible(); s != "" {
buf.setString(s)
selected = 0
histNav = -1
} else {
buf.right()
}
}
case 'D': // left arrow moves the cursor (cross-line at column 0)
if len(params) == 0 {
buf.left()
}
case 'H': // Home
if len(params) == 0 {
buf.home()
}
case 'F': // End
if len(params) == 0 {
buf.end()
}
case 'A': // up arrow
if len(params) == 0 {
if !buf.single() {
buf.up()
} else if buf.isEmpty() || histNav >= 0 {
// Browse history: step toward older entries.
if histNav < 0 {
histNav = len(e.history)
}
if histNav > 0 {
histNav--
buf.setString(e.history[histNav])
selected = 0
}
} else if n := len(e.suggestions(buf.String())); n > 0 {
selected = (selected - 1 + n) % n
}
}
case 'B': // down arrow
if len(params) == 0 {
if !buf.single() {
buf.down()
} else if histNav >= 0 {
// Browse history: step toward newer entries; past the
// newest, return to a blank line.
if histNav < len(e.history)-1 {
histNav++
buf.setString(e.history[histNav])
} else {
histNav = -1
buf.setString("")
}
selected = 0
} else if n := len(e.suggestions(buf.String())); n > 0 {
selected = (selected + 1) % n
}
}
}
}
default:
bytes := []byte{b}
want := 1
switch {
case b&0xe0 == 0xc0:
want = 2
case b&0xf0 == 0xe0:
want = 3
case b&0xf8 == 0xf0:
want = 4
}
for len(bytes) < want {
next, readErr := e.in.ReadByte()
if readErr != nil {
return buf.String(), readErr
}
bytes = append(bytes, next)
}
buf.insert(string(bytes))
selected = 0
histNav = -1
}
render()
}
}