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