From 8357b85eef7ab4923696a5f229c137a567077780 Mon Sep 17 00:00:00 2001 From: Daniel Nouri Date: Tue, 28 Apr 2026 14:02:44 +0200 Subject: [PATCH] Keep chat windows filled when thinking collapses When a long thinking block finished, hidden thinking replaced many visible lines with a short stub. A chat window could keep its start inside the deleted text, so the user saw a mostly blank pane instead of the new tail. Remember visible window positions before large chat rewrites and restore them against the rewritten buffer. Tail views stay at the new tail, and other chat windows keep their reading context before or after the thinking block. --- pi-coding-agent-render.el | 313 ++++++++++++++++++---------- test/pi-coding-agent-menu-test.el | 18 -- test/pi-coding-agent-render-test.el | 211 +++++++++++++++++++ 3 files changed, 416 insertions(+), 126 deletions(-) diff --git a/pi-coding-agent-render.el b/pi-coding-agent-render.el index bf682a6..1665859 100644 --- a/pi-coding-agent-render.el +++ b/pi-coding-agent-render.el @@ -447,6 +447,21 @@ separated from preceding content." pi-coding-agent--thinking-raw nil pi-coding-agent--thinking-prev-rendered nil)) +(defmacro pi-coding-agent--with-window-rewrite-preservation (&rest body) + "Execute BODY and keep chat windows useful after a large rewrite. +This is for rewrites that can delete the text under `window-start', such as +collapsing a long thinking block or rebuilding canonical history. Tail views +stay at the new tail; non-tail views keep their point and approximate row, +clamped so the window remains filled when possible." + (declare (indent 0) (debug t)) + `(let ((buffer (current-buffer)) + (saved-windows (pi-coding-agent--capture-window-rewrite-states)) + result) + (unwind-protect + (setq result (progn ,@body)) + (pi-coding-agent--restore-window-rewrite-states buffer saved-windows)) + result)) + (defun pi-coding-agent--display-thinking-start () "Insert opening marker for thinking block (blockquote)." (when pi-coding-agent--streaming-marker @@ -499,21 +514,42 @@ Normalizes boundary and paragraph whitespace while streaming." "End thinking block (blockquote). CONTENT is ignored - we use what was already streamed." (when pi-coding-agent--streaming-marker - (setq pi-coding-agent--in-thinking-block nil) - (let ((inhibit-read-only t)) - (pi-coding-agent--with-scroll-preservation - (save-excursion - (if (and pi-coding-agent--thinking-start-marker - pi-coding-agent--thinking-marker) - (when (pi-coding-agent--replace-thinking-region - (pi-coding-agent--completed-thinking-rendered-text - pi-coding-agent--thinking-raw)) - (goto-char (pi-coding-agent--thinking-insert-position)) - (pi-coding-agent--ensure-blank-line-separator)) - ;; Fallback for malformed event streams that skip thinking_start. - (goto-char (pi-coding-agent--thinking-insert-position)) - (pi-coding-agent--ensure-blank-line-separator)) - (pi-coding-agent--reset-thinking-state)))))) + (let* ((buffer (current-buffer)) + (saved-windows (pi-coding-agent--capture-window-rewrite-states)) + (old-point-max (point-max)) + (rewrite-start (and (markerp pi-coding-agent--thinking-start-marker) + (marker-position pi-coding-agent--thinking-start-marker))) + (rewrite-end (and (markerp pi-coding-agent--thinking-marker) + (marker-position pi-coding-agent--thinking-marker)))) + (unwind-protect + (progn + (setq pi-coding-agent--in-thinking-block nil) + (let ((inhibit-read-only t)) + (pi-coding-agent--with-scroll-preservation + (save-excursion + (if (and pi-coding-agent--thinking-start-marker + pi-coding-agent--thinking-marker) + (when (pi-coding-agent--replace-thinking-region + (pi-coding-agent--completed-thinking-rendered-text + pi-coding-agent--thinking-raw)) + (goto-char (pi-coding-agent--thinking-insert-position)) + (pi-coding-agent--ensure-blank-line-separator)) + ;; Fallback for malformed event streams that skip thinking_start. + (goto-char (pi-coding-agent--thinking-insert-position)) + (pi-coding-agent--ensure-blank-line-separator)) + (pi-coding-agent--reset-thinking-state))))) + (pi-coding-agent--restore-window-rewrite-states + buffer + saved-windows + (when (and rewrite-start rewrite-end) + (let ((replacements + (list (list rewrite-start + rewrite-end + (with-current-buffer buffer + (- (point-max) old-point-max)))))) + (lambda (pos) + (pi-coding-agent--adjust-pos-after-region-replacements + pos replacements))))))))) (defun pi-coding-agent--display-agent-end () "Finalize agent turn: normalize whitespace, handle abort, process queue. @@ -1875,15 +1911,6 @@ Preserves window scroll position during the toggle." (set-window-start win block-start t) (set-window-point win block-start)))))))))) -(defun pi-coding-agent--adjust-pos-after-region-replacement - (pos start end delta) - "Return POS adjusted after replacing [START, END) by DELTA chars. -Returns nil when POS was inside the replaced region itself." - (cond - ((< pos start) pos) - ((>= pos end) (+ pos delta)) - (t nil))) - (defun pi-coding-agent--replace-thinking-block-region (start end rendered) "Replace completed-thinking text in START..END with RENDERED. Returns the new bounds as (START . NEW-END)." @@ -1901,31 +1928,22 @@ Returns the new bounds as (START . NEW-END)." (defun pi-coding-agent--replace-thinking-block (block rendered) "Replace completed thinking BLOCK with RENDERED text. -Preserves window positions outside the rewritten block and clamps windows that -were looking into the block back to the same local section. Returns the new -block bounds as (START . END)." +Returns the new block bounds as (START . END) and preserves useful window +context after the rewrite." (let* ((start (plist-get block :start)) (end (plist-get block :end)) - (saved-windows - (mapcar (lambda (w) - (list w (window-start w) (window-point w))) - (get-buffer-window-list (current-buffer) nil t))) + (buffer (current-buffer)) + (saved-windows (pi-coding-agent--capture-window-rewrite-states)) (new-bounds (pi-coding-agent--replace-thinking-block-region start end rendered)) - (new-end (cdr new-bounds))) - (let ((delta (- new-end end))) - (dolist (win-state saved-windows) - (let ((win (nth 0 win-state)) - (old-start (nth 1 win-state)) - (old-point (nth 2 win-state))) - (when (window-live-p win) - (let ((new-start (pi-coding-agent--adjust-pos-after-region-replacement - old-start start end delta)) - (new-point (pi-coding-agent--adjust-pos-after-region-replacement - old-point start end delta))) - (set-window-start win (or new-start start) t) - (set-window-point win - (min (or new-point start) (point-max)))))))) + (delta (- (cdr new-bounds) end))) + (pi-coding-agent--restore-window-rewrite-states + buffer + saved-windows + (let ((replacements (list (list start end delta)))) + (lambda (pos) + (pi-coding-agent--adjust-pos-after-region-replacements + pos replacements)))) new-bounds)) (defun pi-coding-agent--completed-thinking-blocks () @@ -1944,9 +1962,11 @@ block bounds as (START . END)." (defun pi-coding-agent--apply-thinking-display-to-completed-blocks (display) "Rewrite every completed thinking block in the current buffer for DISPLAY. -DISPLAY is either `visible' or `hidden'. Returns non-nil when at least one -completed thinking block changed. Unrelated buffer content is left alone." - (let ((changed nil)) +DISPLAY is either `visible' or `hidden'. Returns replacement records when at +least one completed thinking block changed, otherwise nil. Unrelated buffer +content is left alone. Each replacement record is (START END DELTA), using +coordinates from before the rewrites." + (let (replacements) (save-excursion (dolist (block (nreverse (pi-coding-agent--completed-thinking-blocks))) (unless (eq (plist-get block :display) display) @@ -1955,12 +1975,13 @@ completed thinking block changed. Unrelated buffer content is left alone." (plist-get block :normalized) (plist-get block :order) display))) - (pi-coding-agent--replace-thinking-block-region - (plist-get block :start) - (plist-get block :end) - rendered) - (setq changed t))))) - changed)) + (let* ((start (plist-get block :start)) + (end (plist-get block :end)) + (new-bounds (pi-coding-agent--replace-thinking-block-region + start end rendered))) + (push (list start end (- (cdr new-bounds) end)) + replacements)))))) + replacements)) (defun pi-coding-agent--toggle-thinking-block-at-point () "Toggle the completed-thinking block at point. @@ -2574,26 +2595,27 @@ RESULTS maps toolCallId strings to matching toolResult messages." block (gethash (plist-get block :id) results)))))) (flush-text)))))) -(defun pi-coding-agent--rerender-tail-window-p +(defun pi-coding-agent--rewrite-tail-window-p (window-point window-end point-max point-row body-height) - "Return non-nil when WINDOW-POINT or WINDOW-END should follow the rebuilt tail. -An exact WINDOW-POINT at POINT-MAX is always tail-following. A WINDOW-END that + "Return non-nil when WINDOW-POINT or WINDOW-END should follow a rewritten tail. +A WINDOW-POINT at or just before POINT-MAX is tail-following. A WINDOW-END that merely reaches POINT-MAX counts only when POINT-ROW already sits in the lower half of BODY-HEIGHT, so tall windows inspecting mid-buffer context do not get misclassified as tail-following just because they can also see the tail." (or (>= window-point (1- point-max)) (and (>= window-end (1- point-max)) + (< point-row (max 1 body-height)) (>= point-row (/ (max 1 body-height) 2))))) -(defun pi-coding-agent--clamp-rerender-point-row (saved-row above-lines tail-lines body-height) - "Clamp SAVED-ROW for a rerendered window with ABOVE-LINES and TAIL-LINES. +(defun pi-coding-agent--clamp-rewrite-point-row (saved-row above-lines tail-lines body-height) + "Clamp SAVED-ROW after a buffer rewrite. ABOVE-LINES counts screen lines before point, TAIL-LINES counts screen lines from point through the tail, and BODY-HEIGHT is the window body height in screen lines. When the whole buffer is shorter than the window, preserving a full window is -impossible, so the row falls back to the highest still-visible row. Otherwise, -clamp the row so the tail still fills the window after the rerender." +impossible, so the row falls back to the highest still-visible row. Otherwise, +clamp the row so the tail still fills the window after the rewrite." (let* ((max-row (min (max 0 (1- body-height)) above-lines)) (total-lines (+ above-lines tail-lines))) (if (< total-lines body-height) @@ -2601,9 +2623,20 @@ clamp the row so the tail still fills the window after the rerender." (let ((min-row (max 0 (- body-height tail-lines)))) (max min-row (min saved-row max-row)))))) -(defun pi-coding-agent--capture-rerender-window-state (window point-max) - "Return the saved rerender state for WINDOW before a rebuild. -POINT-MAX is the old buffer end before the rerender begins." +(defun pi-coding-agent--live-thinking-start-at-pos (pos) + "Return active thinking block start when POS is inside live thinking." + (when (and (markerp pi-coding-agent--thinking-start-marker) + (markerp pi-coding-agent--thinking-marker) + (marker-position pi-coding-agent--thinking-start-marker) + (marker-position pi-coding-agent--thinking-marker)) + (let ((start (marker-position pi-coding-agent--thinking-start-marker)) + (end (marker-position pi-coding-agent--thinking-marker))) + (when (and (<= start pos) (< pos end)) + start)))) + +(defun pi-coding-agent--capture-window-rewrite-state (window point-max) + "Return saved WINDOW state before a buffer rewrite. +POINT-MAX is the old buffer end before the rewrite begins." (let* ((point (window-point window)) (body-height (max 1 (window-body-height window))) (row (count-screen-lines (window-start window) @@ -2611,68 +2644,131 @@ POINT-MAX is the old buffer end before the rerender begins." nil window))) (list :window window - :tail-p (pi-coding-agent--rerender-tail-window-p + :tail-p (pi-coding-agent--rewrite-tail-window-p point (window-end window t) point-max row body-height) + :start (window-start window) :point point :thinking-block (pi-coding-agent--thinking-block-at-pos point) + :live-thinking-start (pi-coding-agent--live-thinking-start-at-pos point) :row row))) -(defun pi-coding-agent--resolve-rerender-point (window-state) - "Return the best restored point for WINDOW-STATE after a rerender. -When point was inside a completed thinking block, prefer the same logical block -in the rebuilt buffer. Otherwise fall back to the saved numeric point clamped -into the rebuilt buffer." +(defun pi-coding-agent--capture-window-rewrite-states () + "Return saved rewrite states for visible windows showing the current buffer." + (let ((old-point-max (point-max)) + (buffer (current-buffer))) + (mapcar (lambda (win) + (pi-coding-agent--capture-window-rewrite-state win old-point-max)) + (get-buffer-window-list buffer nil t)))) + +(defun pi-coding-agent--adjust-pos-after-region-replacements + (pos replacements) + "Return POS adjusted after REPLACEMENTS, or nil when POS was deleted. +Each entry in REPLACEMENTS is (START END DELTA), using coordinates from before +any replacement was applied." + (let ((total-delta 0)) + (catch 'deleted + (dolist (replacement replacements (+ pos total-delta)) + (pcase-let ((`(,start ,end ,delta) replacement)) + (cond + ((and (<= start pos) (< pos end)) + (throw 'deleted nil)) + ((>= pos end) + (setq total-delta (+ total-delta delta))))))))) + +(defun pi-coding-agent--map-window-rewrite-pos (pos map-position fallback) + "Map old POS through MAP-POSITION, or return FALLBACK when POS was deleted." + (if map-position + (or (funcall map-position pos) fallback) + (min pos (point-max)))) + +(defun pi-coding-agent--resolve-window-rewrite-point + (window-state &optional map-position) + "Return the best restored point for WINDOW-STATE after a buffer rewrite. +When point was inside a completed or live thinking block, prefer the rewritten +block start. Otherwise map the saved numeric point through MAP-POSITION when +provided, or clamp it into the rewritten buffer." (or (pi-coding-agent--thinking-block-start (plist-get window-state :thinking-block)) - (min (plist-get window-state :point) (point-max)))) - -(defun pi-coding-agent--restore-rerender-window-state (window-state) - "Restore WINDOW-STATE after a canonical-history rerender. -Tail-following windows stay pinned to the rebuilt tail. Other windows restore -point, then recenter to a clamped screen-line row so the window stays filled -when possible instead of showing a mostly blank tail view." + (plist-get window-state :live-thinking-start) + (pi-coding-agent--map-window-rewrite-pos + (plist-get window-state :point) + map-position + (min (plist-get window-state :point) (point-max))))) + +(defun pi-coding-agent--window-start-fills-window-p + (start point-max body-height window) + "Return non-nil when START still fills WINDOW after a rewrite. +A filled view leaves at most one blank row in BODY-HEIGHT after POINT-MAX. +Preserving the user's viewport is better than scrolling to command point when +the old start remains useful." + (>= (count-screen-lines start point-max nil window) + (1- body-height))) + +(defun pi-coding-agent--restore-window-rewrite-state + (window-state &optional map-position) + "Restore WINDOW-STATE after a large buffer rewrite. +Tail-following windows stay pinned to the rewritten tail. Other windows +restore point, then recenter to a clamped screen-line row so the window stays +filled when possible instead of showing a mostly blank tail view. +MAP-POSITION, when non-nil, maps old buffer positions to new ones and returns +nil for positions deleted by the rewrite." (let ((win (plist-get window-state :window))) (when (and (window-live-p win) (eq (window-buffer win) (current-buffer))) (with-selected-window win (let* ((point-max (point-max)) - (point (pi-coding-agent--resolve-rerender-point window-state))) + (point (pi-coding-agent--resolve-window-rewrite-point + window-state map-position))) (if (plist-get window-state :tail-p) (progn (goto-char point-max) (recenter -1)) (goto-char point) (let* ((body-height (max 1 (window-body-height win))) - (above-lines (count-screen-lines (point-min) point nil win)) - (tail-lines (max 1 (count-screen-lines point point-max nil win))) - (row (pi-coding-agent--clamp-rerender-point-row - (plist-get window-state :row) - above-lines - tail-lines - body-height))) - (recenter row)))))))) + (saved-start + (pi-coding-agent--map-window-rewrite-pos + (plist-get window-state :start) + map-position + point))) + (if (pi-coding-agent--window-start-fills-window-p + saved-start point-max body-height win) + (progn + (set-window-start win saved-start t) + (set-window-point win (max point saved-start))) + (let* ((above-lines (count-screen-lines (point-min) point nil win)) + (tail-lines (max 1 (count-screen-lines point point-max nil win))) + (row (pi-coding-agent--clamp-rewrite-point-row + (plist-get window-state :row) + above-lines + tail-lines + body-height))) + (recenter row)))))))))) + +(defun pi-coding-agent--restore-window-rewrite-states + (buffer window-states &optional map-position) + "Restore WINDOW-STATES for BUFFER after a large rewrite. +MAP-POSITION is passed to `pi-coding-agent--restore-window-rewrite-state'." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (save-selected-window + (dolist (window-state window-states) + (pi-coding-agent--restore-window-rewrite-state + window-state map-position)))))) (defun pi-coding-agent--rerender-canonical-history () "Rebuild the current chat buffer from cached canonical messages. -Visible chat windows keep useful context after the rerender: windows already at +Visible chat windows keep useful context after the rewrite: windows already at or showing the tail stay at the rebuilt tail, while other windows restore point and approximately the same screen-line row, clamped so the window stays filled when possible." - (let* ((messages pi-coding-agent--canonical-messages) - (old-point-max (point-max)) - (saved-windows - (mapcar (lambda (win) - (pi-coding-agent--capture-rerender-window-state win old-point-max)) - (get-buffer-window-list (current-buffer) nil t)))) + (let ((messages pi-coding-agent--canonical-messages)) (when (vectorp messages) - (pi-coding-agent--display-session-history messages (current-buffer)) - (save-selected-window - (dolist (win-state saved-windows) - (pi-coding-agent--restore-rerender-window-state win-state)))))) + (pi-coding-agent--with-window-rewrite-preservation + (pi-coding-agent--display-session-history messages (current-buffer)))))) (defun pi-coding-agent--set-chat-thinking-display (mode) "Set completed-thinking display MODE for the current chat buffer. @@ -2684,17 +2780,18 @@ assistant is still working, and MODE is used when that block completes." (unless chat-buf (user-error "No pi session buffer")) (with-current-buffer chat-buf - (let* ((old-point-max (point-max)) - (saved-windows - (mapcar (lambda (win) - (pi-coding-agent--capture-rerender-window-state - win old-point-max)) - (get-buffer-window-list (current-buffer) nil t)))) - (pi-coding-agent--set-thinking-display mode) - (when (pi-coding-agent--apply-thinking-display-to-completed-blocks mode) - (save-selected-window - (dolist (win-state saved-windows) - (pi-coding-agent--restore-rerender-window-state win-state)))))) + (pi-coding-agent--set-thinking-display mode) + (let ((buffer (current-buffer)) + (saved-windows (pi-coding-agent--capture-window-rewrite-states))) + (when-let* ((replacements + (pi-coding-agent--apply-thinking-display-to-completed-blocks + mode))) + (pi-coding-agent--restore-window-rewrite-states + buffer + saved-windows + (lambda (pos) + (pi-coding-agent--adjust-pos-after-region-replacements + pos replacements)))))) (message "Pi: This chat now %s completed thinking" (if (eq mode 'hidden) "hides" "shows")))) diff --git a/test/pi-coding-agent-menu-test.el b/test/pi-coding-agent-menu-test.el index f269ab2..c06988a 100644 --- a/test/pi-coding-agent-menu-test.el +++ b/test/pi-coding-agent-menu-test.el @@ -1817,24 +1817,6 @@ The tree is built iteratively to avoid recursion in test setup." (should (string-match-p "\\[-\\]" text)) (should-not (string-match-p "\\.\\.\\. ([0-9]+ more lines)" text)))))) -(ert-deftest pi-coding-agent-test-rerender-tail-window-p-keeps-lower-tail-view-following () - "A lower-window tail view should stay in tail-following mode on rerender." - (should (pi-coding-agent--rerender-tail-window-p 10 99 100 18 30)) - (should (pi-coding-agent--rerender-tail-window-p 99 50 100 5 30)) - (should-not (pi-coding-agent--rerender-tail-window-p 10 50 100 18 30))) - -(ert-deftest pi-coding-agent-test-rerender-tail-window-p-keeps-mid-buffer-context-when-tall-window-shows-tail () - "A tall window showing the tail should not outrank an in-view mid-buffer point." - (should-not (pi-coding-agent--rerender-tail-window-p 60 199 200 10 36))) - -(ert-deftest pi-coding-agent-test-clamp-rerender-point-row-pushes-point-lower-when-tail-shrinks () - "Shrinking the tail should move point lower so the rerendered window stays filled." - (should (= 12 (pi-coding-agent--clamp-rerender-point-row 3 40 8 20)))) - -(ert-deftest pi-coding-agent-test-clamp-rerender-point-row-falls-back-when-buffer-too-short () - "When the whole buffer is shorter than the window, preserve the highest visible row." - (should (= 5 (pi-coding-agent--clamp-rerender-point-row 10 5 8 20)))) - (ert-deftest pi-coding-agent-test-menu-model-description-uses-short-name () "Menu model description shows shortened name, not full \"Claude Opus 4.6\"." (pi-coding-agent-test-with-mock-session "/tmp/pi-coding-agent-test-short/" diff --git a/test/pi-coding-agent-render-test.el b/test/pi-coding-agent-render-test.el index 7729c35..93a2a2b 100644 --- a/test/pi-coding-agent-render-test.el +++ b/test/pi-coding-agent-render-test.el @@ -665,6 +665,68 @@ agent_end + next section's leading newline must not create triple newlines." (pi-coding-agent--thinking-hidden-stub (pi-coding-agent--thinking-normalize-text text))) +(defun pi-coding-agent-test--long-thinking-text (&optional count) + "Return COUNT lines of long thinking text." + (mapconcat (lambda (n) + (format "thinking line %03d: %s" n (make-string 40 ?x))) + (number-sequence 1 (or count 120)) + "\n")) + +(defun pi-coding-agent-test--tail-screen-lines (window) + "Return screen lines from WINDOW start through buffer end." + (with-current-buffer (window-buffer window) + (max 0 (count-screen-lines (window-start window) + (point-max) + nil + window)))) + +(defun pi-coding-agent-test--window-mostly-filled-p (window) + "Return non-nil when WINDOW has at most one blank row after buffer end." + (>= (pi-coding-agent-test--tail-screen-lines window) + (1- (window-body-height window)))) + +(defun pi-coding-agent-test--window-shows-tail-p (window) + "Return non-nil when WINDOW shows the current buffer tail." + (with-current-buffer (window-buffer window) + (>= (window-end window t) (point-max)))) + +(defun pi-coding-agent-test--window-start-line (window) + "Return visible text on WINDOW's start line." + (with-current-buffer (window-buffer window) + (save-excursion + (goto-char (window-start window)) + (buffer-substring-no-properties + (line-beginning-position) + (line-end-position))))) + +(defun pi-coding-agent-test--setup-long-live-thinking (buffer display) + "Populate BUFFER with history and a long live thinking block using DISPLAY." + (with-current-buffer buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--thinking-display display) + (let ((inhibit-read-only t)) + (dotimes (i 80) + (insert (format "previous history line %03d\n" (1+ i))))) + (pi-coding-agent--display-agent-start) + (pi-coding-agent--display-thinking-start) + (pi-coding-agent--display-thinking-delta + (pi-coding-agent-test--long-thinking-text)))) + +(defmacro pi-coding-agent-test--with-long-live-thinking-buffer (spec &rest body) + "Run BODY with a buffer containing long live thinking. +SPEC has the form (BUFFER DISPLAY). BUFFER is bound to the temporary buffer, +and DISPLAY controls how completed thinking is rendered." + (declare (indent 1) (debug t)) + (let ((buffer (car spec)) + (display (cadr spec))) + `(let ((,buffer (generate-new-buffer " *pi-long-live-thinking*"))) + (unwind-protect + (progn + (pi-coding-agent-test--setup-long-live-thinking ,buffer ,display) + ,@body) + (when (buffer-live-p ,buffer) + (kill-buffer ,buffer)))))) + (ert-deftest pi-coding-agent-test-tab-expands-completed-thinking-stub () "TAB on a hidden completed-thinking stub expands that block." (let ((pi-coding-agent-thinking-display 'hidden)) @@ -783,6 +845,133 @@ agent_end + next section's leading newline must not create triple newlines." (when (buffer-live-p buf) (kill-buffer buf))))) +(ert-deftest pi-coding-agent-test-hidden-thinking-end-keeps-tail-window-filled () + "Collapsing long live thinking keeps a tail-following window filled." + (let ((pi-coding-agent-thinking-display 'hidden)) + (pi-coding-agent-test--with-long-live-thinking-buffer (buf 'hidden) + (let ((win (display-buffer buf))) + (with-selected-window win + (goto-char (point-max)) + (recenter -1) + (should (pi-coding-agent-test--window-mostly-filled-p win)) + (pi-coding-agent--display-thinking-end "") + (should (pi-coding-agent-test--window-shows-tail-p win)) + (should (pi-coding-agent-test--window-mostly-filled-p win))))))) + +(ert-deftest pi-coding-agent-test-thinking-tab-collapse-keeps-tail-window-filled () + "Collapsing completed thinking with TAB keeps a tail view filled." + (let ((pi-coding-agent-thinking-display 'visible)) + (pi-coding-agent-test--with-long-live-thinking-buffer (buf 'visible) + (with-current-buffer buf + (pi-coding-agent--display-thinking-end "")) + (let ((win (display-buffer buf))) + (with-selected-window win + (goto-char (point-max)) + (recenter -1) + (should (pi-coding-agent-test--window-mostly-filled-p win)) + (search-backward "thinking line 120") + (pi-coding-agent-toggle-tool-section) + (should (pi-coding-agent-test--window-shows-tail-p win)) + (should (pi-coding-agent-test--window-mostly-filled-p win))))))) + +(ert-deftest pi-coding-agent-test-tab-collapse-preserves-window-after-thinking-block () + "Collapsing thinking keeps other windows anchored after the replaced block." + (let ((pi-coding-agent-thinking-display 'visible)) + (pi-coding-agent-test--with-long-live-thinking-buffer (buf 'visible) + (save-window-excursion + (with-current-buffer buf + (pi-coding-agent--display-thinking-end "") + (let ((inhibit-read-only t)) + (goto-char (point-max)) + (dotimes (i 120) + (insert (format "after line %03d\n" (1+ i)))))) + (let* ((reader (display-buffer buf)) + (toggle (split-window reader nil 'right))) + (set-window-buffer toggle buf) + (with-selected-window reader + (goto-char (point-min)) + (search-forward "after line 050") + (beginning-of-line) + (set-window-start reader (point) t) + (set-window-point reader (point))) + (let ((start-line-before + (pi-coding-agent-test--window-start-line reader))) + (with-selected-window toggle + (goto-char (point-min)) + (search-forward "thinking line 120") + (pi-coding-agent-toggle-tool-section)) + (should (equal (pi-coding-agent-test--window-start-line reader) + start-line-before)))))))) + +(ert-deftest pi-coding-agent-test-live-thinking-end-keeps-inspected-block-visible () + "Collapsing live thinking maps an inspected live block to its completed stub." + (let ((pi-coding-agent-thinking-display 'hidden)) + (pi-coding-agent-test--with-long-live-thinking-buffer (buf 'hidden) + (let ((win (display-buffer buf))) + (with-selected-window win + (goto-char (point-min)) + (search-forward "thinking line 060") + (beginning-of-line) + (recenter 0) + (pi-coding-agent--display-thinking-end "") + (let ((visible (buffer-substring-no-properties + (window-start win) + (window-end win t)))) + (should (string-match-p "Thinking:" visible)) + (should (pi-coding-agent-test--window-mostly-filled-p win)))))))) + +(ert-deftest pi-coding-agent-test-chat-thinking-display-preserves-window-after-earlier-block () + "Whole-chat thinking display changes keep later reading windows anchored." + (let ((pi-coding-agent-thinking-display 'visible)) + (pi-coding-agent-test--with-long-live-thinking-buffer (buf 'visible) + (with-current-buffer buf + (pi-coding-agent--display-thinking-end "") + (let ((inhibit-read-only t)) + (goto-char (point-max)) + (dotimes (i 120) + (insert (format "after line %03d\n" (1+ i)))))) + (let ((win (display-buffer buf))) + (with-selected-window win + (goto-char (point-min)) + (search-forward "after line 050") + (beginning-of-line) + (set-window-start win (point) t) + (set-window-point win (point))) + (let ((start-line-before + (pi-coding-agent-test--window-start-line win))) + (with-current-buffer buf + (cl-letf (((symbol-function 'message) #'ignore)) + (pi-coding-agent--set-chat-thinking-display 'hidden))) + (should (equal (pi-coding-agent-test--window-start-line win) + start-line-before))))))) + +(ert-deftest pi-coding-agent-test-chat-thinking-display-noop-preserves-window-start () + "A no-op whole-chat thinking display change does not move the viewport." + (let ((pi-coding-agent-thinking-display 'hidden) + (buf (generate-new-buffer " *pi-thinking-display-noop-scroll*"))) + (unwind-protect + (progn + (with-current-buffer buf + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--thinking-display 'hidden) + (let ((inhibit-read-only t)) + (dotimes (i 120) + (insert (format "plain line %03d\n" (1+ i)))))) + (let ((win (display-buffer buf))) + (with-selected-window win + (goto-char (point-min)) + (search-forward "plain line 050") + (beginning-of-line) + (set-window-start win (point) t) + (set-window-point win (point))) + (let ((start-before (window-start win))) + (with-current-buffer buf + (cl-letf (((symbol-function 'message) #'ignore)) + (pi-coding-agent--set-chat-thinking-display 'hidden))) + (should (= (window-start win) start-before))))) + (when (buffer-live-p buf) + (kill-buffer buf))))) + (ert-deftest pi-coding-agent-test-tab-falls-back-to-outline-when-not-on-section () "TAB still falls back to outline cycling outside thinking and tool sections." (with-temp-buffer @@ -1082,6 +1271,28 @@ agent_end + next section's leading newline must not create triple newlines." (cl-letf (((symbol-function 'window-point) (lambda (_w) 1))) (should-not (pi-coding-agent--window-following-p 'mock-window))))) +(ert-deftest pi-coding-agent-test-rewrite-tail-window-p-keeps-lower-tail-view-following () + "A lower-window tail view should stay in tail-following mode after a rewrite." + (should (pi-coding-agent--rewrite-tail-window-p 10 99 100 18 30)) + (should (pi-coding-agent--rewrite-tail-window-p 99 50 100 5 30)) + (should-not (pi-coding-agent--rewrite-tail-window-p 10 50 100 18 30))) + +(ert-deftest pi-coding-agent-test-rewrite-tail-window-p-keeps-mid-buffer-context-when-tall-window-shows-tail () + "A tall window showing the tail should not outrank an in-view mid-buffer point." + (should-not (pi-coding-agent--rewrite-tail-window-p 60 199 200 10 36))) + +(ert-deftest pi-coding-agent-test-rewrite-tail-window-p-ignores-offscreen-point () + "A stale tail-reaching window end should not make an offscreen point follow." + (should-not (pi-coding-agent--rewrite-tail-window-p 60 199 200 14 11))) + +(ert-deftest pi-coding-agent-test-clamp-rewrite-point-row-pushes-point-lower-when-tail-shrinks () + "Shrinking the tail should move point lower so the rewritten window stays filled." + (should (= 12 (pi-coding-agent--clamp-rewrite-point-row 3 40 8 20)))) + +(ert-deftest pi-coding-agent-test-clamp-rewrite-point-row-falls-back-when-buffer-too-short () + "When the whole buffer is shorter than the window, preserve the highest visible row." + (should (= 5 (pi-coding-agent--clamp-rewrite-point-row 10 5 8 20)))) + ;;; Pandoc Conversion (ert-deftest pi-coding-agent-test-message-start-marker-created ()