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