diff --git a/README.md b/README.md index 18471e2..6a6a751 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if ./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): ```text diff --git a/main.go b/main.go index 056c6e4..2faddcd 100644 --- a/main.go +++ b/main.go @@ -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)) diff --git a/main_test.go b/main_test.go index 6880181..4f67e47 100644 --- a/main_test.go +++ b/main_test.go @@ -3477,3 +3477,142 @@ func TestOffloadToolResultUsesReadPrompt(t *testing.T) { 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) + } +} \ No newline at end of file