Skip to content

Commit add73ab

Browse files
authored
Visit files and run shell commands from chat (#250)
* Add strict chat file target resolver * Harden chat file target resolution * Resolve Markdown link file targets * Run shell command on chat file target * Harden chat shell command execution * Visit chat file targets with RET * Harden chat RET file navigation * Keep button-remap test keybindings off the shared chat mode map local-set-key mutates the shared mode map when the buffer's local map is the mode map, and (kbd "C-c RET") is C-c C-m. Give the test buffer its own keymap copy so C-c C-m keeps its select-model binding for later tests.
1 parent 484a256 commit add73ab

8 files changed

Lines changed: 8672 additions & 131 deletions

README.org

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,29 @@ Markdown pipe tables get a display-only treatment: wide tables are
176176
wrapped and drawn to neatly fit window width, with cleaner separators,
177177
while the underlying buffer text remains ordinary Markdown.
178178

179-
Output from file tools such as =read=, =write=, and =edit=, and custom
180-
tools that include =:path= allow that =RET= on a file-content line
181-
opens the backing file at the matching line. By default the file
182-
opens in the other window so the chat stays visible; use =C-u RET= to
183-
open it in the same window instead.
179+
Press =RET= on one strict file target at point: a file-content row in tool
180+
output, a plain path reference, or the label of a local Markdown link.
181+
Tool headers, fences, preview hints, and other non-content rows do not open
182+
files. Plain locations such as =src/app.el:12:3= visit their one-based
183+
physical file line and optional one-based column; a range such as
184+
=src/app.el#L12-L20= visits its first line only. If an explicit location is
185+
outside an existing narrowing, the default =widen-automatically= policy widens
186+
the file. Coordinates apply only to file-visiting buffers; directory targets
187+
retain native Dired point and marks. A target without a location keeps the
188+
point, mark, and narrowing chosen by normal Emacs file visiting.
189+
190+
By default Pi requests the native other-window opener. Use =C-u RET= to invert
191+
=pi-coding-agent-visit-file-other-window= for that visit; with the default
192+
setting this requests the same-window opener instead. Emacs display policy may
193+
redirect the final placement.
194+
195+
Press =!= on one strict file target at point for a Dired-inspired shell
196+
command. One command word followed only by whitespace-delimited options
197+
beginning with =-= safely receives the quoted target automatically. All other
198+
command text, including ordinary arguments, compound/control syntax, and
199+
multiple lines, must use a textual isolated =*= bounded by a space, tab, or
200+
string edge. A final whitespace-delimited = &= uses the usual asynchronous
201+
shell output.
184202

185203
Press =TAB= on a turn header (=You= or =Assistant=) to fold or unfold
186204
that turn. Use =n= and =p= in the chat buffer to jump between user
@@ -219,7 +237,8 @@ and error thresholds.
219237
| =TAB= | input | 🪄 Complete paths, and =/= commands |
220238
| =M+<prior/next>= | input | 🛗 Scroll linked chat window |
221239
| =TAB= | chat | 🪗 Toggle thinking block, tool block, or turn |
222-
| =RET= | chat | 🎯 Visit file at point from tool output |
240+
| =!= | chat | 🐚 Run a Dired-inspired command on a file |
241+
| =RET= | chat | 🎯 Visit strict file target at point |
223242
| =n= / =p= | chat | 🐾 Navigate user messages |
224243
| =f= | chat | 🌿 Fork from the turn at point |
225244
| =q= | chat | 👋 Quit session |
@@ -422,7 +441,9 @@ Here's some common non-default preferences:
422441
;; Copy source Markdown from the chat buffer instead of only visible text:
423442
(setopt pi-coding-agent-copy-raw-markdown t)
424443

425-
;; Open files from tool output in the current window instead of another window:
444+
;; Request the native same-window opener for chat file targets;
445+
;; use C-u RET to invert this request for one visit. Emacs display policy
446+
;; may redirect final placement:
426447
(setopt pi-coding-agent-visit-file-other-window nil)
427448
#+end_src
428449

pi-coding-agent-core.el

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,16 @@ connection."
163163
(and (pi-coding-agent--remote-home-path-p path)
164164
(not (pi-coding-agent--plain-remote-home-path-p path))))
165165

166+
(defun pi-coding-agent--safe-shell-remote-home-path-p (path)
167+
"Return non-nil when PATH has a portable remote-shell home prefix.
168+
Accept plain `~' and POSIX portable named-user forms such as `~root/path'.
169+
Reject shell-significant and reserved expansion forms before an unquoted tilde
170+
prefix can cross the shell-host path boundary."
171+
(and (stringp path)
172+
(string-match-p
173+
"\\`\\(?:~\\|~[[:alpha:]_][[:alnum:]_.-]*\\)\\(?:/\\|\\'\\)"
174+
path)))
175+
166176
(defun pi-coding-agent--remote-prefix-for-path (path)
167177
"Return PATH's full TRAMP remote prefix, or nil."
168178
(and (stringp path)
@@ -282,6 +292,85 @@ process filters or callbacks."
282292
(substring path (length prefix))
283293
(file-local-name path)))
284294

295+
(defun pi-coding-agent--shell-command-path (path &optional anchor)
296+
"Return unquoted PATH in the shell host's file-name namespace.
297+
The shell host is selected by ANCHOR or `default-directory': local paths stay
298+
local, while paths under a remote ANCHOR are for the remote shell started by
299+
Emacs's `shell-command' file-name handler. Relative paths become absolute,
300+
matching TRAMP prefixes (including multi-hop routes) are removed, and remote
301+
`~/' and `~USER/' forms remain unexpanded for that shell. Local home paths are
302+
expanded by Emacs. Exactly one recognized Emacs file-name quote layer is
303+
removed. Explicit remote local names must then be absolute or home-rooted;
304+
empty and relative forms are rejected rather than reinterpreted lexically.
305+
306+
This is deliberately separate from `pi-coding-agent--process-local-path', whose
307+
outbound JSON contract is specific to Pi RPC and rejects named remote homes.
308+
This pure conversion neither checks file existence nor contacts a remote host.
309+
Malformed or incompatible remote paths signal `user-error'."
310+
(when-let* ((emacs-path (pi-coding-agent--emacs-path path anchor)))
311+
(let* ((remote-anchor (pi-coding-agent--remote-prefix anchor))
312+
(localname (file-name-unquote
313+
(pi-coding-agent--local-name-for-process emacs-path))))
314+
(cond
315+
((not remote-anchor)
316+
(if (file-name-absolute-p localname)
317+
localname
318+
(let ((file-name-handler-alist nil))
319+
(expand-file-name
320+
localname
321+
(file-name-unquote (or anchor default-directory))))))
322+
((string-prefix-p "/" localname)
323+
localname)
324+
((pi-coding-agent--safe-shell-remote-home-path-p localname)
325+
localname)
326+
((pi-coding-agent--remote-home-path-p localname)
327+
(user-error "Unsafe shell home prefix in path: %S" localname))
328+
(t
329+
(user-error "Remote shell path is not absolute or home-rooted: %S"
330+
localname))))))
331+
332+
(defun pi-coding-agent--shell-quote-path (path &optional anchor)
333+
"Quote shell-local PATH exactly once for a shell under ANCHOR.
334+
PATH should be the result of `pi-coding-agent--shell-command-path'. A remote
335+
`~/' or portable `~USER/' prefix is left unquoted so the remote shell can
336+
expand it; only the remaining path is POSIX-quoted. Other remote paths use
337+
POSIX quoting in full, while local paths use the platform's normal
338+
`shell-quote-argument' behavior. A terminal ampersand gets an adjacent empty
339+
quoted fragment because `shell-command' otherwise treats it as an async marker
340+
without parsing the shell escape. Unsafe home prefixes and malformed paths
341+
signal `user-error'."
342+
(pi-coding-agent--ensure-usable-path-string path)
343+
(unless (and (stringp path) (not (string-empty-p path)))
344+
(user-error "Shell path is empty or not a string"))
345+
(let* ((remote-anchor (pi-coding-agent--remote-prefix anchor))
346+
(quoted
347+
(if (and remote-anchor
348+
(pi-coding-agent--remote-home-path-p path))
349+
(if (string-match
350+
"\\`\\(~\\|~[[:alpha:]_][[:alnum:]_.-]*\\)\\(?:/\\|\\'\\)"
351+
path)
352+
(let* ((prefix (match-string 1 path))
353+
(prefix-end (match-end 1))
354+
(slash-p (and (< prefix-end (length path))
355+
(eq (aref path prefix-end) ?/)))
356+
(rest (and slash-p
357+
(substring path (1+ prefix-end)))))
358+
(if slash-p
359+
(concat prefix "/"
360+
(if (string-empty-p rest)
361+
""
362+
(shell-quote-argument rest t)))
363+
prefix))
364+
(user-error "Unsafe shell home prefix in path: %S" path))
365+
(shell-quote-argument path (and remote-anchor t)))))
366+
;; `shell-command' detects a final ampersand lexically, without honoring
367+
;; shell escaping. Concatenate an empty quoted fragment so a filename
368+
;; ending in `&' remains one operand but cannot switch execution to async.
369+
(if (string-suffix-p "&" quoted)
370+
(concat quoted
371+
(shell-quote-argument "" (and remote-anchor t)))
372+
quoted)))
373+
285374
(defun pi-coding-agent--process-local-path (path &optional anchor)
286375
"Return outbound PATH as the process-local path Pi should receive.
287376
PATH must be a nonempty string; otherwise return nil. Emacs/TRAMP paths are

0 commit comments

Comments
 (0)