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