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
+1 -1
View File
@@ -55,7 +55,7 @@ All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if
./mb # MicroBantam (Perl 5) ./mb # MicroBantam (Perl 5)
``` ```
In interactive mode, prompts can span multiple lines: press **Ctrl+J** to insert a real line break (the cursor moves to the next line), then **Enter** to submit the whole multi-line prompt. The Go port ships its own raw-mode line editor, working everywhere without third-party dependencies. It supports arrow keys and Up/Down for history, Backspace and Ctrl+D (non-empty line) to delete, and Emacs-style editing combos: **Ctrl+A** (start of line), **Ctrl+E** (end of line), **Ctrl+B** / **Ctrl+F** (move by character), **Ctrl+W** (delete previous word), **Ctrl+K** (kill to end of line), **Ctrl+U** (kill to start of line), **Ctrl+Left** / **Ctrl+Right** (move by word), and **Home** / **End** keys. **Ctrl+C** clears the line / interrupts an in-flight run, and **Ctrl+D** on an empty line exits. In interactive mode, prompts can span multiple lines: press **Ctrl+J** to insert a real line break (the cursor moves to the next line), then **Enter** to submit the whole multi-line prompt. Bracketed paste is enabled (`ESC[?2004h`) while editing, so a multi-line block pasted from the clipboard is inserted as literal line breaks and only the final **Enter** submits it. The Go port ships its own raw-mode line editor, working everywhere without third-party dependencies. It supports arrow keys and Up/Down for history, Backspace and Ctrl+D (non-empty line) to delete, and Emacs-style editing combos: **Ctrl+A** (start of line), **Ctrl+E** (end of line), **Ctrl+B** / **Ctrl+F** (move by character), **Ctrl+W** (delete previous word), **Ctrl+K** (kill to end of line), **Ctrl+U** (kill to start of line), **Ctrl+Left** / **Ctrl+Right** (move by word), and **Home** / **End** keys. **Ctrl+C** clears the line / interrupts an in-flight run, and **Ctrl+D** on an empty line exits.
After every interaction, Bantam displays token usage (prompt tokens, cached/uncached breakdown when supported by the provider, completion tokens, and context window utilization): After every interaction, Bantam displays token usage (prompt tokens, cached/uncached breakdown when supported by the provider, completion tokens, and context window utilization):
```text ```text
+195 -137
View File
@@ -2230,13 +2230,14 @@ func visibleLen(s string) int {
} }
type editor struct { type editor struct {
prompt string prompt string
buf []rune buf []rune
pos int pos int
hist []string hist []string
hpos int hpos int
draft string draft string
crow int 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) { 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 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) { func readLine(prompt string) (string, bool) {
if !isTerminal(int(os.Stdin.Fd())) { if !isTerminal(int(os.Stdin.Fd())) {
return readPlain(prompt) return readPlain(prompt)
@@ -2376,6 +2552,11 @@ func readLine(prompt string) (string, bool) {
return readPlain(prompt) return readPlain(prompt)
} }
defer restore() 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} e := &editor{prompt: prompt, hpos: -1, hist: hist}
for { for {
e.draw() e.draw()
@@ -2384,13 +2565,8 @@ func readLine(prompt string) (string, bool) {
fmt.Print("\r\n") fmt.Print("\r\n")
return "", false return "", false
} }
switch rn { switch e.feed(rn, stdin) {
case '\r': case keySubmit:
if stdin.Buffered() > 0 {
if b, _ := stdin.Peek(1); len(b) > 0 && b[0] == '\n' {
stdin.ReadByte()
}
}
W := termWidth() W := termWidth()
s := string(e.buf) s := string(e.buf)
P := visibleLen(e.prompt) P := visibleLen(e.prompt)
@@ -2401,130 +2577,12 @@ func readLine(prompt string) (string, bool) {
} }
fmt.Print("\r\n") fmt.Print("\r\n")
return string(e.buf), true return string(e.buf), true
case '\n': case keyAbort:
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:
fmt.Print("^C\r\n") fmt.Print("^C\r\n")
return "", true return "", true
case 0x04: case keyEOF:
if len(e.buf) == 0 { fmt.Print("\r\n")
fmt.Print("\r\n") return "", false
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++
}
} }
} }
} }
@@ -2717,7 +2775,7 @@ func main() {
if re == "" { if re == "" {
re = "high" 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)) 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 { for {
u, ok := readLine(c("> ", 1, 36)) u, ok := readLine(c("> ", 1, 36))
+139
View File
@@ -3477,3 +3477,142 @@ func TestOffloadToolResultUsesReadPrompt(t *testing.T) {
cleanupTemps(tmps) cleanupTemps(tmps)
} }
// ---------- editor.feed (bracketed paste / key handling) ----------
// feedAll decodes every rune in s through e.feed, returning the resulting
// actions. It uses a real bufio.Reader so escape sequences are consumed
// exactly as they would be in the REPL loop.
func feedAll(e *editor, s string) []keyKind {
r := bufio.NewReader(strings.NewReader(s))
var kinds []keyKind
for {
rn, _, err := r.ReadRune()
if err != nil {
break
}
kinds = append(kinds, e.feed(rn, r))
}
return kinds
}
// TestFeedBracketedPasteInsertsNewlines verifies that a block pasted between
// ESC[200~ and ESC[201~ becomes literal line breaks in the buffer and that only
// the final Enter (outside the paste) submits the prompt.
func TestFeedBracketedPasteInsertsNewlines(t *testing.T) {
e := &editor{hpos: -1}
kinds := feedAll(e, "\x1b[200~line1\nline2\nline3\x1b[201~\r")
if got := string(e.buf); got != "line1\nline2\nline3" {
t.Fatalf("buf = %q, want %q", got, "line1\nline2\nline3")
}
subs := 0
for _, k := range kinds {
if k == keySubmit {
subs++
}
}
if subs != 1 {
t.Fatalf("keySubmit count = %d, want 1 (paste must not trigger submission)", subs)
}
if kinds[len(kinds)-1] != keySubmit {
t.Fatalf("last action = %v, want keySubmit", kinds[len(kinds)-1])
}
}
// TestFeedCRLFInsidePasteCountsOnce verifies a CRLF inside a pasted block is
// swallowed as a single line break (not two).
func TestFeedCRLFInsidePasteCountsOnce(t *testing.T) {
e := &editor{hpos: -1}
feedAll(e, "\x1b[200~a\r\nb\x1b[201~")
if got := string(e.buf); got != "a\nb" {
t.Fatalf("buf = %q, want %q", got, "a\nb")
}
}
// TestFeedEnterSubmitsOutsidePaste verifies a plain Enter outside a paste
// submits the buffer.
func TestFeedEnterSubmitsOutsidePaste(t *testing.T) {
e := &editor{hpos: -1, buf: []rune("hi"), pos: 2}
if k := e.feed('\r', bufio.NewReader(strings.NewReader(""))); k != keySubmit {
t.Fatalf("enter action = %v, want keySubmit", k)
}
if got := string(e.buf); got != "hi" {
t.Fatalf("buf = %q, want %q", got, "hi")
}
}
// TestFeedCtrlCAbortsOutsidePaste verifies Ctrl+C interrupts when not pasting.
func TestFeedCtrlCAbortsOutsidePaste(t *testing.T) {
e := &editor{hpos: -1}
if k := e.feed(0x03, bufio.NewReader(strings.NewReader(""))); k != keyAbort {
t.Fatalf("ctrl-c action = %v, want keyAbort", k)
}
}
// TestFeedCtrlCIgnoredInsidePaste verifies a stray ^C byte inside a pasted block
// is ignored instead of interrupting.
func TestFeedCtrlCIgnoredInsidePaste(t *testing.T) {
e := &editor{hpos: -1, pasting: true}
if k := e.feed(0x03, bufio.NewReader(strings.NewReader(""))); k != keyNone {
t.Fatalf("ctrl-c in paste action = %v, want keyNone", k)
}
if len(e.buf) != 0 {
t.Fatalf("buf = %q, want empty", string(e.buf))
}
}
// TestFeedCtrlDEOFEmptyBuffer verifies Ctrl+D exits only on an empty buffer.
func TestFeedCtrlDEOFEmptyBuffer(t *testing.T) {
e := &editor{hpos: -1}
if k := e.feed(0x04, bufio.NewReader(strings.NewReader(""))); k != keyEOF {
t.Fatalf("ctrl-d action = %v, want keyEOF", k)
}
e2 := &editor{hpos: -1, buf: []rune("x"), pos: 1}
if k := e2.feed(0x04, bufio.NewReader(strings.NewReader(""))); k != keyNone {
t.Fatalf("ctrl-d non-empty action = %v, want keyNone", k)
}
}
// TestFeedArrowKeysAndHomeEnd verifies the CSI/SS3 cursor keys still move the
// cursor after the refactor.
func TestFeedArrowKeysAndHomeEnd(t *testing.T) {
e := &editor{hpos: -1, buf: []rune("abc"), pos: 3}
e.feed(0x1b, bufio.NewReader(strings.NewReader("[D")))
if e.pos != 2 {
t.Fatalf("after left arrow pos = %d, want 2", e.pos)
}
e.feed(0x1b, bufio.NewReader(strings.NewReader("[H")))
if e.pos != 0 {
t.Fatalf("after home pos = %d, want 0", e.pos)
}
e.feed(0x1b, bufio.NewReader(strings.NewReader("[F")))
if e.pos != 3 {
t.Fatalf("after end pos = %d, want 3", e.pos)
}
// Up arrow with history should restore the newest entry.
e.hist = []string{"first", "second"}
e.feed(0x1b, bufio.NewReader(strings.NewReader("[A")))
if got := string(e.buf); got != "second" {
t.Fatalf("after up arrow buf = %q, want %q", got, "second")
}
}
// TestFeedTabInserted verifies Tab is inserted literally (not a control no-op).
func TestFeedTabInserted(t *testing.T) {
e := &editor{hpos: -1}
e.feed('\t', bufio.NewReader(strings.NewReader("")))
if got := string(e.buf); got != "\t" {
t.Fatalf("buf = %q, want a tab", got)
}
}
// TestFeedInsertAtCursor verifies insert() splices at the cursor.
func TestFeedInsertAtCursor(t *testing.T) {
e := &editor{hpos: -1, buf: []rune("ac"), pos: 1}
e.feed('b', bufio.NewReader(strings.NewReader("")))
if got := string(e.buf); got != "abc" {
t.Fatalf("buf = %q, want %q", got, "abc")
}
if e.pos != 2 {
t.Fatalf("pos = %d, want 2", e.pos)
}
}