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 out = append(out, runes[i])
916 i++
917 }
918 return string(out)
919}
920
921// WrapANSILine is the exported form of wrapANSILine so other modes /
922// dialogs can reuse the same visible-width-aware wrap behavior.
923func WrapANSILine(s string, limit int) []string { return wrapANSILine(s, limit) }
924
925// wrapANSILine folds a string that may contain ANSI CSI escapes so that
926// the visible width of each line stays within limit. Breaks happen on
927// spaces when possible, falling back to mid-token splits for very long
928// unbroken runs. Escape sequences are preserved verbatim and never
929// counted toward the visible column.
930func wrapANSILine(s string, limit int) []string {
931 if limit <= 0 {
932 return []string{s}
933 }
934 if visibleWidth(s) <= limit {
935 return []string{s}
936 }
937 runes := []rune(s)
938 var out []string
939 var line strings.Builder
940 var word strings.Builder
941 lineW := 0
942 wordW := 0
943
944 flushLine := func() {
945 out = append(out, line.String())
946 line.Reset()
947 lineW = 0
948 }
949 flushWord := func() {
950 if word.Len() == 0 {
951 return
952 }
953 if lineW+wordW > limit && lineW > 0 {
954 flushLine()
955 }
956 // If the word alone is bigger than the limit, split it by runes.
957 if wordW > limit {
958 wr := []rune(word.String())
959 for i := 0; i < len(wr); {
960 r := wr[i]
961 if r == 0x1b && i+1 < len(wr) && wr[i+1] == '[' {
962 line.WriteRune(r)
963 line.WriteRune(wr[i+1])
964 i += 2
965 for i < len(wr) {
966 c := wr[i]
967 line.WriteRune(c)
968 i++
969 if c >= 0x40 && c <= 0x7e {
970 break
971 }
972 }
973 continue
974 }
975 rw := runewidth.RuneWidth(r)
976 if lineW+rw > limit && lineW > 0 {
977 flushLine()
978 }
979 line.WriteRune(r)
980 lineW += rw
981 i++
982 }
983 word.Reset()
984 wordW = 0
985 return
986 }
987 line.WriteString(word.String())
988 lineW += wordW
989 word.Reset()
990 wordW = 0
991 }
992
993 for i := 0; i < len(runes); {
994 r := runes[i]
995 if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
996 word.WriteRune(r)
997 word.WriteRune(runes[i+1])
998 i += 2
999 for i < len(runes) {
1000 c := runes[i]
1001 word.WriteRune(c)
1002 i++
1003 if c >= 0x40 && c <= 0x7e {
1004 break
1005 }
1006 }
1007 continue
1008 }
1009 if r == ' ' || r == '\t' {
1010 flushWord()
1011 rw := runewidth.RuneWidth(r)
1012 if lineW+rw > limit && lineW > 0 {
1013 flushLine()
1014 i++
1015 continue
1016 }
1017 line.WriteRune(r)
1018 lineW += rw
1019 i++
1020 continue
1021 }
1022 word.WriteRune(r)
1023 wordW += runewidth.RuneWidth(r)
1024 i++
1025 }
1026 flushWord()
1027 if line.Len() > 0 {
1028 flushLine()
1029 }
1030 if len(out) == 0 {
1031 out = []string{""}
1032 }
1033 return out
1034}
1035
1036func wrapLine(s string, width int, cont string) []string {
1037 if width <= 0 {
1038 return []string{s}
1039 }
1040
1041 // Tokenize on spaces while preserving their widths. Runs of spaces
1042 // stay attached to the preceding word so trailing whitespace doesn't
1043 // create empty wrapped lines.
1044 type token struct {
1045 text string
1046 width int
1047 space bool // true if this token is whitespace only
1048 }
1049 var tokens []token
1050 {
1051 var buf strings.Builder
1052 var bufW int
1053 inSpace := false
1054 flush := func() {
1055 if buf.Len() == 0 {
1056 return
1057 }
1058 tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace})
1059 buf.Reset()
1060 bufW = 0
1061 }
1062 for _, r := range s {
1063 rSpace := r == ' ' || r == '\t'
1064 if buf.Len() == 0 {
1065 inSpace = rSpace
1066 } else if rSpace != inSpace {
1067 flush()
1068 inSpace = rSpace
1069 }
1070 buf.WriteRune(r)
1071 bufW += runewidth.RuneWidth(r)
1072 }
1073 flush()
1074 }
1075
1076 var out []string
1077 var cur strings.Builder
1078 curW := 0
1079 firstLine := true
1080 contW := visibleWidth(cont)
1081
1082 newLine := func() {
1083 out = append(out, cur.String())
1084 cur.Reset()
1085 curW = 0
1086 // After flushing, everything we append next is a CONTINUATION
1087 // row, which must start with the cont indent so the editor's
1088 // visual cursor alignment stays consistent. The old code only
1089 // wrote cont when !firstLine BEFORE toggling firstLine, which
1090 // meant the very first wrap never got its indent. That caused
1091 // the terminal cursor to land in the wrong column after a
1092 // multi-line paste.
1093 firstLine = false
1094 cur.WriteString(cont)
1095 curW = contW
1096 }
1097
1098 for i := 0; i < len(tokens); i++ {
1099 tk := tokens[i]
1100
1101 // Drop leading whitespace after a wrap.
1102 if tk.space && curW == contW && !firstLine {
1103 continue
1104 }
1105
1106 if curW+tk.width <= width {
1107 cur.WriteString(tk.text)
1108 curW += tk.width
1109 continue
1110 }
1111
1112 // Token overflows.
1113 //
1114 // If the token on its own is wider than what fits on a
1115 // continuation line (width - contW), it'll need to be split
1116 // rune-by-rune no matter what. In that case, don't break
1117 // first: let the rune-split start right here at the current
1118 // column and wrap naturally. Breaking first would strand the
1119 // prompt (on firstLine) or the cont indent (on later lines)
1120 // on a row by itself, which is the drag-drop-long-path bug.
1121 if tk.width > width-contW {
1122 // Rune-by-rune starting from the current column. newLine()
1123 // inside handles wrapping and re-indents.
1124 for _, r := range tk.text {
1125 rw := runewidth.RuneWidth(r)
1126 if curW+rw > width {
1127 newLine()
1128 }
1129 cur.WriteRune(r)
1130 curW += rw
1131 }
1132 continue
1133 }
1134
1135 // Token fits on a fresh continuation line; break first, then
1136 // emit it whole.
1137 if cur.Len() > 0 && (!firstLine || curW != 0) {
1138 trimmed := strings.TrimRight(cur.String(), " \t")
1139 cur.Reset()
1140 cur.WriteString(trimmed)
1141 curW = visibleWidth(trimmed)
1142 newLine()
1143 if tk.space {
1144 continue
1145 }
1146 }
1147 cur.WriteString(tk.text)
1148 curW += tk.width
1149 }
1150
1151 if cur.Len() > 0 || len(out) == 0 {
1152 out = append(out, cur.String())
1153 }
1154 return out
1155}
1156
1157func max(a, b int) int {
1158 if a > b {
1159 return a
1160 }
1161 return b
1162}
1163
1164// pasteCollapseLineThreshold and pasteCollapseCharThreshold govern
1165// when a bracketed paste gets collapsed to a [pasted text #N ...]
1166// placeholder instead of being inserted inline. Either trigger
1167// alone is enough — a 500-line log dump and a 1200-character
1168// one-line log entry both bloat the editor in ways the user
1169// doesn't want to scroll through while composing a prompt.
1170const (
1171 pasteCollapseLineThreshold = 10
1172 pasteCollapseCharThreshold = 1000
1173)
1174
1175// pasteShouldCollapse reports whether a pasted chunk is big enough
1176// to deserve a placeholder token instead of being inserted verbatim.
1177// Collapse on > 10 lines OR > 1000 characters, whichever fires
1178// first.
1179func pasteShouldCollapse(s string) bool {
1180 return countLines(s) > pasteCollapseLineThreshold || len(s) > pasteCollapseCharThreshold
1181}
1182
1183// formatPastePlaceholder builds the visible marker for a
1184// collapsed paste. Two shapes:
1185//
1186// [pasted text #N +L lines] — used when the line count is the
1187// trigger (multi-line dumps)
1188// [pasted text #N C chars] — used when the character count is
1189// the trigger (long single-line
1190// or near-single-line pastes)
1191//
1192// Line-triggered takes precedence so a 12-line 4000-char paste
1193// reads as "+12 lines", not "4000 chars".
1194func formatPastePlaceholder(id int, body string) string {
1195 if countLines(body) > pasteCollapseLineThreshold {
1196 return fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(body))
1197 }
1198 return fmt.Sprintf("[pasted text #%d %d chars]", id, len(body))
1199}
1200
1201// countLines returns the number of visual lines in s. A trailing
1202// newline is not counted as an extra empty line so
1203// "foo\nbar\n" reads as 2 lines (what the user expects in the
1204// "+N lines" summary) instead of 3.
1205func countLines(s string) int {
1206 if s == "" {
1207 return 0
1208 }
1209 n := strings.Count(s, "\n")
1210 if !strings.HasSuffix(s, "\n") {
1211 n++
1212 }
1213 return n
1214}
1215
1216// pastePlaceholderRE matches both placeholder shapes the editor
1217// emits for a collapsed paste:
1218//
1219// [pasted text #N +L lines] — multi-line trigger
1220// [pasted text #N C chars] — long-char trigger
1221//
1222// Capture group 1 is the numeric id used to look up the full
1223// body in e.pastes; the rest of the token is free-form and gets
1224// discarded during expansion.
1225var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)
1226var filePlaceholderRE = regexp.MustCompile(`\[(file|dir):(\d+):[^\]]+\]`)
1227
1228// expandPastePlaceholders returns raw with every paste token
1229// swapped for the body stored under its id in pastes. Tokens
1230// whose id isn't in the map are left as-is (user deleted the
1231// corresponding entry somehow, or the id is spurious user text
1232// that happens to match the shape).
1233func expandPastePlaceholders(raw string, pastes map[int]string) string {
1234 return pastePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
1235 groups := pastePlaceholderRE.FindStringSubmatch(match)
1236 if len(groups) < 2 {
1237 return match
1238 }
1239 var id int
1240 if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
1241 return match
1242 }
1243 if body, ok := pastes[id]; ok {
1244 return body
1245 }
1246 return match
1247 })
1248}
1249
1250// expandFilePlaceholders returns raw with every [file:N:name] and
1251// [dir:N:name/] token swapped for the full path stored under its id.
1252func expandFilePlaceholders(raw string, files, dirs map[int]string) string {
1253 return filePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
1254 groups := filePlaceholderRE.FindStringSubmatch(match)
1255 if len(groups) < 3 {
1256 return match
1257 }
1258 kind := groups[1] // "file" or "dir"
1259 var id int
1260 if _, err := fmt.Sscanf(groups[2], "%d", &id); err != nil {
1261 return match
1262 }
1263 var m map[int]string
1264 if kind == "dir" {
1265 m = dirs
1266 } else {
1267 m = files
1268 }
1269 if path, ok := m[id]; ok {
1270 return path
1271 }
1272 return match
1273 })
1274}
1275
1276// collapseOrQuoteFilePaths checks if the paste is a file path and
1277// collapses it to a [file:basename] chip. URLs are left as-is.
1278// Non-path pastes fall through to quotePastedFilePaths.
1279func (e *Editor) collapseOrQuoteFilePaths(paste string) string {
1280 if paste == "" || strings.ContainsRune(paste, '\n') {
1281 return quotePastedFilePaths(paste)
1282 }
1283 trimmed := strings.TrimSpace(paste)
1284 // Don't collapse URLs.
1285 if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
1286 return paste
1287 }
1288 // Check if it's a single file path.
1289 p, ok := normalisePathToken(trimmed)
1290 if !ok {
1291 return quotePastedFilePaths(paste)
1292 }
1293 // Only collapse if the path is long enough to benefit.
1294 base := filepath.Base(p)
1295 if len(p) <= len(base)+5 {
1296 // Short path like "./foo.txt" - just quote it.
1297 return singleQuote(p)
1298 }
1299 // Check if it's a directory.
1300 info, err := os.Stat(p)
1301 if err == nil && info.IsDir() {
1302 if e.dirs == nil {
1303 e.dirs = map[int]string{}
1304 }
1305 e.dirSeq++
1306 e.dirs[e.dirSeq] = singleQuote(p)
1307 return fmt.Sprintf("[dir:%d:%s/]", e.dirSeq, base)
1308 }
1309 if e.files == nil {
1310 e.files = map[int]string{}
1311 }
1312 e.fileSeq++
1313 e.files[e.fileSeq] = singleQuote(p)
1314 return fmt.Sprintf("[file:%d:%s]", e.fileSeq, base)
1315}