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