v0.0.87 a739ef9

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			e.insert(formatPastePlaceholder(id, k.Paste))
176		} else {
177			// macOS Terminal / iTerm / Ghostty deliver drag-dropped
178			// files as bracketed-paste text. Detect that pattern
179			// and wrap the path(s) in single quotes so the agent
180			// sees them as one argument.
181			e.insert(quotePastedFilePaths(k.Paste))
182		}
183	case KeyEsc:
184		e.Clear()
185	}
186	return false
187}
188
189// quotePastedFilePaths returns paste with any drag-dropped file
190// paths wrapped in single quotes. Heuristics:
191//
192//   - one line only; multi-line text is returned unchanged so we
193//     never touch a real paste of code.
194//   - whitespace-separated tokens are inspected one by one. A token
195//     that looks like a filesystem path (absolute, ~/relative, or
196//     file:// URL) is normalised and re-quoted; everything else is
197//     left exactly as the user dropped it.
198//   - terminal-style backslash escapes ("foo\ bar.png") are folded
199//     back to literal characters before quoting, since wrapping in
200//     single quotes makes them unnecessary.
201//   - file:// URLs are URL-decoded and stripped of the scheme so
202//     the agent receives a plain filesystem path.
203//   - if a path contains a literal single quote, it's escaped using
204//     the standard '\” splice.
205//
206// Pastes that don't contain any path-shaped tokens are returned
207// unchanged.
208func quotePastedFilePaths(paste string) string {
209	if paste == "" || strings.ContainsRune(paste, '\n') {
210		return paste
211	}
212	trimmed := strings.TrimSpace(paste)
213	if trimmed == "" {
214		return paste
215	}
216
217	// Split on runs of whitespace, preserving the original separators
218	// so multi-file drops keep their spacing on rebuild. Backslash
219	// before a space is treated as part of the preceding token, since
220	// macOS Terminal escapes spaces in dropped paths that way.
221	tokens := splitPreservingSeparators(paste)
222	changed := false
223	for i, tk := range tokens {
224		if tk.isSpace {
225			continue
226		}
227		if p, ok := normalisePathToken(tk.text); ok {
228			tokens[i].text = singleQuote(p)
229			changed = true
230		}
231	}
232	if !changed {
233		return paste
234	}
235	var out strings.Builder
236	out.Grow(len(paste) + 8)
237	for _, tk := range tokens {
238		out.WriteString(tk.text)
239	}
240	return out.String()
241}
242
243type pasteToken struct {
244	text    string
245	isSpace bool
246}
247
248// splitPreservingSeparators splits s into runs of whitespace and
249// runs of non-whitespace, keeping the separator runs intact so a
250// rebuild via concatenation reproduces the original string exactly.
251// A backslash immediately followed by a space ("\ ") is treated as
252// part of the preceding non-whitespace run, since that's how macOS
253// Terminal escapes spaces in drag-dropped file paths.
254func splitPreservingSeparators(s string) []pasteToken {
255	runes := []rune(s)
256	var out []pasteToken
257	var buf strings.Builder
258	inSpace := false
259	flush := func() {
260		if buf.Len() == 0 {
261			return
262		}
263		out = append(out, pasteToken{text: buf.String(), isSpace: inSpace})
264		buf.Reset()
265	}
266	for i := 0; i < len(runes); i++ {
267		r := runes[i]
268		isEscapedSpace := r == '\\' && i+1 < len(runes) && (runes[i+1] == ' ' || runes[i+1] == '\t')
269		rSpace := !isEscapedSpace && (r == ' ' || r == '\t')
270		if buf.Len() == 0 {
271			inSpace = rSpace
272		} else if rSpace != inSpace {
273			flush()
274			inSpace = rSpace
275		}
276		if isEscapedSpace {
277			// Skip the backslash; emit the literal space as part of
278			// this non-whitespace token. normalisePathToken's
279			// unescapeBackslashes is now redundant for this path but
280			// stays in case some other source escapes other chars.
281			buf.WriteRune(runes[i+1])
282			i++
283			continue
284		}
285		buf.WriteRune(r)
286	}
287	flush()
288	return out
289}
290
291// normalisePathToken decides whether tk is a drag-dropped path and,
292// if so, returns the cleaned-up path string.
293func normalisePathToken(tk string) (string, bool) {
294	// Strip pre-existing surrounding quotes; we'll re-quote consistently.
295	if n := len(tk); n >= 2 {
296		if (tk[0] == '\'' && tk[n-1] == '\'') || (tk[0] == '"' && tk[n-1] == '"') {
297			tk = tk[1 : n-1]
298		}
299	}
300
301	// file:// URL form: decode and strip the scheme.
302	if strings.HasPrefix(tk, "file://") {
303		decoded, err := url.PathUnescape(strings.TrimPrefix(tk, "file://"))
304		if err != nil {
305			return "", false
306		}
307		if decoded == "" || decoded[0] != '/' {
308			return "", false
309		}
310		return decoded, true
311	}
312
313	// Looks-like-a-path heuristic: starts with /, ~, ~/. Must contain
314	// at least one path separator after the prefix (otherwise a bare
315	// "/" or "~" gets quoted, which is never what the user meant).
316	if !strings.HasPrefix(tk, "/") && !strings.HasPrefix(tk, "~") {
317		return "", false
318	}
319	if !strings.ContainsAny(tk[1:], "/.") {
320		return "", false
321	}
322	unescaped := unescapeBackslashes(tk)
323	if strings.ContainsAny(unescaped, "|;&$`<>") {
324		return "", false
325	}
326	return unescaped, true
327}
328
329// unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal
330// drag-and-drop on macOS uses backslash escaping by default; quoting
331// makes that unnecessary.
332func unescapeBackslashes(s string) string {
333	if !strings.Contains(s, `\`) {
334		return s
335	}
336	var out strings.Builder
337	out.Grow(len(s))
338	prev := false
339	for _, r := range s {
340		if prev {
341			out.WriteRune(r)
342			prev = false
343			continue
344		}
345		if r == '\\' {
346			prev = true
347			continue
348		}
349		out.WriteRune(r)
350	}
351	if prev {
352		out.WriteRune('\\')
353	}
354	return out.String()
355}
356
357// singleQuote wraps s in single quotes, escaping any embedded single
358// quote using the splice idiom: 'foo'\”bar' for foo'bar.
359func singleQuote(s string) string {
360	return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
361}
362
363// Insert places s into the editor at the cursor, splitting on
364// newlines so multi-line pastes preserve their structure.
365func (e *Editor) Insert(s string) { e.insert(s) }
366
367func (e *Editor) insert(s string) {
368	line := e.Lines[e.CursorR]
369	pre := substringBefore(line, e.CursorC)
370	post := substringAfter(line, e.CursorC)
371	// Split pasted text on newlines.
372	parts := strings.Split(s, "\n")
373	if len(parts) == 1 {
374		e.Lines[e.CursorR] = pre + s + post
375		e.CursorC += runeLen(s)
376		return
377	}
378	newLines := make([]string, 0, len(e.Lines)+len(parts)-1)
379	newLines = append(newLines, e.Lines[:e.CursorR]...)
380	newLines = append(newLines, pre+parts[0])
381	for i := 1; i < len(parts)-1; i++ {
382		newLines = append(newLines, parts[i])
383	}
384	last := parts[len(parts)-1]
385	newLines = append(newLines, last+post)
386	newLines = append(newLines, e.Lines[e.CursorR+1:]...)
387	e.Lines = newLines
388	e.CursorR += len(parts) - 1
389	e.CursorC = runeLen(last)
390}
391
392func (e *Editor) newline() {
393	line := e.Lines[e.CursorR]
394	pre := substringBefore(line, e.CursorC)
395	post := substringAfter(line, e.CursorC)
396	e.Lines[e.CursorR] = pre
397	e.Lines = append(e.Lines, "")
398	copy(e.Lines[e.CursorR+2:], e.Lines[e.CursorR+1:])
399	e.Lines[e.CursorR+1] = post
400	e.CursorR++
401	e.CursorC = 0
402}
403
404func (e *Editor) backspace() {
405	if e.CursorC == 0 {
406		if e.CursorR == 0 {
407			return
408		}
409		prev := e.Lines[e.CursorR-1]
410		cur := e.Lines[e.CursorR]
411		e.Lines = append(e.Lines[:e.CursorR], e.Lines[e.CursorR+1:]...)
412		e.CursorR--
413		e.CursorC = runeLen(prev)
414		e.Lines[e.CursorR] = prev + cur
415		return
416	}
417	line := e.Lines[e.CursorR]
418	e.Lines[e.CursorR] = substringBefore(line, e.CursorC-1) + substringAfter(line, e.CursorC)
419	e.CursorC--
420}
421
422func (e *Editor) delete() {
423	line := e.Lines[e.CursorR]
424	if e.CursorC == runeLen(line) {
425		if e.CursorR == len(e.Lines)-1 {
426			return
427		}
428		next := e.Lines[e.CursorR+1]
429		e.Lines = append(e.Lines[:e.CursorR+1], e.Lines[e.CursorR+2:]...)
430		e.Lines[e.CursorR] = line + next
431		return
432	}
433	e.Lines[e.CursorR] = substringBefore(line, e.CursorC) + substringAfter(line, e.CursorC+1)
434}
435
436// moveWordLeft jumps the cursor to the start of the previous word,
437// using the same word-separator rules as deleteWord. If already at the
438// start of the line, wraps to the end of the previous line.
439func (e *Editor) moveWordLeft() {
440	if e.CursorC == 0 {
441		if e.CursorR > 0 {
442			e.CursorR--
443			e.CursorC = runeLen(e.Lines[e.CursorR])
444		}
445		return
446	}
447	r := []rune(e.Lines[e.CursorR])
448	i := e.CursorC
449	for i > 0 && isWordSep(r[i-1]) {
450		i--
451	}
452	for i > 0 && !isWordSep(r[i-1]) {
453		i--
454	}
455	e.CursorC = i
456}
457
458// moveWordRight jumps the cursor to the start of the next word. If
459// already at the end of the line, wraps to the start of the next line.
460func (e *Editor) moveWordRight() {
461	line := e.Lines[e.CursorR]
462	if e.CursorC >= runeLen(line) {
463		if e.CursorR < len(e.Lines)-1 {
464			e.CursorR++
465			e.CursorC = 0
466		}
467		return
468	}
469	r := []rune(line)
470	i := e.CursorC
471	for i < len(r) && !isWordSep(r[i]) {
472		i++
473	}
474	for i < len(r) && isWordSep(r[i]) {
475		i++
476	}
477	e.CursorC = i
478}
479
480func (e *Editor) moveLeft() {
481	if e.CursorC > 0 {
482		e.CursorC--
483		return
484	}
485	if e.CursorR > 0 {
486		e.CursorR--
487		e.CursorC = runeLen(e.Lines[e.CursorR])
488	}
489}
490
491func (e *Editor) moveRight() {
492	if e.CursorC < runeLen(e.Lines[e.CursorR]) {
493		e.CursorC++
494		return
495	}
496	if e.CursorR < len(e.Lines)-1 {
497		e.CursorR++
498		e.CursorC = 0
499	}
500}
501
502// moveCursorVisual moves the cursor one visual row in direction
503// dir (-1 = up, +1 = down) through the wrapped layout the user
504// sees on screen. Handles both multi-line logical inputs and the
505// case where a single long line wraps across several visual
506// rows.
507//
508// Algorithm: rebuild the same wrapped layout Render produces,
509// tagging each visual row with (logicalRow, runeOffsetStart,
510// runeOffsetEnd, leadingWidth). Find the row the cursor sits on,
511// then pick (row+dir) and map the cursor's current visual column
512// (minus the target row's leading indent) to a rune index inside
513// that row's slice of its logical line. No-op at the top/bottom
514// edges of the whole buffer.
515func (e *Editor) moveCursorVisual(dir int) {
516	width := e.lastRenderWidth
517	if width <= 0 {
518		// Fall back to logical-line navigation if Render hasn't
519		// been called yet (shouldn't happen in practice; the
520		// host always renders once before accepting input).
521		e.moveCursorLogical(dir)
522		return
523	}
524
525	type vrow struct {
526		logical    int    // e.Lines index
527		runeStart  int    // rune offset into e.Lines[logical]
528		runeEnd    int    // exclusive
529		leadWidth  int    // width of prompt / cont indent on this row
530		leadPrefix string // the prefix used (prompt on row 0, indent on cont)
531	}
532
533	promptLen := visibleWidth(e.Prompt)
534	indent := strings.Repeat(" ", promptLen)
535
536	var rows []vrow
537	curVRow, curVCol := 0, 0
538	for r, line := range e.Lines {
539		prefix := indent
540		if r == 0 {
541			prefix = e.Prompt
542		}
543		wrapped := wrapLine(prefix+line, width, indent)
544		lineRunes := []rune(line)
545		seen := 0
546		for wi, w := range wrapped {
547			var leadW int
548			var leadP string
549			body := w
550			if wi == 0 {
551				if strings.HasPrefix(body, prefix) {
552					body = body[len(prefix):]
553				}
554				leadW = promptLen
555				leadP = prefix
556			} else {
557				if strings.HasPrefix(body, indent) {
558					body = body[len(indent):]
559				}
560				leadW = promptLen
561				leadP = indent
562			}
563			bodyRunes := []rune(body)
564			start := seen
565			end := seen + len(bodyRunes)
566			rows = append(rows, vrow{
567				logical: r, runeStart: start, runeEnd: end,
568				leadWidth: leadW, leadPrefix: leadP,
569			})
570			// Record where the cursor currently sits.
571			if r == e.CursorR && e.CursorC >= start && e.CursorC <= end {
572				curVRow = len(rows) - 1
573				inner := e.CursorC - start
574				if inner < 0 {
575					inner = 0
576				}
577				if inner > len(bodyRunes) {
578					inner = len(bodyRunes)
579				}
580				curVCol = leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
581			}
582			seen = end
583			// Word-wrap often drops a single space at the boundary.
584			for seen < len(lineRunes) && lineRunes[seen] == ' ' {
585				seen++
586			}
587		}
588	}
589
590	target := curVRow + dir
591	if target < 0 || target >= len(rows) {
592		return
593	}
594	tr := rows[target]
595	line := e.Lines[tr.logical]
596	lineRunes := []rune(line)
597	bodyRunes := lineRunes[tr.runeStart:tr.runeEnd]
598
599	// Find the rune offset inside bodyRunes whose visible column
600	// most closely matches curVCol after accounting for leadWidth.
601	want := curVCol - tr.leadWidth
602	if want < 0 {
603		want = 0
604	}
605	best := 0
606	bestW := 0
607	for i := 1; i <= len(bodyRunes); i++ {
608		w := runewidth.StringWidth(string(bodyRunes[:i]))
609		if w > want {
610			break
611		}
612		best = i
613		bestW = w
614		if w == want {
615			break
616		}
617	}
618	_ = bestW
619	e.CursorR = tr.logical
620	e.CursorC = tr.runeStart + best
621}
622
623// moveCursorLogical is the pre-visual-navigation fallback used
624// when Render hasn't told us the terminal width yet. Walks the
625// e.Lines array directly.
626func (e *Editor) moveCursorLogical(dir int) {
627	switch {
628	case dir < 0 && e.CursorR > 0:
629		e.CursorR--
630	case dir > 0 && e.CursorR < len(e.Lines)-1:
631		e.CursorR++
632	default:
633		return
634	}
635	if e.CursorC > runeLen(e.Lines[e.CursorR]) {
636		e.CursorC = runeLen(e.Lines[e.CursorR])
637	}
638}
639
640func (e *Editor) deleteWord() {
641	line := e.Lines[e.CursorR]
642	if e.CursorC == 0 {
643		e.backspace()
644		return
645	}
646	r := []rune(line)
647	i := e.CursorC
648	// Step 1: walk over any trailing whitespace to the left of the cursor.
649	for i > 0 && isWordSep(r[i-1]) {
650		i--
651	}
652	// Step 2: walk over the word itself (non-separator runes).
653	for i > 0 && !isWordSep(r[i-1]) {
654		i--
655	}
656	e.Lines[e.CursorR] = string(r[:i]) + string(r[e.CursorC:])
657	e.CursorC = i
658}
659
660// isWordSep reports whether r is a word separator. Anything that isn't
661// a letter, digit, or underscore counts as a separator so delete-word
662// feels natural on paths, code, and prose.
663func isWordSep(r rune) bool {
664	switch {
665	case r == '_':
666		return false
667	case r >= '0' && r <= '9':
668		return false
669	case r >= 'a' && r <= 'z':
670		return false
671	case r >= 'A' && r <= 'Z':
672		return false
673	case r >= 0x80:
674		// Treat all non-ASCII as word chars so CJK/äöü don't get chopped.
675		return false
676	}
677	return true
678}
679
680// ---- rendering ----
681
682// Render returns the editor's visible lines (wrapped to width).
683// visualRow/visualCol describe where the cursor lands within the returned lines.
684func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
685	e.lastRenderWidth = width
686	promptLen := visibleWidth(e.Prompt)
687	indent := strings.Repeat(" ", promptLen)
688
689	for r, line := range e.Lines {
690		var prefix string
691		if r == 0 {
692			prefix = e.Prompt
693		} else {
694			prefix = indent
695		}
696		wrapped := wrapLine(prefix+line, width, indent)
697		if r == e.CursorR {
698			// Compute where CursorC lands inside the wrapped rows by
699			// walking the wrapped output character-by-character and
700			// tracking which wrapped row + column corresponds to the
701			// cursor's rune index in `line`. This is the only reliable
702			// answer under word-wrap, where a simple (promptLen+col)/width
703			// formula overshoots when wrapLine broke on a space.
704			targetRunes := e.CursorC
705			row, col := locateCursor(wrapped, prefix, line, targetRunes, indent)
706			visualRow = len(lines) + row
707			visualCol = col
708		}
709		lines = append(lines, wrapped...)
710	}
711	return lines, visualRow, visualCol
712}
713
714// locateCursor finds the wrapped row + visible column corresponding to
715// `targetRunes` rune positions into the logical `line`, given that the
716// wrapped output `wrapped` started with `prefix` on its first row and
717// uses `cont` as the continuation indent on subsequent rows.
718func locateCursor(wrapped []string, prefix, line string, targetRunes int, cont string) (int, int) {
719	prefixW := visibleWidth(prefix)
720	contW := visibleWidth(cont)
721	lineRunes := []rune(line)
722	if targetRunes > len(lineRunes) {
723		targetRunes = len(lineRunes)
724	}
725
726	seenRunes := 0
727	for row, w := range wrapped {
728		// strip leading prefix / continuation indent before counting runes
729		body := w
730		var leadW int
731		if row == 0 {
732			if strings.HasPrefix(body, prefix) {
733				body = body[len(prefix):]
734			}
735			leadW = prefixW
736		} else {
737			if strings.HasPrefix(body, cont) {
738				body = body[len(cont):]
739			}
740			leadW = contW
741		}
742		bodyRunes := []rune(body)
743		// Could this wrapped row contain the cursor?
744		if targetRunes <= seenRunes+len(bodyRunes) {
745			// Column inside body.
746			inner := targetRunes - seenRunes
747			if inner < 0 {
748				inner = 0
749			}
750			col := leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
751			return row, col
752		}
753		seenRunes += len(bodyRunes)
754		// Word-wrap may have dropped whitespace at the boundary; skip the
755		// corresponding runes in `line` so counts stay aligned.
756		for seenRunes < len(lineRunes) && lineRunes[seenRunes] == ' ' {
757			seenRunes++
758			if seenRunes >= targetRunes {
759				// Cursor landed in the skipped whitespace — place it at
760				// the start of the next wrapped row.
761				nextRow := row + 1
762				if nextRow >= len(wrapped) {
763					nextRow = row
764				}
765				return nextRow, contW
766			}
767		}
768	}
769	// Fallback: end of the last wrapped row.
770	if len(wrapped) == 0 {
771		return 0, prefixW
772	}
773	last := wrapped[len(wrapped)-1]
774	return len(wrapped) - 1, visibleWidth(last)
775}
776
777// ---- helpers ----
778
779func substringBefore(s string, col int) string {
780	r := []rune(s)
781	if col > len(r) {
782		col = len(r)
783	}
784	return string(r[:col])
785}
786
787func substringAfter(s string, col int) string {
788	r := []rune(s)
789	if col > len(r) {
790		col = len(r)
791	}
792	return string(r[col:])
793}
794
795func runeLen(s string) int { return len([]rune(s)) }
796
797func visualColumn(s string, runeCol int) int {
798	r := []rune(s)
799	if runeCol > len(r) {
800		runeCol = len(r)
801	}
802	return runewidth.StringWidth(string(r[:runeCol]))
803}
804
805func visibleWidth(s string) int {
806	return runewidth.StringWidth(stripANSI(s))
807}
808
809func stripANSI(s string) string {
810	// Minimal ANSI stripper; handles CSI sequences (ESC [ ... final).
811	var out []rune
812	i := 0
813	runes := []rune(s)
814	for i < len(runes) {
815		if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
816			i += 2
817			for i < len(runes) {
818				c := runes[i]
819				i++
820				if c >= 0x40 && c <= 0x7e {
821					break
822				}
823			}
824			continue
825		}
826		out = append(out, runes[i])
827		i++
828	}
829	return string(out)
830}
831
832func wrapLine(s string, width int, cont string) []string {
833	if width <= 0 {
834		return []string{s}
835	}
836
837	// Tokenize on spaces while preserving their widths. Runs of spaces
838	// stay attached to the preceding word so trailing whitespace doesn't
839	// create empty wrapped lines.
840	type token struct {
841		text  string
842		width int
843		space bool // true if this token is whitespace only
844	}
845	var tokens []token
846	{
847		var buf strings.Builder
848		var bufW int
849		inSpace := false
850		flush := func() {
851			if buf.Len() == 0 {
852				return
853			}
854			tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace})
855			buf.Reset()
856			bufW = 0
857		}
858		for _, r := range s {
859			rSpace := r == ' ' || r == '\t'
860			if buf.Len() == 0 {
861				inSpace = rSpace
862			} else if rSpace != inSpace {
863				flush()
864				inSpace = rSpace
865			}
866			buf.WriteRune(r)
867			bufW += runewidth.RuneWidth(r)
868		}
869		flush()
870	}
871
872	var out []string
873	var cur strings.Builder
874	curW := 0
875	firstLine := true
876	contW := visibleWidth(cont)
877
878	newLine := func() {
879		out = append(out, cur.String())
880		cur.Reset()
881		curW = 0
882		// After flushing, everything we append next is a CONTINUATION
883		// row, which must start with the cont indent so the editor's
884		// visual cursor alignment stays consistent. The old code only
885		// wrote cont when !firstLine BEFORE toggling firstLine, which
886		// meant the very first wrap never got its indent. That caused
887		// the terminal cursor to land in the wrong column after a
888		// multi-line paste.
889		firstLine = false
890		cur.WriteString(cont)
891		curW = contW
892	}
893
894	for i := 0; i < len(tokens); i++ {
895		tk := tokens[i]
896
897		// Drop leading whitespace after a wrap.
898		if tk.space && curW == contW && !firstLine {
899			continue
900		}
901
902		if curW+tk.width <= width {
903			cur.WriteString(tk.text)
904			curW += tk.width
905			continue
906		}
907
908		// Token overflows.
909		//
910		// If the token on its own is wider than what fits on a
911		// continuation line (width - contW), it'll need to be split
912		// rune-by-rune no matter what. In that case, don't break
913		// first: let the rune-split start right here at the current
914		// column and wrap naturally. Breaking first would strand the
915		// prompt (on firstLine) or the cont indent (on later lines)
916		// on a row by itself, which is the drag-drop-long-path bug.
917		if tk.width > width-contW {
918			// Rune-by-rune starting from the current column. newLine()
919			// inside handles wrapping and re-indents.
920			for _, r := range tk.text {
921				rw := runewidth.RuneWidth(r)
922				if curW+rw > width {
923					newLine()
924				}
925				cur.WriteRune(r)
926				curW += rw
927			}
928			continue
929		}
930
931		// Token fits on a fresh continuation line; break first, then
932		// emit it whole.
933		if cur.Len() > 0 && !(firstLine && curW == 0) {
934			trimmed := strings.TrimRight(cur.String(), " \t")
935			cur.Reset()
936			cur.WriteString(trimmed)
937			curW = visibleWidth(trimmed)
938			newLine()
939			if tk.space {
940				continue
941			}
942		}
943		cur.WriteString(tk.text)
944		curW += tk.width
945	}
946
947	if cur.Len() > 0 || len(out) == 0 {
948		out = append(out, cur.String())
949	}
950	return out
951}
952
953func max(a, b int) int {
954	if a > b {
955		return a
956	}
957	return b
958}
959
960// pasteCollapseLineThreshold and pasteCollapseCharThreshold govern
961// when a bracketed paste gets collapsed to a [pasted text #N …]
962// placeholder instead of being inserted inline. Either trigger
963// alone is enough — a 500-line log dump and a 1200-character
964// one-line log entry both bloat the editor in ways the user
965// doesn't want to scroll through while composing a prompt.
966const (
967	pasteCollapseLineThreshold = 10
968	pasteCollapseCharThreshold = 1000
969)
970
971// pasteShouldCollapse reports whether a pasted chunk is big enough
972// to deserve a placeholder token instead of being inserted verbatim.
973// Collapse on > 10 lines OR > 1000 characters, whichever fires
974// first.
975func pasteShouldCollapse(s string) bool {
976	return countLines(s) > pasteCollapseLineThreshold || len(s) > pasteCollapseCharThreshold
977}
978
979// formatPastePlaceholder builds the visible marker for a
980// collapsed paste. Two shapes:
981//
982//	[pasted text #N +L lines]  — used when the line count is the
983//	                             trigger (multi-line dumps)
984//	[pasted text #N C chars]   — used when the character count is
985//	                             the trigger (long single-line
986//	                             or near-single-line pastes)
987//
988// Line-triggered takes precedence so a 12-line 4000-char paste
989// reads as "+12 lines", not "4000 chars".
990func formatPastePlaceholder(id int, body string) string {
991	if countLines(body) > pasteCollapseLineThreshold {
992		return fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(body))
993	}
994	return fmt.Sprintf("[pasted text #%d %d chars]", id, len(body))
995}
996
997// countLines returns the number of visual lines in s. A trailing
998// newline is not counted as an extra empty line so
999// "foo\nbar\n" reads as 2 lines (what the user expects in the
1000// "+N lines" summary) instead of 3.
1001func countLines(s string) int {
1002	if s == "" {
1003		return 0
1004	}
1005	n := strings.Count(s, "\n")
1006	if !strings.HasSuffix(s, "\n") {
1007		n++
1008	}
1009	return n
1010}
1011
1012// pastePlaceholderRE matches both placeholder shapes the editor
1013// emits for a collapsed paste:
1014//
1015//	[pasted text #N +L lines]   — multi-line trigger
1016//	[pasted text #N C chars]    — long-char trigger
1017//
1018// Capture group 1 is the numeric id used to look up the full
1019// body in e.pastes; the rest of the token is free-form and gets
1020// discarded during expansion.
1021var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)
1022
1023// expandPastePlaceholders returns raw with every paste token
1024// swapped for the body stored under its id in pastes. Tokens
1025// whose id isn't in the map are left as-is (user deleted the
1026// corresponding entry somehow, or the id is spurious user text
1027// that happens to match the shape).
1028func expandPastePlaceholders(raw string, pastes map[int]string) string {
1029	return pastePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
1030		groups := pastePlaceholderRE.FindStringSubmatch(match)
1031		if len(groups) < 2 {
1032			return match
1033		}
1034		var id int
1035		if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
1036			return match
1037		}
1038		if body, ok := pastes[id]; ok {
1039			return body
1040		}
1041		return match
1042	})
1043}

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