v0.1.10 8a38171

editor.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close