mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
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.
This commit is contained in:
parent
5a2cfb525e
commit
36e0efe9ea
4 changed files with 261 additions and 3 deletions
|
|
@ -187,7 +187,13 @@ func (i *Interactive) Run(ctx context.Context) error {
|
|||
term.OnResize(func() {
|
||||
c, r := term.Size()
|
||||
i.rend.Resize(c, r)
|
||||
i.invalidate()
|
||||
// Force an immediate redraw on resize. The throttled invalidate
|
||||
// path is fine for animation, but a window resize is a discrete
|
||||
// user action where any visible delay (or stale frame) reads as
|
||||
// brokenness. redraw() is mutex-safe; the worst that happens is
|
||||
// a duplicate paint if the throttler is mid-flight, which is
|
||||
// invisible.
|
||||
i.redraw()
|
||||
})
|
||||
|
||||
if i.cfg.InitialInput != "" {
|
||||
|
|
@ -448,7 +454,15 @@ func (i *Interactive) redraw() {
|
|||
}
|
||||
|
||||
if i.statusOK != "" {
|
||||
chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, "✓ "+i.statusOK), "")
|
||||
// Hard-truncate the OK line to the visible width so a long
|
||||
// session path ("resumed session: /Users/.../sessions/...")
|
||||
// doesn't overflow past the right edge and look broken on a
|
||||
// narrow terminal.
|
||||
line := "✓ " + i.statusOK
|
||||
if cols > 4 && len(line) > cols {
|
||||
line = line[:cols-1] + "…"
|
||||
}
|
||||
chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
|
||||
}
|
||||
|
||||
// Dialogs (login or model picker) render between chat and the editor.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
|
|
@ -124,13 +125,192 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
|
|||
case KeyCtrlW:
|
||||
e.deleteWord()
|
||||
case KeyPaste:
|
||||
e.insert(k.Paste)
|
||||
// macOS Terminal / iTerm / Ghostty deliver drag-dropped files
|
||||
// as bracketed-paste text. Detect that pattern and wrap the
|
||||
// path(s) in single quotes so the agent sees them as one
|
||||
// argument and any spaces / parens in the filename don't
|
||||
// confuse downstream tool calls.
|
||||
e.insert(quotePastedFilePaths(k.Paste))
|
||||
case KeyEsc:
|
||||
e.Clear()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// quotePastedFilePaths returns paste with any drag-dropped file
|
||||
// paths wrapped in single quotes. Heuristics:
|
||||
//
|
||||
// - one line only; multi-line text is returned unchanged so we
|
||||
// never touch a real paste of code.
|
||||
// - whitespace-separated tokens are inspected one by one. A token
|
||||
// that looks like a filesystem path (absolute, ~/relative, or
|
||||
// file:// URL) is normalised and re-quoted; everything else is
|
||||
// left exactly as the user dropped it.
|
||||
// - terminal-style backslash escapes ("foo\ bar.png") are folded
|
||||
// back to literal characters before quoting, since wrapping in
|
||||
// single quotes makes them unnecessary.
|
||||
// - file:// URLs are URL-decoded and stripped of the scheme so
|
||||
// the agent receives a plain filesystem path.
|
||||
// - if a path contains a literal single quote, it's escaped using
|
||||
// the standard '\” splice.
|
||||
//
|
||||
// Pastes that don't contain any path-shaped tokens are returned
|
||||
// unchanged.
|
||||
func quotePastedFilePaths(paste string) string {
|
||||
if paste == "" || strings.ContainsRune(paste, '\n') {
|
||||
return paste
|
||||
}
|
||||
trimmed := strings.TrimSpace(paste)
|
||||
if trimmed == "" {
|
||||
return paste
|
||||
}
|
||||
|
||||
// Split on runs of whitespace, preserving the original separators
|
||||
// so multi-file drops keep their spacing on rebuild. Backslash
|
||||
// before a space is treated as part of the preceding token, since
|
||||
// macOS Terminal escapes spaces in dropped paths that way.
|
||||
tokens := splitPreservingSeparators(paste)
|
||||
changed := false
|
||||
for i, tk := range tokens {
|
||||
if tk.isSpace {
|
||||
continue
|
||||
}
|
||||
if p, ok := normalisePathToken(tk.text); ok {
|
||||
tokens[i].text = singleQuote(p)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return paste
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(paste) + 8)
|
||||
for _, tk := range tokens {
|
||||
out.WriteString(tk.text)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
type pasteToken struct {
|
||||
text string
|
||||
isSpace bool
|
||||
}
|
||||
|
||||
// splitPreservingSeparators splits s into runs of whitespace and
|
||||
// runs of non-whitespace, keeping the separator runs intact so a
|
||||
// rebuild via concatenation reproduces the original string exactly.
|
||||
// A backslash immediately followed by a space ("\ ") is treated as
|
||||
// part of the preceding non-whitespace run, since that's how macOS
|
||||
// Terminal escapes spaces in drag-dropped file paths.
|
||||
func splitPreservingSeparators(s string) []pasteToken {
|
||||
runes := []rune(s)
|
||||
var out []pasteToken
|
||||
var buf strings.Builder
|
||||
inSpace := false
|
||||
flush := func() {
|
||||
if buf.Len() == 0 {
|
||||
return
|
||||
}
|
||||
out = append(out, pasteToken{text: buf.String(), isSpace: inSpace})
|
||||
buf.Reset()
|
||||
}
|
||||
for i := 0; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
isEscapedSpace := r == '\\' && i+1 < len(runes) && (runes[i+1] == ' ' || runes[i+1] == '\t')
|
||||
rSpace := !isEscapedSpace && (r == ' ' || r == '\t')
|
||||
if buf.Len() == 0 {
|
||||
inSpace = rSpace
|
||||
} else if rSpace != inSpace {
|
||||
flush()
|
||||
inSpace = rSpace
|
||||
}
|
||||
if isEscapedSpace {
|
||||
// Skip the backslash; emit the literal space as part of
|
||||
// this non-whitespace token. normalisePathToken's
|
||||
// unescapeBackslashes is now redundant for this path but
|
||||
// stays in case some other source escapes other chars.
|
||||
buf.WriteRune(runes[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// normalisePathToken decides whether tk is a drag-dropped path and,
|
||||
// if so, returns the cleaned-up path string.
|
||||
func normalisePathToken(tk string) (string, bool) {
|
||||
// Strip pre-existing surrounding quotes; we'll re-quote consistently.
|
||||
if n := len(tk); n >= 2 {
|
||||
if (tk[0] == '\'' && tk[n-1] == '\'') || (tk[0] == '"' && tk[n-1] == '"') {
|
||||
tk = tk[1 : n-1]
|
||||
}
|
||||
}
|
||||
|
||||
// file:// URL form: decode and strip the scheme.
|
||||
if strings.HasPrefix(tk, "file://") {
|
||||
decoded, err := url.PathUnescape(strings.TrimPrefix(tk, "file://"))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if decoded == "" || decoded[0] != '/' {
|
||||
return "", false
|
||||
}
|
||||
return decoded, true
|
||||
}
|
||||
|
||||
// Looks-like-a-path heuristic: starts with /, ~, ~/. Must contain
|
||||
// at least one path separator after the prefix (otherwise a bare
|
||||
// "/" or "~" gets quoted, which is never what the user meant).
|
||||
if !strings.HasPrefix(tk, "/") && !strings.HasPrefix(tk, "~") {
|
||||
return "", false
|
||||
}
|
||||
if !strings.ContainsAny(tk[1:], "/.") {
|
||||
return "", false
|
||||
}
|
||||
unescaped := unescapeBackslashes(tk)
|
||||
if strings.ContainsAny(unescaped, "|;&$`<>") {
|
||||
return "", false
|
||||
}
|
||||
return unescaped, true
|
||||
}
|
||||
|
||||
// unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal
|
||||
// drag-and-drop on macOS uses backslash escaping by default; quoting
|
||||
// makes that unnecessary.
|
||||
func unescapeBackslashes(s string) string {
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(s))
|
||||
prev := false
|
||||
for _, r := range s {
|
||||
if prev {
|
||||
out.WriteRune(r)
|
||||
prev = false
|
||||
continue
|
||||
}
|
||||
if r == '\\' {
|
||||
prev = true
|
||||
continue
|
||||
}
|
||||
out.WriteRune(r)
|
||||
}
|
||||
if prev {
|
||||
out.WriteRune('\\')
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// singleQuote wraps s in single quotes, escaping any embedded single
|
||||
// quote using the splice idiom: 'foo'\”bar' for foo'bar.
|
||||
func singleQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
func (e *Editor) insert(s string) {
|
||||
e.histIdx = -1
|
||||
line := e.Lines[e.CursorR]
|
||||
|
|
|
|||
56
internal/tui/quote_paste_test.go
Normal file
56
internal/tui/quote_paste_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package tui
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestQuotePastedFilePaths(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
// macOS Terminal default: backslash-escaped spaces in path.
|
||||
{"backslash-escaped space", `/Users/pat/foo\ bar.png`, `'/Users/pat/foo bar.png'`},
|
||||
|
||||
// Plain absolute path with no special chars.
|
||||
{"plain absolute", `/Users/pat/file.png`, `'/Users/pat/file.png'`},
|
||||
|
||||
// file:// URL form; URL-decoded and scheme stripped.
|
||||
{"file url", `file:///Users/pat/foo%20bar.png`, `'/Users/pat/foo bar.png'`},
|
||||
|
||||
// Tilde paths.
|
||||
{"tilde", `~/Pictures/x.png`, `'~/Pictures/x.png'`},
|
||||
|
||||
// Multi-file drop, each path quoted independently.
|
||||
{"two paths", `/a.png /b.png`, `'/a.png' '/b.png'`},
|
||||
|
||||
// Already single-quoted: re-normalise to consistent quoting.
|
||||
{"already single quoted", `'/Users/pat/x.png'`, `'/Users/pat/x.png'`},
|
||||
|
||||
// Already double-quoted: re-normalise.
|
||||
{"already double quoted", `"/Users/pat/x.png"`, `'/Users/pat/x.png'`},
|
||||
|
||||
// Embedded apostrophe gets the standard '\'' splice escape.
|
||||
{"embedded apostrophe", `/Users/pat/it's.png`, `'/Users/pat/it'\''s.png'`},
|
||||
|
||||
// Plain prose left alone.
|
||||
{"prose", `hello world`, `hello world`},
|
||||
|
||||
// Multi-line paste left alone (typical code paste).
|
||||
{"multiline", "foo\nbar", "foo\nbar"},
|
||||
|
||||
// Anything containing shell metacharacters is left alone so a
|
||||
// crafted "drop" can't smuggle a command.
|
||||
{"metachar", `/foo;rm -rf /`, `/foo;rm -rf /`},
|
||||
|
||||
// Mixed: valid path token quoted, surrounding words preserved.
|
||||
{"path mixed with prose", `/x.png is good`, `'/x.png' is good`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := quotePastedFilePaths(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("input %q\n want %q\n got %q", c.in, c.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -40,11 +40,19 @@ func NewRenderer(out io.Writer) *Renderer {
|
|||
}
|
||||
|
||||
// Resize tells the renderer the current terminal size.
|
||||
//
|
||||
// On a real size change we also issue a clear-screen so the next Draw
|
||||
// starts from a blank slate. Without the clear, characters from the
|
||||
// old (wider) layout linger past the new right edge and rows from
|
||||
// before the new bottom hang around as garbage.
|
||||
func (r *Renderer) Resize(cols, rows int) {
|
||||
if cols != r.cols || rows != r.rows {
|
||||
r.cols = cols
|
||||
r.rows = rows
|
||||
r.prev = nil
|
||||
if r.out != nil {
|
||||
_, _ = io.WriteString(r.out, SeqClearScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue