1package tui
2
3import (
4 "fmt"
5 "net/url"
6 "regexp"
7 "strings"
8
9 "github.com/mattn/go-runewidth"
10)
11
12// Editor is a simple multi-line text editor for the input area.
13//
14// Users can type, paste, move the cursor, and submit. The editor
15// exposes its rendered height and the current cursor row/col for the
16// outer renderer. It does NOT draw itself directly; instead, Render()
17// returns the visible lines.
18type Editor struct {
19 // Lines is the current buffer, one entry per line.
20 Lines []string
21 CursorR int // row index into Lines
22 CursorC int // rune index into Lines[CursorR]
23 Prompt string
24 MaxWidth int
25
26 // History is a ring of previously submitted buffers (newest last).
27 History []string
28 histIdx int // -1 means "editing current buffer"
29 savedDraft string
30
31 // pastes stores the full content of every multi-line paste,
32 // keyed by the id embedded in the visible placeholder token.
33 // Pasted text is collapsed to "[paste #N +L lines]" in the
34 // editor so a 500-line drop doesn't explode the input area;
35 // SubmitValue() expands placeholders back to their real bodies
36 // right before the prompt goes to the agent. The map is reset
37 // on Clear() so stale pastes never leak into a follow-up turn.
38 pastes map[int]string
39 pasteSeq int
40}
41
42// NewEditor returns an empty editor with the given prompt.
43func NewEditor(prompt string) *Editor {
44 return &Editor{
45 Lines: []string{""},
46 Prompt: prompt,
47 histIdx: -1,
48 }
49}
50
51// Value returns the buffer as a single string, WITHOUT expanding
52// paste placeholders. Used for anything that should reflect what's
53// visible on screen (history, slash-command detection, editor
54// state). For the string that actually goes to the agent, use
55// SubmitValue(), which expands each [paste #N +L lines] token
56// back into the full pasted body.
57func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") }
58
59// SubmitValue returns the buffer with every paste placeholder
60// expanded to its stored body. Call once at submit time; the
61// expansion is lossless (placeholders are only injected in
62// HandleKey for KeyPaste with multi-line content).
63//
64// Expansion is non-destructive: the internal paste map isn't
65// touched. Clear() is what resets both the placeholder text and
66// the map, and the caller already calls Clear() right after
67// reading SubmitValue() as part of the submit flow.
68func (e *Editor) SubmitValue() string {
69 raw := e.Value()
70 if len(e.pastes) == 0 || !strings.Contains(raw, "[pasted text #") {
71 return raw
72 }
73 return expandPastePlaceholders(raw, e.pastes)
74}
75
76// SetValue replaces the buffer and places the cursor at the end.
77// Also drops any stored pastes because the placeholders they back
78// are now gone from the visible text.
79func (e *Editor) SetValue(s string) {
80 e.Lines = strings.Split(s, "\n")
81 if len(e.Lines) == 0 {
82 e.Lines = []string{""}
83 }
84 e.CursorR = len(e.Lines) - 1
85 e.CursorC = runeLen(e.Lines[e.CursorR])
86 e.histIdx = -1
87 e.pastes = nil
88 e.pasteSeq = 0
89}
90
91// Clear resets the buffer.
92func (e *Editor) Clear() { e.SetValue("") }
93
94// IsEmpty reports whether the buffer has no visible content.
95func (e *Editor) IsEmpty() bool {
96 return len(e.Lines) == 1 && e.Lines[0] == ""
97}
98
99// HandleKey applies k to the editor. It returns (submit, key).
100// If submit is true, the caller should read Value() and call
101// PushHistory + Clear.
102func (e *Editor) HandleKey(k Key) (submit bool) {
103 switch k.Kind {
104 case KeyRune:
105 if k.Alt && (k.Rune == '\r' || k.Rune == '\n') {
106 e.newline()
107 return false
108 }
109 e.insert(string(k.Rune))
110 case KeyEnter:
111 // Shift+Enter would be a separate key in some terminals; we treat
112 // the literal newline byte as Enter. Newline on submit is a decision
113 // for the outer UI via slash commands. Here Enter submits.
114 return true
115 case KeyBackspace:
116 if k.Alt {
117 // Alt+Backspace (Option+Delete on macOS) — delete previous word.
118 e.deleteWord()
119 } else {
120 e.backspace()
121 }
122 case KeyDelete:
123 e.delete()
124 case KeyLeft:
125 if k.Alt {
126 e.moveWordLeft()
127 } else {
128 e.moveLeft()
129 }
130 case KeyRight:
131 if k.Alt {
132 e.moveWordRight()
133 } else {
134 e.moveRight()
135 }
136 case KeyUp:
137 if e.CursorR == 0 {
138 e.historyPrev()
139 } else {
140 e.CursorR--
141 if e.CursorC > runeLen(e.Lines[e.CursorR]) {
142 e.CursorC = runeLen(e.Lines[e.CursorR])
143 }
144 }
145 case KeyDown:
146 if e.CursorR == len(e.Lines)-1 {
147 e.historyNext()
148 } else {
149 e.CursorR++
150 if e.CursorC > runeLen(e.Lines[e.CursorR]) {
151 e.CursorC = runeLen(e.Lines[e.CursorR])
152 }
153 }
154 case KeyHome, KeyCtrlA:
155 e.CursorC = 0
156 case KeyEnd, KeyCtrlE:
157 e.CursorC = runeLen(e.Lines[e.CursorR])
158 case KeyCtrlU:
159 e.Lines[e.CursorR] = substringAfter(e.Lines[e.CursorR], e.CursorC)
160 e.CursorC = 0
161 case KeyCtrlK:
162 e.Lines[e.CursorR] = substringBefore(e.Lines[e.CursorR], e.CursorC)
163 case KeyCtrlW:
164 e.deleteWord()
165 case KeyPaste:
166 // Large multi-line pastes are collapsed to a short
167 // placeholder token so the editor doesn't balloon to
168 // hundreds of rows. The full body is stashed in e.pastes,
169 // keyed by a monotonically increasing id, and swapped
170 // back in at submit time via SubmitValue. Threshold:
171 // two or more newlines triggers collapse; one-liners and
172 // drag-dropped file paths fall through to the original
173 // insert path (including file-path quoting).
174 if pasteShouldCollapse(k.Paste) {
175 if e.pastes == nil {
176 e.pastes = map[int]string{}
177 }
178 e.pasteSeq++
179 id := e.pasteSeq
180 e.pastes[id] = k.Paste
181 placeholder := fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(k.Paste))
182 e.insert(placeholder)
183 } else {
184 // macOS Terminal / iTerm / Ghostty deliver drag-dropped
185 // files as bracketed-paste text. Detect that pattern
186 // and wrap the path(s) in single quotes so the agent
187 // sees them as one argument.
188 e.insert(quotePastedFilePaths(k.Paste))
189 }
190 case KeyEsc:
191 e.Clear()
192 }
193 return false
194}
195
196// quotePastedFilePaths returns paste with any drag-dropped file
197// paths wrapped in single quotes. Heuristics:
198//
199// - one line only; multi-line text is returned unchanged so we
200// never touch a real paste of code.
201// - whitespace-separated tokens are inspected one by one. A token
202// that looks like a filesystem path (absolute, ~/relative, or
203// file:// URL) is normalised and re-quoted; everything else is
204// left exactly as the user dropped it.
205// - terminal-style backslash escapes ("foo\ bar.png") are folded
206// back to literal characters before quoting, since wrapping in
207// single quotes makes them unnecessary.
208// - file:// URLs are URL-decoded and stripped of the scheme so
209// the agent receives a plain filesystem path.
210// - if a path contains a literal single quote, it's escaped using
211// the standard '\” splice.
212//
213// Pastes that don't contain any path-shaped tokens are returned
214// unchanged.
215func quotePastedFilePaths(paste string) string {
216 if paste == "" || strings.ContainsRune(paste, '\n') {
217 return paste
218 }
219 trimmed := strings.TrimSpace(paste)
220 if trimmed == "" {
221 return paste
222 }
223
224 // Split on runs of whitespace, preserving the original separators
225 // so multi-file drops keep their spacing on rebuild. Backslash
226 // before a space is treated as part of the preceding token, since
227 // macOS Terminal escapes spaces in dropped paths that way.
228 tokens := splitPreservingSeparators(paste)
229 changed := false
230 for i, tk := range tokens {
231 if tk.isSpace {
232 continue
233 }
234 if p, ok := normalisePathToken(tk.text); ok {
235 tokens[i].text = singleQuote(p)
236 changed = true
237 }
238 }
239 if !changed {
240 return paste
241 }
242 var out strings.Builder
243 out.Grow(len(paste) + 8)
244 for _, tk := range tokens {
245 out.WriteString(tk.text)
246 }
247 return out.String()
248}
249
250type pasteToken struct {
251 text string
252 isSpace bool
253}
254
255// splitPreservingSeparators splits s into runs of whitespace and
256// runs of non-whitespace, keeping the separator runs intact so a
257// rebuild via concatenation reproduces the original string exactly.
258// A backslash immediately followed by a space ("\ ") is treated as
259// part of the preceding non-whitespace run, since that's how macOS
260// Terminal escapes spaces in drag-dropped file paths.
261func splitPreservingSeparators(s string) []pasteToken {
262 runes := []rune(s)
263 var out []pasteToken
264 var buf strings.Builder
265 inSpace := false
266 flush := func() {
267 if buf.Len() == 0 {
268 return
269 }
270 out = append(out, pasteToken{text: buf.String(), isSpace: inSpace})
271 buf.Reset()
272 }
273 for i := 0; i < len(runes); i++ {
274 r := runes[i]
275 isEscapedSpace := r == '\\' && i+1 < len(runes) && (runes[i+1] == ' ' || runes[i+1] == '\t')
276 rSpace := !isEscapedSpace && (r == ' ' || r == '\t')
277 if buf.Len() == 0 {
278 inSpace = rSpace
279 } else if rSpace != inSpace {
280 flush()
281 inSpace = rSpace
282 }
283 if isEscapedSpace {
284 // Skip the backslash; emit the literal space as part of
285 // this non-whitespace token. normalisePathToken's
286 // unescapeBackslashes is now redundant for this path but
287 // stays in case some other source escapes other chars.
288 buf.WriteRune(runes[i+1])
289 i++
290 continue
291 }
292 buf.WriteRune(r)
293 }
294 flush()
295 return out
296}
297
298// normalisePathToken decides whether tk is a drag-dropped path and,
299// if so, returns the cleaned-up path string.
300func normalisePathToken(tk string) (string, bool) {
301 // Strip pre-existing surrounding quotes; we'll re-quote consistently.
302 if n := len(tk); n >= 2 {
303 if (tk[0] == '\'' && tk[n-1] == '\'') || (tk[0] == '"' && tk[n-1] == '"') {
304 tk = tk[1 : n-1]
305 }
306 }
307
308 // file:// URL form: decode and strip the scheme.
309 if strings.HasPrefix(tk, "file://") {
310 decoded, err := url.PathUnescape(strings.TrimPrefix(tk, "file://"))
311 if err != nil {
312 return "", false
313 }
314 if decoded == "" || decoded[0] != '/' {
315 return "", false
316 }
317 return decoded, true
318 }
319
320 // Looks-like-a-path heuristic: starts with /, ~, ~/. Must contain
321 // at least one path separator after the prefix (otherwise a bare
322 // "/" or "~" gets quoted, which is never what the user meant).
323 if !strings.HasPrefix(tk, "/") && !strings.HasPrefix(tk, "~") {
324 return "", false
325 }
326 if !strings.ContainsAny(tk[1:], "/.") {
327 return "", false
328 }
329 unescaped := unescapeBackslashes(tk)
330 if strings.ContainsAny(unescaped, "|;&$`<>") {
331 return "", false
332 }
333 return unescaped, true
334}
335
336// unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal
337// drag-and-drop on macOS uses backslash escaping by default; quoting
338// makes that unnecessary.
339func unescapeBackslashes(s string) string {
340 if !strings.Contains(s, `\`) {
341 return s
342 }
343 var out strings.Builder
344 out.Grow(len(s))
345 prev := false
346 for _, r := range s {
347 if prev {
348 out.WriteRune(r)
349 prev = false
350 continue
351 }
352 if r == '\\' {
353 prev = true
354 continue
355 }
356 out.WriteRune(r)
357 }
358 if prev {
359 out.WriteRune('\\')
360 }
361 return out.String()
362}
363
364// singleQuote wraps s in single quotes, escaping any embedded single
365// quote using the splice idiom: 'foo'\”bar' for foo'bar.
366func singleQuote(s string) string {
367 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
368}
369
370// Insert places s into the editor at the cursor, splitting on
371// newlines so multi-line pastes preserve their structure.
372func (e *Editor) Insert(s string) { e.insert(s) }
373
374func (e *Editor) insert(s string) {
375 e.histIdx = -1
376 line := e.Lines[e.CursorR]
377 pre := substringBefore(line, e.CursorC)
378 post := substringAfter(line, e.CursorC)
379 // Split pasted text on newlines.
380 parts := strings.Split(s, "\n")
381 if len(parts) == 1 {
382 e.Lines[e.CursorR] = pre + s + post
383 e.CursorC += runeLen(s)
384 return
385 }
386 newLines := make([]string, 0, len(e.Lines)+len(parts)-1)
387 newLines = append(newLines, e.Lines[:e.CursorR]...)
388 newLines = append(newLines, pre+parts[0])
389 for i := 1; i < len(parts)-1; i++ {
390 newLines = append(newLines, parts[i])
391 }
392 last := parts[len(parts)-1]
393 newLines = append(newLines, last+post)
394 newLines = append(newLines, e.Lines[e.CursorR+1:]...)
395 e.Lines = newLines
396 e.CursorR += len(parts) - 1
397 e.CursorC = runeLen(last)
398}
399
400func (e *Editor) newline() {
401 e.histIdx = -1
402 line := e.Lines[e.CursorR]
403 pre := substringBefore(line, e.CursorC)
404 post := substringAfter(line, e.CursorC)
405 e.Lines[e.CursorR] = pre
406 e.Lines = append(e.Lines, "")
407 copy(e.Lines[e.CursorR+2:], e.Lines[e.CursorR+1:])
408 e.Lines[e.CursorR+1] = post
409 e.CursorR++
410 e.CursorC = 0
411}
412
413func (e *Editor) backspace() {
414 e.histIdx = -1
415 if e.CursorC == 0 {
416 if e.CursorR == 0 {
417 return
418 }
419 prev := e.Lines[e.CursorR-1]
420 cur := e.Lines[e.CursorR]
421 e.Lines = append(e.Lines[:e.CursorR], e.Lines[e.CursorR+1:]...)
422 e.CursorR--
423 e.CursorC = runeLen(prev)
424 e.Lines[e.CursorR] = prev + cur
425 return
426 }
427 line := e.Lines[e.CursorR]
428 e.Lines[e.CursorR] = substringBefore(line, e.CursorC-1) + substringAfter(line, e.CursorC)
429 e.CursorC--
430}
431
432func (e *Editor) delete() {
433 e.histIdx = -1
434 line := e.Lines[e.CursorR]
435 if e.CursorC == runeLen(line) {
436 if e.CursorR == len(e.Lines)-1 {
437 return
438 }
439 next := e.Lines[e.CursorR+1]
440 e.Lines = append(e.Lines[:e.CursorR+1], e.Lines[e.CursorR+2:]...)
441 e.Lines[e.CursorR] = line + next
442 return
443 }
444 e.Lines[e.CursorR] = substringBefore(line, e.CursorC) + substringAfter(line, e.CursorC+1)
445}
446
447// moveWordLeft jumps the cursor to the start of the previous word,
448// using the same word-separator rules as deleteWord. If already at the
449// start of the line, wraps to the end of the previous line.
450func (e *Editor) moveWordLeft() {
451 if e.CursorC == 0 {
452 if e.CursorR > 0 {
453 e.CursorR--
454 e.CursorC = runeLen(e.Lines[e.CursorR])
455 }
456 return
457 }
458 r := []rune(e.Lines[e.CursorR])
459 i := e.CursorC
460 for i > 0 && isWordSep(r[i-1]) {
461 i--
462 }
463 for i > 0 && !isWordSep(r[i-1]) {
464 i--
465 }
466 e.CursorC = i
467}
468
469// moveWordRight jumps the cursor to the start of the next word. If
470// already at the end of the line, wraps to the start of the next line.
471func (e *Editor) moveWordRight() {
472 line := e.Lines[e.CursorR]
473 if e.CursorC >= runeLen(line) {
474 if e.CursorR < len(e.Lines)-1 {
475 e.CursorR++
476 e.CursorC = 0
477 }
478 return
479 }
480 r := []rune(line)
481 i := e.CursorC
482 for i < len(r) && !isWordSep(r[i]) {
483 i++
484 }
485 for i < len(r) && isWordSep(r[i]) {
486 i++
487 }
488 e.CursorC = i
489}
490
491func (e *Editor) moveLeft() {
492 if e.CursorC > 0 {
493 e.CursorC--
494 return
495 }
496 if e.CursorR > 0 {
497 e.CursorR--
498 e.CursorC = runeLen(e.Lines[e.CursorR])
499 }
500}
501
502func (e *Editor) moveRight() {
503 if e.CursorC < runeLen(e.Lines[e.CursorR]) {
504 e.CursorC++
505 return
506 }
507 if e.CursorR < len(e.Lines)-1 {
508 e.CursorR++
509 e.CursorC = 0
510 }
511}
512
513func (e *Editor) deleteWord() {
514 e.histIdx = -1
515 line := e.Lines[e.CursorR]
516 if e.CursorC == 0 {
517 e.backspace()
518 return
519 }
520 r := []rune(line)
521 i := e.CursorC
522 // Step 1: walk over any trailing whitespace to the left of the cursor.
523 for i > 0 && isWordSep(r[i-1]) {
524 i--
525 }
526 // Step 2: walk over the word itself (non-separator runes).
527 for i > 0 && !isWordSep(r[i-1]) {
528 i--
529 }
530 e.Lines[e.CursorR] = string(r[:i]) + string(r[e.CursorC:])
531 e.CursorC = i
532}
533
534// isWordSep reports whether r is a word separator. Anything that isn't
535// a letter, digit, or underscore counts as a separator so delete-word
536// feels natural on paths, code, and prose.
537func isWordSep(r rune) bool {
538 switch {
539 case r == '_':
540 return false
541 case r >= '0' && r <= '9':
542 return false
543 case r >= 'a' && r <= 'z':
544 return false
545 case r >= 'A' && r <= 'Z':
546 return false
547 case r >= 0x80:
548 // Treat all non-ASCII as word chars so CJK/äöü don't get chopped.
549 return false
550 }
551 return true
552}
553
554// ---- history ----
555
556// PushHistory saves s to the history ring.
557func (e *Editor) PushHistory(s string) {
558 s = strings.TrimRight(s, "\n")
559 if s == "" {
560 return
561 }
562 if n := len(e.History); n > 0 && e.History[n-1] == s {
563 return
564 }
565 e.History = append(e.History, s)
566 if len(e.History) > 200 {
567 e.History = e.History[len(e.History)-200:]
568 }
569 e.histIdx = -1
570}
571
572func (e *Editor) historyPrev() {
573 if len(e.History) == 0 {
574 return
575 }
576 if e.histIdx == -1 {
577 e.savedDraft = e.Value()
578 e.histIdx = len(e.History) - 1
579 } else if e.histIdx > 0 {
580 e.histIdx--
581 }
582 e.SetValue(e.History[e.histIdx])
583 // SetValue resets histIdx; restore.
584 e.histIdx = e.findInHistory()
585}
586
587func (e *Editor) historyNext() {
588 if e.histIdx == -1 {
589 return
590 }
591 if e.histIdx == len(e.History)-1 {
592 e.histIdx = -1
593 e.SetValue(e.savedDraft)
594 e.histIdx = -1
595 return
596 }
597 e.histIdx++
598 e.SetValue(e.History[e.histIdx])
599 e.histIdx = e.findInHistory()
600}
601
602func (e *Editor) findInHistory() int {
603 v := e.Value()
604 for i, h := range e.History {
605 if h == v {
606 return i
607 }
608 }
609 return -1
610}
611
612// ---- rendering ----
613
614// Render returns the editor's visible lines (wrapped to width).
615// visualRow/visualCol describe where the cursor lands within the returned lines.
616func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
617 promptLen := visibleWidth(e.Prompt)
618 indent := strings.Repeat(" ", promptLen)
619
620 for r, line := range e.Lines {
621 var prefix string
622 if r == 0 {
623 prefix = e.Prompt
624 } else {
625 prefix = indent
626 }
627 wrapped := wrapLine(prefix+line, width, indent)
628 if r == e.CursorR {
629 // Compute where CursorC lands inside the wrapped rows by
630 // walking the wrapped output character-by-character and
631 // tracking which wrapped row + column corresponds to the
632 // cursor's rune index in `line`. This is the only reliable
633 // answer under word-wrap, where a simple (promptLen+col)/width
634 // formula overshoots when wrapLine broke on a space.
635 targetRunes := e.CursorC
636 row, col := locateCursor(wrapped, prefix, line, targetRunes, indent)
637 visualRow = len(lines) + row
638 visualCol = col
639 }
640 lines = append(lines, wrapped...)
641 }
642 return lines, visualRow, visualCol
643}
644
645// locateCursor finds the wrapped row + visible column corresponding to
646// `targetRunes` rune positions into the logical `line`, given that the
647// wrapped output `wrapped` started with `prefix` on its first row and
648// uses `cont` as the continuation indent on subsequent rows.
649func locateCursor(wrapped []string, prefix, line string, targetRunes int, cont string) (int, int) {
650 prefixW := visibleWidth(prefix)
651 contW := visibleWidth(cont)
652 lineRunes := []rune(line)
653 if targetRunes > len(lineRunes) {
654 targetRunes = len(lineRunes)
655 }
656
657 seenRunes := 0
658 for row, w := range wrapped {
659 // strip leading prefix / continuation indent before counting runes
660 body := w
661 var leadW int
662 if row == 0 {
663 if strings.HasPrefix(body, prefix) {
664 body = body[len(prefix):]
665 }
666 leadW = prefixW
667 } else {
668 if strings.HasPrefix(body, cont) {
669 body = body[len(cont):]
670 }
671 leadW = contW
672 }
673 bodyRunes := []rune(body)
674 // Could this wrapped row contain the cursor?
675 if targetRunes <= seenRunes+len(bodyRunes) {
676 // Column inside body.
677 inner := targetRunes - seenRunes
678 if inner < 0 {
679 inner = 0
680 }
681 col := leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
682 return row, col
683 }
684 seenRunes += len(bodyRunes)
685 // Word-wrap may have dropped whitespace at the boundary; skip the
686 // corresponding runes in `line` so counts stay aligned.
687 for seenRunes < len(lineRunes) && lineRunes[seenRunes] == ' ' {
688 seenRunes++
689 if seenRunes >= targetRunes {
690 // Cursor landed in the skipped whitespace — place it at
691 // the start of the next wrapped row.
692 nextRow := row + 1
693 if nextRow >= len(wrapped) {
694 nextRow = row
695 }
696 return nextRow, contW
697 }
698 }
699 }
700 // Fallback: end of the last wrapped row.
701 if len(wrapped) == 0 {
702 return 0, prefixW
703 }
704 last := wrapped[len(wrapped)-1]
705 return len(wrapped) - 1, visibleWidth(last)
706}
707
708// ---- helpers ----
709
710func substringBefore(s string, col int) string {
711 r := []rune(s)
712 if col > len(r) {
713 col = len(r)
714 }
715 return string(r[:col])
716}
717
718func substringAfter(s string, col int) string {
719 r := []rune(s)
720 if col > len(r) {
721 col = len(r)
722 }
723 return string(r[col:])
724}
725
726func runeLen(s string) int { return len([]rune(s)) }
727
728func visualColumn(s string, runeCol int) int {
729 r := []rune(s)
730 if runeCol > len(r) {
731 runeCol = len(r)
732 }
733 return runewidth.StringWidth(string(r[:runeCol]))
734}
735
736func visibleWidth(s string) int {
737 return runewidth.StringWidth(stripANSI(s))
738}
739
740func stripANSI(s string) string {
741 // Minimal ANSI stripper; handles CSI sequences (ESC [ ... final).
742 var out []rune
743 i := 0
744 runes := []rune(s)
745 for i < len(runes) {
746 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
747 i += 2
748 for i < len(runes) {
749 c := runes[i]
750 i++
751 if c >= 0x40 && c <= 0x7e {
752 break
753 }
754 }
755 continue
756 }
757 out = append(out, runes[i])
758 i++
759 }
760 return string(out)
761}
762
763func wrapLine(s string, width int, cont string) []string {
764 if width <= 0 {
765 return []string{s}
766 }
767
768 // Tokenize on spaces while preserving their widths. Runs of spaces
769 // stay attached to the preceding word so trailing whitespace doesn't
770 // create empty wrapped lines.
771 type token struct {
772 text string
773 width int
774 space bool // true if this token is whitespace only
775 }
776 var tokens []token
777 {
778 var buf strings.Builder
779 var bufW int
780 inSpace := false
781 flush := func() {
782 if buf.Len() == 0 {
783 return
784 }
785 tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace})
786 buf.Reset()
787 bufW = 0
788 }
789 for _, r := range s {
790 rSpace := r == ' ' || r == '\t'
791 if buf.Len() == 0 {
792 inSpace = rSpace
793 } else if rSpace != inSpace {
794 flush()
795 inSpace = rSpace
796 }
797 buf.WriteRune(r)
798 bufW += runewidth.RuneWidth(r)
799 }
800 flush()
801 }
802
803 var out []string
804 var cur strings.Builder
805 curW := 0
806 firstLine := true
807 contW := visibleWidth(cont)
808
809 newLine := func() {
810 out = append(out, cur.String())
811 cur.Reset()
812 curW = 0
813 // After flushing, everything we append next is a CONTINUATION
814 // row, which must start with the cont indent so the editor's
815 // visual cursor alignment stays consistent. The old code only
816 // wrote cont when !firstLine BEFORE toggling firstLine, which
817 // meant the very first wrap never got its indent. That caused
818 // the terminal cursor to land in the wrong column after a
819 // multi-line paste.
820 firstLine = false
821 cur.WriteString(cont)
822 curW = contW
823 }
824
825 for i := 0; i < len(tokens); i++ {
826 tk := tokens[i]
827
828 // Drop leading whitespace after a wrap.
829 if tk.space && curW == contW && !firstLine {
830 continue
831 }
832
833 if curW+tk.width <= width {
834 cur.WriteString(tk.text)
835 curW += tk.width
836 continue
837 }
838
839 // Token overflows.
840 //
841 // If the token on its own is wider than what fits on a
842 // continuation line (width - contW), it'll need to be split
843 // rune-by-rune no matter what. In that case, don't break
844 // first: let the rune-split start right here at the current
845 // column and wrap naturally. Breaking first would strand the
846 // prompt (on firstLine) or the cont indent (on later lines)
847 // on a row by itself, which is the drag-drop-long-path bug.
848 if tk.width > width-contW {
849 // Rune-by-rune starting from the current column. newLine()
850 // inside handles wrapping and re-indents.
851 for _, r := range tk.text {
852 rw := runewidth.RuneWidth(r)
853 if curW+rw > width {
854 newLine()
855 }
856 cur.WriteRune(r)
857 curW += rw
858 }
859 continue
860 }
861
862 // Token fits on a fresh continuation line; break first, then
863 // emit it whole.
864 if cur.Len() > 0 && !(firstLine && curW == 0) {
865 trimmed := strings.TrimRight(cur.String(), " \t")
866 cur.Reset()
867 cur.WriteString(trimmed)
868 curW = visibleWidth(trimmed)
869 newLine()
870 if tk.space {
871 continue
872 }
873 }
874 cur.WriteString(tk.text)
875 curW += tk.width
876 }
877
878 if cur.Len() > 0 || len(out) == 0 {
879 out = append(out, cur.String())
880 }
881 return out
882}
883
884func max(a, b int) int {
885 if a > b {
886 return a
887 }
888 return b
889}
890
891// pasteShouldCollapse reports whether a pasted chunk is big enough
892// to deserve a placeholder token instead of being inserted verbatim.
893// Two or more newlines (i.e. three-plus lines) is the cutoff: that
894// covers real multi-line code/log pastes while leaving single-line
895// file-drop paths and two-line copy-pastes of a sentence alone.
896func pasteShouldCollapse(s string) bool {
897 return strings.Count(s, "\n") >= 2
898}
899
900// countLines returns the number of visual lines in s. A trailing
901// newline is not counted as an extra empty line so
902// "foo\nbar\n" reads as 2 lines (what the user expects in the
903// "+N lines" summary) instead of 3.
904func countLines(s string) int {
905 if s == "" {
906 return 0
907 }
908 n := strings.Count(s, "\n")
909 if !strings.HasSuffix(s, "\n") {
910 n++
911 }
912 return n
913}
914
915// pastePlaceholderRE matches the "[pasted text #N +L lines]" token
916// that collapsed pastes leave in the editor. Capture group 1 is
917// the numeric id used to look up the full body in e.pastes.
918var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) \+\d+ lines?\]`)
919
920// expandPastePlaceholders returns raw with every paste token
921// swapped for the body stored under its id in pastes. Tokens
922// whose id isn't in the map are left as-is (user deleted the
923// corresponding entry somehow, or the id is spurious user text
924// that happens to match the shape).
925func expandPastePlaceholders(raw string, pastes map[int]string) string {
926 return pastePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
927 groups := pastePlaceholderRE.FindStringSubmatch(match)
928 if len(groups) < 2 {
929 return match
930 }
931 var id int
932 if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
933 return match
934 }
935 if body, ok := pastes[id]; ok {
936 return body
937 }
938 return match
939 })
940}