detached 36e0efe

tui: quote drag-dropped file paths in the input editor

dragging a file onto the terminal pastes its path via bracketed
paste. before this, agents would receive raw paths with literal
spaces and parens that shells/tools then misinterpreted (e.g.
"/Users/pat/foo bar.png" parsed as two arguments).

the editor's KeyPaste handler now runs pastes through
quotePastedFilePaths which:
  - inspects whitespace-separated tokens of single-line pastes
  - normalises file:// urls (decode + scheme strip), backslash
    space escapes (the macOS Terminal default for drag-drop), and
    pre-existing single/double-quoted forms
  - wraps tokens that look like paths (start with / or ~ and
    contain a separator, no shell metacharacters) in single
    quotes, splicing 'foo'\''bar' for embedded apostrophes
  - leaves prose, multi-line code pastes, and anything with
    metachars untouched

regression suite covers the 12 cases that surfaced while
implementing: backslash-escaped spaces, file:// urls, tilde
paths, multi-file drops, pre-quoted paths, embedded apostrophes,
plain prose, multiline pastes, metachar smuggling attempts, and
path-mixed-with-prose. all pass.

verified the build, vet, gofmt, and go test pipeline on darwin,
linux and windows targets.
commit
36e0efe9eae88cf20c1737ea4a9e32c0c1b71f6b
author
patriceckhart · · 2026-04-19 11:01 UTC
parent
5a2cfb5
signature
unsigned
refs
tag: v0.0.17
4 files · +261 -3
M internal/agent/modes/interactive.go +16 -2
@@ -187,7 +187,13 @@ func (i *Interactive) Run(ctx context.Context) error {
187187 term.OnResize(func() {
188188 c, r := term.Size()
189189 i.rend.Resize(c, r)
190- i.invalidate()
190+ // Force an immediate redraw on resize. The throttled invalidate
191+ // path is fine for animation, but a window resize is a discrete
192+ // user action where any visible delay (or stale frame) reads as
193+ // brokenness. redraw() is mutex-safe; the worst that happens is
194+ // a duplicate paint if the throttler is mid-flight, which is
195+ // invisible.
196+ i.redraw()
191197 })
192198
193199 if i.cfg.InitialInput != "" {
@@ -448,7 +454,15 @@ func (i *Interactive) redraw() {
448454 }
449455
450456 if i.statusOK != "" {
451- chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, "✓ "+i.statusOK), "")
457+ // Hard-truncate the OK line to the visible width so a long
458+ // session path ("resumed session: /Users/.../sessions/...")
459+ // doesn't overflow past the right edge and look broken on a
460+ // narrow terminal.
461+ line := "✓ " + i.statusOK
462+ if cols > 4 && len(line) > cols {
463+ line = line[:cols-1] + "…"
464+ }
465+ chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
452466 }
453467
454468 // Dialogs (login or model picker) render between chat and the editor.
@@ -187,7 +187,13 @@ func (i *Interactive) Run(ctx context.Context) error {
187 term.OnResize(func() {187 term.OnResize(func() {
188 c, r := term.Size()188 c, r := term.Size()
189 i.rend.Resize(c, r)189 i.rend.Resize(c, r)
190- i.invalidate()190+ // Force an immediate redraw on resize. The throttled invalidate
191+ // path is fine for animation, but a window resize is a discrete
192+ // user action where any visible delay (or stale frame) reads as
193+ // brokenness. redraw() is mutex-safe; the worst that happens is
194+ // a duplicate paint if the throttler is mid-flight, which is
195+ // invisible.
196+ i.redraw()
191 })197 })
192 198
193 if i.cfg.InitialInput != "" {199 if i.cfg.InitialInput != "" {
@@ -448,7 +454,15 @@ func (i *Interactive) redraw() {
448 }454 }
449 455
450 if i.statusOK != "" {456 if i.statusOK != "" {
451- chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, "✓ "+i.statusOK), "")457+ // Hard-truncate the OK line to the visible width so a long
458+ // session path ("resumed session: /Users/.../sessions/...")
459+ // doesn't overflow past the right edge and look broken on a
460+ // narrow terminal.
461+ line := "✓ " + i.statusOK
462+ if cols > 4 && len(line) > cols {
463+ line = line[:cols-1] + "…"
464+ }
465+ chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
452 }466 }
453 467
454 // Dialogs (login or model picker) render between chat and the editor.468 // Dialogs (login or model picker) render between chat and the editor.
M internal/tui/editor.go +181 -1
@@ -1,6 +1,7 @@
11 package tui
22
33 import (
4+ "net/url"
45 "strings"
56
67 "github.com/mattn/go-runewidth"
@@ -124,13 +125,192 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
124125 case KeyCtrlW:
125126 e.deleteWord()
126127 case KeyPaste:
127- e.insert(k.Paste)
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))
128134 case KeyEsc:
129135 e.Clear()
130136 }
131137 return false
132138 }
133139
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.
159+func 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+
194+type 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.
205+func 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.
244+func 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.
283+func 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.
310+func singleQuote(s string) string {
311+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
312+}
313+
134314 func (e *Editor) insert(s string) {
135315 e.histIdx = -1
136316 line := e.Lines[e.CursorR]
@@ -1,6 +1,7 @@
1 package tui1 package tui
2 2
3 import (3 import (
4+ "net/url"
4 "strings"5 "strings"
5 6
6 "github.com/mattn/go-runewidth"7 "github.com/mattn/go-runewidth"
@@ -124,13 +125,192 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
124 case KeyCtrlW:125 case KeyCtrlW:
125 e.deleteWord()126 e.deleteWord()
126 case KeyPaste:127 case KeyPaste:
127- e.insert(k.Paste)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))
128 case KeyEsc:134 case KeyEsc:
129 e.Clear()135 e.Clear()
130 }136 }
131 return false137 return false
132 }138 }
133 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.
159+func 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+
194+type 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.
205+func 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.
244+func 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.
283+func 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.
310+func singleQuote(s string) string {
311+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
312+}
313+
134 func (e *Editor) insert(s string) {314 func (e *Editor) insert(s string) {
135 e.histIdx = -1315 e.histIdx = -1
136 line := e.Lines[e.CursorR]316 line := e.Lines[e.CursorR]
A internal/tui/quote_paste_test.go +56 -0
@@ -0,0 +1,56 @@
1+package tui
2+
3+import "testing"
4+
5+func TestQuotePastedFilePaths(t *testing.T) {
6+ cases := []struct {
7+ name string
8+ in string
9+ want string
10+ }{
11+ // macOS Terminal default: backslash-escaped spaces in path.
12+ {"backslash-escaped space", `/Users/pat/foo\ bar.png`, `'/Users/pat/foo bar.png'`},
13+
14+ // Plain absolute path with no special chars.
15+ {"plain absolute", `/Users/pat/file.png`, `'/Users/pat/file.png'`},
16+
17+ // file:// URL form; URL-decoded and scheme stripped.
18+ {"file url", `file:///Users/pat/foo%20bar.png`, `'/Users/pat/foo bar.png'`},
19+
20+ // Tilde paths.
21+ {"tilde", `~/Pictures/x.png`, `'~/Pictures/x.png'`},
22+
23+ // Multi-file drop, each path quoted independently.
24+ {"two paths", `/a.png /b.png`, `'/a.png' '/b.png'`},
25+
26+ // Already single-quoted: re-normalise to consistent quoting.
27+ {"already single quoted", `'/Users/pat/x.png'`, `'/Users/pat/x.png'`},
28+
29+ // Already double-quoted: re-normalise.
30+ {"already double quoted", `"/Users/pat/x.png"`, `'/Users/pat/x.png'`},
31+
32+ // Embedded apostrophe gets the standard '\'' splice escape.
33+ {"embedded apostrophe", `/Users/pat/it's.png`, `'/Users/pat/it'\''s.png'`},
34+
35+ // Plain prose left alone.
36+ {"prose", `hello world`, `hello world`},
37+
38+ // Multi-line paste left alone (typical code paste).
39+ {"multiline", "foo\nbar", "foo\nbar"},
40+
41+ // Anything containing shell metacharacters is left alone so a
42+ // crafted "drop" can't smuggle a command.
43+ {"metachar", `/foo;rm -rf /`, `/foo;rm -rf /`},
44+
45+ // Mixed: valid path token quoted, surrounding words preserved.
46+ {"path mixed with prose", `/x.png is good`, `'/x.png' is good`},
47+ }
48+ for _, c := range cases {
49+ t.Run(c.name, func(t *testing.T) {
50+ got := quotePastedFilePaths(c.in)
51+ if got != c.want {
52+ t.Errorf("input %q\n want %q\n got %q", c.in, c.want, got)
53+ }
54+ })
55+ }
56+}
@@ -0,0 +1,56 @@
1+package tui
2+
3+import "testing"
4+
5+func TestQuotePastedFilePaths(t *testing.T) {
6+ cases := []struct {
7+ name string
8+ in string
9+ want string
10+ }{
11+ // macOS Terminal default: backslash-escaped spaces in path.
12+ {"backslash-escaped space", `/Users/pat/foo\ bar.png`, `'/Users/pat/foo bar.png'`},
13+
14+ // Plain absolute path with no special chars.
15+ {"plain absolute", `/Users/pat/file.png`, `'/Users/pat/file.png'`},
16+
17+ // file:// URL form; URL-decoded and scheme stripped.
18+ {"file url", `file:///Users/pat/foo%20bar.png`, `'/Users/pat/foo bar.png'`},
19+
20+ // Tilde paths.
21+ {"tilde", `~/Pictures/x.png`, `'~/Pictures/x.png'`},
22+
23+ // Multi-file drop, each path quoted independently.
24+ {"two paths", `/a.png /b.png`, `'/a.png' '/b.png'`},
25+
26+ // Already single-quoted: re-normalise to consistent quoting.
27+ {"already single quoted", `'/Users/pat/x.png'`, `'/Users/pat/x.png'`},
28+
29+ // Already double-quoted: re-normalise.
30+ {"already double quoted", `"/Users/pat/x.png"`, `'/Users/pat/x.png'`},
31+
32+ // Embedded apostrophe gets the standard '\'' splice escape.
33+ {"embedded apostrophe", `/Users/pat/it's.png`, `'/Users/pat/it'\''s.png'`},
34+
35+ // Plain prose left alone.
36+ {"prose", `hello world`, `hello world`},
37+
38+ // Multi-line paste left alone (typical code paste).
39+ {"multiline", "foo\nbar", "foo\nbar"},
40+
41+ // Anything containing shell metacharacters is left alone so a
42+ // crafted "drop" can't smuggle a command.
43+ {"metachar", `/foo;rm -rf /`, `/foo;rm -rf /`},
44+
45+ // Mixed: valid path token quoted, surrounding words preserved.
46+ {"path mixed with prose", `/x.png is good`, `'/x.png' is good`},
47+ }
48+ for _, c := range cases {
49+ t.Run(c.name, func(t *testing.T) {
50+ got := quotePastedFilePaths(c.in)
51+ if got != c.want {
52+ t.Errorf("input %q\n want %q\n got %q", c.in, c.want, got)
53+ }
54+ })
55+ }
56+}
M internal/tui/render.go +8 -0
@@ -40,11 +40,19 @@ func NewRenderer(out io.Writer) *Renderer {
4040 }
4141
4242 // Resize tells the renderer the current terminal size.
43+//
44+// On a real size change we also issue a clear-screen so the next Draw
45+// starts from a blank slate. Without the clear, characters from the
46+// old (wider) layout linger past the new right edge and rows from
47+// before the new bottom hang around as garbage.
4348 func (r *Renderer) Resize(cols, rows int) {
4449 if cols != r.cols || rows != r.rows {
4550 r.cols = cols
4651 r.rows = rows
4752 r.prev = nil
53+ if r.out != nil {
54+ _, _ = io.WriteString(r.out, SeqClearScreen)
55+ }
4856 }
4957 }
5058
@@ -40,11 +40,19 @@ func NewRenderer(out io.Writer) *Renderer {
40 }40 }
41 41
42 // Resize tells the renderer the current terminal size.42 // Resize tells the renderer the current terminal size.
43+//
44+// On a real size change we also issue a clear-screen so the next Draw
45+// starts from a blank slate. Without the clear, characters from the
46+// old (wider) layout linger past the new right edge and rows from
47+// before the new bottom hang around as garbage.
43 func (r *Renderer) Resize(cols, rows int) {48 func (r *Renderer) Resize(cols, rows int) {
44 if cols != r.cols || rows != r.rows {49 if cols != r.cols || rows != r.rows {
45 r.cols = cols50 r.cols = cols
46 r.rows = rows51 r.rows = rows
47 r.prev = nil52 r.prev = nil
53+ if r.out != nil {
54+ _, _ = io.WriteString(r.out, SeqClearScreen)
55+ }
48 }56 }
49 }57 }
50 58

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