v0.3.7 ee6f014

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

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