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