v0.1.47 5a64f87

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

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