v0.3.90 6f6753c

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

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