From fce956984514e6d563b958067c6e70bd5948c63a Mon Sep 17 00:00:00 2001 From: Tim Visher <194828183+timvisher-dd@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:20:12 -0400 Subject: [PATCH] Keep point with the prompt across streamed fragment inserts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell render path (agent-shell--update-fragment/--update-text) leaned on shell-maker-with-auto-scroll-edit's save-excursion to preserve point. That marker is insertion-type nil, so when the insert-cursor sits exactly at point — an empty prompt, or point at the start of pending input — and auto-scroll is suppressed (window-end trails point-max while data streams), text inserted at the marker leaves it behind. Point ends up above the freshly inserted fragment and the prompt drops below it. Add agent-shell--with-preserved-point, which saves point in an insertion-type t marker (so it advances past insertions made at point) and snaps to point-max when point was at end-of-buffer. Wrap both shell-branch call sites. This mirrors agent-shell--append-tool-call-output, which was already immune for the same reason. Regression of the class fixed by 6babf80, re-exposed by the insert-cursor streaming rework in 25a3b75 — neither shipped a test covering point at the insert position, so add two. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.org | 1 + agent-shell.el | 35 ++++++++++++++++--- tests/agent-shell-streaming-tests.el | 51 ++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/README.org b/README.org index 5ebf67db..69b344ee 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]]) +- Fix streaming cursor drop: a streamed message fragment inserted at the prompt no longer strands point above the inserted text. The shell render path relied on shell-maker's insertion-type nil =save-excursion=, which stays before text inserted at point, so when the cursor sat at an empty prompt (or the start of pending input) with auto-scroll suppressed it was left behind the freshly streamed block. A new =agent-shell--with-preserved-point= saves point in an insertion-type t marker (mirroring the tool-output path) and snaps to =point-max= when point was at end-of-buffer, so the cursor always tracks the prompt ----- diff --git a/agent-shell.el b/agent-shell.el index 3e6721ba..a04cbbeb 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3050,6 +3050,31 @@ live process-mark is pushed forward by the insertion." (marker-position (process-mark ,proc-sym))) (set-marker ,saved-sym nil)))))) +(defmacro agent-shell--with-preserved-point (&rest body) + "Evaluate BODY, then restore point so it stays with the prompt. +Fragment updates insert text at the insert-cursor, which can sit +exactly at point when the prompt is empty or point is at the start +of the input area. shell-maker's `save-excursion'-based point +preservation uses an insertion-type nil marker, which stays BEFORE +text inserted at point, stranding point above the freshly inserted +fragment (the cursor appears to \"drop behind\" streamed text). + +Save point in a marker with insertion-type t so it advances past +insertions made at point, and snap to `point-max' when point was at +end-of-buffer — the prompt — so a streaming chunk never leaves the +cursor behind regardless of shell-maker's auto-scroll decision." + (declare (indent 0) (debug body)) + (let ((was-at-end-sym (make-symbol "was-at-end")) + (saved-sym (make-symbol "saved-point"))) + `(let ((,was-at-end-sym (eobp)) + (,saved-sym (copy-marker (point) t))) + (unwind-protect + (progn ,@body) + (if ,was-at-end-sym + (goto-char (point-max)) + (goto-char ,saved-sym)) + (set-marker ,saved-sym nil))))) + (defun agent-shell--insert-cursor () "Return the insertion cursor for the current shell buffer. The cursor is a marker with insertion-type t that advances past @@ -3249,7 +3274,8 @@ by default, RENDER-BODY-IMAGES to enable inline image rendering in body." (equal (current-buffer) (map-elt state :buffer))) (error "Editing the wrong buffer: %s" (current-buffer))) - (agent-shell--with-preserved-process-mark + (agent-shell--with-preserved-point + (agent-shell--with-preserved-process-mark (shell-maker-with-auto-scroll-edit (when-let* ((range (agent-shell-ui-update-fragment (agent-shell-ui-make-fragment-model @@ -3290,7 +3316,7 @@ by default, RENDER-BODY-IMAGES to enable inline image rendering in body." (append "append") (t "rebuild")) (or namespace-id (map-elt state :request-count)) - block-id append)))))) + block-id append))))))) (cl-defun agent-shell--update-text (&key state namespace-id block-id text append create-new) "Update plain text entry in the shell buffer. @@ -3316,7 +3342,8 @@ APPEND and CREATE-NEW control update behavior." :create-new create-new :no-undo t)))) (with-current-buffer (map-elt state :buffer) - (agent-shell--with-preserved-process-mark + (agent-shell--with-preserved-point + (agent-shell--with-preserved-process-mark (shell-maker-with-auto-scroll-edit (when-let* ((range (agent-shell-ui-update-text :namespace-id ns @@ -3334,7 +3361,7 @@ APPEND and CREATE-NEW control update behavior." (cond (create-new "create") (append "append") (t "rebuild")) - ns block-id append))))))) + ns block-id append)))))))) (defun agent-shell-toggle-logging () "Toggle logging." diff --git a/tests/agent-shell-streaming-tests.el b/tests/agent-shell-streaming-tests.el index 2bb88e8b..1d7f529b 100644 --- a/tests/agent-shell-streaming-tests.el +++ b/tests/agent-shell-streaming-tests.el @@ -723,6 +723,57 @@ must appear before the prompt text, not after it." (should (string-suffix-p "hello\n\n" (buffer-substring-no-properties (point-min) (point-max))))))) +;;; Point preservation across streaming insertions (cursor drop regression) + +(ert-deftest agent-shell--with-preserved-point-keeps-cursor-at-empty-prompt-test () + "A streaming insertion at point must not strand the cursor behind it. +Regression: when point sits at the insert-cursor position (an empty +prompt at end-of-buffer) and shell-maker's non-auto-scroll branch +preserves point with an insertion-type nil `save-excursion' marker, +text inserted at point leaves that marker — and thus point — BEFORE +the inserted text, so the cursor appears to drop behind streamed +output. `agent-shell--with-preserved-point' must snap point back to +the prompt at `point-max'." + (with-temp-buffer + (insert "previous output\n\nClaude Code> ") + ;; Point is at the (empty) prompt = where a streaming fragment is + ;; inserted = end-of-buffer. + (let ((insert-pos (point))) + (agent-shell--with-preserved-point + ;; Mimic shell-maker-with-auto-scroll-edit's non-auto-scroll + ;; branch: an insertion-type nil save-excursion around an + ;; insertion made at the insert-cursor (= point). + (save-excursion + (goto-char insert-pos) + (insert "STREAMED"))) + ;; Without the fix point would be stranded at INSERT-POS, behind + ;; "STREAMED"; it must instead stay with the prompt at point-max. + (should (= (point) (point-max))) + (should (string-suffix-p "STREAMED" + (buffer-substring-no-properties (point-min) (point-max))))))) + +(ert-deftest agent-shell--with-preserved-point-advances-past-insertion-test () + "Point not at end-of-buffer must advance past text inserted at point. +When the user has moved point to the start of their pending input (the +insert-cursor position) but is not at end-of-buffer, a streamed +fragment inserted there must push point forward with the input rather +than leaving it stranded above the inserted text." + (with-temp-buffer + (insert "previous output\n\nClaude Code> hello") + ;; Move point to the start of the input, the insert-cursor position, + ;; but NOT end-of-buffer. + (goto-char (- (point) (length "hello"))) + (let ((insert-pos (point))) + (agent-shell--with-preserved-point + (save-excursion + (goto-char insert-pos) + (insert "STREAMED"))) + ;; Point must sit just after the inserted text, still at the start + ;; of "hello" — not stranded above "STREAMED". + (should (looking-at-p "hello")) + (should (string= (buffer-substring-no-properties (point) (point-max)) + "hello"))))) + (ert-deftest agent-shell--mark-tool-calls-cancelled-with-nil-transcript-test () "Cancelling in-flight tool calls must not signal when transcript-file is nil. agent-shell--mark-tool-calls-cancelled invokes handle-tool-call-final