Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.org
Original file line number Diff line number Diff line change
Expand Up @@ -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

-----

Expand Down
102 changes: 99 additions & 3 deletions agent-shell.el
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand All @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand Down
172 changes: 169 additions & 3 deletions tests/agent-shell-tests.el
Original file line number Diff line number Diff line change
Expand Up @@ -3124,20 +3124,22 @@ 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)
(unwind-protect
(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 ()
Expand Down Expand Up @@ -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
Loading