767 lines
24 KiB
Go
767 lines
24 KiB
Go
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()
|
|
}
|
|
}
|