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 placeholder := fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(k.Paste))
176 e.insert(placeholder)
177 } else {
178 // macOS Terminal / iTerm / Ghostty deliver drag-dropped
179 // files as bracketed-paste text. Detect that pattern
180 // and wrap the path(s) in single quotes so the agent
181 // sees them as one argument.
182 e.insert(quotePastedFilePaths(k.Paste))
183 }
184 case KeyEsc:
185 e.Clear()
186 }
187 return false
188}
189
190// quotePastedFilePaths returns paste with any drag-dropped file
191// paths wrapped in single quotes. Heuristics:
192//
193// - one line only; multi-line text is returned unchanged so we
194// never touch a real paste of code.
195// - whitespace-separated tokens are inspected one by one. A token
196// that looks like a filesystem path (absolute, ~/relative, or
197// file:// URL) is normalised and re-quoted; everything else is
198// left exactly as the user dropped it.
199// - terminal-style backslash escapes ("foo\ bar.png") are folded
200// back to literal characters before quoting, since wrapping in
201// single quotes makes them unnecessary.
202// - file:// URLs are URL-decoded and stripped of the scheme so
203// the agent receives a plain filesystem path.
204// - if a path contains a literal single quote, it's escaped using
205// the standard '\” splice.
206//
207// Pastes that don't contain any path-shaped tokens are returned
208// unchanged.
209func quotePastedFilePaths(paste string) string {
210 if paste == "" || strings.ContainsRune(paste, '\n') {
211 return paste
212 }
213 trimmed := strings.TrimSpace(paste)
214 if trimmed == "" {
215 return paste
216 }
217
218 // Split on runs of whitespace, preserving the original separators
219 // so multi-file drops keep their spacing on rebuild. Backslash
220 // before a space is treated as part of the preceding token, since
221 // macOS Terminal escapes spaces in dropped paths that way.
222 tokens := splitPreservingSeparators(paste)
223 changed := false
224 for i, tk := range tokens {
225 if tk.isSpace {
226 continue
227 }
228 if p, ok := normalisePathToken(tk.text); ok {
229 tokens[i].text = singleQuote(p)
230 changed = true
231 }
232 }
233 if !changed {
234 return paste
235 }
236 var out strings.Builder
237 out.Grow(len(paste) + 8)
238 for _, tk := range tokens {
239 out.WriteString(tk.text)
240 }
241 return out.String()
242}
243
244type pasteToken struct {
245 text string
246 isSpace bool
247}
248
249// splitPreservingSeparators splits s into runs of whitespace and
250// runs of non-whitespace, keeping the separator runs intact so a
251// rebuild via concatenation reproduces the original string exactly.
252// A backslash immediately followed by a space ("\ ") is treated as
253// part of the preceding non-whitespace run, since that's how macOS
254// Terminal escapes spaces in drag-dropped file paths.
255func splitPreservingSeparators(s string) []pasteToken {
256 runes := []rune(s)
257 var out []pasteToken
258 var buf strings.Builder
259 inSpace := false
260 flush := func() {
261 if buf.Len() == 0 {
262 return
263 }
264 out = append(out, pasteToken{text: buf.String(), isSpace: inSpace})
265 buf.Reset()
266 }
267 for i := 0; i < len(runes); i++ {
268 r := runes[i]
269 isEscapedSpace := r == '\\' && i+1 < len(runes) && (runes[i+1] == ' ' || runes[i+1] == '\t')
270 rSpace := !isEscapedSpace && (r == ' ' || r == '\t')
271 if buf.Len() == 0 {
272 inSpace = rSpace
273 } else if rSpace != inSpace {
274 flush()
275 inSpace = rSpace
276 }
277 if isEscapedSpace {
278 // Skip the backslash; emit the literal space as part of
279 // this non-whitespace token. normalisePathToken's
280 // unescapeBackslashes is now redundant for this path but
281 // stays in case some other source escapes other chars.
282 buf.WriteRune(runes[i+1])
283 i++
284 continue
285 }
286 buf.WriteRune(r)
287 }
288 flush()
289 return out
290}
291
292// normalisePathToken decides whether tk is a drag-dropped path and,
293// if so, returns the cleaned-up path string.
294func normalisePathToken(tk string) (string, bool) {
295 // Strip pre-existing surrounding quotes; we'll re-quote consistently.
296 if n := len(tk); n >= 2 {
297 if (tk[0] == '\'' && tk[n-1] == '\'') || (tk[0] == '"' && tk[n-1] == '"') {
298 tk = tk[1 : n-1]
299 }
300 }
301
302 // file:// URL form: decode and strip the scheme.
303 if strings.HasPrefix(tk, "file://") {
304 decoded, err := url.PathUnescape(strings.TrimPrefix(tk, "file://"))
305 if err != nil {
306 return "", false
307 }
308 if decoded == "" || decoded[0] != '/' {
309 return "", false
310 }
311 return decoded, true
312 }
313
314 // Looks-like-a-path heuristic: starts with /, ~, ~/. Must contain
315 // at least one path separator after the prefix (otherwise a bare
316 // "/" or "~" gets quoted, which is never what the user meant).
317 if !strings.HasPrefix(tk, "/") && !strings.HasPrefix(tk, "~") {
318 return "", false
319 }
320 if !strings.ContainsAny(tk[1:], "/.") {
321 return "", false
322 }
323 unescaped := unescapeBackslashes(tk)
324 if strings.ContainsAny(unescaped, "|;&$`<>") {
325 return "", false
326 }
327 return unescaped, true
328}
329
330// unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal
331// drag-and-drop on macOS uses backslash escaping by default; quoting
332// makes that unnecessary.
333func unescapeBackslashes(s string) string {
334 if !strings.Contains(s, `\`) {
335 return s
336 }
337 var out strings.Builder
338 out.Grow(len(s))
339 prev := false
340 for _, r := range s {
341 if prev {
342 out.WriteRune(r)
343 prev = false
344 continue
345 }
346 if r == '\\' {
347 prev = true
348 continue
349 }
350 out.WriteRune(r)
351 }
352 if prev {
353 out.WriteRune('\\')
354 }
355 return out.String()
356}
357
358// singleQuote wraps s in single quotes, escaping any embedded single
359// quote using the splice idiom: 'foo'\”bar' for foo'bar.
360func singleQuote(s string) string {
361 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
362}
363
364// Insert places s into the editor at the cursor, splitting on
365// newlines so multi-line pastes preserve their structure.
366func (e *Editor) Insert(s string) { e.insert(s) }
367
368func (e *Editor) insert(s string) {
369 line := e.Lines[e.CursorR]
370 pre := substringBefore(line, e.CursorC)
371 post := substringAfter(line, e.CursorC)
372 // Split pasted text on newlines.
373 parts := strings.Split(s, "\n")
374 if len(parts) == 1 {
375 e.Lines[e.CursorR] = pre + s + post
376 e.CursorC += runeLen(s)
377 return
378 }
379 newLines := make([]string, 0, len(e.Lines)+len(parts)-1)
380 newLines = append(newLines, e.Lines[:e.CursorR]...)
381 newLines = append(newLines, pre+parts[0])
382 for i := 1; i < len(parts)-1; i++ {
383 newLines = append(newLines, parts[i])
384 }
385 last := parts[len(parts)-1]
386 newLines = append(newLines, last+post)
387 newLines = append(newLines, e.Lines[e.CursorR+1:]...)
388 e.Lines = newLines
389 e.CursorR += len(parts) - 1
390 e.CursorC = runeLen(last)
391}
392
393func (e *Editor) newline() {
394 line := e.Lines[e.CursorR]
395 pre := substringBefore(line, e.CursorC)
396 post := substringAfter(line, e.CursorC)
397 e.Lines[e.CursorR] = pre
398 e.Lines = append(e.Lines, "")
399 copy(e.Lines[e.CursorR+2:], e.Lines[e.CursorR+1:])
400 e.Lines[e.CursorR+1] = post
401 e.CursorR++
402 e.CursorC = 0
403}
404
405func (e *Editor) backspace() {
406 if e.CursorC == 0 {
407 if e.CursorR == 0 {
408 return
409 }
410 prev := e.Lines[e.CursorR-1]
411 cur := e.Lines[e.CursorR]
412 e.Lines = append(e.Lines[:e.CursorR], e.Lines[e.CursorR+1:]...)
413 e.CursorR--
414 e.CursorC = runeLen(prev)
415 e.Lines[e.CursorR] = prev + cur
416 return
417 }
418 line := e.Lines[e.CursorR]
419 e.Lines[e.CursorR] = substringBefore(line, e.CursorC-1) + substringAfter(line, e.CursorC)
420 e.CursorC--
421}
422
423func (e *Editor) delete() {
424 line := e.Lines[e.CursorR]
425 if e.CursorC == runeLen(line) {
426 if e.CursorR == len(e.Lines)-1 {
427 return
428 }
429 next := e.Lines[e.CursorR+1]
430 e.Lines = append(e.Lines[:e.CursorR+1], e.Lines[e.CursorR+2:]...)
431 e.Lines[e.CursorR] = line + next
432 return
433 }
434 e.Lines[e.CursorR] = substringBefore(line, e.CursorC) + substringAfter(line, e.CursorC+1)
435}
436
437// moveWordLeft jumps the cursor to the start of the previous word,
438// using the same word-separator rules as deleteWord. If already at the
439// start of the line, wraps to the end of the previous line.
440func (e *Editor) moveWordLeft() {
441 if e.CursorC == 0 {
442 if e.CursorR > 0 {
443 e.CursorR--
444 e.CursorC = runeLen(e.Lines[e.CursorR])
445 }
446 return
447 }
448 r := []rune(e.Lines[e.CursorR])
449 i := e.CursorC
450 for i > 0 && isWordSep(r[i-1]) {
451 i--
452 }
453 for i > 0 && !isWordSep(r[i-1]) {
454 i--
455 }
456 e.CursorC = i
457}
458
459// moveWordRight jumps the cursor to the start of the next word. If
460// already at the end of the line, wraps to the start of the next line.
461func (e *Editor) moveWordRight() {
462 line := e.Lines[e.CursorR]
463 if e.CursorC >= runeLen(line) {
464 if e.CursorR < len(e.Lines)-1 {
465 e.CursorR++
466 e.CursorC = 0
467 }
468 return
469 }
470 r := []rune(line)
471 i := e.CursorC
472 for i < len(r) && !isWordSep(r[i]) {
473 i++
474 }
475 for i < len(r) && isWordSep(r[i]) {
476 i++
477 }
478 e.CursorC = i
479}
480
481func (e *Editor) moveLeft() {
482 if e.CursorC > 0 {
483 e.CursorC--
484 return
485 }
486 if e.CursorR > 0 {
487 e.CursorR--
488 e.CursorC = runeLen(e.Lines[e.CursorR])
489 }
490}
491
492func (e *Editor) moveRight() {
493 if e.CursorC < runeLen(e.Lines[e.CursorR]) {
494 e.CursorC++
495 return
496 }
497 if e.CursorR < len(e.Lines)-1 {
498 e.CursorR++
499 e.CursorC = 0
500 }
501}
502
503// moveCursorVisual moves the cursor one visual row in direction
504// dir (-1 = up, +1 = down) through the wrapped layout the user
505// sees on screen. Handles both multi-line logical inputs and the
506// case where a single long line wraps across several visual
507// rows.
508//
509// Algorithm: rebuild the same wrapped layout Render produces,
510// tagging each visual row with (logicalRow, runeOffsetStart,
511// runeOffsetEnd, leadingWidth). Find the row the cursor sits on,
512// then pick (row+dir) and map the cursor's current visual column
513// (minus the target row's leading indent) to a rune index inside
514// that row's slice of its logical line. No-op at the top/bottom
515// edges of the whole buffer.
516func (e *Editor) moveCursorVisual(dir int) {
517 width := e.lastRenderWidth
518 if width <= 0 {
519 // Fall back to logical-line navigation if Render hasn't
520 // been called yet (shouldn't happen in practice; the
521 // host always renders once before accepting input).
522 e.moveCursorLogical(dir)
523 return
524 }
525
526 type vrow struct {
527 logical int // e.Lines index
528 runeStart int // rune offset into e.Lines[logical]
529 runeEnd int // exclusive
530 leadWidth int // width of prompt / cont indent on this row
531 leadPrefix string // the prefix used (prompt on row 0, indent on cont)
532 }
533
534 promptLen := visibleWidth(e.Prompt)
535 indent := strings.Repeat(" ", promptLen)
536
537 var rows []vrow
538 curVRow, curVCol := 0, 0
539 for r, line := range e.Lines {
540 prefix := indent
541 if r == 0 {
542 prefix = e.Prompt
543 }
544 wrapped := wrapLine(prefix+line, width, indent)
545 lineRunes := []rune(line)
546 seen := 0
547 for wi, w := range wrapped {
548 var leadW int
549 var leadP string
550 body := w
551 if wi == 0 {
552 if strings.HasPrefix(body, prefix) {
553 body = body[len(prefix):]
554 }
555 leadW = promptLen
556 leadP = prefix
557 } else {
558 if strings.HasPrefix(body, indent) {
559 body = body[len(indent):]
560 }
561 leadW = promptLen
562 leadP = indent
563 }
564 bodyRunes := []rune(body)
565 start := seen
566 end := seen + len(bodyRunes)
567 rows = append(rows, vrow{
568 logical: r, runeStart: start, runeEnd: end,
569 leadWidth: leadW, leadPrefix: leadP,
570 })
571 // Record where the cursor currently sits.
572 if r == e.CursorR && e.CursorC >= start && e.CursorC <= end {
573 curVRow = len(rows) - 1
574 inner := e.CursorC - start
575 if inner < 0 {
576 inner = 0
577 }
578 if inner > len(bodyRunes) {
579 inner = len(bodyRunes)
580 }
581 curVCol = leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
582 }
583 seen = end
584 // Word-wrap often drops a single space at the boundary.
585 for seen < len(lineRunes) && lineRunes[seen] == ' ' {
586 seen++
587 }
588 }
589 }
590
591 target := curVRow + dir
592 if target < 0 || target >= len(rows) {
593 return
594 }
595 tr := rows[target]
596 line := e.Lines[tr.logical]
597 lineRunes := []rune(line)
598 bodyRunes := lineRunes[tr.runeStart:tr.runeEnd]
599
600 // Find the rune offset inside bodyRunes whose visible column
601 // most closely matches curVCol after accounting for leadWidth.
602 want := curVCol - tr.leadWidth
603 if want < 0 {
604 want = 0
605 }
606 best := 0
607 bestW := 0
608 for i := 1; i <= len(bodyRunes); i++ {
609 w := runewidth.StringWidth(string(bodyRunes[:i]))
610 if w > want {
611 break
612 }
613 best = i
614 bestW = w
615 if w == want {
616 break
617 }
618 }
619 _ = bestW
620 e.CursorR = tr.logical
621 e.CursorC = tr.runeStart + best
622}
623
624// moveCursorLogical is the pre-visual-navigation fallback used
625// when Render hasn't told us the terminal width yet. Walks the
626// e.Lines array directly.
627func (e *Editor) moveCursorLogical(dir int) {
628 switch {
629 case dir < 0 && e.CursorR > 0:
630 e.CursorR--
631 case dir > 0 && e.CursorR < len(e.Lines)-1:
632 e.CursorR++
633 default:
634 return
635 }
636 if e.CursorC > runeLen(e.Lines[e.CursorR]) {
637 e.CursorC = runeLen(e.Lines[e.CursorR])
638 }
639}
640
641func (e *Editor) deleteWord() {
642 line := e.Lines[e.CursorR]
643 if e.CursorC == 0 {
644 e.backspace()
645 return
646 }
647 r := []rune(line)
648 i := e.CursorC
649 // Step 1: walk over any trailing whitespace to the left of the cursor.
650 for i > 0 && isWordSep(r[i-1]) {
651 i--
652 }
653 // Step 2: walk over the word itself (non-separator runes).
654 for i > 0 && !isWordSep(r[i-1]) {
655 i--
656 }
657 e.Lines[e.CursorR] = string(r[:i]) + string(r[e.CursorC:])
658 e.CursorC = i
659}
660
661// isWordSep reports whether r is a word separator. Anything that isn't
662// a letter, digit, or underscore counts as a separator so delete-word
663// feels natural on paths, code, and prose.
664func isWordSep(r rune) bool {
665 switch {
666 case r == '_':
667 return false
668 case r >= '0' && r <= '9':
669 return false
670 case r >= 'a' && r <= 'z':
671 return false
672 case r >= 'A' && r <= 'Z':
673 return false
674 case r >= 0x80:
675 // Treat all non-ASCII as word chars so CJK/äöü don't get chopped.
676 return false
677 }
678 return true
679}
680
681// ---- rendering ----
682
683// Render returns the editor's visible lines (wrapped to width).
684// visualRow/visualCol describe where the cursor lands within the returned lines.
685func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
686 e.lastRenderWidth = width
687 promptLen := visibleWidth(e.Prompt)
688 indent := strings.Repeat(" ", promptLen)
689
690 for r, line := range e.Lines {
691 var prefix string
692 if r == 0 {
693 prefix = e.Prompt
694 } else {
695 prefix = indent
696 }
697 wrapped := wrapLine(prefix+line, width, indent)
698 if r == e.CursorR {
699 // Compute where CursorC lands inside the wrapped rows by
700 // walking the wrapped output character-by-character and
701 // tracking which wrapped row + column corresponds to the
702 // cursor's rune index in `line`. This is the only reliable
703 // answer under word-wrap, where a simple (promptLen+col)/width
704 // formula overshoots when wrapLine broke on a space.
705 targetRunes := e.CursorC
706 row, col := locateCursor(wrapped, prefix, line, targetRunes, indent)
707 visualRow = len(lines) + row
708 visualCol = col
709 }
710 lines = append(lines, wrapped...)
711 }
712 return lines, visualRow, visualCol
713}
714
715// locateCursor finds the wrapped row + visible column corresponding to
716// `targetRunes` rune positions into the logical `line`, given that the
717// wrapped output `wrapped` started with `prefix` on its first row and
718// uses `cont` as the continuation indent on subsequent rows.
719func locateCursor(wrapped []string, prefix, line string, targetRunes int, cont string) (int, int) {
720 prefixW := visibleWidth(prefix)
721 contW := visibleWidth(cont)
722 lineRunes := []rune(line)
723 if targetRunes > len(lineRunes) {
724 targetRunes = len(lineRunes)
725 }
726
727 seenRunes := 0
728 for row, w := range wrapped {
729 // strip leading prefix / continuation indent before counting runes
730 body := w
731 var leadW int
732 if row == 0 {
733 if strings.HasPrefix(body, prefix) {
734 body = body[len(prefix):]
735 }
736 leadW = prefixW
737 } else {
738 if strings.HasPrefix(body, cont) {
739 body = body[len(cont):]
740 }
741 leadW = contW
742 }
743 bodyRunes := []rune(body)
744 // Could this wrapped row contain the cursor?
745 if targetRunes <= seenRunes+len(bodyRunes) {
746 // Column inside body.
747 inner := targetRunes - seenRunes
748 if inner < 0 {
749 inner = 0
750 }
751 col := leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
752 return row, col
753 }
754 seenRunes += len(bodyRunes)
755 // Word-wrap may have dropped whitespace at the boundary; skip the
756 // corresponding runes in `line` so counts stay aligned.
757 for seenRunes < len(lineRunes) && lineRunes[seenRunes] == ' ' {
758 seenRunes++
759 if seenRunes >= targetRunes {
760 // Cursor landed in the skipped whitespace — place it at
761 // the start of the next wrapped row.
762 nextRow := row + 1
763 if nextRow >= len(wrapped) {
764 nextRow = row
765 }
766 return nextRow, contW
767 }
768 }
769 }
770 // Fallback: end of the last wrapped row.
771 if len(wrapped) == 0 {
772 return 0, prefixW
773 }
774 last := wrapped[len(wrapped)-1]
775 return len(wrapped) - 1, visibleWidth(last)
776}
777
778// ---- helpers ----
779
780func substringBefore(s string, col int) string {
781 r := []rune(s)
782 if col > len(r) {
783 col = len(r)
784 }
785 return string(r[:col])
786}
787
788func substringAfter(s string, col int) string {
789 r := []rune(s)
790 if col > len(r) {
791 col = len(r)
792 }
793 return string(r[col:])
794}
795
796func runeLen(s string) int { return len([]rune(s)) }
797
798func visualColumn(s string, runeCol int) int {
799 r := []rune(s)
800 if runeCol > len(r) {
801 runeCol = len(r)
802 }
803 return runewidth.StringWidth(string(r[:runeCol]))
804}
805
806func visibleWidth(s string) int {
807 return runewidth.StringWidth(stripANSI(s))
808}
809
810func stripANSI(s string) string {
811 // Minimal ANSI stripper; handles CSI sequences (ESC [ ... final).
812 var out []rune
813 i := 0
814 runes := []rune(s)
815 for i < len(runes) {
816 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
817 i += 2
818 for i < len(runes) {
819 c := runes[i]
820 i++
821 if c >= 0x40 && c <= 0x7e {
822 break
823 }
824 }
825 continue
826 }
827 out = append(out, runes[i])
828 i++
829 }
830 return string(out)
831}
832
833func wrapLine(s string, width int, cont string) []string {
834 if width <= 0 {
835 return []string{s}
836 }
837
838 // Tokenize on spaces while preserving their widths. Runs of spaces
839 // stay attached to the preceding word so trailing whitespace doesn't
840 // create empty wrapped lines.
841 type token struct {
842 text string
843 width int
844 space bool // true if this token is whitespace only
845 }
846 var tokens []token
847 {
848 var buf strings.Builder
849 var bufW int
850 inSpace := false
851 flush := func() {
852 if buf.Len() == 0 {
853 return
854 }
855 tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace})
856 buf.Reset()
857 bufW = 0
858 }
859 for _, r := range s {
860 rSpace := r == ' ' || r == '\t'
861 if buf.Len() == 0 {
862 inSpace = rSpace
863 } else if rSpace != inSpace {
864 flush()
865 inSpace = rSpace
866 }
867 buf.WriteRune(r)
868 bufW += runewidth.RuneWidth(r)
869 }
870 flush()
871 }
872
873 var out []string
874 var cur strings.Builder
875 curW := 0
876 firstLine := true
877 contW := visibleWidth(cont)
878
879 newLine := func() {
880 out = append(out, cur.String())
881 cur.Reset()
882 curW = 0
883 // After flushing, everything we append next is a CONTINUATION
884 // row, which must start with the cont indent so the editor's
885 // visual cursor alignment stays consistent. The old code only
886 // wrote cont when !firstLine BEFORE toggling firstLine, which
887 // meant the very first wrap never got its indent. That caused
888 // the terminal cursor to land in the wrong column after a
889 // multi-line paste.
890 firstLine = false
891 cur.WriteString(cont)
892 curW = contW
893 }
894
895 for i := 0; i < len(tokens); i++ {
896 tk := tokens[i]
897
898 // Drop leading whitespace after a wrap.
899 if tk.space && curW == contW && !firstLine {
900 continue
901 }
902
903 if curW+tk.width <= width {
904 cur.WriteString(tk.text)
905 curW += tk.width
906 continue
907 }
908
909 // Token overflows.
910 //
911 // If the token on its own is wider than what fits on a
912 // continuation line (width - contW), it'll need to be split
913 // rune-by-rune no matter what. In that case, don't break
914 // first: let the rune-split start right here at the current
915 // column and wrap naturally. Breaking first would strand the
916 // prompt (on firstLine) or the cont indent (on later lines)
917 // on a row by itself, which is the drag-drop-long-path bug.
918 if tk.width > width-contW {
919 // Rune-by-rune starting from the current column. newLine()
920 // inside handles wrapping and re-indents.
921 for _, r := range tk.text {
922 rw := runewidth.RuneWidth(r)
923 if curW+rw > width {
924 newLine()
925 }
926 cur.WriteRune(r)
927 curW += rw
928 }
929 continue
930 }
931
932 // Token fits on a fresh continuation line; break first, then
933 // emit it whole.
934 if cur.Len() > 0 && !(firstLine && curW == 0) {
935 trimmed := strings.TrimRight(cur.String(), " \t")
936 cur.Reset()
937 cur.WriteString(trimmed)
938 curW = visibleWidth(trimmed)
939 newLine()
940 if tk.space {
941 continue
942 }
943 }
944 cur.WriteString(tk.text)
945 curW += tk.width
946 }
947
948 if cur.Len() > 0 || len(out) == 0 {
949 out = append(out, cur.String())
950 }
951 return out
952}
953
954func max(a, b int) int {
955 if a > b {
956 return a
957 }
958 return b
959}
960
961// pasteShouldCollapse reports whether a pasted chunk is big enough
962// to deserve a placeholder token instead of being inserted verbatim.
963// Two or more newlines (i.e. three-plus lines) is the cutoff: that
964// covers real multi-line code/log pastes while leaving single-line
965// file-drop paths and two-line copy-pastes of a sentence alone.
966func pasteShouldCollapse(s string) bool {
967 return strings.Count(s, "\n") >= 2
968}
969
970// countLines returns the number of visual lines in s. A trailing
971// newline is not counted as an extra empty line so
972// "foo\nbar\n" reads as 2 lines (what the user expects in the
973// "+N lines" summary) instead of 3.
974func countLines(s string) int {
975 if s == "" {
976 return 0
977 }
978 n := strings.Count(s, "\n")
979 if !strings.HasSuffix(s, "\n") {
980 n++
981 }
982 return n
983}
984
985// pastePlaceholderRE matches the "[pasted text #N +L lines]" token
986// that collapsed pastes leave in the editor. Capture group 1 is
987// the numeric id used to look up the full body in e.pastes.
988var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) \+\d+ lines?\]`)
989
990// expandPastePlaceholders returns raw with every paste token
991// swapped for the body stored under its id in pastes. Tokens
992// whose id isn't in the map are left as-is (user deleted the
993// corresponding entry somehow, or the id is spurious user text
994// that happens to match the shape).
995func expandPastePlaceholders(raw string, pastes map[int]string) string {
996 return pastePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
997 groups := pastePlaceholderRE.FindStringSubmatch(match)
998 if len(groups) < 2 {
999 return match
1000 }
1001 var id int
1002 if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
1003 return match
1004 }
1005 if body, ok := pastes[id]; ok {
1006 return body
1007 }
1008 return match
1009 })
1010}