v0.1.84 ad93054

editor.go RawHistoryFolder

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

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