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