1package tui
2
3import (
4 "fmt"
5 "net/url"
6 "regexp"
7 "strings"
8
9 "github.com/mattn/go-runewidth"
10)
11
12// Editor is a simple multi-line text editor for the input area.
13//
14// Users can type, paste, move the cursor, and submit. The editor
15// exposes its rendered height and the current cursor row/col for the
16// outer renderer. It does NOT draw itself directly; instead, Render()
17// returns the visible lines.
18type Editor struct {
19 // Lines is the current buffer, one entry per line.
20 Lines []string
21 CursorR int // row index into Lines
22 CursorC int // rune index into Lines[CursorR]
23 Prompt string
24 MaxWidth int
25
26 // lastRenderWidth is the column count passed to the most recent
27 // Render() call. Up/Down key handling needs this to walk the
28 // same visual layout the user sees: a logical line that wraps to
29 // two rows should respond to Up by moving to the previous visual
30 // row, not do nothing because CursorR is already 0.
31 lastRenderWidth int
32
33 // pastes stores the full content of every multi-line paste,
34 // keyed by the id embedded in the visible placeholder token.
35 // Pasted text is collapsed to "[paste #N +L lines]" in the
36 // editor so a 500-line drop doesn't explode the input area;
37 // SubmitValue() expands placeholders back to their real bodies
38 // right before the prompt goes to the agent. The map is reset
39 // on Clear() so stale pastes never leak into a follow-up turn.
40 pastes map[int]string
41 pasteSeq int
42}
43
44// NewEditor returns an empty editor with the given prompt.
45func NewEditor(prompt string) *Editor {
46 return &Editor{
47 Lines: []string{""},
48 Prompt: prompt,
49 }
50}
51
52// Value returns the buffer as a single string, WITHOUT expanding
53// paste placeholders. Used for anything that should reflect what's
54// visible on screen (history, slash-command detection, editor
55// state). For the string that actually goes to the agent, use
56// SubmitValue(), which expands each [paste #N +L lines] token
57// back into the full pasted body.
58func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") }
59
60// SubmitValue returns the buffer with every paste placeholder
61// expanded to its stored body. Call once at submit time; the
62// expansion is lossless (placeholders are only injected in
63// HandleKey for KeyPaste with multi-line content).
64//
65// Expansion is non-destructive: the internal paste map isn't
66// touched. Clear() is what resets both the placeholder text and
67// the map, and the caller already calls Clear() right after
68// reading SubmitValue() as part of the submit flow.
69func (e *Editor) SubmitValue() string {
70 raw := e.Value()
71 if len(e.pastes) == 0 || !strings.Contains(raw, "[pasted text #") {
72 return raw
73 }
74 return expandPastePlaceholders(raw, e.pastes)
75}
76
77// SetValue replaces the buffer and places the cursor at the end.
78// Also drops any stored pastes because the placeholders they back
79// are now gone from the visible text.
80func (e *Editor) SetValue(s string) {
81 e.Lines = strings.Split(s, "\n")
82 if len(e.Lines) == 0 {
83 e.Lines = []string{""}
84 }
85 e.CursorR = len(e.Lines) - 1
86 e.CursorC = runeLen(e.Lines[e.CursorR])
87 e.pastes = nil
88 e.pasteSeq = 0
89}
90
91// Clear resets the buffer.
92func (e *Editor) Clear() { e.SetValue("") }
93
94// IsEmpty reports whether the buffer has no visible content.
95func (e *Editor) IsEmpty() bool {
96 return len(e.Lines) == 1 && e.Lines[0] == ""
97}
98
99// HandleKey applies k to the editor. It returns submit=true when
100// the user pressed enter and there is content to send; the caller
101// should read SubmitValue() and then Clear().
102func (e *Editor) HandleKey(k Key) (submit bool) {
103 switch k.Kind {
104 case KeyRune:
105 if k.Alt && (k.Rune == '\r' || k.Rune == '\n') {
106 e.newline()
107 return false
108 }
109 e.insert(string(k.Rune))
110 case KeyEnter:
111 // Shift+Enter would be a separate key in some terminals; we treat
112 // the literal newline byte as Enter. Newline on submit is a decision
113 // for the outer UI via slash commands. Here Enter submits.
114 return true
115 case KeyBackspace:
116 if k.Alt {
117 // Alt+Backspace (Option+Delete on macOS) — delete previous word.
118 e.deleteWord()
119 } else {
120 e.backspace()
121 }
122 case KeyDelete:
123 e.delete()
124 case KeyLeft:
125 if k.Alt {
126 e.moveWordLeft()
127 } else {
128 e.moveLeft()
129 }
130 case KeyRight:
131 if k.Alt {
132 e.moveWordRight()
133 } else {
134 e.moveRight()
135 }
136 case KeyUp:
137 // Visual-row navigation. When a single logical line wraps
138 // to several visual rows, Up needs to climb one visual
139 // row — which may mean moving within the same
140 // e.Lines[CursorR] back toward an earlier rune index,
141 // not jumping to CursorR-1. Buffer-line navigation is
142 // subsumed: a visual row above may also live in the
143 // previous logical line when a short line precedes a
144 // wrapped one.
145 e.moveCursorVisual(-1)
146 case KeyDown:
147 e.moveCursorVisual(+1)
148 case KeyHome, KeyCtrlA:
149 e.CursorC = 0
150 case KeyEnd, KeyCtrlE:
151 e.CursorC = runeLen(e.Lines[e.CursorR])
152 case KeyCtrlU:
153 e.Lines[e.CursorR] = substringAfter(e.Lines[e.CursorR], e.CursorC)
154 e.CursorC = 0
155 case KeyCtrlK:
156 e.Lines[e.CursorR] = substringBefore(e.Lines[e.CursorR], e.CursorC)
157 case KeyCtrlW:
158 e.deleteWord()
159 case KeyPaste:
160 // Large multi-line pastes are collapsed to a short
161 // placeholder token so the editor doesn't balloon to
162 // hundreds of rows. The full body is stashed in e.pastes,
163 // keyed by a monotonically increasing id, and swapped
164 // back in at submit time via SubmitValue. Threshold:
165 // two or more newlines triggers collapse; one-liners and
166 // drag-dropped file paths fall through to the original
167 // insert path (including file-path quoting).
168 if pasteShouldCollapse(k.Paste) {
169 if e.pastes == nil {
170 e.pastes = map[int]string{}
171 }
172 e.pasteSeq++
173 id := e.pasteSeq
174 e.pastes[id] = k.Paste
175 e.insert(formatPastePlaceholder(id, k.Paste))
176 } else {
177 // macOS Terminal / iTerm / Ghostty deliver drag-dropped
178 // files as bracketed-paste text. Detect that pattern
179 // and wrap the path(s) in single quotes so the agent
180 // sees them as one argument.
181 e.insert(quotePastedFilePaths(k.Paste))
182 }
183 case KeyEsc:
184 e.Clear()
185 }
186 return false
187}
188
189// quotePastedFilePaths returns paste with any drag-dropped file
190// paths wrapped in single quotes. Heuristics:
191//
192// - one line only; multi-line text is returned unchanged so we
193// never touch a real paste of code.
194// - whitespace-separated tokens are inspected one by one. A token
195// that looks like a filesystem path (absolute, ~/relative, or
196// file:// URL) is normalised and re-quoted; everything else is
197// left exactly as the user dropped it.
198// - terminal-style backslash escapes ("foo\ bar.png") are folded
199// back to literal characters before quoting, since wrapping in
200// single quotes makes them unnecessary.
201// - file:// URLs are URL-decoded and stripped of the scheme so
202// the agent receives a plain filesystem path.
203// - if a path contains a literal single quote, it's escaped using
204// the standard '\” splice.
205//
206// Pastes that don't contain any path-shaped tokens are returned
207// unchanged.
208func quotePastedFilePaths(paste string) string {
209 if paste == "" || strings.ContainsRune(paste, '\n') {
210 return paste
211 }
212 trimmed := strings.TrimSpace(paste)
213 if trimmed == "" {
214 return paste
215 }
216
217 // Split on runs of whitespace, preserving the original separators
218 // so multi-file drops keep their spacing on rebuild. Backslash
219 // before a space is treated as part of the preceding token, since
220 // macOS Terminal escapes spaces in dropped paths that way.
221 tokens := splitPreservingSeparators(paste)
222 changed := false
223 for i, tk := range tokens {
224 if tk.isSpace {
225 continue
226 }
227 if p, ok := normalisePathToken(tk.text); ok {
228 tokens[i].text = singleQuote(p)
229 changed = true
230 }
231 }
232 if !changed {
233 return paste
234 }
235 var out strings.Builder
236 out.Grow(len(paste) + 8)
237 for _, tk := range tokens {
238 out.WriteString(tk.text)
239 }
240 return out.String()
241}
242
243type pasteToken struct {
244 text string
245 isSpace bool
246}
247
248// splitPreservingSeparators splits s into runs of whitespace and
249// runs of non-whitespace, keeping the separator runs intact so a
250// rebuild via concatenation reproduces the original string exactly.
251// A backslash immediately followed by a space ("\ ") is treated as
252// part of the preceding non-whitespace run, since that's how macOS
253// Terminal escapes spaces in drag-dropped file paths.
254func splitPreservingSeparators(s string) []pasteToken {
255 runes := []rune(s)
256 var out []pasteToken
257 var buf strings.Builder
258 inSpace := false
259 flush := func() {
260 if buf.Len() == 0 {
261 return
262 }
263 out = append(out, pasteToken{text: buf.String(), isSpace: inSpace})
264 buf.Reset()
265 }
266 for i := 0; i < len(runes); i++ {
267 r := runes[i]
268 isEscapedSpace := r == '\\' && i+1 < len(runes) && (runes[i+1] == ' ' || runes[i+1] == '\t')
269 rSpace := !isEscapedSpace && (r == ' ' || r == '\t')
270 if buf.Len() == 0 {
271 inSpace = rSpace
272 } else if rSpace != inSpace {
273 flush()
274 inSpace = rSpace
275 }
276 if isEscapedSpace {
277 // Skip the backslash; emit the literal space as part of
278 // this non-whitespace token. normalisePathToken's
279 // unescapeBackslashes is now redundant for this path but
280 // stays in case some other source escapes other chars.
281 buf.WriteRune(runes[i+1])
282 i++
283 continue
284 }
285 buf.WriteRune(r)
286 }
287 flush()
288 return out
289}
290
291// normalisePathToken decides whether tk is a drag-dropped path and,
292// if so, returns the cleaned-up path string.
293func normalisePathToken(tk string) (string, bool) {
294 // Strip pre-existing surrounding quotes; we'll re-quote consistently.
295 if n := len(tk); n >= 2 {
296 if (tk[0] == '\'' && tk[n-1] == '\'') || (tk[0] == '"' && tk[n-1] == '"') {
297 tk = tk[1 : n-1]
298 }
299 }
300
301 // file:// URL form: decode and strip the scheme.
302 if strings.HasPrefix(tk, "file://") {
303 decoded, err := url.PathUnescape(strings.TrimPrefix(tk, "file://"))
304 if err != nil {
305 return "", false
306 }
307 if decoded == "" || decoded[0] != '/' {
308 return "", false
309 }
310 return decoded, true
311 }
312
313 // Looks-like-a-path heuristic: starts with /, ~, ~/. Must contain
314 // at least one path separator after the prefix (otherwise a bare
315 // "/" or "~" gets quoted, which is never what the user meant).
316 if !strings.HasPrefix(tk, "/") && !strings.HasPrefix(tk, "~") {
317 return "", false
318 }
319 if !strings.ContainsAny(tk[1:], "/.") {
320 return "", false
321 }
322 unescaped := unescapeBackslashes(tk)
323 if strings.ContainsAny(unescaped, "|;&$`<>") {
324 return "", false
325 }
326 return unescaped, true
327}
328
329// unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal
330// drag-and-drop on macOS uses backslash escaping by default; quoting
331// makes that unnecessary.
332func unescapeBackslashes(s string) string {
333 if !strings.Contains(s, `\`) {
334 return s
335 }
336 var out strings.Builder
337 out.Grow(len(s))
338 prev := false
339 for _, r := range s {
340 if prev {
341 out.WriteRune(r)
342 prev = false
343 continue
344 }
345 if r == '\\' {
346 prev = true
347 continue
348 }
349 out.WriteRune(r)
350 }
351 if prev {
352 out.WriteRune('\\')
353 }
354 return out.String()
355}
356
357// singleQuote wraps s in single quotes, escaping any embedded single
358// quote using the splice idiom: 'foo'\”bar' for foo'bar.
359func singleQuote(s string) string {
360 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
361}
362
363// Insert places s into the editor at the cursor, splitting on
364// newlines so multi-line pastes preserve their structure.
365func (e *Editor) Insert(s string) { e.insert(s) }
366
367func (e *Editor) insert(s string) {
368 line := e.Lines[e.CursorR]
369 pre := substringBefore(line, e.CursorC)
370 post := substringAfter(line, e.CursorC)
371 // Split pasted text on newlines.
372 parts := strings.Split(s, "\n")
373 if len(parts) == 1 {
374 e.Lines[e.CursorR] = pre + s + post
375 e.CursorC += runeLen(s)
376 return
377 }
378 newLines := make([]string, 0, len(e.Lines)+len(parts)-1)
379 newLines = append(newLines, e.Lines[:e.CursorR]...)
380 newLines = append(newLines, pre+parts[0])
381 for i := 1; i < len(parts)-1; i++ {
382 newLines = append(newLines, parts[i])
383 }
384 last := parts[len(parts)-1]
385 newLines = append(newLines, last+post)
386 newLines = append(newLines, e.Lines[e.CursorR+1:]...)
387 e.Lines = newLines
388 e.CursorR += len(parts) - 1
389 e.CursorC = runeLen(last)
390}
391
392func (e *Editor) newline() {
393 line := e.Lines[e.CursorR]
394 pre := substringBefore(line, e.CursorC)
395 post := substringAfter(line, e.CursorC)
396 e.Lines[e.CursorR] = pre
397 e.Lines = append(e.Lines, "")
398 copy(e.Lines[e.CursorR+2:], e.Lines[e.CursorR+1:])
399 e.Lines[e.CursorR+1] = post
400 e.CursorR++
401 e.CursorC = 0
402}
403
404func (e *Editor) backspace() {
405 if e.CursorC == 0 {
406 if e.CursorR == 0 {
407 return
408 }
409 prev := e.Lines[e.CursorR-1]
410 cur := e.Lines[e.CursorR]
411 e.Lines = append(e.Lines[:e.CursorR], e.Lines[e.CursorR+1:]...)
412 e.CursorR--
413 e.CursorC = runeLen(prev)
414 e.Lines[e.CursorR] = prev + cur
415 return
416 }
417 line := e.Lines[e.CursorR]
418 e.Lines[e.CursorR] = substringBefore(line, e.CursorC-1) + substringAfter(line, e.CursorC)
419 e.CursorC--
420}
421
422func (e *Editor) delete() {
423 line := e.Lines[e.CursorR]
424 if e.CursorC == runeLen(line) {
425 if e.CursorR == len(e.Lines)-1 {
426 return
427 }
428 next := e.Lines[e.CursorR+1]
429 e.Lines = append(e.Lines[:e.CursorR+1], e.Lines[e.CursorR+2:]...)
430 e.Lines[e.CursorR] = line + next
431 return
432 }
433 e.Lines[e.CursorR] = substringBefore(line, e.CursorC) + substringAfter(line, e.CursorC+1)
434}
435
436// moveWordLeft jumps the cursor to the start of the previous word,
437// using the same word-separator rules as deleteWord. If already at the
438// start of the line, wraps to the end of the previous line.
439func (e *Editor) moveWordLeft() {
440 if e.CursorC == 0 {
441 if e.CursorR > 0 {
442 e.CursorR--
443 e.CursorC = runeLen(e.Lines[e.CursorR])
444 }
445 return
446 }
447 r := []rune(e.Lines[e.CursorR])
448 i := e.CursorC
449 for i > 0 && isWordSep(r[i-1]) {
450 i--
451 }
452 for i > 0 && !isWordSep(r[i-1]) {
453 i--
454 }
455 e.CursorC = i
456}
457
458// moveWordRight jumps the cursor to the start of the next word. If
459// already at the end of the line, wraps to the start of the next line.
460func (e *Editor) moveWordRight() {
461 line := e.Lines[e.CursorR]
462 if e.CursorC >= runeLen(line) {
463 if e.CursorR < len(e.Lines)-1 {
464 e.CursorR++
465 e.CursorC = 0
466 }
467 return
468 }
469 r := []rune(line)
470 i := e.CursorC
471 for i < len(r) && !isWordSep(r[i]) {
472 i++
473 }
474 for i < len(r) && isWordSep(r[i]) {
475 i++
476 }
477 e.CursorC = i
478}
479
480func (e *Editor) moveLeft() {
481 if e.CursorC > 0 {
482 e.CursorC--
483 return
484 }
485 if e.CursorR > 0 {
486 e.CursorR--
487 e.CursorC = runeLen(e.Lines[e.CursorR])
488 }
489}
490
491func (e *Editor) moveRight() {
492 if e.CursorC < runeLen(e.Lines[e.CursorR]) {
493 e.CursorC++
494 return
495 }
496 if e.CursorR < len(e.Lines)-1 {
497 e.CursorR++
498 e.CursorC = 0
499 }
500}
501
502// moveCursorVisual moves the cursor one visual row in direction
503// dir (-1 = up, +1 = down) through the wrapped layout the user
504// sees on screen. Handles both multi-line logical inputs and the
505// case where a single long line wraps across several visual
506// rows.
507//
508// Algorithm: rebuild the same wrapped layout Render produces,
509// tagging each visual row with (logicalRow, runeOffsetStart,
510// runeOffsetEnd, leadingWidth). Find the row the cursor sits on,
511// then pick (row+dir) and map the cursor's current visual column
512// (minus the target row's leading indent) to a rune index inside
513// that row's slice of its logical line. No-op at the top/bottom
514// edges of the whole buffer.
515func (e *Editor) moveCursorVisual(dir int) {
516 width := e.lastRenderWidth
517 if width <= 0 {
518 // Fall back to logical-line navigation if Render hasn't
519 // been called yet (shouldn't happen in practice; the
520 // host always renders once before accepting input).
521 e.moveCursorLogical(dir)
522 return
523 }
524
525 type vrow struct {
526 logical int // e.Lines index
527 runeStart int // rune offset into e.Lines[logical]
528 runeEnd int // exclusive
529 leadWidth int // width of prompt / cont indent on this row
530 leadPrefix string // the prefix used (prompt on row 0, indent on cont)
531 }
532
533 promptLen := visibleWidth(e.Prompt)
534 indent := strings.Repeat(" ", promptLen)
535
536 var rows []vrow
537 curVRow, curVCol := 0, 0
538 for r, line := range e.Lines {
539 prefix := indent
540 if r == 0 {
541 prefix = e.Prompt
542 }
543 wrapped := wrapLine(prefix+line, width, indent)
544 lineRunes := []rune(line)
545 seen := 0
546 for wi, w := range wrapped {
547 var leadW int
548 var leadP string
549 body := w
550 if wi == 0 {
551 if strings.HasPrefix(body, prefix) {
552 body = body[len(prefix):]
553 }
554 leadW = promptLen
555 leadP = prefix
556 } else {
557 if strings.HasPrefix(body, indent) {
558 body = body[len(indent):]
559 }
560 leadW = promptLen
561 leadP = indent
562 }
563 bodyRunes := []rune(body)
564 start := seen
565 end := seen + len(bodyRunes)
566 rows = append(rows, vrow{
567 logical: r, runeStart: start, runeEnd: end,
568 leadWidth: leadW, leadPrefix: leadP,
569 })
570 // Record where the cursor currently sits.
571 if r == e.CursorR && e.CursorC >= start && e.CursorC <= end {
572 curVRow = len(rows) - 1
573 inner := e.CursorC - start
574 if inner < 0 {
575 inner = 0
576 }
577 if inner > len(bodyRunes) {
578 inner = len(bodyRunes)
579 }
580 curVCol = leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
581 }
582 seen = end
583 // Word-wrap often drops a single space at the boundary.
584 for seen < len(lineRunes) && lineRunes[seen] == ' ' {
585 seen++
586 }
587 }
588 }
589
590 target := curVRow + dir
591 if target < 0 || target >= len(rows) {
592 return
593 }
594 tr := rows[target]
595 line := e.Lines[tr.logical]
596 lineRunes := []rune(line)
597 bodyRunes := lineRunes[tr.runeStart:tr.runeEnd]
598
599 // Find the rune offset inside bodyRunes whose visible column
600 // most closely matches curVCol after accounting for leadWidth.
601 want := curVCol - tr.leadWidth
602 if want < 0 {
603 want = 0
604 }
605 best := 0
606 bestW := 0
607 for i := 1; i <= len(bodyRunes); i++ {
608 w := runewidth.StringWidth(string(bodyRunes[:i]))
609 if w > want {
610 break
611 }
612 best = i
613 bestW = w
614 if w == want {
615 break
616 }
617 }
618 _ = bestW
619 e.CursorR = tr.logical
620 e.CursorC = tr.runeStart + best
621}
622
623// moveCursorLogical is the pre-visual-navigation fallback used
624// when Render hasn't told us the terminal width yet. Walks the
625// e.Lines array directly.
626func (e *Editor) moveCursorLogical(dir int) {
627 switch {
628 case dir < 0 && e.CursorR > 0:
629 e.CursorR--
630 case dir > 0 && e.CursorR < len(e.Lines)-1:
631 e.CursorR++
632 default:
633 return
634 }
635 if e.CursorC > runeLen(e.Lines[e.CursorR]) {
636 e.CursorC = runeLen(e.Lines[e.CursorR])
637 }
638}
639
640func (e *Editor) deleteWord() {
641 line := e.Lines[e.CursorR]
642 if e.CursorC == 0 {
643 e.backspace()
644 return
645 }
646 r := []rune(line)
647 i := e.CursorC
648 // Step 1: walk over any trailing whitespace to the left of the cursor.
649 for i > 0 && isWordSep(r[i-1]) {
650 i--
651 }
652 // Step 2: walk over the word itself (non-separator runes).
653 for i > 0 && !isWordSep(r[i-1]) {
654 i--
655 }
656 e.Lines[e.CursorR] = string(r[:i]) + string(r[e.CursorC:])
657 e.CursorC = i
658}
659
660// isWordSep reports whether r is a word separator. Anything that isn't
661// a letter, digit, or underscore counts as a separator so delete-word
662// feels natural on paths, code, and prose.
663func isWordSep(r rune) bool {
664 switch {
665 case r == '_':
666 return false
667 case r >= '0' && r <= '9':
668 return false
669 case r >= 'a' && r <= 'z':
670 return false
671 case r >= 'A' && r <= 'Z':
672 return false
673 case r >= 0x80:
674 // Treat all non-ASCII as word chars so CJK/äöü don't get chopped.
675 return false
676 }
677 return true
678}
679
680// ---- rendering ----
681
682// Render returns the editor's visible lines (wrapped to width).
683// visualRow/visualCol describe where the cursor lands within the returned lines.
684func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
685 e.lastRenderWidth = width
686 promptLen := visibleWidth(e.Prompt)
687 indent := strings.Repeat(" ", promptLen)
688
689 for r, line := range e.Lines {
690 var prefix string
691 if r == 0 {
692 prefix = e.Prompt
693 } else {
694 prefix = indent
695 }
696 wrapped := wrapLine(prefix+line, width, indent)
697 if r == e.CursorR {
698 // Compute where CursorC lands inside the wrapped rows by
699 // walking the wrapped output character-by-character and
700 // tracking which wrapped row + column corresponds to the
701 // cursor's rune index in `line`. This is the only reliable
702 // answer under word-wrap, where a simple (promptLen+col)/width
703 // formula overshoots when wrapLine broke on a space.
704 targetRunes := e.CursorC
705 row, col := locateCursor(wrapped, prefix, line, targetRunes, indent)
706 visualRow = len(lines) + row
707 visualCol = col
708 }
709 lines = append(lines, wrapped...)
710 }
711 return lines, visualRow, visualCol
712}
713
714// locateCursor finds the wrapped row + visible column corresponding to
715// `targetRunes` rune positions into the logical `line`, given that the
716// wrapped output `wrapped` started with `prefix` on its first row and
717// uses `cont` as the continuation indent on subsequent rows.
718func locateCursor(wrapped []string, prefix, line string, targetRunes int, cont string) (int, int) {
719 prefixW := visibleWidth(prefix)
720 contW := visibleWidth(cont)
721 lineRunes := []rune(line)
722 if targetRunes > len(lineRunes) {
723 targetRunes = len(lineRunes)
724 }
725
726 seenRunes := 0
727 for row, w := range wrapped {
728 // strip leading prefix / continuation indent before counting runes
729 body := w
730 var leadW int
731 if row == 0 {
732 if strings.HasPrefix(body, prefix) {
733 body = body[len(prefix):]
734 }
735 leadW = prefixW
736 } else {
737 if strings.HasPrefix(body, cont) {
738 body = body[len(cont):]
739 }
740 leadW = contW
741 }
742 bodyRunes := []rune(body)
743 // Could this wrapped row contain the cursor?
744 if targetRunes <= seenRunes+len(bodyRunes) {
745 // Column inside body.
746 inner := targetRunes - seenRunes
747 if inner < 0 {
748 inner = 0
749 }
750 col := leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
751 return row, col
752 }
753 seenRunes += len(bodyRunes)
754 // Word-wrap may have dropped whitespace at the boundary; skip the
755 // corresponding runes in `line` so counts stay aligned.
756 for seenRunes < len(lineRunes) && lineRunes[seenRunes] == ' ' {
757 seenRunes++
758 if seenRunes >= targetRunes {
759 // Cursor landed in the skipped whitespace — place it at
760 // the start of the next wrapped row.
761 nextRow := row + 1
762 if nextRow >= len(wrapped) {
763 nextRow = row
764 }
765 return nextRow, contW
766 }
767 }
768 }
769 // Fallback: end of the last wrapped row.
770 if len(wrapped) == 0 {
771 return 0, prefixW
772 }
773 last := wrapped[len(wrapped)-1]
774 return len(wrapped) - 1, visibleWidth(last)
775}
776
777// ---- helpers ----
778
779func substringBefore(s string, col int) string {
780 r := []rune(s)
781 if col > len(r) {
782 col = len(r)
783 }
784 return string(r[:col])
785}
786
787func substringAfter(s string, col int) string {
788 r := []rune(s)
789 if col > len(r) {
790 col = len(r)
791 }
792 return string(r[col:])
793}
794
795func runeLen(s string) int { return len([]rune(s)) }
796
797func visualColumn(s string, runeCol int) int {
798 r := []rune(s)
799 if runeCol > len(r) {
800 runeCol = len(r)
801 }
802 return runewidth.StringWidth(string(r[:runeCol]))
803}
804
805func visibleWidth(s string) int {
806 return runewidth.StringWidth(stripANSI(s))
807}
808
809func stripANSI(s string) string {
810 // Minimal ANSI stripper; handles CSI sequences (ESC [ ... final).
811 var out []rune
812 i := 0
813 runes := []rune(s)
814 for i < len(runes) {
815 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
816 i += 2
817 for i < len(runes) {
818 c := runes[i]
819 i++
820 if c >= 0x40 && c <= 0x7e {
821 break
822 }
823 }
824 continue
825 }
826 out = append(out, runes[i])
827 i++
828 }
829 return string(out)
830}
831
832// wrapANSILine folds a string that may contain ANSI CSI escapes so that
833// the visible width of each line stays within limit. Breaks happen on
834// spaces when possible, falling back to mid-token splits for very long
835// unbroken runs. Escape sequences are preserved verbatim and never
836// counted toward the visible column.
837func wrapANSILine(s string, limit int) []string {
838 if limit <= 0 {
839 return []string{s}
840 }
841 if visibleWidth(s) <= limit {
842 return []string{s}
843 }
844 runes := []rune(s)
845 var out []string
846 var line strings.Builder
847 var word strings.Builder
848 lineW := 0
849 wordW := 0
850
851 flushLine := func() {
852 out = append(out, line.String())
853 line.Reset()
854 lineW = 0
855 }
856 flushWord := func() {
857 if word.Len() == 0 {
858 return
859 }
860 if lineW+wordW > limit && lineW > 0 {
861 flushLine()
862 }
863 // If the word alone is bigger than the limit, split it by runes.
864 if wordW > limit {
865 wr := []rune(word.String())
866 for i := 0; i < len(wr); {
867 r := wr[i]
868 if r == 0x1b && i+1 < len(wr) && wr[i+1] == '[' {
869 line.WriteRune(r)
870 line.WriteRune(wr[i+1])
871 i += 2
872 for i < len(wr) {
873 c := wr[i]
874 line.WriteRune(c)
875 i++
876 if c >= 0x40 && c <= 0x7e {
877 break
878 }
879 }
880 continue
881 }
882 rw := runewidth.RuneWidth(r)
883 if lineW+rw > limit && lineW > 0 {
884 flushLine()
885 }
886 line.WriteRune(r)
887 lineW += rw
888 i++
889 }
890 word.Reset()
891 wordW = 0
892 return
893 }
894 line.WriteString(word.String())
895 lineW += wordW
896 word.Reset()
897 wordW = 0
898 }
899
900 for i := 0; i < len(runes); {
901 r := runes[i]
902 if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
903 word.WriteRune(r)
904 word.WriteRune(runes[i+1])
905 i += 2
906 for i < len(runes) {
907 c := runes[i]
908 word.WriteRune(c)
909 i++
910 if c >= 0x40 && c <= 0x7e {
911 break
912 }
913 }
914 continue
915 }
916 if r == ' ' || r == '\t' {
917 flushWord()
918 rw := runewidth.RuneWidth(r)
919 if lineW+rw > limit && lineW > 0 {
920 flushLine()
921 i++
922 continue
923 }
924 line.WriteRune(r)
925 lineW += rw
926 i++
927 continue
928 }
929 word.WriteRune(r)
930 wordW += runewidth.RuneWidth(r)
931 i++
932 }
933 flushWord()
934 if line.Len() > 0 {
935 flushLine()
936 }
937 if len(out) == 0 {
938 out = []string{""}
939 }
940 return out
941}
942
943func wrapLine(s string, width int, cont string) []string {
944 if width <= 0 {
945 return []string{s}
946 }
947
948 // Tokenize on spaces while preserving their widths. Runs of spaces
949 // stay attached to the preceding word so trailing whitespace doesn't
950 // create empty wrapped lines.
951 type token struct {
952 text string
953 width int
954 space bool // true if this token is whitespace only
955 }
956 var tokens []token
957 {
958 var buf strings.Builder
959 var bufW int
960 inSpace := false
961 flush := func() {
962 if buf.Len() == 0 {
963 return
964 }
965 tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace})
966 buf.Reset()
967 bufW = 0
968 }
969 for _, r := range s {
970 rSpace := r == ' ' || r == '\t'
971 if buf.Len() == 0 {
972 inSpace = rSpace
973 } else if rSpace != inSpace {
974 flush()
975 inSpace = rSpace
976 }
977 buf.WriteRune(r)
978 bufW += runewidth.RuneWidth(r)
979 }
980 flush()
981 }
982
983 var out []string
984 var cur strings.Builder
985 curW := 0
986 firstLine := true
987 contW := visibleWidth(cont)
988
989 newLine := func() {
990 out = append(out, cur.String())
991 cur.Reset()
992 curW = 0
993 // After flushing, everything we append next is a CONTINUATION
994 // row, which must start with the cont indent so the editor's
995 // visual cursor alignment stays consistent. The old code only
996 // wrote cont when !firstLine BEFORE toggling firstLine, which
997 // meant the very first wrap never got its indent. That caused
998 // the terminal cursor to land in the wrong column after a
999 // multi-line paste.
1000 firstLine = false
1001 cur.WriteString(cont)
1002 curW = contW
1003 }
1004
1005 for i := 0; i < len(tokens); i++ {
1006 tk := tokens[i]
1007
1008 // Drop leading whitespace after a wrap.
1009 if tk.space && curW == contW && !firstLine {
1010 continue
1011 }
1012
1013 if curW+tk.width <= width {
1014 cur.WriteString(tk.text)
1015 curW += tk.width
1016 continue
1017 }
1018
1019 // Token overflows.
1020 //
1021 // If the token on its own is wider than what fits on a
1022 // continuation line (width - contW), it'll need to be split
1023 // rune-by-rune no matter what. In that case, don't break
1024 // first: let the rune-split start right here at the current
1025 // column and wrap naturally. Breaking first would strand the
1026 // prompt (on firstLine) or the cont indent (on later lines)
1027 // on a row by itself, which is the drag-drop-long-path bug.
1028 if tk.width > width-contW {
1029 // Rune-by-rune starting from the current column. newLine()
1030 // inside handles wrapping and re-indents.
1031 for _, r := range tk.text {
1032 rw := runewidth.RuneWidth(r)
1033 if curW+rw > width {
1034 newLine()
1035 }
1036 cur.WriteRune(r)
1037 curW += rw
1038 }
1039 continue
1040 }
1041
1042 // Token fits on a fresh continuation line; break first, then
1043 // emit it whole.
1044 if cur.Len() > 0 && !(firstLine && curW == 0) {
1045 trimmed := strings.TrimRight(cur.String(), " \t")
1046 cur.Reset()
1047 cur.WriteString(trimmed)
1048 curW = visibleWidth(trimmed)
1049 newLine()
1050 if tk.space {
1051 continue
1052 }
1053 }
1054 cur.WriteString(tk.text)
1055 curW += tk.width
1056 }
1057
1058 if cur.Len() > 0 || len(out) == 0 {
1059 out = append(out, cur.String())
1060 }
1061 return out
1062}
1063
1064func max(a, b int) int {
1065 if a > b {
1066 return a
1067 }
1068 return b
1069}
1070
1071// pasteCollapseLineThreshold and pasteCollapseCharThreshold govern
1072// when a bracketed paste gets collapsed to a [pasted text #N …]
1073// placeholder instead of being inserted inline. Either trigger
1074// alone is enough — a 500-line log dump and a 1200-character
1075// one-line log entry both bloat the editor in ways the user
1076// doesn't want to scroll through while composing a prompt.
1077const (
1078 pasteCollapseLineThreshold = 10
1079 pasteCollapseCharThreshold = 1000
1080)
1081
1082// pasteShouldCollapse reports whether a pasted chunk is big enough
1083// to deserve a placeholder token instead of being inserted verbatim.
1084// Collapse on > 10 lines OR > 1000 characters, whichever fires
1085// first.
1086func pasteShouldCollapse(s string) bool {
1087 return countLines(s) > pasteCollapseLineThreshold || len(s) > pasteCollapseCharThreshold
1088}
1089
1090// formatPastePlaceholder builds the visible marker for a
1091// collapsed paste. Two shapes:
1092//
1093// [pasted text #N +L lines] — used when the line count is the
1094// trigger (multi-line dumps)
1095// [pasted text #N C chars] — used when the character count is
1096// the trigger (long single-line
1097// or near-single-line pastes)
1098//
1099// Line-triggered takes precedence so a 12-line 4000-char paste
1100// reads as "+12 lines", not "4000 chars".
1101func formatPastePlaceholder(id int, body string) string {
1102 if countLines(body) > pasteCollapseLineThreshold {
1103 return fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(body))
1104 }
1105 return fmt.Sprintf("[pasted text #%d %d chars]", id, len(body))
1106}
1107
1108// countLines returns the number of visual lines in s. A trailing
1109// newline is not counted as an extra empty line so
1110// "foo\nbar\n" reads as 2 lines (what the user expects in the
1111// "+N lines" summary) instead of 3.
1112func countLines(s string) int {
1113 if s == "" {
1114 return 0
1115 }
1116 n := strings.Count(s, "\n")
1117 if !strings.HasSuffix(s, "\n") {
1118 n++
1119 }
1120 return n
1121}
1122
1123// pastePlaceholderRE matches both placeholder shapes the editor
1124// emits for a collapsed paste:
1125//
1126// [pasted text #N +L lines] — multi-line trigger
1127// [pasted text #N C chars] — long-char trigger
1128//
1129// Capture group 1 is the numeric id used to look up the full
1130// body in e.pastes; the rest of the token is free-form and gets
1131// discarded during expansion.
1132var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)
1133
1134// expandPastePlaceholders returns raw with every paste token
1135// swapped for the body stored under its id in pastes. Tokens
1136// whose id isn't in the map are left as-is (user deleted the
1137// corresponding entry somehow, or the id is spurious user text
1138// that happens to match the shape).
1139func expandPastePlaceholders(raw string, pastes map[int]string) string {
1140 return pastePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
1141 groups := pastePlaceholderRE.FindStringSubmatch(match)
1142 if len(groups) < 2 {
1143 return match
1144 }
1145 var id int
1146 if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
1147 return match
1148 }
1149 if body, ok := pastes[id]; ok {
1150 return body
1151 }
1152 return match
1153 })
1154}