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