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