v0.0.77 cb8526c

editor.go RawHistoryFolder

1package tui
2
3import (
4	"fmt"
5	"net/url"
6	"regexp"
7	"strings"
8
9	"github.com/mattn/go-runewidth"
10)
11
12// Editor is a simple multi-line text editor for the input area.
13//
14// Users can type, paste, move the cursor, and submit. The editor
15// exposes its rendered height and the current cursor row/col for the
16// outer renderer. It does NOT draw itself directly; instead, Render()
17// returns the visible lines.
18type Editor struct {
19	// Lines is the current buffer, one entry per line.
20	Lines    []string
21	CursorR  int // row index into Lines
22	CursorC  int // rune index into Lines[CursorR]
23	Prompt   string
24	MaxWidth int
25
26	// lastRenderWidth is the column count passed to the most recent
27	// Render() call. Up/Down key handling needs this to walk the
28	// same visual layout the user sees: a logical line that wraps to
29	// two rows should respond to Up by moving to the previous visual
30	// row, not do nothing because CursorR is already 0.
31	lastRenderWidth int
32
33	// pastes stores the full content of every multi-line paste,
34	// keyed by the id embedded in the visible placeholder token.
35	// Pasted text is collapsed to "[paste #N +L lines]" in the
36	// editor so a 500-line drop doesn't explode the input area;
37	// SubmitValue() expands placeholders back to their real bodies
38	// right before the prompt goes to the agent. The map is reset
39	// on Clear() so stale pastes never leak into a follow-up turn.
40	pastes   map[int]string
41	pasteSeq int
42}
43
44// NewEditor returns an empty editor with the given prompt.
45func NewEditor(prompt string) *Editor {
46	return &Editor{
47		Lines:  []string{""},
48		Prompt: prompt,
49	}
50}
51
52// Value returns the buffer as a single string, WITHOUT expanding
53// paste placeholders. Used for anything that should reflect what's
54// visible on screen (history, slash-command detection, editor
55// state). For the string that actually goes to the agent, use
56// SubmitValue(), which expands each [paste #N +L lines] token
57// back into the full pasted body.
58func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") }
59
60// SubmitValue returns the buffer with every paste placeholder
61// expanded to its stored body. Call once at submit time; the
62// expansion is lossless (placeholders are only injected in
63// HandleKey for KeyPaste with multi-line content).
64//
65// Expansion is non-destructive: the internal paste map isn't
66// touched. Clear() is what resets both the placeholder text and
67// the map, and the caller already calls Clear() right after
68// reading SubmitValue() as part of the submit flow.
69func (e *Editor) SubmitValue() string {
70	raw := e.Value()
71	if len(e.pastes) == 0 || !strings.Contains(raw, "[pasted text #") {
72		return raw
73	}
74	return expandPastePlaceholders(raw, e.pastes)
75}
76
77// SetValue replaces the buffer and places the cursor at the end.
78// Also drops any stored pastes because the placeholders they back
79// are now gone from the visible text.
80func (e *Editor) SetValue(s string) {
81	e.Lines = strings.Split(s, "\n")
82	if len(e.Lines) == 0 {
83		e.Lines = []string{""}
84	}
85	e.CursorR = len(e.Lines) - 1
86	e.CursorC = runeLen(e.Lines[e.CursorR])
87	e.pastes = nil
88	e.pasteSeq = 0
89}
90
91// Clear resets the buffer.
92func (e *Editor) Clear() { e.SetValue("") }
93
94// IsEmpty reports whether the buffer has no visible content.
95func (e *Editor) IsEmpty() bool {
96	return len(e.Lines) == 1 && e.Lines[0] == ""
97}
98
99// HandleKey applies k to the editor. It returns submit=true when
100// the user pressed enter and there is content to send; the caller
101// should read SubmitValue() and then Clear().
102func (e *Editor) HandleKey(k Key) (submit bool) {
103	switch k.Kind {
104	case KeyRune:
105		if k.Alt && (k.Rune == '\r' || k.Rune == '\n') {
106			e.newline()
107			return false
108		}
109		e.insert(string(k.Rune))
110	case KeyEnter:
111		// Shift+Enter would be a separate key in some terminals; we treat
112		// the literal newline byte as Enter. Newline on submit is a decision
113		// for the outer UI via slash commands. Here Enter submits.
114		return true
115	case KeyBackspace:
116		if k.Alt {
117			// Alt+Backspace (Option+Delete on macOS) — delete previous word.
118			e.deleteWord()
119		} else {
120			e.backspace()
121		}
122	case KeyDelete:
123		e.delete()
124	case KeyLeft:
125		if k.Alt {
126			e.moveWordLeft()
127		} else {
128			e.moveLeft()
129		}
130	case KeyRight:
131		if k.Alt {
132			e.moveWordRight()
133		} else {
134			e.moveRight()
135		}
136	case KeyUp:
137		// Visual-row navigation. When a single logical line wraps
138		// to several visual rows, Up needs to climb one visual
139		// row — which may mean moving within the same
140		// e.Lines[CursorR] back toward an earlier rune index,
141		// not jumping to CursorR-1. Buffer-line navigation is
142		// subsumed: a visual row above may also live in the
143		// previous logical line when a short line precedes a
144		// wrapped one.
145		e.moveCursorVisual(-1)
146	case KeyDown:
147		e.moveCursorVisual(+1)
148	case KeyHome, KeyCtrlA:
149		e.CursorC = 0
150	case KeyEnd, KeyCtrlE:
151		e.CursorC = runeLen(e.Lines[e.CursorR])
152	case KeyCtrlU:
153		e.Lines[e.CursorR] = substringAfter(e.Lines[e.CursorR], e.CursorC)
154		e.CursorC = 0
155	case KeyCtrlK:
156		e.Lines[e.CursorR] = substringBefore(e.Lines[e.CursorR], e.CursorC)
157	case KeyCtrlW:
158		e.deleteWord()
159	case KeyPaste:
160		// Large multi-line pastes are collapsed to a short
161		// placeholder token so the editor doesn't balloon to
162		// hundreds of rows. The full body is stashed in e.pastes,
163		// keyed by a monotonically increasing id, and swapped
164		// back in at submit time via SubmitValue. Threshold:
165		// two or more newlines triggers collapse; one-liners and
166		// drag-dropped file paths fall through to the original
167		// insert path (including file-path quoting).
168		if pasteShouldCollapse(k.Paste) {
169			if e.pastes == nil {
170				e.pastes = map[int]string{}
171			}
172			e.pasteSeq++
173			id := e.pasteSeq
174			e.pastes[id] = k.Paste
175			placeholder := fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(k.Paste))
176			e.insert(placeholder)
177		} else {
178			// macOS Terminal / iTerm / Ghostty deliver drag-dropped
179			// files as bracketed-paste text. Detect that pattern
180			// and wrap the path(s) in single quotes so the agent
181			// sees them as one argument.
182			e.insert(quotePastedFilePaths(k.Paste))
183		}
184	case KeyEsc:
185		e.Clear()
186	}
187	return false
188}
189
190// quotePastedFilePaths returns paste with any drag-dropped file
191// paths wrapped in single quotes. Heuristics:
192//
193//   - one line only; multi-line text is returned unchanged so we
194//     never touch a real paste of code.
195//   - whitespace-separated tokens are inspected one by one. A token
196//     that looks like a filesystem path (absolute, ~/relative, or
197//     file:// URL) is normalised and re-quoted; everything else is
198//     left exactly as the user dropped it.
199//   - terminal-style backslash escapes ("foo\ bar.png") are folded
200//     back to literal characters before quoting, since wrapping in
201//     single quotes makes them unnecessary.
202//   - file:// URLs are URL-decoded and stripped of the scheme so
203//     the agent receives a plain filesystem path.
204//   - if a path contains a literal single quote, it's escaped using
205//     the standard '\” splice.
206//
207// Pastes that don't contain any path-shaped tokens are returned
208// unchanged.
209func quotePastedFilePaths(paste string) string {
210	if paste == "" || strings.ContainsRune(paste, '\n') {
211		return paste
212	}
213	trimmed := strings.TrimSpace(paste)
214	if trimmed == "" {
215		return paste
216	}
217
218	// Split on runs of whitespace, preserving the original separators
219	// so multi-file drops keep their spacing on rebuild. Backslash
220	// before a space is treated as part of the preceding token, since
221	// macOS Terminal escapes spaces in dropped paths that way.
222	tokens := splitPreservingSeparators(paste)
223	changed := false
224	for i, tk := range tokens {
225		if tk.isSpace {
226			continue
227		}
228		if p, ok := normalisePathToken(tk.text); ok {
229			tokens[i].text = singleQuote(p)
230			changed = true
231		}
232	}
233	if !changed {
234		return paste
235	}
236	var out strings.Builder
237	out.Grow(len(paste) + 8)
238	for _, tk := range tokens {
239		out.WriteString(tk.text)
240	}
241	return out.String()
242}
243
244type pasteToken struct {
245	text    string
246	isSpace bool
247}
248
249// splitPreservingSeparators splits s into runs of whitespace and
250// runs of non-whitespace, keeping the separator runs intact so a
251// rebuild via concatenation reproduces the original string exactly.
252// A backslash immediately followed by a space ("\ ") is treated as
253// part of the preceding non-whitespace run, since that's how macOS
254// Terminal escapes spaces in drag-dropped file paths.
255func splitPreservingSeparators(s string) []pasteToken {
256	runes := []rune(s)
257	var out []pasteToken
258	var buf strings.Builder
259	inSpace := false
260	flush := func() {
261		if buf.Len() == 0 {
262			return
263		}
264		out = append(out, pasteToken{text: buf.String(), isSpace: inSpace})
265		buf.Reset()
266	}
267	for i := 0; i < len(runes); i++ {
268		r := runes[i]
269		isEscapedSpace := r == '\\' && i+1 < len(runes) && (runes[i+1] == ' ' || runes[i+1] == '\t')
270		rSpace := !isEscapedSpace && (r == ' ' || r == '\t')
271		if buf.Len() == 0 {
272			inSpace = rSpace
273		} else if rSpace != inSpace {
274			flush()
275			inSpace = rSpace
276		}
277		if isEscapedSpace {
278			// Skip the backslash; emit the literal space as part of
279			// this non-whitespace token. normalisePathToken's
280			// unescapeBackslashes is now redundant for this path but
281			// stays in case some other source escapes other chars.
282			buf.WriteRune(runes[i+1])
283			i++
284			continue
285		}
286		buf.WriteRune(r)
287	}
288	flush()
289	return out
290}
291
292// normalisePathToken decides whether tk is a drag-dropped path and,
293// if so, returns the cleaned-up path string.
294func normalisePathToken(tk string) (string, bool) {
295	// Strip pre-existing surrounding quotes; we'll re-quote consistently.
296	if n := len(tk); n >= 2 {
297		if (tk[0] == '\'' && tk[n-1] == '\'') || (tk[0] == '"' && tk[n-1] == '"') {
298			tk = tk[1 : n-1]
299		}
300	}
301
302	// file:// URL form: decode and strip the scheme.
303	if strings.HasPrefix(tk, "file://") {
304		decoded, err := url.PathUnescape(strings.TrimPrefix(tk, "file://"))
305		if err != nil {
306			return "", false
307		}
308		if decoded == "" || decoded[0] != '/' {
309			return "", false
310		}
311		return decoded, true
312	}
313
314	// Looks-like-a-path heuristic: starts with /, ~, ~/. Must contain
315	// at least one path separator after the prefix (otherwise a bare
316	// "/" or "~" gets quoted, which is never what the user meant).
317	if !strings.HasPrefix(tk, "/") && !strings.HasPrefix(tk, "~") {
318		return "", false
319	}
320	if !strings.ContainsAny(tk[1:], "/.") {
321		return "", false
322	}
323	unescaped := unescapeBackslashes(tk)
324	if strings.ContainsAny(unescaped, "|;&$`<>") {
325		return "", false
326	}
327	return unescaped, true
328}
329
330// unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal
331// drag-and-drop on macOS uses backslash escaping by default; quoting
332// makes that unnecessary.
333func unescapeBackslashes(s string) string {
334	if !strings.Contains(s, `\`) {
335		return s
336	}
337	var out strings.Builder
338	out.Grow(len(s))
339	prev := false
340	for _, r := range s {
341		if prev {
342			out.WriteRune(r)
343			prev = false
344			continue
345		}
346		if r == '\\' {
347			prev = true
348			continue
349		}
350		out.WriteRune(r)
351	}
352	if prev {
353		out.WriteRune('\\')
354	}
355	return out.String()
356}
357
358// singleQuote wraps s in single quotes, escaping any embedded single
359// quote using the splice idiom: 'foo'\”bar' for foo'bar.
360func singleQuote(s string) string {
361	return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
362}
363
364// Insert places s into the editor at the cursor, splitting on
365// newlines so multi-line pastes preserve their structure.
366func (e *Editor) Insert(s string) { e.insert(s) }
367
368func (e *Editor) insert(s string) {
369	line := e.Lines[e.CursorR]
370	pre := substringBefore(line, e.CursorC)
371	post := substringAfter(line, e.CursorC)
372	// Split pasted text on newlines.
373	parts := strings.Split(s, "\n")
374	if len(parts) == 1 {
375		e.Lines[e.CursorR] = pre + s + post
376		e.CursorC += runeLen(s)
377		return
378	}
379	newLines := make([]string, 0, len(e.Lines)+len(parts)-1)
380	newLines = append(newLines, e.Lines[:e.CursorR]...)
381	newLines = append(newLines, pre+parts[0])
382	for i := 1; i < len(parts)-1; i++ {
383		newLines = append(newLines, parts[i])
384	}
385	last := parts[len(parts)-1]
386	newLines = append(newLines, last+post)
387	newLines = append(newLines, e.Lines[e.CursorR+1:]...)
388	e.Lines = newLines
389	e.CursorR += len(parts) - 1
390	e.CursorC = runeLen(last)
391}
392
393func (e *Editor) newline() {
394	line := e.Lines[e.CursorR]
395	pre := substringBefore(line, e.CursorC)
396	post := substringAfter(line, e.CursorC)
397	e.Lines[e.CursorR] = pre
398	e.Lines = append(e.Lines, "")
399	copy(e.Lines[e.CursorR+2:], e.Lines[e.CursorR+1:])
400	e.Lines[e.CursorR+1] = post
401	e.CursorR++
402	e.CursorC = 0
403}
404
405func (e *Editor) backspace() {
406	if e.CursorC == 0 {
407		if e.CursorR == 0 {
408			return
409		}
410		prev := e.Lines[e.CursorR-1]
411		cur := e.Lines[e.CursorR]
412		e.Lines = append(e.Lines[:e.CursorR], e.Lines[e.CursorR+1:]...)
413		e.CursorR--
414		e.CursorC = runeLen(prev)
415		e.Lines[e.CursorR] = prev + cur
416		return
417	}
418	line := e.Lines[e.CursorR]
419	e.Lines[e.CursorR] = substringBefore(line, e.CursorC-1) + substringAfter(line, e.CursorC)
420	e.CursorC--
421}
422
423func (e *Editor) delete() {
424	line := e.Lines[e.CursorR]
425	if e.CursorC == runeLen(line) {
426		if e.CursorR == len(e.Lines)-1 {
427			return
428		}
429		next := e.Lines[e.CursorR+1]
430		e.Lines = append(e.Lines[:e.CursorR+1], e.Lines[e.CursorR+2:]...)
431		e.Lines[e.CursorR] = line + next
432		return
433	}
434	e.Lines[e.CursorR] = substringBefore(line, e.CursorC) + substringAfter(line, e.CursorC+1)
435}
436
437// moveWordLeft jumps the cursor to the start of the previous word,
438// using the same word-separator rules as deleteWord. If already at the
439// start of the line, wraps to the end of the previous line.
440func (e *Editor) moveWordLeft() {
441	if e.CursorC == 0 {
442		if e.CursorR > 0 {
443			e.CursorR--
444			e.CursorC = runeLen(e.Lines[e.CursorR])
445		}
446		return
447	}
448	r := []rune(e.Lines[e.CursorR])
449	i := e.CursorC
450	for i > 0 && isWordSep(r[i-1]) {
451		i--
452	}
453	for i > 0 && !isWordSep(r[i-1]) {
454		i--
455	}
456	e.CursorC = i
457}
458
459// moveWordRight jumps the cursor to the start of the next word. If
460// already at the end of the line, wraps to the start of the next line.
461func (e *Editor) moveWordRight() {
462	line := e.Lines[e.CursorR]
463	if e.CursorC >= runeLen(line) {
464		if e.CursorR < len(e.Lines)-1 {
465			e.CursorR++
466			e.CursorC = 0
467		}
468		return
469	}
470	r := []rune(line)
471	i := e.CursorC
472	for i < len(r) && !isWordSep(r[i]) {
473		i++
474	}
475	for i < len(r) && isWordSep(r[i]) {
476		i++
477	}
478	e.CursorC = i
479}
480
481func (e *Editor) moveLeft() {
482	if e.CursorC > 0 {
483		e.CursorC--
484		return
485	}
486	if e.CursorR > 0 {
487		e.CursorR--
488		e.CursorC = runeLen(e.Lines[e.CursorR])
489	}
490}
491
492func (e *Editor) moveRight() {
493	if e.CursorC < runeLen(e.Lines[e.CursorR]) {
494		e.CursorC++
495		return
496	}
497	if e.CursorR < len(e.Lines)-1 {
498		e.CursorR++
499		e.CursorC = 0
500	}
501}
502
503// moveCursorVisual moves the cursor one visual row in direction
504// dir (-1 = up, +1 = down) through the wrapped layout the user
505// sees on screen. Handles both multi-line logical inputs and the
506// case where a single long line wraps across several visual
507// rows.
508//
509// Algorithm: rebuild the same wrapped layout Render produces,
510// tagging each visual row with (logicalRow, runeOffsetStart,
511// runeOffsetEnd, leadingWidth). Find the row the cursor sits on,
512// then pick (row+dir) and map the cursor's current visual column
513// (minus the target row's leading indent) to a rune index inside
514// that row's slice of its logical line. No-op at the top/bottom
515// edges of the whole buffer.
516func (e *Editor) moveCursorVisual(dir int) {
517	width := e.lastRenderWidth
518	if width <= 0 {
519		// Fall back to logical-line navigation if Render hasn't
520		// been called yet (shouldn't happen in practice; the
521		// host always renders once before accepting input).
522		e.moveCursorLogical(dir)
523		return
524	}
525
526	type vrow struct {
527		logical    int    // e.Lines index
528		runeStart  int    // rune offset into e.Lines[logical]
529		runeEnd    int    // exclusive
530		leadWidth  int    // width of prompt / cont indent on this row
531		leadPrefix string // the prefix used (prompt on row 0, indent on cont)
532	}
533
534	promptLen := visibleWidth(e.Prompt)
535	indent := strings.Repeat(" ", promptLen)
536
537	var rows []vrow
538	curVRow, curVCol := 0, 0
539	for r, line := range e.Lines {
540		prefix := indent
541		if r == 0 {
542			prefix = e.Prompt
543		}
544		wrapped := wrapLine(prefix+line, width, indent)
545		lineRunes := []rune(line)
546		seen := 0
547		for wi, w := range wrapped {
548			var leadW int
549			var leadP string
550			body := w
551			if wi == 0 {
552				if strings.HasPrefix(body, prefix) {
553					body = body[len(prefix):]
554				}
555				leadW = promptLen
556				leadP = prefix
557			} else {
558				if strings.HasPrefix(body, indent) {
559					body = body[len(indent):]
560				}
561				leadW = promptLen
562				leadP = indent
563			}
564			bodyRunes := []rune(body)
565			start := seen
566			end := seen + len(bodyRunes)
567			rows = append(rows, vrow{
568				logical: r, runeStart: start, runeEnd: end,
569				leadWidth: leadW, leadPrefix: leadP,
570			})
571			// Record where the cursor currently sits.
572			if r == e.CursorR && e.CursorC >= start && e.CursorC <= end {
573				curVRow = len(rows) - 1
574				inner := e.CursorC - start
575				if inner < 0 {
576					inner = 0
577				}
578				if inner > len(bodyRunes) {
579					inner = len(bodyRunes)
580				}
581				curVCol = leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
582			}
583			seen = end
584			// Word-wrap often drops a single space at the boundary.
585			for seen < len(lineRunes) && lineRunes[seen] == ' ' {
586				seen++
587			}
588		}
589	}
590
591	target := curVRow + dir
592	if target < 0 || target >= len(rows) {
593		return
594	}
595	tr := rows[target]
596	line := e.Lines[tr.logical]
597	lineRunes := []rune(line)
598	bodyRunes := lineRunes[tr.runeStart:tr.runeEnd]
599
600	// Find the rune offset inside bodyRunes whose visible column
601	// most closely matches curVCol after accounting for leadWidth.
602	want := curVCol - tr.leadWidth
603	if want < 0 {
604		want = 0
605	}
606	best := 0
607	bestW := 0
608	for i := 1; i <= len(bodyRunes); i++ {
609		w := runewidth.StringWidth(string(bodyRunes[:i]))
610		if w > want {
611			break
612		}
613		best = i
614		bestW = w
615		if w == want {
616			break
617		}
618	}
619	_ = bestW
620	e.CursorR = tr.logical
621	e.CursorC = tr.runeStart + best
622}
623
624// moveCursorLogical is the pre-visual-navigation fallback used
625// when Render hasn't told us the terminal width yet. Walks the
626// e.Lines array directly.
627func (e *Editor) moveCursorLogical(dir int) {
628	switch {
629	case dir < 0 && e.CursorR > 0:
630		e.CursorR--
631	case dir > 0 && e.CursorR < len(e.Lines)-1:
632		e.CursorR++
633	default:
634		return
635	}
636	if e.CursorC > runeLen(e.Lines[e.CursorR]) {
637		e.CursorC = runeLen(e.Lines[e.CursorR])
638	}
639}
640
641func (e *Editor) deleteWord() {
642	line := e.Lines[e.CursorR]
643	if e.CursorC == 0 {
644		e.backspace()
645		return
646	}
647	r := []rune(line)
648	i := e.CursorC
649	// Step 1: walk over any trailing whitespace to the left of the cursor.
650	for i > 0 && isWordSep(r[i-1]) {
651		i--
652	}
653	// Step 2: walk over the word itself (non-separator runes).
654	for i > 0 && !isWordSep(r[i-1]) {
655		i--
656	}
657	e.Lines[e.CursorR] = string(r[:i]) + string(r[e.CursorC:])
658	e.CursorC = i
659}
660
661// isWordSep reports whether r is a word separator. Anything that isn't
662// a letter, digit, or underscore counts as a separator so delete-word
663// feels natural on paths, code, and prose.
664func isWordSep(r rune) bool {
665	switch {
666	case r == '_':
667		return false
668	case r >= '0' && r <= '9':
669		return false
670	case r >= 'a' && r <= 'z':
671		return false
672	case r >= 'A' && r <= 'Z':
673		return false
674	case r >= 0x80:
675		// Treat all non-ASCII as word chars so CJK/äöü don't get chopped.
676		return false
677	}
678	return true
679}
680
681// ---- rendering ----
682
683// Render returns the editor's visible lines (wrapped to width).
684// visualRow/visualCol describe where the cursor lands within the returned lines.
685func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
686	e.lastRenderWidth = width
687	promptLen := visibleWidth(e.Prompt)
688	indent := strings.Repeat(" ", promptLen)
689
690	for r, line := range e.Lines {
691		var prefix string
692		if r == 0 {
693			prefix = e.Prompt
694		} else {
695			prefix = indent
696		}
697		wrapped := wrapLine(prefix+line, width, indent)
698		if r == e.CursorR {
699			// Compute where CursorC lands inside the wrapped rows by
700			// walking the wrapped output character-by-character and
701			// tracking which wrapped row + column corresponds to the
702			// cursor's rune index in `line`. This is the only reliable
703			// answer under word-wrap, where a simple (promptLen+col)/width
704			// formula overshoots when wrapLine broke on a space.
705			targetRunes := e.CursorC
706			row, col := locateCursor(wrapped, prefix, line, targetRunes, indent)
707			visualRow = len(lines) + row
708			visualCol = col
709		}
710		lines = append(lines, wrapped...)
711	}
712	return lines, visualRow, visualCol
713}
714
715// locateCursor finds the wrapped row + visible column corresponding to
716// `targetRunes` rune positions into the logical `line`, given that the
717// wrapped output `wrapped` started with `prefix` on its first row and
718// uses `cont` as the continuation indent on subsequent rows.
719func locateCursor(wrapped []string, prefix, line string, targetRunes int, cont string) (int, int) {
720	prefixW := visibleWidth(prefix)
721	contW := visibleWidth(cont)
722	lineRunes := []rune(line)
723	if targetRunes > len(lineRunes) {
724		targetRunes = len(lineRunes)
725	}
726
727	seenRunes := 0
728	for row, w := range wrapped {
729		// strip leading prefix / continuation indent before counting runes
730		body := w
731		var leadW int
732		if row == 0 {
733			if strings.HasPrefix(body, prefix) {
734				body = body[len(prefix):]
735			}
736			leadW = prefixW
737		} else {
738			if strings.HasPrefix(body, cont) {
739				body = body[len(cont):]
740			}
741			leadW = contW
742		}
743		bodyRunes := []rune(body)
744		// Could this wrapped row contain the cursor?
745		if targetRunes <= seenRunes+len(bodyRunes) {
746			// Column inside body.
747			inner := targetRunes - seenRunes
748			if inner < 0 {
749				inner = 0
750			}
751			col := leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
752			return row, col
753		}
754		seenRunes += len(bodyRunes)
755		// Word-wrap may have dropped whitespace at the boundary; skip the
756		// corresponding runes in `line` so counts stay aligned.
757		for seenRunes < len(lineRunes) && lineRunes[seenRunes] == ' ' {
758			seenRunes++
759			if seenRunes >= targetRunes {
760				// Cursor landed in the skipped whitespace — place it at
761				// the start of the next wrapped row.
762				nextRow := row + 1
763				if nextRow >= len(wrapped) {
764					nextRow = row
765				}
766				return nextRow, contW
767			}
768		}
769	}
770	// Fallback: end of the last wrapped row.
771	if len(wrapped) == 0 {
772		return 0, prefixW
773	}
774	last := wrapped[len(wrapped)-1]
775	return len(wrapped) - 1, visibleWidth(last)
776}
777
778// ---- helpers ----
779
780func substringBefore(s string, col int) string {
781	r := []rune(s)
782	if col > len(r) {
783		col = len(r)
784	}
785	return string(r[:col])
786}
787
788func substringAfter(s string, col int) string {
789	r := []rune(s)
790	if col > len(r) {
791		col = len(r)
792	}
793	return string(r[col:])
794}
795
796func runeLen(s string) int { return len([]rune(s)) }
797
798func visualColumn(s string, runeCol int) int {
799	r := []rune(s)
800	if runeCol > len(r) {
801		runeCol = len(r)
802	}
803	return runewidth.StringWidth(string(r[:runeCol]))
804}
805
806func visibleWidth(s string) int {
807	return runewidth.StringWidth(stripANSI(s))
808}
809
810func stripANSI(s string) string {
811	// Minimal ANSI stripper; handles CSI sequences (ESC [ ... final).
812	var out []rune
813	i := 0
814	runes := []rune(s)
815	for i < len(runes) {
816		if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
817			i += 2
818			for i < len(runes) {
819				c := runes[i]
820				i++
821				if c >= 0x40 && c <= 0x7e {
822					break
823				}
824			}
825			continue
826		}
827		out = append(out, runes[i])
828		i++
829	}
830	return string(out)
831}
832
833func wrapLine(s string, width int, cont string) []string {
834	if width <= 0 {
835		return []string{s}
836	}
837
838	// Tokenize on spaces while preserving their widths. Runs of spaces
839	// stay attached to the preceding word so trailing whitespace doesn't
840	// create empty wrapped lines.
841	type token struct {
842		text  string
843		width int
844		space bool // true if this token is whitespace only
845	}
846	var tokens []token
847	{
848		var buf strings.Builder
849		var bufW int
850		inSpace := false
851		flush := func() {
852			if buf.Len() == 0 {
853				return
854			}
855			tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace})
856			buf.Reset()
857			bufW = 0
858		}
859		for _, r := range s {
860			rSpace := r == ' ' || r == '\t'
861			if buf.Len() == 0 {
862				inSpace = rSpace
863			} else if rSpace != inSpace {
864				flush()
865				inSpace = rSpace
866			}
867			buf.WriteRune(r)
868			bufW += runewidth.RuneWidth(r)
869		}
870		flush()
871	}
872
873	var out []string
874	var cur strings.Builder
875	curW := 0
876	firstLine := true
877	contW := visibleWidth(cont)
878
879	newLine := func() {
880		out = append(out, cur.String())
881		cur.Reset()
882		curW = 0
883		// After flushing, everything we append next is a CONTINUATION
884		// row, which must start with the cont indent so the editor's
885		// visual cursor alignment stays consistent. The old code only
886		// wrote cont when !firstLine BEFORE toggling firstLine, which
887		// meant the very first wrap never got its indent. That caused
888		// the terminal cursor to land in the wrong column after a
889		// multi-line paste.
890		firstLine = false
891		cur.WriteString(cont)
892		curW = contW
893	}
894
895	for i := 0; i < len(tokens); i++ {
896		tk := tokens[i]
897
898		// Drop leading whitespace after a wrap.
899		if tk.space && curW == contW && !firstLine {
900			continue
901		}
902
903		if curW+tk.width <= width {
904			cur.WriteString(tk.text)
905			curW += tk.width
906			continue
907		}
908
909		// Token overflows.
910		//
911		// If the token on its own is wider than what fits on a
912		// continuation line (width - contW), it'll need to be split
913		// rune-by-rune no matter what. In that case, don't break
914		// first: let the rune-split start right here at the current
915		// column and wrap naturally. Breaking first would strand the
916		// prompt (on firstLine) or the cont indent (on later lines)
917		// on a row by itself, which is the drag-drop-long-path bug.
918		if tk.width > width-contW {
919			// Rune-by-rune starting from the current column. newLine()
920			// inside handles wrapping and re-indents.
921			for _, r := range tk.text {
922				rw := runewidth.RuneWidth(r)
923				if curW+rw > width {
924					newLine()
925				}
926				cur.WriteRune(r)
927				curW += rw
928			}
929			continue
930		}
931
932		// Token fits on a fresh continuation line; break first, then
933		// emit it whole.
934		if cur.Len() > 0 && !(firstLine && curW == 0) {
935			trimmed := strings.TrimRight(cur.String(), " \t")
936			cur.Reset()
937			cur.WriteString(trimmed)
938			curW = visibleWidth(trimmed)
939			newLine()
940			if tk.space {
941				continue
942			}
943		}
944		cur.WriteString(tk.text)
945		curW += tk.width
946	}
947
948	if cur.Len() > 0 || len(out) == 0 {
949		out = append(out, cur.String())
950	}
951	return out
952}
953
954func max(a, b int) int {
955	if a > b {
956		return a
957	}
958	return b
959}
960
961// pasteShouldCollapse reports whether a pasted chunk is big enough
962// to deserve a placeholder token instead of being inserted verbatim.
963// Two or more newlines (i.e. three-plus lines) is the cutoff: that
964// covers real multi-line code/log pastes while leaving single-line
965// file-drop paths and two-line copy-pastes of a sentence alone.
966func pasteShouldCollapse(s string) bool {
967	return strings.Count(s, "\n") >= 2
968}
969
970// countLines returns the number of visual lines in s. A trailing
971// newline is not counted as an extra empty line so
972// "foo\nbar\n" reads as 2 lines (what the user expects in the
973// "+N lines" summary) instead of 3.
974func countLines(s string) int {
975	if s == "" {
976		return 0
977	}
978	n := strings.Count(s, "\n")
979	if !strings.HasSuffix(s, "\n") {
980		n++
981	}
982	return n
983}
984
985// pastePlaceholderRE matches the "[pasted text #N +L lines]" token
986// that collapsed pastes leave in the editor. Capture group 1 is
987// the numeric id used to look up the full body in e.pastes.
988var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) \+\d+ lines?\]`)
989
990// expandPastePlaceholders returns raw with every paste token
991// swapped for the body stored under its id in pastes. Tokens
992// whose id isn't in the map are left as-is (user deleted the
993// corresponding entry somehow, or the id is spurious user text
994// that happens to match the shape).
995func expandPastePlaceholders(raw string, pastes map[int]string) string {
996	return pastePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
997		groups := pastePlaceholderRE.FindStringSubmatch(match)
998		if len(groups) < 2 {
999			return match
1000		}
1001		var id int
1002		if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
1003			return match
1004		}
1005		if body, ok := pastes[id]; ok {
1006			return body
1007		}
1008		return match
1009	})
1010}

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 477

↑↓ moveenter openesc close