enabled multi-line paste

This commit is contained in:
Luxferre
2026-09-24 14:38:57 +03:00
parent 3b5a8a5bfd
commit 4eee5d6ef9
3 changed files with 335 additions and 138 deletions
+195 -137
View File
@@ -2230,13 +2230,14 @@ func visibleLen(s string) int {
}
type editor struct {
prompt string
buf []rune
pos int
hist []string
hpos int
draft string
crow int
prompt string
buf []rune
pos int
hist []string
hpos int
draft string
crow int
pasting bool // true while inside a bracketed paste (ESC[200~ .. ESC[201~)
}
func textPos(promptLen, W int, s string, pos int) (row, col int) {
@@ -2367,6 +2368,181 @@ func readPlain(prompt string) (string, bool) {
return strings.TrimRight(line, "\r\n"), true
}
// keyKind classifies what readLine should do after handling one input rune.
type keyKind int
const (
keyNone keyKind = iota // keep editing
keySubmit // submit the current buffer
keyAbort // interrupt (Ctrl+C)
keyEOF // end of input (Ctrl+D on an empty buffer)
)
// insert places r at the cursor, shifting the tail right.
func (e *editor) insert(r rune) {
e.buf = append(e.buf, 0)
copy(e.buf[e.pos+1:], e.buf[e.pos:])
e.buf[e.pos] = r
e.pos++
}
// handleCSI interprets a complete CSI sequence (the bytes between ESC[ and its
// final byte): cursor movement, history and bracketed-paste markers.
func (e *editor) handleCSI(seq []byte) {
if len(seq) == 0 {
return
}
switch seq[len(seq)-1] {
case 'A':
e.histNav(true)
case 'B':
e.histNav(false)
case 'C':
if strings.Contains(string(seq), "5") {
e.wordFwd() // Ctrl+Right
} else if e.pos < len(e.buf) {
e.pos++
}
case 'D':
if strings.Contains(string(seq), "5") {
e.wordBack() // Ctrl+Left
} else if e.pos > 0 {
e.pos--
}
case 'H':
e.pos = 0 // Home (ESC[H)
case 'F':
e.pos = len(e.buf) // End (ESC[F)
case '~':
s := string(seq)
if strings.HasSuffix(s, "200~") {
e.pasting = true // bracketed paste start: ESC[200~
} else if strings.HasSuffix(s, "201~") {
e.pasting = false // bracketed paste end: ESC[201~
} else if seq[0] == '1' {
e.pos = 0 // Home (ESC[1~)
} else if seq[0] == '4' {
e.pos = len(e.buf) // End (ESC[4~)
}
}
}
// swallowLF consumes a single buffered LF so CRLF input is not counted as two
// separate line breaks.
func (e *editor) swallowLF(r *bufio.Reader) {
if r.Buffered() > 0 {
if b, _ := r.Peek(1); len(b) > 0 && b[0] == '\n' {
r.ReadByte()
}
}
}
// feed processes one decoded key. r supplies the remainder of an escape
// sequence. It returns the action the REPL loop must take next. Keeping the key
// logic here, rather than inline in readLine, makes multi-line paste handling
// unit-testable without a real terminal.
func (e *editor) feed(rn rune, r *bufio.Reader) keyKind {
switch rn {
case '\r':
// Inside a bracketed paste a CR is a literal line break; only a real
// Enter (outside a paste) submits the prompt.
e.swallowLF(r)
if e.pasting {
e.insert('\n')
return keyNone
}
return keySubmit
case '\n':
e.insert('\n')
case 0x03:
if e.pasting {
return keyNone // ignore a stray ^C inside a pasted block
}
return keyAbort
case 0x04:
if len(e.buf) == 0 {
return keyEOF
}
// Ctrl+D with non-empty buffer: delete the character under the cursor
if e.pos < len(e.buf) {
e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...)
}
case 0x01:
e.pos = 0 // Ctrl+A: beginning of line
case 0x05:
e.pos = len(e.buf) // Ctrl+E: end of line
case 0x02:
if e.pos > 0 {
e.pos--
} // Ctrl+B: backward char
case 0x06:
if e.pos < len(e.buf) {
e.pos++
} // Ctrl+F: forward char
case 0x17:
e.delWordBack() // Ctrl+W: delete previous word
case 0x0b:
e.buf = e.buf[:e.pos] // Ctrl+K: kill to end of line
case 0x15:
e.buf = e.buf[e.pos:] // Ctrl+U: kill to start of line
e.pos = 0
case 0x7f, 0x08:
if e.pos > 0 {
e.buf = append(e.buf[:e.pos-1], e.buf[e.pos:]...)
e.pos--
}
case 0x1b:
b1, err := r.ReadByte()
if err != nil {
return keyNone
}
if b1 == '[' {
// Read the full CSI sequence (terminated by a byte in 0x40-0x7e).
var seq []byte
for {
b, err := r.ReadByte()
if err != nil {
break
}
seq = append(seq, b)
if b >= 0x40 && b <= 0x7e {
break
}
}
e.handleCSI(seq)
} else if b1 == 'O' {
// xterm application cursor keys: ESC O H/F (Home/End), A-D (arrows)
b2, err2 := r.ReadByte()
if err2 != nil {
return keyNone
}
switch b2 {
case 'H':
e.pos = 0
case 'F':
e.pos = len(e.buf)
case 'A':
e.histNav(true)
case 'B':
e.histNav(false)
case 'C':
if e.pos < len(e.buf) {
e.pos++
}
case 'D':
if e.pos > 0 {
e.pos--
}
}
}
default:
if rn >= 32 || rn == '\t' {
e.insert(rn)
}
}
return keyNone
}
func readLine(prompt string) (string, bool) {
if !isTerminal(int(os.Stdin.Fd())) {
return readPlain(prompt)
@@ -2376,6 +2552,11 @@ func readLine(prompt string) (string, bool) {
return readPlain(prompt)
}
defer restore()
// Enable bracketed paste for the duration of editing: pasted blocks then
// arrive wrapped in ESC[200~ .. ESC[201~, letting feed() treat their
// embedded newlines as literal line breaks instead of submitting mid-paste.
fmt.Print("\033[?2004h")
defer fmt.Print("\033[?2004l")
e := &editor{prompt: prompt, hpos: -1, hist: hist}
for {
e.draw()
@@ -2384,13 +2565,8 @@ func readLine(prompt string) (string, bool) {
fmt.Print("\r\n")
return "", false
}
switch rn {
case '\r':
if stdin.Buffered() > 0 {
if b, _ := stdin.Peek(1); len(b) > 0 && b[0] == '\n' {
stdin.ReadByte()
}
}
switch e.feed(rn, stdin) {
case keySubmit:
W := termWidth()
s := string(e.buf)
P := visibleLen(e.prompt)
@@ -2401,130 +2577,12 @@ func readLine(prompt string) (string, bool) {
}
fmt.Print("\r\n")
return string(e.buf), true
case '\n':
e.buf = append(e.buf, 0)
copy(e.buf[e.pos+1:], e.buf[e.pos:])
e.buf[e.pos] = '\n'
e.pos++
case 0x03:
case keyAbort:
fmt.Print("^C\r\n")
return "", true
case 0x04:
if len(e.buf) == 0 {
fmt.Print("\r\n")
return "", false
}
// Ctrl+D with non-empty buffer: delete character under cursor
if e.pos < len(e.buf) {
e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...)
}
case 0x01:
e.pos = 0 // Ctrl+A: beginning of line
case 0x05:
e.pos = len(e.buf) // Ctrl+E: end of line
case 0x02:
if e.pos > 0 {
e.pos--
} // Ctrl+B: backward char
case 0x06:
if e.pos < len(e.buf) {
e.pos++
} // Ctrl+F: forward char
case 0x17:
e.delWordBack() // Ctrl+W: delete previous word
case 0x0b:
e.buf = e.buf[:e.pos] // Ctrl+K: kill to end of line
case 0x15:
e.buf = e.buf[e.pos:] // Ctrl+U: kill to start of line
e.pos = 0
case 0x7f, 0x08:
if e.pos > 0 {
e.buf = append(e.buf[:e.pos-1], e.buf[e.pos:]...)
e.pos--
}
case 0x1b:
b1, err1 := stdin.ReadByte()
if err1 != nil {
continue
}
if b1 == '[' {
// Read the full CSI sequence (terminated by a byte in 0x40-0x7e).
var seq []byte
for {
b, err := stdin.ReadByte()
if err != nil {
break
}
seq = append(seq, b)
if b >= 0x40 && b <= 0x7e {
break
}
}
if len(seq) == 0 {
continue
}
last := seq[len(seq)-1]
switch last {
case 'A':
e.histNav(true)
case 'B':
e.histNav(false)
case 'C':
if strings.Contains(string(seq), "5") {
e.wordFwd() // Ctrl+Right
} else if e.pos < len(e.buf) {
e.pos++
}
case 'D':
if strings.Contains(string(seq), "5") {
e.wordBack() // Ctrl+Left
} else if e.pos > 0 {
e.pos--
}
case 'H':
e.pos = 0 // Home (ESC[H)
case 'F':
e.pos = len(e.buf) // End (ESC[F)
case '~':
// Home/End on some terminals: ESC[1~ / ESC[4~
if len(seq) >= 1 && seq[0] == '1' {
e.pos = 0
} else if len(seq) >= 1 && seq[0] == '4' {
e.pos = len(e.buf)
}
}
} else if b1 == 'O' {
// xterm application cursor keys: ESC O H/F (Home/End), A-D (arrows)
b2, err2 := stdin.ReadByte()
if err2 != nil {
continue
}
switch b2 {
case 'H':
e.pos = 0
case 'F':
e.pos = len(e.buf)
case 'A':
e.histNav(true)
case 'B':
e.histNav(false)
case 'C':
if e.pos < len(e.buf) {
e.pos++
}
case 'D':
if e.pos > 0 {
e.pos--
}
}
}
default:
if rn >= 32 {
e.buf = append(e.buf, 0)
copy(e.buf[e.pos+1:], e.buf[e.pos:])
e.buf[e.pos] = rn
e.pos++
}
case keyEOF:
fmt.Print("\r\n")
return "", false
}
}
}
@@ -2717,7 +2775,7 @@ func main() {
if re == "" {
re = "high"
}
fmt.Println(c("Bantam Agent ready", 1, 32) + c(" (Ctrl+J = new line)", 2))
fmt.Println(c("Bantam Agent ready", 1, 32) + c(" (Enter submits; Ctrl+J or paste = new line)", 2))
fmt.Println(c(fmt.Sprintf("endpoint: %s model: %s temp: %v reasoning: %s context: %d", cfg.Endpoint, cfg.Model, cfg.Temperature, re, cfg.ContextWindow), 2))
for {
u, ok := readLine(c("> ", 1, 36))