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 1/4] 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 From 3ac141ca8f6905302f140b303c287559cb5e8f0b Mon Sep 17 00:00:00 2001 From: Tim Visher <194828183+timvisher-dd@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:47:51 -0400 Subject: [PATCH 2/4] Keep point at the prompt when a queued request submits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-shell--insert-to-shell-buffer parked point at the start of the inserted text — the DWIM "land on your context" behavior — for every insertion, including the :submit t path used by agent-shell--process-pending-request and interactive C-c C-c on a queued request. After submit, that text becomes transcript above the new prompt, so point was left at the end of the last agent message. shell-maker only auto-scrolls streamed output while point is at point-max, so the cursor stayed stranded above the prompt for the whole turn. Leave point at point-max on the submit path so it tracks the prompt; keep the start-of-context placement only for non-submit insertions. Add a regression test asserting point is at end-of-buffer when shell-maker-submit fires (fails without the fix). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.org | 1 + agent-shell.el | 21 ++++++++++++++------- tests/agent-shell-tests.el | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/README.org b/README.org index ed12598b..c0ee2016 100644 --- a/README.org +++ b/README.org @@ -28,6 +28,7 @@ A soft fork of [[https://github.com/xenodium/agent-shell][agent-shell]] with ext - =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 +- Fix queued-request submit stranding point: when a queued request submits (auto-resume on turn completion, or interactive =C-c C-c=), point no longer lands at the end of the last agent message above the new prompt. =agent-shell--insert-to-shell-buffer= parked point at the start of the inserted text (the DWIM "land on your context" behavior), but for the =:submit t= path that text becomes transcript above the new prompt — and shell-maker only auto-scrolls streamed output while point is at =point-max=, so the cursor stayed stranded there for the whole turn. Submit now leaves point at =point-max= so it tracks the prompt; the DWIM start-of-context placement is kept only for non-submit insertions ----- diff --git a/agent-shell.el b/agent-shell.el index 627c1f62..a31d2245 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -6224,13 +6224,20 @@ Returns an alist with insertion details or nil otherwise: (let ((markdown-overlays-highlight-blocks agent-shell-highlight-blocks) (markdown-overlays-render-images nil)) (markdown-overlays-put)))) - ;; Leave point at the start of the inserted region so the - ;; user lands on their context, not after it — DWIM users - ;; expect to keep typing where the prompt is, not below - ;; the freshly-inserted text. - (goto-char insert-start) - (when submit - (shell-maker-submit))) + ;; For a plain insertion, leave point at the start of the + ;; inserted region so the user lands on their context, not + ;; after it — DWIM users expect to keep typing where the + ;; prompt is, not below the freshly-inserted text. When + ;; submitting, that text becomes transcript above the new + ;; prompt; leave point at `point-max' so it tracks the + ;; prompt. shell-maker only auto-scrolls streamed output + ;; while point is at end-of-buffer, so parking it above the + ;; prompt would strand the cursor there for the whole turn. + (if submit + (progn + (goto-char (point-max)) + (shell-maker-submit)) + (goto-char insert-start))) `((:buffer . ,shell-buffer) (:start . ,insert-start) (:end . ,insert-end))) diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index 49f62a14..0231e21c 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -2926,6 +2926,39 @@ Asserts: (message \"world\")) ```")) +(ert-deftest agent-shell--insert-to-shell-buffer-submit-leaves-point-at-prompt-test () + "A submitted (e.g. queued) request must leave point at the prompt. + +Regression: `agent-shell--insert-to-shell-buffer' parked point at the +START of the inserted text (the DWIM \"land on your context\" +behavior). For the :submit t path used by +`agent-shell--process-pending-request' (and interactive C-c C-c on a +queued request), that text becomes transcript ABOVE the new prompt, so +point was left at the end of the last agent message. shell-maker only +auto-scrolls streamed output while point is at `point-max', so the +cursor stayed stranded above the prompt for the whole turn. Submit +must leave point at end-of-buffer so it tracks the prompt." + (require 'agent-shell-fakes) + (let* ((agent-shell-session-strategy 'new) + (shell-buffer (agent-shell-fakes-start-agent + agent-shell-tests--bootstrap-messages)) + submit-at-eob) + (unwind-protect + (with-current-buffer shell-buffer + (cl-letf (((symbol-function 'shell-maker-submit) + (lambda (&rest _) (setq submit-at-eob (eobp))))) + (agent-shell--insert-to-shell-buffer :text "queued request" + :submit t + :no-focus t + :shell-buffer shell-buffer)) + ;; At the moment of submission point must be at end-of-buffer + ;; so shell-maker's streaming auto-scroll keeps the cursor at + ;; the prompt instead of stranding it above the inserted text. + (should submit-at-eob) + (should (= (point) (point-max)))) + (when (buffer-live-p shell-buffer) + (kill-buffer shell-buffer))))) + (ert-deftest agent-shell-filter-buffer-substring-strips-hidden-markup () "Copying text should exclude markdown syntax hidden by overlays." (with-temp-buffer From 531fcc4292dae7cda690625678293fabf1176f9f Mon Sep 17 00:00:00 2001 From: Tim Visher <194828183+timvisher-dd@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:49:49 -0400 Subject: [PATCH 3/4] Keep point at the prompt when an interactive RET submits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive submit path (typing at the shell prompt and pressing RET) stranded point above the streamed reply for the whole turn. shell-maker-submit skips its own goto-char when shell-maker-point-at-last-prompt-p is true, so after comint-send-input point sits at the response boundary just below the echoed input — not at point-max. The new prompt and the streamed reply land below point, and shell-maker only auto-scrolls while point is at end-of-buffer, so the cursor stayed above the reply until the turn finished. 3ac141c fixed only the programmatic :submit t path (goto point-max before shell-maker-submit); the interactive RET path was never covered. The bug reproduces with or without agent-shell--with-preserved-point, so fix it at the submission entry point: agent-shell--handle (the :execute-command callback) now snaps point to point-max whenever a command is submitted, so the cursor follows streaming output down to the new prompt. A user who scrolls up mid-stream is still honored. Verified live against a real Claude turn: without the fix point sat 4 lines above point-max after the turn; with it point lands at point-max. Add two regression tests asserting agent-shell--handle moves point to point-max for a real command and leaves it alone for a nil (bootstrap) command. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.org | 1 + agent-shell.el | 10 ++++++ tests/agent-shell-tests.el | 62 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/README.org b/README.org index c0ee2016..fa88b667 100644 --- a/README.org +++ b/README.org @@ -29,6 +29,7 @@ A soft fork of [[https://github.com/xenodium/agent-shell][agent-shell]] with ext - =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 - Fix queued-request submit stranding point: when a queued request submits (auto-resume on turn completion, or interactive =C-c C-c=), point no longer lands at the end of the last agent message above the new prompt. =agent-shell--insert-to-shell-buffer= parked point at the start of the inserted text (the DWIM "land on your context" behavior), but for the =:submit t= path that text becomes transcript above the new prompt — and shell-maker only auto-scrolls streamed output while point is at =point-max=, so the cursor stayed stranded there for the whole turn. Submit now leaves point at =point-max= so it tracks the prompt; the DWIM start-of-context placement is kept only for non-submit insertions +- Fix interactive =RET= submit stranding point above the streamed reply: when you submit a prompt by typing at the shell prompt and pressing =RET=, =shell-maker-submit= leaves point at the response boundary just below the echoed input rather than at =point-max= (because =shell-maker-point-at-last-prompt-p= is true, it skips its own =goto-char=), so the streamed reply and new prompt land below point and the cursor is stranded above them for the whole turn. =agent-shell--handle= (the =:execute-command= callback) now snaps point to =point-max= whenever a command is submitted so the cursor follows streaming output down to the new prompt. This is the interactive sibling of the queued-request submit fix above; the programmatic =:submit t= path was already covered ----- diff --git a/agent-shell.el b/agent-shell.el index a31d2245..81166a07 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -1461,6 +1461,16 @@ Flow: (shell-maker--current-request-id)) (map-put! (agent-shell--state) :last-activity-time (current-time)) (agent-shell--reset-insert-cursor) + ;; A freshly submitted prompt should track the streaming response down + ;; to the new prompt. Interactive `shell-maker-submit' (RET) leaves + ;; point at the response boundary just below the echoed input — not at + ;; `point-max' — because `shell-maker-point-at-last-prompt-p' is true, + ;; so it skips its own `goto-char'. Streaming then inserts the reply + ;; below point and the cursor is stranded above it for the whole turn + ;; (shell-maker only auto-scrolls while point is at end-of-buffer). + ;; Snap to `point-max' on submit so the cursor follows output. + (when command + (goto-char (point-max))) (cond ((not (map-elt (agent-shell--state) :client)) ;; Needs a client (agent-shell--emit-event :event 'init-started) diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index 0231e21c..ed7ab450 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -2959,6 +2959,68 @@ must leave point at end-of-buffer so it tracks the prompt." (when (buffer-live-p shell-buffer) (kill-buffer shell-buffer))))) +(ert-deftest agent-shell--handle-command-moves-point-to-prompt-test () + "Submitting a command must move point to `point-max' so it tracks output. + +Regression: interactive `shell-maker-submit' (RET) leaves point at the +response boundary just below the echoed input — not at `point-max' — +because `shell-maker-point-at-last-prompt-p' is true, so it skips its +own `goto-char'. The streamed reply then lands below point and the +cursor is stranded above it for the whole turn. `agent-shell--handle' +(the `:execute-command' callback) must snap point to `point-max' when a +command is submitted. Verified live on `dev': without the fix point +sat several lines above `point-max' after a real turn." + (require 'agent-shell-fakes) + (let* ((agent-shell-session-strategy 'new) + (shell-buffer (agent-shell-fakes-start-agent + agent-shell-tests--bootstrap-messages))) + (unwind-protect + (with-current-buffer shell-buffer + ;; Stub every dispatch the cond can reach so no real ACP traffic + ;; runs; the point move happens before the cond regardless. + (cl-letf (((symbol-function 'agent-shell--send-command) #'ignore) + ((symbol-function 'agent-shell--initialize-client) #'ignore) + ((symbol-function 'agent-shell--initialize-subscriptions) #'ignore) + ((symbol-function 'agent-shell--initiate-handshake) #'ignore) + ((symbol-function 'agent-shell--authenticate) #'ignore) + ((symbol-function 'agent-shell--initiate-session) #'ignore) + ((symbol-function 'agent-shell--set-default-model) #'ignore) + ((symbol-function 'agent-shell--set-default-session-mode) #'ignore)) + ;; Park point above end-of-buffer, as RET does after the echoed + ;; input when the new prompt sits below. + (goto-char (point-min)) + (should (not (eobp))) + (agent-shell--handle :command "hello" :shell-buffer shell-buffer) + (should (= (point) (point-max))))) + (when (buffer-live-p shell-buffer) + (kill-buffer shell-buffer))))) + +(ert-deftest agent-shell--handle-nil-command-leaves-point-test () + "Initialization (nil command) must not move point. + +Only a real submission should snap point to `point-max'; bootstrap +re-entries of `agent-shell--handle' with no command must leave the +user's point alone." + (require 'agent-shell-fakes) + (let* ((agent-shell-session-strategy 'new) + (shell-buffer (agent-shell-fakes-start-agent + agent-shell-tests--bootstrap-messages))) + (unwind-protect + (with-current-buffer shell-buffer + (cl-letf (((symbol-function 'agent-shell--send-command) #'ignore) + ((symbol-function 'agent-shell--initialize-client) #'ignore) + ((symbol-function 'agent-shell--initialize-subscriptions) #'ignore) + ((symbol-function 'agent-shell--initiate-handshake) #'ignore) + ((symbol-function 'agent-shell--authenticate) #'ignore) + ((symbol-function 'agent-shell--initiate-session) #'ignore) + ((symbol-function 'agent-shell--set-default-model) #'ignore) + ((symbol-function 'agent-shell--set-default-session-mode) #'ignore)) + (goto-char (point-min)) + (agent-shell--handle :command nil :shell-buffer shell-buffer) + (should (= (point) (point-min))))) + (when (buffer-live-p shell-buffer) + (kill-buffer shell-buffer))))) + (ert-deftest agent-shell-filter-buffer-substring-strips-hidden-markup () "Copying text should exclude markdown syntax hidden by overlays." (with-temp-buffer From 51c023b8422498bc9afdf41ab106aa75c706d1b1 Mon Sep 17 00:00:00 2001 From: Tim Visher <194828183+timvisher-dd@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:57:15 -0400 Subject: [PATCH 4/4] Move window-point on submit so the cursor tracks the prompt when unselected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queue-compose C-c C-c path submits with :no-focus t, so the shell window is never selected. The submit-time point fixes (3ac141c, 531fcc4) moved only buffer point via goto-char, which does not update the window-point of a non-selected window. The shell window therefore kept the position the user had parked point at before invoking agent-shell-queue-request, and the visible cursor snapped back there for the whole turn. with-preserved-point cannot cover this — it manages buffer point only. agent-shell--handle now also pushes window-point to point-max for every window showing the shell, mirroring the set-window-point pattern in agent-shell-jump-to-latest-permission-button-row. Add a regression test that displays the shell in an unselected window, parks its window-point above the prompt, and asserts submit moves window-point to point-max (fails without the fix). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.org | 1 + agent-shell.el | 13 ++++++++++- tests/agent-shell-tests.el | 46 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/README.org b/README.org index fa88b667..b004979a 100644 --- a/README.org +++ b/README.org @@ -30,6 +30,7 @@ A soft fork of [[https://github.com/xenodium/agent-shell][agent-shell]] with ext - 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 - Fix queued-request submit stranding point: when a queued request submits (auto-resume on turn completion, or interactive =C-c C-c=), point no longer lands at the end of the last agent message above the new prompt. =agent-shell--insert-to-shell-buffer= parked point at the start of the inserted text (the DWIM "land on your context" behavior), but for the =:submit t= path that text becomes transcript above the new prompt — and shell-maker only auto-scrolls streamed output while point is at =point-max=, so the cursor stayed stranded there for the whole turn. Submit now leaves point at =point-max= so it tracks the prompt; the DWIM start-of-context placement is kept only for non-submit insertions - Fix interactive =RET= submit stranding point above the streamed reply: when you submit a prompt by typing at the shell prompt and pressing =RET=, =shell-maker-submit= leaves point at the response boundary just below the echoed input rather than at =point-max= (because =shell-maker-point-at-last-prompt-p= is true, it skips its own =goto-char=), so the streamed reply and new prompt land below point and the cursor is stranded above them for the whole turn. =agent-shell--handle= (the =:execute-command= callback) now snaps point to =point-max= whenever a command is submitted so the cursor follows streaming output down to the new prompt. This is the interactive sibling of the queued-request submit fix above; the programmatic =:submit t= path was already covered +- Fix queue-compose =C-c C-c= leaving the shell cursor at its stale pre-queue position: the compose-buffer submit path is =:no-focus t=, so the shell window is never selected and the earlier submit fixes — which moved only buffer point via =goto-char= — left the shell window's =window-point= where the user had parked it before invoking =agent-shell-queue-request=. =agent-shell--handle= now also pushes =window-point= to =point-max= for every window showing the shell, so the visible cursor tracks the prompt even when the shell window is unselected (mirrors =agent-shell-jump-to-latest-permission-button-row=) ----- diff --git a/agent-shell.el b/agent-shell.el index 81166a07..d2f7a7f3 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -1469,8 +1469,19 @@ Flow: ;; below point and the cursor is stranded above it for the whole turn ;; (shell-maker only auto-scrolls while point is at end-of-buffer). ;; Snap to `point-max' on submit so the cursor follows output. + ;; + ;; `goto-char' only moves BUFFER point. On the `:no-focus t' + ;; queue-compose submit path (C-c C-c in the compose buffer) the + ;; shell window is never selected, so its `window-point' keeps the + ;; pre-queue position the user had parked point at and the cursor + ;; visibly snaps back there. Push `window-point' to `point-max' for + ;; every window showing the shell so the visible cursor tracks the + ;; prompt too — mirrors + ;; `agent-shell-jump-to-latest-permission-button-row'. (when command - (goto-char (point-max))) + (goto-char (point-max)) + (dolist (window (get-buffer-window-list nil nil t)) + (set-window-point window (point-max)))) (cond ((not (map-elt (agent-shell--state) :client)) ;; Needs a client (agent-shell--emit-event :event 'init-started) diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index ed7ab450..b4472752 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -3021,6 +3021,52 @@ user's point alone." (when (buffer-live-p shell-buffer) (kill-buffer shell-buffer))))) +(ert-deftest agent-shell--handle-command-moves-window-point-when-unselected-test () + "Submitting must move `window-point' even when the shell window is not selected. + +Regression (agent-shell-rnpd): the `:no-focus t' queue-compose submit +path (C-c C-c in the compose buffer) submits while a different window is +selected. `agent-shell--handle' moved only BUFFER point with +`goto-char', so the shell window kept the `window-point' the user had +parked above the prompt and the cursor visibly snapped back there. +`agent-shell--handle' must push `window-point' to `point-max' for every +window showing the shell." + (require 'agent-shell-fakes) + (let* ((agent-shell-session-strategy 'new) + (shell-buffer (agent-shell-fakes-start-agent + agent-shell-tests--bootstrap-messages)) + (other-buffer (get-buffer-create "*agent-shell-rnpd-other*")) + shell-window) + (unwind-protect + (progn + ;; Show the shell in one window and select a DIFFERENT window, so + ;; the shell window's `window-point' is decoupled from buffer point + ;; — exactly the no-focus compose-submit situation. + (delete-other-windows) + (switch-to-buffer shell-buffer) + (setq shell-window (selected-window)) + (let ((other-window (split-window))) + (set-window-buffer other-window other-buffer) + ;; Park the shell window above the prompt, then move focus away. + (set-window-point shell-window (point-min)) + (select-window other-window)) + (with-current-buffer shell-buffer + (cl-letf (((symbol-function 'agent-shell--send-command) #'ignore) + ((symbol-function 'agent-shell--initialize-client) #'ignore) + ((symbol-function 'agent-shell--initialize-subscriptions) #'ignore) + ((symbol-function 'agent-shell--initiate-handshake) #'ignore) + ((symbol-function 'agent-shell--authenticate) #'ignore) + ((symbol-function 'agent-shell--initiate-session) #'ignore) + ((symbol-function 'agent-shell--set-default-model) #'ignore) + ((symbol-function 'agent-shell--set-default-session-mode) #'ignore)) + (should (/= (window-point shell-window) (point-max))) + (agent-shell--handle :command "hello" :shell-buffer shell-buffer) + (should (= (window-point shell-window) (point-max)))))) + (when (buffer-live-p shell-buffer) + (kill-buffer shell-buffer)) + (when (buffer-live-p other-buffer) + (kill-buffer other-buffer))))) + (ert-deftest agent-shell-filter-buffer-substring-strips-hidden-markup () "Copying text should exclude markdown syntax hidden by overlays." (with-temp-buffer