v0.2.69 c6b35a2

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

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