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 if strings.HasPrefix(body, prefix) {
622 body = body[len(prefix):]
623 }
624 leadW = promptLen
625 leadP = prefix
626 } else {
627 if strings.HasPrefix(body, indent) {
628 body = body[len(indent):]
629 }
630 leadW = promptLen
631 leadP = indent
632 }
633 bodyRunes := []rune(body)
634 start := seen
635 end := seen + len(bodyRunes)
636 rows = append(rows, vrow{
637 logical: r, runeStart: start, runeEnd: end,
638 leadWidth: leadW, leadPrefix: leadP,
639 })
640 // Record where the cursor currently sits.
641 if r == e.CursorR && e.CursorC >= start && e.CursorC <= end {
642 curVRow = len(rows) - 1
643 inner := e.CursorC - start
644 if inner < 0 {
645 inner = 0
646 }
647 if inner > len(bodyRunes) {
648 inner = len(bodyRunes)
649 }
650 curVCol = leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
651 }
652 seen = end
653 // Word-wrap often drops a single space at the boundary.
654 for seen < len(lineRunes) && lineRunes[seen] == ' ' {
655 seen++
656 }
657 }
658 }
659
660 target := curVRow + dir
661 if target < 0 || target >= len(rows) {
662 return false
663 }
664 tr := rows[target]
665 line := e.Lines[tr.logical]
666 lineRunes := []rune(line)
667 bodyRunes := lineRunes[tr.runeStart:tr.runeEnd]
668
669 // Find the rune offset inside bodyRunes whose visible column
670 // most closely matches curVCol after accounting for leadWidth.
671 want := curVCol - tr.leadWidth
672 if want < 0 {
673 want = 0
674 }
675 best := 0
676 bestW := 0
677 for i := 1; i <= len(bodyRunes); i++ {
678 w := runewidth.StringWidth(string(bodyRunes[:i]))
679 if w > want {
680 break
681 }
682 best = i
683 bestW = w
684 if w == want {
685 break
686 }
687 }
688 _ = bestW
689 e.CursorR = tr.logical
690 e.CursorC = tr.runeStart + best
691 return true
692}
693
694// moveCursorLogical is the pre-visual-navigation fallback used
695// when Render hasn't told us the terminal width yet. Walks the
696// e.Lines array directly.
697func (e *Editor) moveCursorLogical(dir int) bool {
698 switch {
699 case dir < 0 && e.CursorR > 0:
700 e.CursorR--
701 case dir > 0 && e.CursorR < len(e.Lines)-1:
702 e.CursorR++
703 default:
704 return false
705 }
706 if e.CursorC > runeLen(e.Lines[e.CursorR]) {
707 e.CursorC = runeLen(e.Lines[e.CursorR])
708 }
709 return true
710}
711
712func (e *Editor) deleteWord() {
713 line := e.Lines[e.CursorR]
714 if e.CursorC == 0 {
715 e.backspace()
716 return
717 }
718 r := []rune(line)
719 i := e.CursorC
720 // Step 1: walk over any trailing whitespace to the left of the cursor.
721 for i > 0 && isWordSep(r[i-1]) {
722 i--
723 }
724 // Step 2: walk over the word itself (non-separator runes).
725 for i > 0 && !isWordSep(r[i-1]) {
726 i--
727 }
728 e.Lines[e.CursorR] = string(r[:i]) + string(r[e.CursorC:])
729 e.CursorC = i
730}
731
732// isWordSep reports whether r is a word separator. Anything that isn't
733// a letter, digit, or underscore counts as a separator so delete-word
734// feels natural on paths, code, and prose.
735func isWordSep(r rune) bool {
736 switch {
737 case r == '_':
738 return false
739 case r >= '0' && r <= '9':
740 return false
741 case r >= 'a' && r <= 'z':
742 return false
743 case r >= 'A' && r <= 'Z':
744 return false
745 case r >= 0x80:
746 // Treat all non-ASCII as word chars so CJK/äöü don't get chopped.
747 return false
748 }
749 return true
750}
751
752// ---- rendering ----
753
754// Render returns the editor's visible lines (wrapped to width).
755// visualRow/visualCol describe where the cursor lands within the returned lines.
756func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
757 e.lastRenderWidth = width
758 // The Prompt may carry ANSI styling (theme-coloured glyph + reset).
759 // We must compute wrap geometry using its *visible* width only;
760 // otherwise the raw escape bytes leak into wrapLine's per-rune
761 // width accounting and the cursor column lands inside the row
762 // instead of at the end. See the wrapLine docs for why we keep it
763 // ANSI-unaware: the rest of the codebase relies on its simple
764 // rune-based behaviour for plain text and would regress if we
765 // taught it about escape sequences here. Strip ANSI from the
766 // prompt used for layout, then re-apply the original styling to
767 // the very first wrapped row before returning.
768 plainPrompt := stripANSI(e.Prompt)
769 promptLen := runewidth.StringWidth(plainPrompt)
770 indent := strings.Repeat(" ", promptLen)
771
772 for r, line := range e.Lines {
773 renderedLine := line
774 if e.Mask {
775 renderedLine = strings.Repeat("*", len([]rune(line)))
776 }
777 var prefix string
778 if r == 0 {
779 prefix = plainPrompt
780 } else {
781 prefix = indent
782 }
783 wrapped := wrapLine(prefix+renderedLine, width, indent)
784 if r == e.CursorR {
785 // Compute where CursorC lands inside the wrapped rows by
786 // walking the wrapped output character-by-character and
787 // tracking which wrapped row + column corresponds to the
788 // cursor's rune index in `line`. This is the only reliable
789 // answer under word-wrap, where a simple (promptLen+col)/width
790 // formula overshoots when wrapLine broke on a space.
791 targetRunes := e.CursorC
792 row, col := locateCursor(wrapped, prefix, renderedLine, targetRunes, indent)
793 visualRow = len(lines) + row
794 visualCol = col
795 }
796 // Re-attach the styled prompt to the very first wrapped row of
797 // the first logical line. All later rows are indent-only and
798 // don't need styling. The replacement is byte-exact because the
799 // plain prompt sits at the row's start.
800 if r == 0 && len(wrapped) > 0 && strings.HasPrefix(wrapped[0], plainPrompt) {
801 wrapped[0] = e.Prompt + wrapped[0][len(plainPrompt):]
802 }
803 lines = append(lines, wrapped...)
804 }
805 return lines, visualRow, visualCol
806}
807
808// locateCursor finds the wrapped row + visible column corresponding to
809// `targetRunes` rune positions into the logical `line`, given that the
810// wrapped output `wrapped` started with `prefix` on its first row and
811// uses `cont` as the continuation indent on subsequent rows.
812func locateCursor(wrapped []string, prefix, line string, targetRunes int, cont string) (int, int) {
813 prefixW := visibleWidth(prefix)
814 contW := visibleWidth(cont)
815 lineRunes := []rune(line)
816 if targetRunes > len(lineRunes) {
817 targetRunes = len(lineRunes)
818 }
819
820 seenRunes := 0
821 for row, w := range wrapped {
822 // strip leading prefix / continuation indent before counting runes
823 body := w
824 var leadW int
825 if row == 0 {
826 if strings.HasPrefix(body, prefix) {
827 body = body[len(prefix):]
828 }
829 leadW = prefixW
830 } else {
831 if strings.HasPrefix(body, cont) {
832 body = body[len(cont):]
833 }
834 leadW = contW
835 }
836 bodyRunes := []rune(body)
837 // Could this wrapped row contain the cursor?
838 if targetRunes <= seenRunes+len(bodyRunes) {
839 // Column inside body.
840 inner := targetRunes - seenRunes
841 if inner < 0 {
842 inner = 0
843 }
844 col := leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
845 return row, col
846 }
847 seenRunes += len(bodyRunes)
848 // Word-wrap may have dropped whitespace at the boundary; skip the
849 // corresponding runes in `line` so counts stay aligned.
850 for seenRunes < len(lineRunes) && lineRunes[seenRunes] == ' ' {
851 seenRunes++
852 if seenRunes >= targetRunes {
853 // Cursor landed in the skipped whitespace — place it at
854 // the start of the next wrapped row.
855 nextRow := row + 1
856 if nextRow >= len(wrapped) {
857 nextRow = row
858 }
859 return nextRow, contW
860 }
861 }
862 }
863 // Fallback: end of the last wrapped row.
864 if len(wrapped) == 0 {
865 return 0, prefixW
866 }
867 last := wrapped[len(wrapped)-1]
868 return len(wrapped) - 1, visibleWidth(last)
869}
870
871// ---- helpers ----
872
873func substringBefore(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 substringAfter(s string, col int) string {
882 r := []rune(s)
883 if col > len(r) {
884 col = len(r)
885 }
886 return string(r[col:])
887}
888
889func runeLen(s string) int { return len([]rune(s)) }
890
891// normalizeEditorText converts all common line endings to \n before text
892// reaches the renderer. A literal carriage return would move the terminal
893// cursor back to column 0 and overwrite the left side of the input row.
894func normalizeEditorText(s string) string {
895 if !strings.ContainsRune(s, '\r') {
896 return s
897 }
898 s = strings.ReplaceAll(s, "\r\n", "\n")
899 return strings.ReplaceAll(s, "\r", "\n")
900}
901
902func visualColumn(s string, runeCol int) int {
903 r := []rune(s)
904 if runeCol > len(r) {
905 runeCol = len(r)
906 }
907 return runewidth.StringWidth(string(r[:runeCol]))
908}
909
910func visibleWidth(s string) int {
911 return runewidth.StringWidth(stripANSI(s))
912}
913
914func stripANSI(s string) string {
915 // Minimal ANSI stripper; handles CSI sequences (ESC [ ... final).
916 var out []rune
917 i := 0
918 runes := []rune(s)
919 for i < len(runes) {
920 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
921 i += 2
922 for i < len(runes) {
923 c := runes[i]
924 i++
925 if c >= 0x40 && c <= 0x7e {
926 break
927 }
928 }
929 continue
930 }
931 out = append(out, runes[i])
932 i++
933 }
934 return string(out)
935}
936
937// WrapANSILine is the exported form of wrapANSILine so other modes /
938// dialogs can reuse the same visible-width-aware wrap behavior.
939func WrapANSILine(s string, limit int) []string { return wrapANSILine(s, limit) }
940
941// wrapANSILine folds a string that may contain ANSI CSI escapes so that
942// the visible width of each line stays within limit. Breaks happen on
943// spaces when possible, falling back to mid-token splits for very long
944// unbroken runs. Escape sequences are preserved verbatim and never
945// counted toward the visible column.
946func wrapANSILine(s string, limit int) []string {
947 if limit <= 0 {
948 return []string{s}
949 }
950 if visibleWidth(s) <= limit {
951 return []string{s}
952 }
953 runes := []rune(s)
954 var out []string
955 var line strings.Builder
956 var word strings.Builder
957 lineW := 0
958 wordW := 0
959
960 flushLine := func() {
961 out = append(out, line.String())
962 line.Reset()
963 lineW = 0
964 }
965 flushWord := func() {
966 if word.Len() == 0 {
967 return
968 }
969 if lineW+wordW > limit && lineW > 0 {
970 flushLine()
971 }
972 // If the word alone is bigger than the limit, split it by runes.
973 if wordW > limit {
974 wr := []rune(word.String())
975 for i := 0; i < len(wr); {
976 r := wr[i]
977 if r == 0x1b && i+1 < len(wr) && wr[i+1] == '[' {
978 line.WriteRune(r)
979 line.WriteRune(wr[i+1])
980 i += 2
981 for i < len(wr) {
982 c := wr[i]
983 line.WriteRune(c)
984 i++
985 if c >= 0x40 && c <= 0x7e {
986 break
987 }
988 }
989 continue
990 }
991 rw := runewidth.RuneWidth(r)
992 if lineW+rw > limit && lineW > 0 {
993 flushLine()
994 }
995 line.WriteRune(r)
996 lineW += rw
997 i++
998 }
999 word.Reset()
1000 wordW = 0
1001 return
1002 }
1003 line.WriteString(word.String())
1004 lineW += wordW
1005 word.Reset()
1006 wordW = 0
1007 }
1008
1009 for i := 0; i < len(runes); {
1010 r := runes[i]
1011 if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
1012 word.WriteRune(r)
1013 word.WriteRune(runes[i+1])
1014 i += 2
1015 for i < len(runes) {
1016 c := runes[i]
1017 word.WriteRune(c)
1018 i++
1019 if c >= 0x40 && c <= 0x7e {
1020 break
1021 }
1022 }
1023 continue
1024 }
1025 if r == ' ' || r == '\t' {
1026 flushWord()
1027 rw := runewidth.RuneWidth(r)
1028 if lineW+rw > limit && lineW > 0 {
1029 flushLine()
1030 i++
1031 continue
1032 }
1033 line.WriteRune(r)
1034 lineW += rw
1035 i++
1036 continue
1037 }
1038 word.WriteRune(r)
1039 wordW += runewidth.RuneWidth(r)
1040 i++
1041 }
1042 flushWord()
1043 if line.Len() > 0 {
1044 flushLine()
1045 }
1046 if len(out) == 0 {
1047 out = []string{""}
1048 }
1049 return out
1050}
1051
1052func wrapLine(s string, width int, cont string) []string {
1053 if width <= 0 {
1054 return []string{s}
1055 }
1056
1057 // Tokenize on spaces while preserving their widths. Runs of spaces
1058 // stay attached to the preceding word so trailing whitespace doesn't
1059 // create empty wrapped lines.
1060 type token struct {
1061 text string
1062 width int
1063 space bool // true if this token is whitespace only
1064 }
1065 var tokens []token
1066 {
1067 var buf strings.Builder
1068 var bufW int
1069 inSpace := false
1070 flush := func() {
1071 if buf.Len() == 0 {
1072 return
1073 }
1074 tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace})
1075 buf.Reset()
1076 bufW = 0
1077 }
1078 for _, r := range s {
1079 rSpace := r == ' ' || r == '\t'
1080 if buf.Len() == 0 {
1081 inSpace = rSpace
1082 } else if rSpace != inSpace {
1083 flush()
1084 inSpace = rSpace
1085 }
1086 buf.WriteRune(r)
1087 bufW += runewidth.RuneWidth(r)
1088 }
1089 flush()
1090 }
1091
1092 var out []string
1093 var cur strings.Builder
1094 curW := 0
1095 firstLine := true
1096 contW := visibleWidth(cont)
1097
1098 newLine := func() {
1099 out = append(out, cur.String())
1100 cur.Reset()
1101 curW = 0
1102 // After flushing, everything we append next is a CONTINUATION
1103 // row, which must start with the cont indent so the editor's
1104 // visual cursor alignment stays consistent. The old code only
1105 // wrote cont when !firstLine BEFORE toggling firstLine, which
1106 // meant the very first wrap never got its indent. That caused
1107 // the terminal cursor to land in the wrong column after a
1108 // multi-line paste.
1109 firstLine = false
1110 cur.WriteString(cont)
1111 curW = contW
1112 }
1113
1114 for i := 0; i < len(tokens); i++ {
1115 tk := tokens[i]
1116
1117 // Drop leading whitespace after a wrap.
1118 if tk.space && curW == contW && !firstLine {
1119 continue
1120 }
1121
1122 if curW+tk.width <= width {
1123 cur.WriteString(tk.text)
1124 curW += tk.width
1125 continue
1126 }
1127
1128 // Token overflows.
1129 //
1130 // If the token on its own is wider than what fits on a
1131 // continuation line (width - contW), it'll need to be split
1132 // rune-by-rune no matter what. In that case, don't break
1133 // first: let the rune-split start right here at the current
1134 // column and wrap naturally. Breaking first would strand the
1135 // prompt (on firstLine) or the cont indent (on later lines)
1136 // on a row by itself, which is the drag-drop-long-path bug.
1137 if tk.width > width-contW {
1138 // Rune-by-rune starting from the current column. newLine()
1139 // inside handles wrapping and re-indents.
1140 for _, r := range tk.text {
1141 rw := runewidth.RuneWidth(r)
1142 if curW+rw > width {
1143 newLine()
1144 }
1145 cur.WriteRune(r)
1146 curW += rw
1147 }
1148 continue
1149 }
1150
1151 // Token fits on a fresh continuation line; break first, then
1152 // emit it whole.
1153 if cur.Len() > 0 && !(firstLine && curW == 0) {
1154 trimmed := strings.TrimRight(cur.String(), " \t")
1155 cur.Reset()
1156 cur.WriteString(trimmed)
1157 curW = visibleWidth(trimmed)
1158 newLine()
1159 if tk.space {
1160 continue
1161 }
1162 }
1163 cur.WriteString(tk.text)
1164 curW += tk.width
1165 }
1166
1167 if cur.Len() > 0 || len(out) == 0 {
1168 out = append(out, cur.String())
1169 }
1170 return out
1171}
1172
1173func max(a, b int) int {
1174 if a > b {
1175 return a
1176 }
1177 return b
1178}
1179
1180// pasteCollapseLineThreshold and pasteCollapseCharThreshold govern
1181// when a bracketed paste gets collapsed to a [pasted text #N ...]
1182// placeholder instead of being inserted inline. Either trigger
1183// alone is enough — a 500-line log dump and a 1200-character
1184// one-line log entry both bloat the editor in ways the user
1185// doesn't want to scroll through while composing a prompt.
1186const (
1187 pasteCollapseLineThreshold = 10
1188 pasteCollapseCharThreshold = 1000
1189)
1190
1191// pasteShouldCollapse reports whether a pasted chunk is big enough
1192// to deserve a placeholder token instead of being inserted verbatim.
1193// Collapse on > 10 lines OR > 1000 characters, whichever fires
1194// first.
1195func pasteShouldCollapse(s string) bool {
1196 return countLines(s) > pasteCollapseLineThreshold || len(s) > pasteCollapseCharThreshold
1197}
1198
1199// formatPastePlaceholder builds the visible marker for a
1200// collapsed paste. Two shapes:
1201//
1202// [pasted text #N +L lines] — used when the line count is the
1203// trigger (multi-line dumps)
1204// [pasted text #N C chars] — used when the character count is
1205// the trigger (long single-line
1206// or near-single-line pastes)
1207//
1208// Line-triggered takes precedence so a 12-line 4000-char paste
1209// reads as "+12 lines", not "4000 chars".
1210func formatPastePlaceholder(id int, body string) string {
1211 if countLines(body) > pasteCollapseLineThreshold {
1212 return fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(body))
1213 }
1214 return fmt.Sprintf("[pasted text #%d %d chars]", id, len(body))
1215}
1216
1217// countLines returns the number of visual lines in s. A trailing
1218// newline is not counted as an extra empty line so
1219// "foo\nbar\n" reads as 2 lines (what the user expects in the
1220// "+N lines" summary) instead of 3.
1221func countLines(s string) int {
1222 if s == "" {
1223 return 0
1224 }
1225 n := strings.Count(s, "\n")
1226 if !strings.HasSuffix(s, "\n") {
1227 n++
1228 }
1229 return n
1230}
1231
1232// pastePlaceholderRE matches both placeholder shapes the editor
1233// emits for a collapsed paste:
1234//
1235// [pasted text #N +L lines] — multi-line trigger
1236// [pasted text #N C chars] — long-char trigger
1237//
1238// Capture group 1 is the numeric id used to look up the full
1239// body in e.pastes; the rest of the token is free-form and gets
1240// discarded during expansion.
1241var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)
1242var filePlaceholderRE = regexp.MustCompile(`\[(file|dir):(\d+):[^\]]+\]`)
1243
1244// expandPastePlaceholders returns raw with every paste token
1245// swapped for the body stored under its id in pastes. Tokens
1246// whose id isn't in the map are left as-is (user deleted the
1247// corresponding entry somehow, or the id is spurious user text
1248// that happens to match the shape).
1249func expandPastePlaceholders(raw string, pastes map[int]string) string {
1250 return pastePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
1251 groups := pastePlaceholderRE.FindStringSubmatch(match)
1252 if len(groups) < 2 {
1253 return match
1254 }
1255 var id int
1256 if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
1257 return match
1258 }
1259 if body, ok := pastes[id]; ok {
1260 return body
1261 }
1262 return match
1263 })
1264}
1265
1266// expandFilePlaceholders returns raw with every [file:N:name] and
1267// [dir:N:name/] token swapped for the full path stored under its id.
1268func expandFilePlaceholders(raw string, files, dirs map[int]string) string {
1269 return filePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
1270 groups := filePlaceholderRE.FindStringSubmatch(match)
1271 if len(groups) < 3 {
1272 return match
1273 }
1274 kind := groups[1] // "file" or "dir"
1275 var id int
1276 if _, err := fmt.Sscanf(groups[2], "%d", &id); err != nil {
1277 return match
1278 }
1279 var m map[int]string
1280 if kind == "dir" {
1281 m = dirs
1282 } else {
1283 m = files
1284 }
1285 if path, ok := m[id]; ok {
1286 return path
1287 }
1288 return match
1289 })
1290}
1291
1292// collapseOrQuoteFilePaths checks if the paste is a file path and
1293// collapses it to a [file:basename] chip. URLs are left as-is.
1294// Non-path pastes fall through to quotePastedFilePaths.
1295func (e *Editor) collapseOrQuoteFilePaths(paste string) string {
1296 if paste == "" || strings.ContainsRune(paste, '\n') {
1297 return quotePastedFilePaths(paste)
1298 }
1299 trimmed := strings.TrimSpace(paste)
1300 // Don't collapse URLs.
1301 if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
1302 return paste
1303 }
1304 // Check if it's a single file path.
1305 p, ok := normalisePathToken(trimmed)
1306 if !ok {
1307 return quotePastedFilePaths(paste)
1308 }
1309 // Only collapse if the path is long enough to benefit.
1310 base := filepath.Base(p)
1311 if len(p) <= len(base)+5 {
1312 // Short path like "./foo.txt" - just quote it.
1313 return singleQuote(p)
1314 }
1315 // Check if it's a directory.
1316 info, err := os.Stat(p)
1317 if err == nil && info.IsDir() {
1318 if e.dirs == nil {
1319 e.dirs = map[int]string{}
1320 }
1321 e.dirSeq++
1322 e.dirs[e.dirSeq] = singleQuote(p)
1323 return fmt.Sprintf("[dir:%d:%s/]", e.dirSeq, base)
1324 }
1325 if e.files == nil {
1326 e.files = map[int]string{}
1327 }
1328 e.fileSeq++
1329 e.files[e.fileSeq] = singleQuote(p)
1330 return fmt.Sprintf("[file:%d:%s]", e.fileSeq, base)
1331}