v0.1.20 e52a560

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.
311func normalisePathToken(tk string) (string, bool) {
312	// Strip pre-existing surrounding quotes; we'll re-quote consistently.
313	if n := len(tk); n >= 2 {
314		if (tk[0] == '\'' && tk[n-1] == '\'') || (tk[0] == '"' && tk[n-1] == '"') {
315			tk = tk[1 : n-1]
316		}
317	}
318
319	// file:// URL form: decode and strip the scheme.
320	if strings.HasPrefix(tk, "file://") {
321		decoded, err := url.PathUnescape(strings.TrimPrefix(tk, "file://"))
322		if err != nil {
323			return "", false
324		}
325		if decoded == "" || decoded[0] != '/' {
326			return "", false
327		}
328		return decoded, true
329	}
330
331	// Looks-like-a-path heuristic: starts with /, ~, ~/. Must contain
332	// at least one path separator after the prefix (otherwise a bare
333	// "/" or "~" gets quoted, which is never what the user meant).
334	if !strings.HasPrefix(tk, "/") && !strings.HasPrefix(tk, "~") {
335		return "", false
336	}
337	if !strings.ContainsAny(tk[1:], "/.") {
338		return "", false
339	}
340	unescaped := unescapeBackslashes(tk)
341	if strings.ContainsAny(unescaped, "|;&$`<>") {
342		return "", false
343	}
344	return unescaped, true
345}
346
347// unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal
348// drag-and-drop on macOS uses backslash escaping by default; quoting
349// makes that unnecessary.
350func unescapeBackslashes(s string) string {
351	if !strings.Contains(s, `\`) {
352		return s
353	}
354	var out strings.Builder
355	out.Grow(len(s))
356	prev := false
357	for _, r := range s {
358		if prev {
359			out.WriteRune(r)
360			prev = false
361			continue
362		}
363		if r == '\\' {
364			prev = true
365			continue
366		}
367		out.WriteRune(r)
368	}
369	if prev {
370		out.WriteRune('\\')
371	}
372	return out.String()
373}
374
375// singleQuote wraps s in single quotes, escaping any embedded single
376// quote using the splice idiom: 'foo'\”bar' for foo'bar.
377func singleQuote(s string) string {
378	return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
379}
380
381// Insert places s into the editor at the cursor, splitting on
382// newlines so multi-line pastes preserve their structure.
383func (e *Editor) Insert(s string) { e.insert(s) }
384
385func (e *Editor) insert(s string) {
386	line := e.Lines[e.CursorR]
387	pre := substringBefore(line, e.CursorC)
388	post := substringAfter(line, e.CursorC)
389	// Split pasted text on newlines.
390	parts := strings.Split(s, "\n")
391	if len(parts) == 1 {
392		e.Lines[e.CursorR] = pre + s + post
393		e.CursorC += runeLen(s)
394		return
395	}
396	newLines := make([]string, 0, len(e.Lines)+len(parts)-1)
397	newLines = append(newLines, e.Lines[:e.CursorR]...)
398	newLines = append(newLines, pre+parts[0])
399	for i := 1; i < len(parts)-1; i++ {
400		newLines = append(newLines, parts[i])
401	}
402	last := parts[len(parts)-1]
403	newLines = append(newLines, last+post)
404	newLines = append(newLines, e.Lines[e.CursorR+1:]...)
405	e.Lines = newLines
406	e.CursorR += len(parts) - 1
407	e.CursorC = runeLen(last)
408}
409
410func (e *Editor) newline() {
411	line := e.Lines[e.CursorR]
412	pre := substringBefore(line, e.CursorC)
413	post := substringAfter(line, e.CursorC)
414	e.Lines[e.CursorR] = pre
415	e.Lines = append(e.Lines, "")
416	copy(e.Lines[e.CursorR+2:], e.Lines[e.CursorR+1:])
417	e.Lines[e.CursorR+1] = post
418	e.CursorR++
419	e.CursorC = 0
420}
421
422func (e *Editor) backspace() {
423	if e.CursorC == 0 {
424		if e.CursorR == 0 {
425			return
426		}
427		prev := e.Lines[e.CursorR-1]
428		cur := e.Lines[e.CursorR]
429		e.Lines = append(e.Lines[:e.CursorR], e.Lines[e.CursorR+1:]...)
430		e.CursorR--
431		e.CursorC = runeLen(prev)
432		e.Lines[e.CursorR] = prev + cur
433		return
434	}
435	line := e.Lines[e.CursorR]
436	e.Lines[e.CursorR] = substringBefore(line, e.CursorC-1) + substringAfter(line, e.CursorC)
437	e.CursorC--
438}
439
440func (e *Editor) delete() {
441	line := e.Lines[e.CursorR]
442	if e.CursorC == runeLen(line) {
443		if e.CursorR == len(e.Lines)-1 {
444			return
445		}
446		next := e.Lines[e.CursorR+1]
447		e.Lines = append(e.Lines[:e.CursorR+1], e.Lines[e.CursorR+2:]...)
448		e.Lines[e.CursorR] = line + next
449		return
450	}
451	e.Lines[e.CursorR] = substringBefore(line, e.CursorC) + substringAfter(line, e.CursorC+1)
452}
453
454// moveWordLeft jumps the cursor to the start of the previous word,
455// using the same word-separator rules as deleteWord. If already at the
456// start of the line, wraps to the end of the previous line.
457func (e *Editor) moveWordLeft() {
458	if e.CursorC == 0 {
459		if e.CursorR > 0 {
460			e.CursorR--
461			e.CursorC = runeLen(e.Lines[e.CursorR])
462		}
463		return
464	}
465	r := []rune(e.Lines[e.CursorR])
466	i := e.CursorC
467	for i > 0 && isWordSep(r[i-1]) {
468		i--
469	}
470	for i > 0 && !isWordSep(r[i-1]) {
471		i--
472	}
473	e.CursorC = i
474}
475
476// moveWordRight jumps the cursor to the start of the next word. If
477// already at the end of the line, wraps to the start of the next line.
478func (e *Editor) moveWordRight() {
479	line := e.Lines[e.CursorR]
480	if e.CursorC >= runeLen(line) {
481		if e.CursorR < len(e.Lines)-1 {
482			e.CursorR++
483			e.CursorC = 0
484		}
485		return
486	}
487	r := []rune(line)
488	i := e.CursorC
489	for i < len(r) && !isWordSep(r[i]) {
490		i++
491	}
492	for i < len(r) && isWordSep(r[i]) {
493		i++
494	}
495	e.CursorC = i
496}
497
498func (e *Editor) moveLeft() {
499	if e.CursorC > 0 {
500		e.CursorC--
501		return
502	}
503	if e.CursorR > 0 {
504		e.CursorR--
505		e.CursorC = runeLen(e.Lines[e.CursorR])
506	}
507}
508
509func (e *Editor) moveRight() {
510	if e.CursorC < runeLen(e.Lines[e.CursorR]) {
511		e.CursorC++
512		return
513	}
514	if e.CursorR < len(e.Lines)-1 {
515		e.CursorR++
516		e.CursorC = 0
517	}
518}
519
520// moveCursorVisual moves the cursor one visual row in direction
521// dir (-1 = up, +1 = down) through the wrapped layout the user
522// sees on screen. Handles both multi-line logical inputs and the
523// case where a single long line wraps across several visual
524// rows.
525//
526// Algorithm: rebuild the same wrapped layout Render produces,
527// tagging each visual row with (logicalRow, runeOffsetStart,
528// runeOffsetEnd, leadingWidth). Find the row the cursor sits on,
529// then pick (row+dir) and map the cursor's current visual column
530// (minus the target row's leading indent) to a rune index inside
531// that row's slice of its logical line. No-op at the top/bottom
532// edges of the whole buffer.
533func (e *Editor) moveCursorVisual(dir int) {
534	width := e.lastRenderWidth
535	if width <= 0 {
536		// Fall back to logical-line navigation if Render hasn't
537		// been called yet (shouldn't happen in practice; the
538		// host always renders once before accepting input).
539		e.moveCursorLogical(dir)
540		return
541	}
542
543	type vrow struct {
544		logical    int    // e.Lines index
545		runeStart  int    // rune offset into e.Lines[logical]
546		runeEnd    int    // exclusive
547		leadWidth  int    // width of prompt / cont indent on this row
548		leadPrefix string // the prefix used (prompt on row 0, indent on cont)
549	}
550
551	promptLen := visibleWidth(e.Prompt)
552	indent := strings.Repeat(" ", promptLen)
553
554	var rows []vrow
555	curVRow, curVCol := 0, 0
556	for r, line := range e.Lines {
557		prefix := indent
558		if r == 0 {
559			prefix = e.Prompt
560		}
561		wrapped := wrapLine(prefix+line, width, indent)
562		lineRunes := []rune(line)
563		seen := 0
564		for wi, w := range wrapped {
565			var leadW int
566			var leadP string
567			body := w
568			if wi == 0 {
569				if strings.HasPrefix(body, prefix) {
570					body = body[len(prefix):]
571				}
572				leadW = promptLen
573				leadP = prefix
574			} else {
575				if strings.HasPrefix(body, indent) {
576					body = body[len(indent):]
577				}
578				leadW = promptLen
579				leadP = indent
580			}
581			bodyRunes := []rune(body)
582			start := seen
583			end := seen + len(bodyRunes)
584			rows = append(rows, vrow{
585				logical: r, runeStart: start, runeEnd: end,
586				leadWidth: leadW, leadPrefix: leadP,
587			})
588			// Record where the cursor currently sits.
589			if r == e.CursorR && e.CursorC >= start && e.CursorC <= end {
590				curVRow = len(rows) - 1
591				inner := e.CursorC - start
592				if inner < 0 {
593					inner = 0
594				}
595				if inner > len(bodyRunes) {
596					inner = len(bodyRunes)
597				}
598				curVCol = leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
599			}
600			seen = end
601			// Word-wrap often drops a single space at the boundary.
602			for seen < len(lineRunes) && lineRunes[seen] == ' ' {
603				seen++
604			}
605		}
606	}
607
608	target := curVRow + dir
609	if target < 0 || target >= len(rows) {
610		return
611	}
612	tr := rows[target]
613	line := e.Lines[tr.logical]
614	lineRunes := []rune(line)
615	bodyRunes := lineRunes[tr.runeStart:tr.runeEnd]
616
617	// Find the rune offset inside bodyRunes whose visible column
618	// most closely matches curVCol after accounting for leadWidth.
619	want := curVCol - tr.leadWidth
620	if want < 0 {
621		want = 0
622	}
623	best := 0
624	bestW := 0
625	for i := 1; i <= len(bodyRunes); i++ {
626		w := runewidth.StringWidth(string(bodyRunes[:i]))
627		if w > want {
628			break
629		}
630		best = i
631		bestW = w
632		if w == want {
633			break
634		}
635	}
636	_ = bestW
637	e.CursorR = tr.logical
638	e.CursorC = tr.runeStart + best
639}
640
641// moveCursorLogical is the pre-visual-navigation fallback used
642// when Render hasn't told us the terminal width yet. Walks the
643// e.Lines array directly.
644func (e *Editor) moveCursorLogical(dir int) {
645	switch {
646	case dir < 0 && e.CursorR > 0:
647		e.CursorR--
648	case dir > 0 && e.CursorR < len(e.Lines)-1:
649		e.CursorR++
650	default:
651		return
652	}
653	if e.CursorC > runeLen(e.Lines[e.CursorR]) {
654		e.CursorC = runeLen(e.Lines[e.CursorR])
655	}
656}
657
658func (e *Editor) deleteWord() {
659	line := e.Lines[e.CursorR]
660	if e.CursorC == 0 {
661		e.backspace()
662		return
663	}
664	r := []rune(line)
665	i := e.CursorC
666	// Step 1: walk over any trailing whitespace to the left of the cursor.
667	for i > 0 && isWordSep(r[i-1]) {
668		i--
669	}
670	// Step 2: walk over the word itself (non-separator runes).
671	for i > 0 && !isWordSep(r[i-1]) {
672		i--
673	}
674	e.Lines[e.CursorR] = string(r[:i]) + string(r[e.CursorC:])
675	e.CursorC = i
676}
677
678// isWordSep reports whether r is a word separator. Anything that isn't
679// a letter, digit, or underscore counts as a separator so delete-word
680// feels natural on paths, code, and prose.
681func isWordSep(r rune) bool {
682	switch {
683	case r == '_':
684		return false
685	case r >= '0' && r <= '9':
686		return false
687	case r >= 'a' && r <= 'z':
688		return false
689	case r >= 'A' && r <= 'Z':
690		return false
691	case r >= 0x80:
692		// Treat all non-ASCII as word chars so CJK/äöü don't get chopped.
693		return false
694	}
695	return true
696}
697
698// ---- rendering ----
699
700// Render returns the editor's visible lines (wrapped to width).
701// visualRow/visualCol describe where the cursor lands within the returned lines.
702func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
703	e.lastRenderWidth = width
704	promptLen := visibleWidth(e.Prompt)
705	indent := strings.Repeat(" ", promptLen)
706
707	for r, line := range e.Lines {
708		var prefix string
709		if r == 0 {
710			prefix = e.Prompt
711		} else {
712			prefix = indent
713		}
714		wrapped := wrapLine(prefix+line, width, indent)
715		if r == e.CursorR {
716			// Compute where CursorC lands inside the wrapped rows by
717			// walking the wrapped output character-by-character and
718			// tracking which wrapped row + column corresponds to the
719			// cursor's rune index in `line`. This is the only reliable
720			// answer under word-wrap, where a simple (promptLen+col)/width
721			// formula overshoots when wrapLine broke on a space.
722			targetRunes := e.CursorC
723			row, col := locateCursor(wrapped, prefix, line, targetRunes, indent)
724			visualRow = len(lines) + row
725			visualCol = col
726		}
727		lines = append(lines, wrapped...)
728	}
729	return lines, visualRow, visualCol
730}
731
732// locateCursor finds the wrapped row + visible column corresponding to
733// `targetRunes` rune positions into the logical `line`, given that the
734// wrapped output `wrapped` started with `prefix` on its first row and
735// uses `cont` as the continuation indent on subsequent rows.
736func locateCursor(wrapped []string, prefix, line string, targetRunes int, cont string) (int, int) {
737	prefixW := visibleWidth(prefix)
738	contW := visibleWidth(cont)
739	lineRunes := []rune(line)
740	if targetRunes > len(lineRunes) {
741		targetRunes = len(lineRunes)
742	}
743
744	seenRunes := 0
745	for row, w := range wrapped {
746		// strip leading prefix / continuation indent before counting runes
747		body := w
748		var leadW int
749		if row == 0 {
750			if strings.HasPrefix(body, prefix) {
751				body = body[len(prefix):]
752			}
753			leadW = prefixW
754		} else {
755			if strings.HasPrefix(body, cont) {
756				body = body[len(cont):]
757			}
758			leadW = contW
759		}
760		bodyRunes := []rune(body)
761		// Could this wrapped row contain the cursor?
762		if targetRunes <= seenRunes+len(bodyRunes) {
763			// Column inside body.
764			inner := targetRunes - seenRunes
765			if inner < 0 {
766				inner = 0
767			}
768			col := leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
769			return row, col
770		}
771		seenRunes += len(bodyRunes)
772		// Word-wrap may have dropped whitespace at the boundary; skip the
773		// corresponding runes in `line` so counts stay aligned.
774		for seenRunes < len(lineRunes) && lineRunes[seenRunes] == ' ' {
775			seenRunes++
776			if seenRunes >= targetRunes {
777				// Cursor landed in the skipped whitespace — place it at
778				// the start of the next wrapped row.
779				nextRow := row + 1
780				if nextRow >= len(wrapped) {
781					nextRow = row
782				}
783				return nextRow, contW
784			}
785		}
786	}
787	// Fallback: end of the last wrapped row.
788	if len(wrapped) == 0 {
789		return 0, prefixW
790	}
791	last := wrapped[len(wrapped)-1]
792	return len(wrapped) - 1, visibleWidth(last)
793}
794
795// ---- helpers ----
796
797func substringBefore(s string, col int) string {
798	r := []rune(s)
799	if col > len(r) {
800		col = len(r)
801	}
802	return string(r[:col])
803}
804
805func substringAfter(s string, col int) string {
806	r := []rune(s)
807	if col > len(r) {
808		col = len(r)
809	}
810	return string(r[col:])
811}
812
813func runeLen(s string) int { return len([]rune(s)) }
814
815func visualColumn(s string, runeCol int) int {
816	r := []rune(s)
817	if runeCol > len(r) {
818		runeCol = len(r)
819	}
820	return runewidth.StringWidth(string(r[:runeCol]))
821}
822
823func visibleWidth(s string) int {
824	return runewidth.StringWidth(stripANSI(s))
825}
826
827func stripANSI(s string) string {
828	// Minimal ANSI stripper; handles CSI sequences (ESC [ ... final).
829	var out []rune
830	i := 0
831	runes := []rune(s)
832	for i < len(runes) {
833		if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
834			i += 2
835			for i < len(runes) {
836				c := runes[i]
837				i++
838				if c >= 0x40 && c <= 0x7e {
839					break
840				}
841			}
842			continue
843		}
844		out = append(out, runes[i])
845		i++
846	}
847	return string(out)
848}
849
850// WrapANSILine is the exported form of wrapANSILine so other modes /
851// dialogs can reuse the same visible-width-aware wrap behavior.
852func WrapANSILine(s string, limit int) []string { return wrapANSILine(s, limit) }
853
854// wrapANSILine folds a string that may contain ANSI CSI escapes so that
855// the visible width of each line stays within limit. Breaks happen on
856// spaces when possible, falling back to mid-token splits for very long
857// unbroken runs. Escape sequences are preserved verbatim and never
858// counted toward the visible column.
859func wrapANSILine(s string, limit int) []string {
860	if limit <= 0 {
861		return []string{s}
862	}
863	if visibleWidth(s) <= limit {
864		return []string{s}
865	}
866	runes := []rune(s)
867	var out []string
868	var line strings.Builder
869	var word strings.Builder
870	lineW := 0
871	wordW := 0
872
873	flushLine := func() {
874		out = append(out, line.String())
875		line.Reset()
876		lineW = 0
877	}
878	flushWord := func() {
879		if word.Len() == 0 {
880			return
881		}
882		if lineW+wordW > limit && lineW > 0 {
883			flushLine()
884		}
885		// If the word alone is bigger than the limit, split it by runes.
886		if wordW > limit {
887			wr := []rune(word.String())
888			for i := 0; i < len(wr); {
889				r := wr[i]
890				if r == 0x1b && i+1 < len(wr) && wr[i+1] == '[' {
891					line.WriteRune(r)
892					line.WriteRune(wr[i+1])
893					i += 2
894					for i < len(wr) {
895						c := wr[i]
896						line.WriteRune(c)
897						i++
898						if c >= 0x40 && c <= 0x7e {
899							break
900						}
901					}
902					continue
903				}
904				rw := runewidth.RuneWidth(r)
905				if lineW+rw > limit && lineW > 0 {
906					flushLine()
907				}
908				line.WriteRune(r)
909				lineW += rw
910				i++
911			}
912			word.Reset()
913			wordW = 0
914			return
915		}
916		line.WriteString(word.String())
917		lineW += wordW
918		word.Reset()
919		wordW = 0
920	}
921
922	for i := 0; i < len(runes); {
923		r := runes[i]
924		if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
925			word.WriteRune(r)
926			word.WriteRune(runes[i+1])
927			i += 2
928			for i < len(runes) {
929				c := runes[i]
930				word.WriteRune(c)
931				i++
932				if c >= 0x40 && c <= 0x7e {
933					break
934				}
935			}
936			continue
937		}
938		if r == ' ' || r == '\t' {
939			flushWord()
940			rw := runewidth.RuneWidth(r)
941			if lineW+rw > limit && lineW > 0 {
942				flushLine()
943				i++
944				continue
945			}
946			line.WriteRune(r)
947			lineW += rw
948			i++
949			continue
950		}
951		word.WriteRune(r)
952		wordW += runewidth.RuneWidth(r)
953		i++
954	}
955	flushWord()
956	if line.Len() > 0 {
957		flushLine()
958	}
959	if len(out) == 0 {
960		out = []string{""}
961	}
962	return out
963}
964
965func wrapLine(s string, width int, cont string) []string {
966	if width <= 0 {
967		return []string{s}
968	}
969
970	// Tokenize on spaces while preserving their widths. Runs of spaces
971	// stay attached to the preceding word so trailing whitespace doesn't
972	// create empty wrapped lines.
973	type token struct {
974		text  string
975		width int
976		space bool // true if this token is whitespace only
977	}
978	var tokens []token
979	{
980		var buf strings.Builder
981		var bufW int
982		inSpace := false
983		flush := func() {
984			if buf.Len() == 0 {
985				return
986			}
987			tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace})
988			buf.Reset()
989			bufW = 0
990		}
991		for _, r := range s {
992			rSpace := r == ' ' || r == '\t'
993			if buf.Len() == 0 {
994				inSpace = rSpace
995			} else if rSpace != inSpace {
996				flush()
997				inSpace = rSpace
998			}
999			buf.WriteRune(r)
1000			bufW += runewidth.RuneWidth(r)
1001		}
1002		flush()
1003	}
1004
1005	var out []string
1006	var cur strings.Builder
1007	curW := 0
1008	firstLine := true
1009	contW := visibleWidth(cont)
1010
1011	newLine := func() {
1012		out = append(out, cur.String())
1013		cur.Reset()
1014		curW = 0
1015		// After flushing, everything we append next is a CONTINUATION
1016		// row, which must start with the cont indent so the editor's
1017		// visual cursor alignment stays consistent. The old code only
1018		// wrote cont when !firstLine BEFORE toggling firstLine, which
1019		// meant the very first wrap never got its indent. That caused
1020		// the terminal cursor to land in the wrong column after a
1021		// multi-line paste.
1022		firstLine = false
1023		cur.WriteString(cont)
1024		curW = contW
1025	}
1026
1027	for i := 0; i < len(tokens); i++ {
1028		tk := tokens[i]
1029
1030		// Drop leading whitespace after a wrap.
1031		if tk.space && curW == contW && !firstLine {
1032			continue
1033		}
1034
1035		if curW+tk.width <= width {
1036			cur.WriteString(tk.text)
1037			curW += tk.width
1038			continue
1039		}
1040
1041		// Token overflows.
1042		//
1043		// If the token on its own is wider than what fits on a
1044		// continuation line (width - contW), it'll need to be split
1045		// rune-by-rune no matter what. In that case, don't break
1046		// first: let the rune-split start right here at the current
1047		// column and wrap naturally. Breaking first would strand the
1048		// prompt (on firstLine) or the cont indent (on later lines)
1049		// on a row by itself, which is the drag-drop-long-path bug.
1050		if tk.width > width-contW {
1051			// Rune-by-rune starting from the current column. newLine()
1052			// inside handles wrapping and re-indents.
1053			for _, r := range tk.text {
1054				rw := runewidth.RuneWidth(r)
1055				if curW+rw > width {
1056					newLine()
1057				}
1058				cur.WriteRune(r)
1059				curW += rw
1060			}
1061			continue
1062		}
1063
1064		// Token fits on a fresh continuation line; break first, then
1065		// emit it whole.
1066		if cur.Len() > 0 && !(firstLine && curW == 0) {
1067			trimmed := strings.TrimRight(cur.String(), " \t")
1068			cur.Reset()
1069			cur.WriteString(trimmed)
1070			curW = visibleWidth(trimmed)
1071			newLine()
1072			if tk.space {
1073				continue
1074			}
1075		}
1076		cur.WriteString(tk.text)
1077		curW += tk.width
1078	}
1079
1080	if cur.Len() > 0 || len(out) == 0 {
1081		out = append(out, cur.String())
1082	}
1083	return out
1084}
1085
1086func max(a, b int) int {
1087	if a > b {
1088		return a
1089	}
1090	return b
1091}
1092
1093// pasteCollapseLineThreshold and pasteCollapseCharThreshold govern
1094// when a bracketed paste gets collapsed to a [pasted text #N …]
1095// placeholder instead of being inserted inline. Either trigger
1096// alone is enough — a 500-line log dump and a 1200-character
1097// one-line log entry both bloat the editor in ways the user
1098// doesn't want to scroll through while composing a prompt.
1099const (
1100	pasteCollapseLineThreshold = 10
1101	pasteCollapseCharThreshold = 1000
1102)
1103
1104// pasteShouldCollapse reports whether a pasted chunk is big enough
1105// to deserve a placeholder token instead of being inserted verbatim.
1106// Collapse on > 10 lines OR > 1000 characters, whichever fires
1107// first.
1108func pasteShouldCollapse(s string) bool {
1109	return countLines(s) > pasteCollapseLineThreshold || len(s) > pasteCollapseCharThreshold
1110}
1111
1112// formatPastePlaceholder builds the visible marker for a
1113// collapsed paste. Two shapes:
1114//
1115//	[pasted text #N +L lines]  — used when the line count is the
1116//	                             trigger (multi-line dumps)
1117//	[pasted text #N C chars]   — used when the character count is
1118//	                             the trigger (long single-line
1119//	                             or near-single-line pastes)
1120//
1121// Line-triggered takes precedence so a 12-line 4000-char paste
1122// reads as "+12 lines", not "4000 chars".
1123func formatPastePlaceholder(id int, body string) string {
1124	if countLines(body) > pasteCollapseLineThreshold {
1125		return fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(body))
1126	}
1127	return fmt.Sprintf("[pasted text #%d %d chars]", id, len(body))
1128}
1129
1130// countLines returns the number of visual lines in s. A trailing
1131// newline is not counted as an extra empty line so
1132// "foo\nbar\n" reads as 2 lines (what the user expects in the
1133// "+N lines" summary) instead of 3.
1134func countLines(s string) int {
1135	if s == "" {
1136		return 0
1137	}
1138	n := strings.Count(s, "\n")
1139	if !strings.HasSuffix(s, "\n") {
1140		n++
1141	}
1142	return n
1143}
1144
1145// pastePlaceholderRE matches both placeholder shapes the editor
1146// emits for a collapsed paste:
1147//
1148//	[pasted text #N +L lines]   — multi-line trigger
1149//	[pasted text #N C chars]    — long-char trigger
1150//
1151// Capture group 1 is the numeric id used to look up the full
1152// body in e.pastes; the rest of the token is free-form and gets
1153// discarded during expansion.
1154var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)
1155var filePlaceholderRE = regexp.MustCompile(`\[(file|dir):(\d+):[^\]]+\]`)
1156
1157// expandPastePlaceholders returns raw with every paste token
1158// swapped for the body stored under its id in pastes. Tokens
1159// whose id isn't in the map are left as-is (user deleted the
1160// corresponding entry somehow, or the id is spurious user text
1161// that happens to match the shape).
1162func expandPastePlaceholders(raw string, pastes map[int]string) string {
1163	return pastePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
1164		groups := pastePlaceholderRE.FindStringSubmatch(match)
1165		if len(groups) < 2 {
1166			return match
1167		}
1168		var id int
1169		if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
1170			return match
1171		}
1172		if body, ok := pastes[id]; ok {
1173			return body
1174		}
1175		return match
1176	})
1177}
1178
1179// expandFilePlaceholders returns raw with every [file:N:name] and
1180// [dir:N:name/] token swapped for the full path stored under its id.
1181func expandFilePlaceholders(raw string, files, dirs map[int]string) string {
1182	return filePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
1183		groups := filePlaceholderRE.FindStringSubmatch(match)
1184		if len(groups) < 3 {
1185			return match
1186		}
1187		kind := groups[1] // "file" or "dir"
1188		var id int
1189		if _, err := fmt.Sscanf(groups[2], "%d", &id); err != nil {
1190			return match
1191		}
1192		var m map[int]string
1193		if kind == "dir" {
1194			m = dirs
1195		} else {
1196			m = files
1197		}
1198		if path, ok := m[id]; ok {
1199			return path
1200		}
1201		return match
1202	})
1203}
1204
1205// collapseOrQuoteFilePaths checks if the paste is a file path and
1206// collapses it to a [file:basename] chip. URLs are left as-is.
1207// Non-path pastes fall through to quotePastedFilePaths.
1208func (e *Editor) collapseOrQuoteFilePaths(paste string) string {
1209	if paste == "" || strings.ContainsRune(paste, '\n') {
1210		return quotePastedFilePaths(paste)
1211	}
1212	trimmed := strings.TrimSpace(paste)
1213	// Don't collapse URLs.
1214	if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
1215		return paste
1216	}
1217	// Check if it's a single file path.
1218	p, ok := normalisePathToken(trimmed)
1219	if !ok {
1220		return quotePastedFilePaths(paste)
1221	}
1222	// Only collapse if the path is long enough to benefit.
1223	base := filepath.Base(p)
1224	if len(p) <= len(base)+5 {
1225		// Short path like "./foo.txt" - just quote it.
1226		return singleQuote(p)
1227	}
1228	// Check if it's a directory.
1229	info, err := os.Stat(p)
1230	if err == nil && info.IsDir() {
1231		if e.dirs == nil {
1232			e.dirs = map[int]string{}
1233		}
1234		e.dirSeq++
1235		e.dirs[e.dirSeq] = singleQuote(p)
1236		return fmt.Sprintf("[dir:%d:%s/]", e.dirSeq, base)
1237	}
1238	if e.files == nil {
1239		e.files = map[int]string{}
1240	}
1241	e.fileSeq++
1242	e.files[e.fileSeq] = singleQuote(p)
1243	return fmt.Sprintf("[file:%d:%s]", e.fileSeq, base)
1244}

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