From 0d66b2fb2d847b99cdeedd9e5d80bcb43b2fede7 Mon Sep 17 00:00:00 2001 From: Tim Visher <194828183+timvisher-dd@users.noreply.github.com> Date: Sat, 23 May 2026 17:27:03 -0400 Subject: [PATCH] Add agent-shell-quote-reply and DWIM context prefill for queue-compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent-shell-quote-reply' pops the queue-compose buffer prefilled with the agent's last prose reply wrapped as a GFM block quote, and `agent-shell-queue-request' now gathers any DWIM context available in the source buffer (active region, error at point, dired marked files) and prefills the compose buffer with it. Regions from buffers with no file association are wrapped as GFM block quotes; file-visiting and dired context keep their existing purpose-built decoration. Cannot use `shell-maker--command-and-response-at-point' to find the last reply because `shell-maker--extract-history' returns the entire rendered turn span, including tool-call drawers and thought blocks. `agent-shell--last-agent-message-text' walks back to the most recent `agent_message_chunk' fragment and pulls the raw body from `agent-shell-ui--content-store' instead. `text-property-search-backward' returns nil when its predicate rejects the nearest match rather than continuing the walk, so the implementation loops over property changes and applies the suffix check manually — otherwise a tool-call fragment between point-max and the agent message silently swallows the result. Bound to `q' in the `agent-shell-help-menu' "Insert" group. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.org | 1 + agent-shell.el | 102 +++++++++++++++++++++- tests/agent-shell-tests.el | 172 ++++++++++++++++++++++++++++++++++++- 3 files changed, 269 insertions(+), 6 deletions(-) diff --git a/README.org b/README.org index 5ebf67db..ed12598b 100644 --- a/README.org +++ b/README.org @@ -27,6 +27,7 @@ A soft fork of [[https://github.com/xenodium/agent-shell][agent-shell]] with ext - Live-validate workflow doc (=.agents/commands/live-validate.md=) describing the batch-mode rendering verification used for rendering-pipeline changes ([[https://github.com/timvisher-dd/agent-shell-plus/pull/7][#7]]) - =gfm-mode= compose buffer for the interactive =agent-shell-queue-request=, replacing the read-string minibuffer prompt (non-interactive callers still pass =PROMPT= directly) ([[https://github.com/timvisher-dd/agent-shell-plus/pull/9][#9]]) - =agent-shell-resume-session= repurposed from an ID-prompt command into a session-picker entry point: opens the same fuzzy-completion picker that =agent-shell-session-strategy= ='prompt= uses, and force-shows session IDs in the picker (via a new =:show-session-id= keyword on =agent-shell--start=) so users with an ID in hand can find it via their completion framework ([[https://github.com/timvisher-dd/agent-shell-plus/pull/11][#11]]) +- New =agent-shell-quote-reply= command (bound to =q= in the =agent-shell-help-menu= "Insert" group) pops the queue-compose buffer prefilled with the agent's last prose reply wrapped as a GFM block quote. Filters out tool-call drawers and thought blocks by walking back to the most recent =agent_message_chunk= fragment instead of using =shell-maker--command-and-response-at-point=, which would otherwise include rendered drawer text in the quoted output. =agent-shell-queue-request= now also gathers any DWIM context available in the source buffer (active region, error at point, dired marked files) and prefills the compose buffer with it; regions from buffers with no file association are wrapped as GFM block quotes while file-visiting and dired context keep their existing purpose-built decoration ----- diff --git a/agent-shell.el b/agent-shell.el index 3e6721ba..627c1f62 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -7068,6 +7068,7 @@ Optionally, get notified of completion with ON-SUCCESS function." ("!" "Shell command" agent-shell-insert-shell-command-output :transient t) ("@" "File" agent-shell-insert-file :transient t) ("d" "Dwim" agent-shell-send-dwim :transient t) + ("q" "Quote last reply" agent-shell-quote-reply :transient t) ]] [["Session" ("m" "Cycle modes" agent-shell-cycle-session-mode :transient t) @@ -7335,6 +7336,76 @@ Remove: M-x agent-shell-remove-pending-request (append pending (list prompt))) (message "Request queued (%d pending)" (length (map-elt agent-shell--state :pending-requests))))) +(defun agent-shell--gfm-blockquote (text) + "Return TEXT wrapped as a GFM block quote. + +Each line is prefixed with `> ' via `markdown-blockquote-region', +which also normalizes surrounding blank lines." + (with-temp-buffer + (insert text) + (markdown-blockquote-region (point-min) (point-max)) + (string-trim (buffer-string)))) + +(defun agent-shell--last-agent-message-text () + "Return the most recent agent prose reply as raw text, or nil. + +Walks the current buffer backward from `point-max' for the latest +fragment whose `agent-shell-ui-state' qualified-id ends in +`-agent_message_chunk', then pulls the raw body from +`agent-shell-ui--content-store'. Tool-call drawers and thought +blocks are skipped. + +`text-property-search-backward' returns nil when its predicate +rejects the nearest match instead of continuing the walk, so loop +manually — visiting each propertied region and checking the +qualified-id ourselves." + (when-let ((content-store agent-shell-ui--content-store)) + (save-mark-and-excursion + (goto-char (point-max)) + (let (qualified-id) + (while (and (not qualified-id) + (text-property-search-backward + 'agent-shell-ui-state nil nil t)) + (when-let* ((state (get-text-property (point) 'agent-shell-ui-state)) + (qid (map-elt state :qualified-id)) + ((string-suffix-p "-agent_message_chunk" qid))) + (setq qualified-id qid))) + (when qualified-id + (gethash (concat qualified-id "-body") content-store)))))) + +(defun agent-shell--compose-initial-content (shell-buffer) + "Return initial content for a compose buffer popped from `current-buffer'. + +For an active region in a buffer with no file association, block-quote +the raw region content. Otherwise delegate to `agent-shell--context', +which already produces purpose-built decoration for file-visiting +buffers, dired marked files, errors, etc. SHELL-BUFFER scopes +relative paths to the target shell's project." + (cond + ((and (region-active-p) (not (buffer-file-name))) + (when-let* ((region (agent-shell--get-region :deactivate t)) + (content (map-elt region :content))) + (agent-shell--gfm-blockquote content))) + (t + (agent-shell--context :shell-buffer shell-buffer)))) + +(defun agent-shell-quote-reply () + "Pop the compose buffer prefilled with the agent's last reply, quoted. + +Locates the most recent agent_message_chunk fragment in the current +shell buffer, wraps its body as a GFM block quote, and pops the +queue-compose buffer with that as initial content." + (declare (modes agent-shell-mode)) + (interactive) + (unless (derived-mode-p 'agent-shell-mode) + (user-error "Not in a shell")) + (let ((text (agent-shell--last-agent-message-text))) + (unless text + (user-error "No agent reply found in this shell")) + (agent-shell-queue-compose-pop + (current-buffer) + :initial-content (agent-shell--gfm-blockquote text)))) + (defun agent-shell-queue-request (&optional prompt) "Queue or immediately send a request depending on shell busy state. @@ -7344,6 +7415,13 @@ If the shell is busy when submitted, add to the pending requests queue; otherwise submit immediately. Queued requests will be automatically sent when the current request completes. +When called interactively, any DWIM context available in the current +buffer (active region, error at point, dired marked files, etc.) is +gathered via `agent-shell--context' and prefilled into the compose +buffer. An active region in a buffer with no file association is +wrapped as a GFM block quote; other context sources keep their +purpose-built formatting. + When called non-interactively with PROMPT, submit or queue PROMPT directly, bypassing the compose buffer." (declare (modes agent-shell-mode)) @@ -7352,7 +7430,10 @@ PROMPT directly, bypassing the compose buffer." (user-error "Not in a shell")) (cond ((not prompt) - (agent-shell-queue-compose-pop (current-buffer))) + (let ((shell-buffer (current-buffer))) + (agent-shell-queue-compose-pop + shell-buffer + :initial-content (agent-shell--compose-initial-content shell-buffer)))) ((string-empty-p (string-trim prompt)) (user-error "PROMPT is empty")) (t @@ -7447,11 +7528,15 @@ idle." (agent-shell--insert-to-shell-buffer :shell-buffer shell-buffer :text prompt :submit t :no-focus t)))) -(defun agent-shell-queue-compose-pop (shell-buffer) +(cl-defun agent-shell-queue-compose-pop (shell-buffer &key initial-content) "Pop a `gfm-mode' compose buffer bound to SHELL-BUFFER. Reuses the shell's existing compose buffer when alive so an -in-progress draft survives a re-invocation." +in-progress draft survives a re-invocation. + +INITIAL-CONTENT, when supplied, is inserted at `point-max'. If the +buffer already has draft content, INITIAL-CONTENT is appended after +a blank-line separator." (unless (buffer-live-p shell-buffer) (user-error "Shell buffer is not live")) (let* ((existing (buffer-local-value 'agent-shell--queue-compose-buffer @@ -7482,6 +7567,17 @@ in-progress draft survives a re-invocation." (with-current-buffer shell-buffer (setq agent-shell--queue-compose-buffer new)) new)))) + (when initial-content + (with-current-buffer buffer + (goto-char (point-max)) + ;; Ensure a blank-line separator before appending to an + ;; existing draft so the new content doesn't run into it. + (unless (zerop (buffer-size)) + (unless (bolp) (insert "\n")) + (unless (save-excursion (forward-line -1) (looking-at-p "^$")) + (insert "\n"))) + (insert initial-content) + (set-buffer-modified-p nil))) (pop-to-buffer buffer) buffer)) diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index a0548a37..49f62a14 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -3124,7 +3124,7 @@ Stubs `pop-to-buffer' to avoid display side-effects in batch mode." (should (null popped)))) (ert-deftest agent-shell-queue-request-without-prompt-pops-compose () - "Calling without PROMPT pops the compose buffer." + "Calling without PROMPT pops the compose buffer with gathered context." (let ((shell (generate-new-buffer "*test-shell*")) submitted popped) @@ -3132,12 +3132,14 @@ Stubs `pop-to-buffer' to avoid display side-effects in batch mode." (cl-letf (((symbol-function 'agent-shell--queue-or-submit) (lambda (p sb) (setq submitted (list p sb)))) ((symbol-function 'agent-shell-queue-compose-pop) - (lambda (sb) (setq popped sb)))) + (lambda (sb &rest args) (setq popped (cons sb args)))) + ((symbol-function 'agent-shell--compose-initial-content) + (lambda (_sb) "context text"))) (with-current-buffer shell (setq major-mode 'agent-shell-mode) (agent-shell-queue-request))) (kill-buffer shell)) - (should (eq popped shell)) + (should (equal popped (list shell :initial-content "context text"))) (should (null submitted)))) (ert-deftest agent-shell-queue-compose-pop-after-submit-creates-fresh-buffer () @@ -3217,5 +3219,169 @@ Stubs `pop-to-buffer' to avoid display side-effects in batch mode." (should (eq killed-buffer (current-buffer))))) (should (null quit-called)))) +;;; gfm-blockquote / quote-reply tests + +(ert-deftest agent-shell--gfm-blockquote-single-line () + "A single-line input becomes a single quoted line." + (should (string= "> hello" + (agent-shell--gfm-blockquote "hello")))) + +(ert-deftest agent-shell--gfm-blockquote-multiline () + "Each line gets `> ' and blank lines become `>'." + (should (string= "> first\n>\n> third" + (agent-shell--gfm-blockquote "first\n\nthird")))) + +(ert-deftest agent-shell--gfm-blockquote-preserves-code-fence () + "GFM block quotes can wrap nested fenced code blocks." + (should (string= "> ```\n> code\n> ```" + (agent-shell--gfm-blockquote "```\ncode\n```")))) + +(ert-deftest agent-shell--gfm-blockquote-quotes-existing-quote () + "Nested quotes get a leading `> ' added to the existing `> '." + (should (string= "> > already quoted" + (agent-shell--gfm-blockquote "> already quoted")))) + +(ert-deftest agent-shell--last-agent-message-text-returns-body () + "Returns the raw body from the most recent agent_message_chunk fragment." + (with-temp-buffer + (setq agent-shell-ui--content-store (make-hash-table :test 'equal)) + (let ((qid "1-0-agent_message_chunk")) + (insert (propertize "Agent prose" + 'agent-shell-ui-state (list (cons :qualified-id qid)))) + (puthash (concat qid "-body") "Agent prose" agent-shell-ui--content-store)) + (should (string= "Agent prose" + (agent-shell--last-agent-message-text))))) + +(ert-deftest agent-shell--last-agent-message-text-skips-tool-calls () + "Tool-call fragments after the last agent message are skipped." + (with-temp-buffer + (setq agent-shell-ui--content-store (make-hash-table :test 'equal)) + (let ((agent-qid "1-0-agent_message_chunk") + (tool-qid "1-toolCallId-grep")) + (insert (propertize "Agent reply" + 'agent-shell-ui-state (list (cons :qualified-id agent-qid)))) + (insert "\n") + (insert (propertize "Tool drawer" + 'agent-shell-ui-state (list (cons :qualified-id tool-qid)))) + (puthash (concat agent-qid "-body") "Agent reply" agent-shell-ui--content-store) + (puthash (concat tool-qid "-body") "Tool drawer" agent-shell-ui--content-store)) + (should (string= "Agent reply" + (agent-shell--last-agent-message-text))))) + +(ert-deftest agent-shell--last-agent-message-text-returns-last-of-many () + "Returns the LATEST agent_message_chunk when several are present." + (with-temp-buffer + (setq agent-shell-ui--content-store (make-hash-table :test 'equal)) + (let ((first-qid "1-0-agent_message_chunk") + (second-qid "1-1-agent_message_chunk")) + (insert (propertize "First reply" + 'agent-shell-ui-state (list (cons :qualified-id first-qid)))) + (insert "\n") + (insert (propertize "Second reply" + 'agent-shell-ui-state (list (cons :qualified-id second-qid)))) + (puthash (concat first-qid "-body") "First reply" agent-shell-ui--content-store) + (puthash (concat second-qid "-body") "Second reply" agent-shell-ui--content-store)) + (should (string= "Second reply" + (agent-shell--last-agent-message-text))))) + +(ert-deftest agent-shell--last-agent-message-text-nil-when-empty () + "Returns nil when no agent_message_chunk fragments exist." + (with-temp-buffer + (setq agent-shell-ui--content-store (make-hash-table :test 'equal)) + (should-not (agent-shell--last-agent-message-text)))) + +(ert-deftest agent-shell-quote-reply-errors-outside-shell () + "`agent-shell-quote-reply' refuses to run outside `agent-shell-mode'." + (with-temp-buffer + (should-error (agent-shell-quote-reply) :type 'user-error))) + +(ert-deftest agent-shell-quote-reply-errors-when-no-reply () + "`agent-shell-quote-reply' surfaces a user-error when the shell has no reply." + (let ((shell (generate-new-buffer "*test-shell*"))) + (unwind-protect + (with-current-buffer shell + (setq major-mode 'agent-shell-mode) + (should-error (agent-shell-quote-reply) :type 'user-error)) + (kill-buffer shell)))) + +(ert-deftest agent-shell-quote-reply-pops-compose-with-quoted-reply () + "Quote-reply pops the compose buffer with the agent's last reply quoted." + (let ((shell (generate-new-buffer "*test-shell*")) + popped) + (unwind-protect + (cl-letf (((symbol-function 'agent-shell--last-agent-message-text) + (lambda () "Hello there")) + ((symbol-function 'agent-shell-queue-compose-pop) + (lambda (sb &rest args) (setq popped (cons sb args))))) + (with-current-buffer shell + (setq major-mode 'agent-shell-mode) + (agent-shell-quote-reply))) + (kill-buffer shell)) + (should (equal popped (list shell :initial-content "> Hello there"))))) + +;;; compose-pop initial-content tests + +(ert-deftest agent-shell-queue-compose-pop-inserts-initial-content-into-fresh-buffer () + "Fresh compose buffer is prefilled with INITIAL-CONTENT when supplied." + (let ((shell (generate-new-buffer "*test-shell*")) + compose) + (unwind-protect + (cl-letf (((symbol-function 'pop-to-buffer) (lambda (b &rest _) b))) + (with-current-buffer shell + (setq major-mode 'agent-shell-mode)) + (setq compose (agent-shell-queue-compose-pop + shell :initial-content "seeded text")) + (with-current-buffer compose + (should (string= "seeded text" (buffer-string))) + ;; Buffer-modified-p stays nil so the modeline doesn't show + ;; `**' for content the user didn't type. + (should-not (buffer-modified-p)))) + (when (buffer-live-p compose) (kill-buffer compose)) + (when (buffer-live-p shell) (kill-buffer shell))))) + +(ert-deftest agent-shell-queue-compose-pop-appends-initial-content-to-existing-draft () + "When draft already has content, INITIAL-CONTENT is appended after a blank line." + (let ((shell (generate-new-buffer "*test-shell*")) + compose) + (unwind-protect + (cl-letf (((symbol-function 'pop-to-buffer) (lambda (b &rest _) b))) + (with-current-buffer shell + (setq major-mode 'agent-shell-mode)) + (setq compose (agent-shell-queue-compose-pop shell)) + (with-current-buffer compose + (insert "draft line")) + (agent-shell-queue-compose-pop shell :initial-content "> quoted") + (with-current-buffer compose + (should (string= "draft line\n\n> quoted" (buffer-string))))) + (when (buffer-live-p compose) (kill-buffer compose)) + (when (buffer-live-p shell) (kill-buffer shell))))) + +(ert-deftest agent-shell-queue-compose-pop-without-initial-content-leaves-buffer-empty () + "Omitting INITIAL-CONTENT preserves existing behavior (fresh empty buffer)." + (agent-shell-tests--with-compose shell compose + (with-current-buffer compose + (should (string= "" (buffer-string)))))) + +;;; compose-initial-content tests + +(ert-deftest agent-shell--compose-initial-content-quotes-region-from-non-file-buffer () + "Active region in a buffer with no file association is block-quoted." + (with-temp-buffer + (insert "shell output line 1\nshell output line 2") + (goto-char (point-min)) + (set-mark (point)) + (goto-char (point-max)) + (activate-mark) + (should (string= "> shell output line 1\n> shell output line 2" + (agent-shell--compose-initial-content nil))))) + +(ert-deftest agent-shell--compose-initial-content-delegates-to-context-otherwise () + "With no region (or in a file-visiting buffer) defer to `agent-shell--context'." + (cl-letf (((symbol-function 'agent-shell--context) + (lambda (&rest _) "decorated context"))) + (with-temp-buffer + (should (string= "decorated context" + (agent-shell--compose-initial-content nil)))))) + (provide 'agent-shell-tests) ;;; agent-shell-tests.el ends here