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 { | |||
| 187 | 187 | term.OnResize(func() { | |
| 188 | 188 | c, r := term.Size() | |
| 189 | 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 | 199 | if i.cfg.InitialInput != "" { | |
| @@ -448,7 +454,15 @@ func (i *Interactive) redraw() { | |||
| 448 | 454 | } | |
| 449 | 455 | ||
| 450 | 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 | 468 | // 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 @@ | |||
| 1 | 1 | package tui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "net/url" | |
| 4 | 5 | "strings" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/mattn/go-runewidth" | |
| @@ -124,13 +125,192 @@ func (e *Editor) HandleKey(k Key) (submit bool) { | |||
| 124 | 125 | case KeyCtrlW: | |
| 125 | 126 | e.deleteWord() | |
| 126 | 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 | 134 | case KeyEsc: | |
| 129 | 135 | e.Clear() | |
| 130 | 136 | } | |
| 131 | 137 | 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 | 314 | func (e *Editor) insert(s string) { | |
| 135 | 315 | e.histIdx = -1 | |
| 136 | 316 | line := e.Lines[e.CursorR] | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package tui | 1 | 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 false | 137 | 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 = -1 | 315 | 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 { | |||
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 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 | 48 | func (r *Renderer) Resize(cols, rows int) { | |
| 44 | 49 | if cols != r.cols || rows != r.rows { | |
| 45 | 50 | r.cols = cols | |
| 46 | 51 | r.rows = rows | |
| 47 | 52 | r.prev = nil | |
| 53 | + | if r.out != nil { | |
| 54 | + | _, _ = io.WriteString(r.out, SeqClearScreen) | |
| 55 | + | } | |
| 48 | 56 | } | |
| 49 | 57 | } | |
| 50 | 58 | ||
| @@ -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 = cols | 50 | r.cols = cols | ||
| 46 | r.rows = rows | 51 | r.rows = rows | ||
| 47 | r.prev = nil | 52 | r.prev = nil | ||
| 53 | + | if r.out != nil { | |||
| 54 | + | _, _ = io.WriteString(r.out, SeqClearScreen) | |||
| 55 | + | } | |||
| 48 | } | 56 | } | ||
| 49 | } | 57 | } | ||
| 50 | 58 | ||||