v0.0.27 e9b466a

editor.go RawHistoryFolder

1package tui
2
3import (
4	"net/url"
5	"strings"
6
7	"github.com/mattn/go-runewidth"
8)
9
10// Editor is a simple multi-line text editor for the input area.
11//
12// Users can type, paste, move the cursor, and submit. The editor
13// exposes its rendered height and the current cursor row/col for the
14// outer renderer. It does NOT draw itself directly; instead, Render()
15// returns the visible lines.
16type Editor struct {
17	// Lines is the current buffer, one entry per line.
18	Lines    []string
19	CursorR  int // row index into Lines
20	CursorC  int // rune index into Lines[CursorR]
21	Prompt   string
22	MaxWidth int
23
24	// History is a ring of previously submitted buffers (newest last).
25	History    []string
26	histIdx    int // -1 means "editing current buffer"
27	savedDraft string
28}
29
30// NewEditor returns an empty editor with the given prompt.
31func NewEditor(prompt string) *Editor {
32	return &Editor{
33		Lines:   []string{""},
34		Prompt:  prompt,
35		histIdx: -1,
36	}
37}
38
39// Value returns the buffer as a single string.
40func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") }
41
42// SetValue replaces the buffer and places the cursor at the end.
43func (e *Editor) SetValue(s string) {
44	e.Lines = strings.Split(s, "\n")
45	if len(e.Lines) == 0 {
46		e.Lines = []string{""}
47	}
48	e.CursorR = len(e.Lines) - 1
49	e.CursorC = runeLen(e.Lines[e.CursorR])
50	e.histIdx = -1
51}
52
53// Clear resets the buffer.
54func (e *Editor) Clear() { e.SetValue("") }
55
56// IsEmpty reports whether the buffer has no visible content.
57func (e *Editor) IsEmpty() bool {
58	return len(e.Lines) == 1 && e.Lines[0] == ""
59}
60
61// HandleKey applies k to the editor. It returns (submit, key).
62// If submit is true, the caller should read Value() and call
63// PushHistory + Clear.
64func (e *Editor) HandleKey(k Key) (submit bool) {
65	switch k.Kind {
66	case KeyRune:
67		if k.Alt && (k.Rune == '\r' || k.Rune == '\n') {
68			e.newline()
69			return false
70		}
71		e.insert(string(k.Rune))
72	case KeyEnter:
73		// Shift+Enter would be a separate key in some terminals; we treat
74		// the literal newline byte as Enter. Newline on submit is a decision
75		// for the outer UI via slash commands. Here Enter submits.
76		return true
77	case KeyBackspace:
78		if k.Alt {
79			// Alt+Backspace (Option+Delete on macOS) — delete previous word.
80			e.deleteWord()
81		} else {
82			e.backspace()
83		}
84	case KeyDelete:
85		e.delete()
86	case KeyLeft:
87		if k.Alt {
88			e.moveWordLeft()
89		} else {
90			e.moveLeft()
91		}
92	case KeyRight:
93		if k.Alt {
94			e.moveWordRight()
95		} else {
96			e.moveRight()
97		}
98	case KeyUp:
99		if e.CursorR == 0 {
100			e.historyPrev()
101		} else {
102			e.CursorR--
103			if e.CursorC > runeLen(e.Lines[e.CursorR]) {
104				e.CursorC = runeLen(e.Lines[e.CursorR])
105			}
106		}
107	case KeyDown:
108		if e.CursorR == len(e.Lines)-1 {
109			e.historyNext()
110		} else {
111			e.CursorR++
112			if e.CursorC > runeLen(e.Lines[e.CursorR]) {
113				e.CursorC = runeLen(e.Lines[e.CursorR])
114			}
115		}
116	case KeyHome, KeyCtrlA:
117		e.CursorC = 0
118	case KeyEnd, KeyCtrlE:
119		e.CursorC = runeLen(e.Lines[e.CursorR])
120	case KeyCtrlU:
121		e.Lines[e.CursorR] = substringAfter(e.Lines[e.CursorR], e.CursorC)
122		e.CursorC = 0
123	case KeyCtrlK:
124		e.Lines[e.CursorR] = substringBefore(e.Lines[e.CursorR], e.CursorC)
125	case KeyCtrlW:
126		e.deleteWord()
127	case KeyPaste:
128		// macOS Terminal / iTerm / Ghostty deliver drag-dropped files
129		// as bracketed-paste text. Detect that pattern and wrap the
130		// path(s) in single quotes so the agent sees them as one
131		// argument and any spaces / parens in the filename don't
132		// confuse downstream tool calls.
133		e.insert(quotePastedFilePaths(k.Paste))
134	case KeyEsc:
135		e.Clear()
136	}
137	return false
138}
139
140// quotePastedFilePaths returns paste with any drag-dropped file
141// paths wrapped in single quotes. Heuristics:
142//
143//   - one line only; multi-line text is returned unchanged so we
144//     never touch a real paste of code.
145//   - whitespace-separated tokens are inspected one by one. A token
146//     that looks like a filesystem path (absolute, ~/relative, or
147//     file:// URL) is normalised and re-quoted; everything else is
148//     left exactly as the user dropped it.
149//   - terminal-style backslash escapes ("foo\ bar.png") are folded
150//     back to literal characters before quoting, since wrapping in
151//     single quotes makes them unnecessary.
152//   - file:// URLs are URL-decoded and stripped of the scheme so
153//     the agent receives a plain filesystem path.
154//   - if a path contains a literal single quote, it's escaped using
155//     the standard '\” splice.
156//
157// Pastes that don't contain any path-shaped tokens are returned
158// unchanged.
159func quotePastedFilePaths(paste string) string {
160	if paste == "" || strings.ContainsRune(paste, '\n') {
161		return paste
162	}
163	trimmed := strings.TrimSpace(paste)
164	if trimmed == "" {
165		return paste
166	}
167
168	// Split on runs of whitespace, preserving the original separators
169	// so multi-file drops keep their spacing on rebuild. Backslash
170	// before a space is treated as part of the preceding token, since
171	// macOS Terminal escapes spaces in dropped paths that way.
172	tokens := splitPreservingSeparators(paste)
173	changed := false
174	for i, tk := range tokens {
175		if tk.isSpace {
176			continue
177		}
178		if p, ok := normalisePathToken(tk.text); ok {
179			tokens[i].text = singleQuote(p)
180			changed = true
181		}
182	}
183	if !changed {
184		return paste
185	}
186	var out strings.Builder
187	out.Grow(len(paste) + 8)
188	for _, tk := range tokens {
189		out.WriteString(tk.text)
190	}
191	return out.String()
192}
193
194type pasteToken struct {
195	text    string
196	isSpace bool
197}
198
199// splitPreservingSeparators splits s into runs of whitespace and
200// runs of non-whitespace, keeping the separator runs intact so a
201// rebuild via concatenation reproduces the original string exactly.
202// A backslash immediately followed by a space ("\ ") is treated as
203// part of the preceding non-whitespace run, since that's how macOS
204// Terminal escapes spaces in drag-dropped file paths.
205func splitPreservingSeparators(s string) []pasteToken {
206	runes := []rune(s)
207	var out []pasteToken
208	var buf strings.Builder
209	inSpace := false
210	flush := func() {
211		if buf.Len() == 0 {
212			return
213		}
214		out = append(out, pasteToken{text: buf.String(), isSpace: inSpace})
215		buf.Reset()
216	}
217	for i := 0; i < len(runes); i++ {
218		r := runes[i]
219		isEscapedSpace := r == '\\' && i+1 < len(runes) && (runes[i+1] == ' ' || runes[i+1] == '\t')
220		rSpace := !isEscapedSpace && (r == ' ' || r == '\t')
221		if buf.Len() == 0 {
222			inSpace = rSpace
223		} else if rSpace != inSpace {
224			flush()
225			inSpace = rSpace
226		}
227		if isEscapedSpace {
228			// Skip the backslash; emit the literal space as part of
229			// this non-whitespace token. normalisePathToken's
230			// unescapeBackslashes is now redundant for this path but
231			// stays in case some other source escapes other chars.
232			buf.WriteRune(runes[i+1])
233			i++
234			continue
235		}
236		buf.WriteRune(r)
237	}
238	flush()
239	return out
240}
241
242// normalisePathToken decides whether tk is a drag-dropped path and,
243// if so, returns the cleaned-up path string.
244func normalisePathToken(tk string) (string, bool) {
245	// Strip pre-existing surrounding quotes; we'll re-quote consistently.
246	if n := len(tk); n >= 2 {
247		if (tk[0] == '\'' && tk[n-1] == '\'') || (tk[0] == '"' && tk[n-1] == '"') {
248			tk = tk[1 : n-1]
249		}
250	}
251
252	// file:// URL form: decode and strip the scheme.
253	if strings.HasPrefix(tk, "file://") {
254		decoded, err := url.PathUnescape(strings.TrimPrefix(tk, "file://"))
255		if err != nil {
256			return "", false
257		}
258		if decoded == "" || decoded[0] != '/' {
259			return "", false
260		}
261		return decoded, true
262	}
263
264	// Looks-like-a-path heuristic: starts with /, ~, ~/. Must contain
265	// at least one path separator after the prefix (otherwise a bare
266	// "/" or "~" gets quoted, which is never what the user meant).
267	if !strings.HasPrefix(tk, "/") && !strings.HasPrefix(tk, "~") {
268		return "", false
269	}
270	if !strings.ContainsAny(tk[1:], "/.") {
271		return "", false
272	}
273	unescaped := unescapeBackslashes(tk)
274	if strings.ContainsAny(unescaped, "|;&$`<>") {
275		return "", false
276	}
277	return unescaped, true
278}
279
280// unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal
281// drag-and-drop on macOS uses backslash escaping by default; quoting
282// makes that unnecessary.
283func unescapeBackslashes(s string) string {
284	if !strings.Contains(s, `\`) {
285		return s
286	}
287	var out strings.Builder
288	out.Grow(len(s))
289	prev := false
290	for _, r := range s {
291		if prev {
292			out.WriteRune(r)
293			prev = false
294			continue
295		}
296		if r == '\\' {
297			prev = true
298			continue
299		}
300		out.WriteRune(r)
301	}
302	if prev {
303		out.WriteRune('\\')
304	}
305	return out.String()
306}
307
308// singleQuote wraps s in single quotes, escaping any embedded single
309// quote using the splice idiom: 'foo'\”bar' for foo'bar.
310func singleQuote(s string) string {
311	return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
312}
313
314// Insert places s into the editor at the cursor, splitting on
315// newlines so multi-line pastes preserve their structure.
316func (e *Editor) Insert(s string) { e.insert(s) }
317
318func (e *Editor) insert(s string) {
319	e.histIdx = -1
320	line := e.Lines[e.CursorR]
321	pre := substringBefore(line, e.CursorC)
322	post := substringAfter(line, e.CursorC)
323	// Split pasted text on newlines.
324	parts := strings.Split(s, "\n")
325	if len(parts) == 1 {
326		e.Lines[e.CursorR] = pre + s + post
327		e.CursorC += runeLen(s)
328		return
329	}
330	newLines := make([]string, 0, len(e.Lines)+len(parts)-1)
331	newLines = append(newLines, e.Lines[:e.CursorR]...)
332	newLines = append(newLines, pre+parts[0])
333	for i := 1; i < len(parts)-1; i++ {
334		newLines = append(newLines, parts[i])
335	}
336	last := parts[len(parts)-1]
337	newLines = append(newLines, last+post)
338	newLines = append(newLines, e.Lines[e.CursorR+1:]...)
339	e.Lines = newLines
340	e.CursorR += len(parts) - 1
341	e.CursorC = runeLen(last)
342}
343
344func (e *Editor) newline() {
345	e.histIdx = -1
346	line := e.Lines[e.CursorR]
347	pre := substringBefore(line, e.CursorC)
348	post := substringAfter(line, e.CursorC)
349	e.Lines[e.CursorR] = pre
350	e.Lines = append(e.Lines, "")
351	copy(e.Lines[e.CursorR+2:], e.Lines[e.CursorR+1:])
352	e.Lines[e.CursorR+1] = post
353	e.CursorR++
354	e.CursorC = 0
355}
356
357func (e *Editor) backspace() {
358	e.histIdx = -1
359	if e.CursorC == 0 {
360		if e.CursorR == 0 {
361			return
362		}
363		prev := e.Lines[e.CursorR-1]
364		cur := e.Lines[e.CursorR]
365		e.Lines = append(e.Lines[:e.CursorR], e.Lines[e.CursorR+1:]...)
366		e.CursorR--
367		e.CursorC = runeLen(prev)
368		e.Lines[e.CursorR] = prev + cur
369		return
370	}
371	line := e.Lines[e.CursorR]
372	e.Lines[e.CursorR] = substringBefore(line, e.CursorC-1) + substringAfter(line, e.CursorC)
373	e.CursorC--
374}
375
376func (e *Editor) delete() {
377	e.histIdx = -1
378	line := e.Lines[e.CursorR]
379	if e.CursorC == runeLen(line) {
380		if e.CursorR == len(e.Lines)-1 {
381			return
382		}
383		next := e.Lines[e.CursorR+1]
384		e.Lines = append(e.Lines[:e.CursorR+1], e.Lines[e.CursorR+2:]...)
385		e.Lines[e.CursorR] = line + next
386		return
387	}
388	e.Lines[e.CursorR] = substringBefore(line, e.CursorC) + substringAfter(line, e.CursorC+1)
389}
390
391// moveWordLeft jumps the cursor to the start of the previous word,
392// using the same word-separator rules as deleteWord. If already at the
393// start of the line, wraps to the end of the previous line.
394func (e *Editor) moveWordLeft() {
395	if e.CursorC == 0 {
396		if e.CursorR > 0 {
397			e.CursorR--
398			e.CursorC = runeLen(e.Lines[e.CursorR])
399		}
400		return
401	}
402	r := []rune(e.Lines[e.CursorR])
403	i := e.CursorC
404	for i > 0 && isWordSep(r[i-1]) {
405		i--
406	}
407	for i > 0 && !isWordSep(r[i-1]) {
408		i--
409	}
410	e.CursorC = i
411}
412
413// moveWordRight jumps the cursor to the start of the next word. If
414// already at the end of the line, wraps to the start of the next line.
415func (e *Editor) moveWordRight() {
416	line := e.Lines[e.CursorR]
417	if e.CursorC >= runeLen(line) {
418		if e.CursorR < len(e.Lines)-1 {
419			e.CursorR++
420			e.CursorC = 0
421		}
422		return
423	}
424	r := []rune(line)
425	i := e.CursorC
426	for i < len(r) && !isWordSep(r[i]) {
427		i++
428	}
429	for i < len(r) && isWordSep(r[i]) {
430		i++
431	}
432	e.CursorC = i
433}
434
435func (e *Editor) moveLeft() {
436	if e.CursorC > 0 {
437		e.CursorC--
438		return
439	}
440	if e.CursorR > 0 {
441		e.CursorR--
442		e.CursorC = runeLen(e.Lines[e.CursorR])
443	}
444}
445
446func (e *Editor) moveRight() {
447	if e.CursorC < runeLen(e.Lines[e.CursorR]) {
448		e.CursorC++
449		return
450	}
451	if e.CursorR < len(e.Lines)-1 {
452		e.CursorR++
453		e.CursorC = 0
454	}
455}
456
457func (e *Editor) deleteWord() {
458	e.histIdx = -1
459	line := e.Lines[e.CursorR]
460	if e.CursorC == 0 {
461		e.backspace()
462		return
463	}
464	r := []rune(line)
465	i := e.CursorC
466	// Step 1: walk over any trailing whitespace to the left of the cursor.
467	for i > 0 && isWordSep(r[i-1]) {
468		i--
469	}
470	// Step 2: walk over the word itself (non-separator runes).
471	for i > 0 && !isWordSep(r[i-1]) {
472		i--
473	}
474	e.Lines[e.CursorR] = string(r[:i]) + string(r[e.CursorC:])
475	e.CursorC = i
476}
477
478// isWordSep reports whether r is a word separator. Anything that isn't
479// a letter, digit, or underscore counts as a separator so delete-word
480// feels natural on paths, code, and prose.
481func isWordSep(r rune) bool {
482	switch {
483	case r == '_':
484		return false
485	case r >= '0' && r <= '9':
486		return false
487	case r >= 'a' && r <= 'z':
488		return false
489	case r >= 'A' && r <= 'Z':
490		return false
491	case r >= 0x80:
492		// Treat all non-ASCII as word chars so CJK/äöü don't get chopped.
493		return false
494	}
495	return true
496}
497
498// ---- history ----
499
500// PushHistory saves s to the history ring.
501func (e *Editor) PushHistory(s string) {
502	s = strings.TrimRight(s, "\n")
503	if s == "" {
504		return
505	}
506	if n := len(e.History); n > 0 && e.History[n-1] == s {
507		return
508	}
509	e.History = append(e.History, s)
510	if len(e.History) > 200 {
511		e.History = e.History[len(e.History)-200:]
512	}
513	e.histIdx = -1
514}
515
516func (e *Editor) historyPrev() {
517	if len(e.History) == 0 {
518		return
519	}
520	if e.histIdx == -1 {
521		e.savedDraft = e.Value()
522		e.histIdx = len(e.History) - 1
523	} else if e.histIdx > 0 {
524		e.histIdx--
525	}
526	e.SetValue(e.History[e.histIdx])
527	// SetValue resets histIdx; restore.
528	e.histIdx = e.findInHistory()
529}
530
531func (e *Editor) historyNext() {
532	if e.histIdx == -1 {
533		return
534	}
535	if e.histIdx == len(e.History)-1 {
536		e.histIdx = -1
537		e.SetValue(e.savedDraft)
538		e.histIdx = -1
539		return
540	}
541	e.histIdx++
542	e.SetValue(e.History[e.histIdx])
543	e.histIdx = e.findInHistory()
544}
545
546func (e *Editor) findInHistory() int {
547	v := e.Value()
548	for i, h := range e.History {
549		if h == v {
550			return i
551		}
552	}
553	return -1
554}
555
556// ---- rendering ----
557
558// Render returns the editor's visible lines (wrapped to width).
559// visualRow/visualCol describe where the cursor lands within the returned lines.
560func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
561	promptLen := visibleWidth(e.Prompt)
562	indent := strings.Repeat(" ", promptLen)
563
564	for r, line := range e.Lines {
565		var prefix string
566		if r == 0 {
567			prefix = e.Prompt
568		} else {
569			prefix = indent
570		}
571		wrapped := wrapLine(prefix+line, width, indent)
572		if r == e.CursorR {
573			// Compute where CursorC lands inside the wrapped rows by
574			// walking the wrapped output character-by-character and
575			// tracking which wrapped row + column corresponds to the
576			// cursor's rune index in `line`. This is the only reliable
577			// answer under word-wrap, where a simple (promptLen+col)/width
578			// formula overshoots when wrapLine broke on a space.
579			targetRunes := e.CursorC
580			row, col := locateCursor(wrapped, prefix, line, targetRunes, indent)
581			visualRow = len(lines) + row
582			visualCol = col
583		}
584		lines = append(lines, wrapped...)
585	}
586	return lines, visualRow, visualCol
587}
588
589// locateCursor finds the wrapped row + visible column corresponding to
590// `targetRunes` rune positions into the logical `line`, given that the
591// wrapped output `wrapped` started with `prefix` on its first row and
592// uses `cont` as the continuation indent on subsequent rows.
593func locateCursor(wrapped []string, prefix, line string, targetRunes int, cont string) (int, int) {
594	prefixW := visibleWidth(prefix)
595	contW := visibleWidth(cont)
596	lineRunes := []rune(line)
597	if targetRunes > len(lineRunes) {
598		targetRunes = len(lineRunes)
599	}
600
601	seenRunes := 0
602	for row, w := range wrapped {
603		// strip leading prefix / continuation indent before counting runes
604		body := w
605		var leadW int
606		if row == 0 {
607			if strings.HasPrefix(body, prefix) {
608				body = body[len(prefix):]
609			}
610			leadW = prefixW
611		} else {
612			if strings.HasPrefix(body, cont) {
613				body = body[len(cont):]
614			}
615			leadW = contW
616		}
617		bodyRunes := []rune(body)
618		// Could this wrapped row contain the cursor?
619		if targetRunes <= seenRunes+len(bodyRunes) {
620			// Column inside body.
621			inner := targetRunes - seenRunes
622			if inner < 0 {
623				inner = 0
624			}
625			col := leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
626			return row, col
627		}
628		seenRunes += len(bodyRunes)
629		// Word-wrap may have dropped whitespace at the boundary; skip the
630		// corresponding runes in `line` so counts stay aligned.
631		for seenRunes < len(lineRunes) && lineRunes[seenRunes] == ' ' {
632			seenRunes++
633			if seenRunes >= targetRunes {
634				// Cursor landed in the skipped whitespace — place it at
635				// the start of the next wrapped row.
636				nextRow := row + 1
637				if nextRow >= len(wrapped) {
638					nextRow = row
639				}
640				return nextRow, contW
641			}
642		}
643	}
644	// Fallback: end of the last wrapped row.
645	if len(wrapped) == 0 {
646		return 0, prefixW
647	}
648	last := wrapped[len(wrapped)-1]
649	return len(wrapped) - 1, visibleWidth(last)
650}
651
652// ---- helpers ----
653
654func substringBefore(s string, col int) string {
655	r := []rune(s)
656	if col > len(r) {
657		col = len(r)
658	}
659	return string(r[:col])
660}
661
662func substringAfter(s string, col int) string {
663	r := []rune(s)
664	if col > len(r) {
665		col = len(r)
666	}
667	return string(r[col:])
668}
669
670func runeLen(s string) int { return len([]rune(s)) }
671
672func visualColumn(s string, runeCol int) int {
673	r := []rune(s)
674	if runeCol > len(r) {
675		runeCol = len(r)
676	}
677	return runewidth.StringWidth(string(r[:runeCol]))
678}
679
680func visibleWidth(s string) int {
681	return runewidth.StringWidth(stripANSI(s))
682}
683
684func stripANSI(s string) string {
685	// Minimal ANSI stripper; handles CSI sequences (ESC [ ... final).
686	var out []rune
687	i := 0
688	runes := []rune(s)
689	for i < len(runes) {
690		if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
691			i += 2
692			for i < len(runes) {
693				c := runes[i]
694				i++
695				if c >= 0x40 && c <= 0x7e {
696					break
697				}
698			}
699			continue
700		}
701		out = append(out, runes[i])
702		i++
703	}
704	return string(out)
705}
706
707func wrapLine(s string, width int, cont string) []string {
708	if width <= 0 {
709		return []string{s}
710	}
711
712	// Tokenize on spaces while preserving their widths. Runs of spaces
713	// stay attached to the preceding word so trailing whitespace doesn't
714	// create empty wrapped lines.
715	type token struct {
716		text  string
717		width int
718		space bool // true if this token is whitespace only
719	}
720	var tokens []token
721	{
722		var buf strings.Builder
723		var bufW int
724		inSpace := false
725		flush := func() {
726			if buf.Len() == 0 {
727				return
728			}
729			tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace})
730			buf.Reset()
731			bufW = 0
732		}
733		for _, r := range s {
734			rSpace := r == ' ' || r == '\t'
735			if buf.Len() == 0 {
736				inSpace = rSpace
737			} else if rSpace != inSpace {
738				flush()
739				inSpace = rSpace
740			}
741			buf.WriteRune(r)
742			bufW += runewidth.RuneWidth(r)
743		}
744		flush()
745	}
746
747	var out []string
748	var cur strings.Builder
749	curW := 0
750	firstLine := true
751	contW := visibleWidth(cont)
752
753	newLine := func() {
754		out = append(out, cur.String())
755		cur.Reset()
756		curW = 0
757		if !firstLine {
758			cur.WriteString(cont)
759			curW = contW
760		}
761		firstLine = false
762	}
763
764	for i := 0; i < len(tokens); i++ {
765		tk := tokens[i]
766
767		// Drop leading whitespace after a wrap.
768		if tk.space && curW == contW && !firstLine {
769			continue
770		}
771
772		if curW+tk.width <= width {
773			cur.WriteString(tk.text)
774			curW += tk.width
775			continue
776		}
777
778		// Token overflows. If current line has content, break first.
779		if cur.Len() > 0 && !(firstLine && curW == 0) {
780			// Drop trailing whitespace on the line we're about to break.
781			trimmed := strings.TrimRight(cur.String(), " \t")
782			cur.Reset()
783			cur.WriteString(trimmed)
784			curW = visibleWidth(trimmed)
785			newLine()
786			// Re-process this token on the fresh line.
787			if tk.space {
788				continue
789			}
790		}
791
792		// Token is longer than the available width on an empty line.
793		// Split the token itself rune-by-rune.
794		if tk.width > width-curW {
795			for _, r := range tk.text {
796				rw := runewidth.RuneWidth(r)
797				if curW+rw > width {
798					newLine()
799				}
800				cur.WriteRune(r)
801				curW += rw
802			}
803		} else {
804			cur.WriteString(tk.text)
805			curW += tk.width
806		}
807	}
808
809	if cur.Len() > 0 || len(out) == 0 {
810		out = append(out, cur.String())
811	}
812	return out
813}
814
815func max(a, b int) int {
816	if a > b {
817		return a
818	}
819	return b
820}

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