diff options
| -rw-r--r-- | modules/ai-vterm.el | 83 | ||||
| -rw-r--r-- | tests/test-ai-vterm--collapse-split.el | 171 | ||||
| -rw-r--r-- | tests/test-ai-vterm--reuse-edge-window.el | 45 | ||||
| -rw-r--r-- | todo.org | 3 |
4 files changed, 261 insertions, 41 deletions
diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el index 00589890..4f086636 100644 --- a/modules/ai-vterm.el +++ b/modules/ai-vterm.el @@ -176,6 +176,21 @@ recently-selected first. Non-AI-vterm buffers are filtered out via `cj/--ai-vterm-buffer-p'." (seq-filter #'cj/--ai-vterm-buffer-p (buffer-list))) +(defun cj/--ai-vterm-most-recent-non-agent-buffer () + "Return the most-recently-selected live non-agent buffer, or nil. + +Walks `buffer-list' (most-recently-selected first) and returns the +first buffer that is not an AI-vterm agent buffer (per +`cj/--ai-vterm-buffer-p') and is not an internal buffer (name starting +with a space). Used by the single-window F9 toggle-off so dismissing a +full-frame agent returns to the file the user was working in (e.g. +todo.org) rather than swapping in another agent." + (seq-find (lambda (b) + (and (buffer-live-p b) + (not (cj/--ai-vterm-buffer-p b)) + (not (string-prefix-p " " (buffer-name b))))) + (buffer-list))) + (defun cj/--ai-vterm-displayed-agent-window (&optional frame) "Return a window in FRAME currently displaying an AI-vterm buffer, or nil. @@ -409,6 +424,17 @@ without deleting), nil when the window was deleted. Consumed by buried agent in the current window (the only one) or splitting per the saved direction.") +(defvar cj/--ai-vterm-last-hidden-buffer nil + "The agent buffer hidden by the most recent F9 toggle-off. + +Captured in `cj/ai-vterm' just before an agent window is torn down, and +consumed by `cj/--ai-vterm-dispatch' so the next toggle-on reopens the +SAME agent that was on screen rather than whichever agent happens to be +most-recent in `buffer-list'. Without this, hiding one agent and +reopening could surface a different one when several agents are alive -- +the \"the displayed buffer changes\" bug. Falls back to the buffer-list +MRU when nil or when the remembered buffer has been killed.") + (defvar cj/--ai-vterm-last-size nil "Last user-chosen body size for the AI-vterm display. @@ -695,7 +721,15 @@ without firing real `display-buffer' or `quit-window' calls." (t (let ((buffers (cj/--ai-vterm-agent-buffers))) (cond - (buffers (cons 'redisplay-recent (car buffers))) + (buffers + ;; Reopen the agent the last toggle-off hid (faithful toggle), so + ;; long as it's still alive and among the live agents. Otherwise + ;; fall back to the most-recently-selected agent. + (cons 'redisplay-recent + (if (and (buffer-live-p cj/--ai-vterm-last-hidden-buffer) + (memq cj/--ai-vterm-last-hidden-buffer buffers)) + cj/--ai-vterm-last-hidden-buffer + (car buffers)))) (t '(pick-project)))))))) (defun cj/--ai-vterm-refuse-in-terminal () @@ -754,6 +788,9 @@ M-F9 (and C-S-F9) close an agent via `cj/ai-vterm-close'." (cj/--ai-vterm-refuse-in-terminal) (pcase (cj/--ai-vterm-dispatch) (`(toggle-off . ,win) + ;; Remember which agent we're hiding so the next toggle-on reopens this + ;; same one, not whichever agent is most-recent in `buffer-list'. + (setq cj/--ai-vterm-last-hidden-buffer (window-buffer win)) (cond ;; Lone fullscreen agent (e.g. after `C-x 1' inside it): there is no ;; prior layout for the native undo to restore and deleting would @@ -770,27 +807,35 @@ M-F9 (and C-S-F9) close an agent via `cj/ai-vterm-close'." (when (and (window-live-p win) (cj/--ai-vterm-buffer-p (window-buffer win))) (with-selected-window win - (switch-to-buffer (other-buffer (window-buffer win) t))))) - ;; Multi-window: `quit-restore-window' is the native undo for a - ;; `display-buffer' display. The agent's display path records the - ;; matching `quit-restore' state -- `display-buffer-record-window' - ;; (type `reuse') in `cj/--ai-vterm-reuse-edge-window' when it takes - ;; over a slot, `display-buffer-in-direction' (type `window') when it - ;; splits a fresh one. So one call restores the displaced buffer - ;; into a reused slot, or deletes a window that was split for the - ;; agent. No BURY-OR-KILL argument: burying would move the agent to - ;; the end of the buffer list, so with several agents alive the next - ;; F9 (`cj/--ai-vterm-dispatch' re-shows the most-recent agent) would - ;; bring back a different one instead of the agent just toggled off. + (switch-to-buffer + (or (cj/--ai-vterm-most-recent-non-agent-buffer) + (other-buffer (window-buffer win) t)))))) + ;; Multi-window: collapse the agent split outright by deleting its + ;; window, so the working buffer (e.g. todo.org) reclaims the space. + ;; F9 is a pure show/hide toggle of THE agent split -- it must never + ;; surface a different agent. `quit-restore-window' can't guarantee + ;; that here: switching among several agents reuses the one slot via + ;; `set-window-buffer' (see `cj/--ai-vterm-reuse-existing-agent'), + ;; which leaves the window's `quit-restore' parameter pointing at the + ;; FIRST agent shown. Once it's stale, `quit-restore-window' falls + ;; back to `switch-to-prev-buffer' and surfaces another agent instead + ;; of removing the window -- exactly the "F9 shows another agent" + ;; bug. `delete-window' is unconditional and slot-history-independent. + ;; Capture geometry first so the next toggle-on splits at the same + ;; size (the user's chosen split width is preserved across the toggle). (t - ;; Capture geometry first: when the agent had its own split window - ;; (axis-mismatch / single-window origin), `quit-restore-window' - ;; removes it and the next toggle-on splits afresh -- replaying the - ;; captured size preserves a user resize across the toggle. Harmless - ;; in the reused-slot case, where the split path is never taken. (cj/--ai-vterm-capture-state win) (setq cj/--ai-vterm-last-was-bury nil) - (quit-restore-window win))) + (if (and (window-live-p win) + (> (length (window-list (window-frame win) 'never)) 1)) + (delete-window win) + ;; Degenerate fallback (window became sole between dispatch and + ;; here): swap to a non-agent buffer rather than leave the agent up. + (when (window-live-p win) + (with-selected-window win + (switch-to-buffer + (or (cj/--ai-vterm-most-recent-non-agent-buffer) + (other-buffer (window-buffer win) t)))))))) nil) (`(redisplay-recent . ,buf) (display-buffer buf) diff --git a/tests/test-ai-vterm--collapse-split.el b/tests/test-ai-vterm--collapse-split.el new file mode 100644 index 00000000..ad299e47 --- /dev/null +++ b/tests/test-ai-vterm--collapse-split.el @@ -0,0 +1,171 @@ +;;; test-ai-vterm--collapse-split.el --- F9 collapses the agent split -*- lexical-binding: t; -*- + +;;; Commentary: +;; Regression coverage for the F9 toggle-off behavior Craig reported: with +;; several agents alive, F9 should HIDE the agent split (collapse it back to the +;; working layout) rather than surfacing a different agent. Two cases: +;; +;; - Multi-window: the agent occupies a split. F9 deletes that window so the +;; working buffer reclaims the frame -- never swaps in another agent. The +;; prior `quit-restore-window' path went stale after the slot was reused +;; across agents (C-F9 switching), so it surfaced a different agent. +;; - Single-window: the agent fills the frame. F9 returns to the most-recent +;; NON-agent buffer (the file being worked on), not another agent -- the prior +;; `other-buffer' call could pick another live agent. +;; +;; Also covers the `cj/--ai-vterm-most-recent-non-agent-buffer' helper. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(require 'ai-vterm) +(require 'testutil-vterm-buffers) + +;;; cj/--ai-vterm-most-recent-non-agent-buffer + +(ert-deftest test-ai-vterm--most-recent-non-agent-buffer-skips-agents () + "Normal: returns a live non-agent buffer even when agents are most-recent." + (cj/test--kill-agent-buffers) + (let ((work (get-buffer-create "*test-mrna-work*")) + (agent-a (get-buffer-create "agent [mrna-a]")) + (agent-b (get-buffer-create "agent [mrna-b]"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + ;; Make the agents most-recent in this window's history. + (set-window-buffer (selected-window) work) + (set-window-buffer (selected-window) agent-b) + (set-window-buffer (selected-window) agent-a) + (let ((result (cj/--ai-vterm-most-recent-non-agent-buffer))) + (should (bufferp result)) + (should (buffer-live-p result)) + (should-not (cj/--ai-vterm-buffer-p result)))) + (when (get-buffer "*test-mrna-work*") (kill-buffer "*test-mrna-work*")) + (cj/test--kill-agent-buffers)))) + +;;; Multi-window: F9 collapses the split + +(ert-deftest test-ai-vterm--collapse-multi-window-deletes-agent-split () + "Normal/Regression: agent in a bottom split with other agents alive; F9 +collapses the split so the working buffer reclaims the frame, and no agent is +surfaced. Before the fix, `quit-restore-window' could switch the slot to a +different agent (stale quit-restore after slot reuse)." + (cj/test--kill-agent-buffers) + (let ((work (get-buffer-create "*test-collapse-work*")) + (agent-a (get-buffer-create "agent [collapse-a]")) + (agent-b (get-buffer-create "agent [collapse-b]")) + (agent-c (get-buffer-create "agent [collapse-c]")) + (cj/--ai-vterm-last-was-bury nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (set-window-buffer (selected-window) work) + (let ((agent-win (split-window (selected-window) nil 'below))) + ;; Reuse the slot across agents (as C-F9 switching does) so the + ;; window's prev-buffer history holds another agent. + (set-window-buffer agent-win agent-a) + (set-window-buffer agent-win agent-b) + (set-window-buffer agent-win agent-c) + (select-window agent-win) + (should-not (one-window-p)) + (cj/test--call-as-gui #'cj/ai-vterm) + (should (one-window-p)) + (should-not (cj/--ai-vterm-displayed-agent-window)) + (should (eq (window-buffer (selected-window)) work)))) + (when (get-buffer "*test-collapse-work*") (kill-buffer "*test-collapse-work*")) + (cj/test--kill-agent-buffers)))) + +;;; Single-window: F9 returns to a non-agent buffer + +(ert-deftest test-ai-vterm--collapse-single-window-returns-non-agent () + "Normal/Regression: agent fills the frame, other agents alive; F9 toggles back +to a NON-agent buffer (the working file), never another agent. Before the fix, +`other-buffer' could pick another live agent." + (cj/test--kill-agent-buffers) + (let ((work (get-buffer-create "*test-collapse-sw-work*")) + (agent-a (get-buffer-create "agent [collapse-sw-a]")) + (agent-b (get-buffer-create "agent [collapse-sw-b]")) + (cj/--ai-vterm-last-was-bury nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + ;; MRU: work, then agent-b, then agent-a (current). `other-buffer' + ;; would pick agent-b; the fix must skip it for a non-agent. + (set-window-buffer (selected-window) work) + (set-window-buffer (selected-window) agent-b) + (set-window-buffer (selected-window) agent-a) + (should (one-window-p)) + (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) + (cj/test--call-as-gui #'cj/ai-vterm)) + (should (one-window-p)) + (should-not (cj/--ai-vterm-buffer-p (window-buffer (selected-window))))) + (when (get-buffer "*test-collapse-sw-work*") (kill-buffer "*test-collapse-sw-work*")) + (cj/test--kill-agent-buffers)))) + +;;; Faithful toggle: reopen the SAME agent that was hidden + +(ert-deftest test-ai-vterm--dispatch-prefers-last-hidden-agent () + "Regression: dispatch reopens the last-hidden agent, not the buffer-list MRU. +After F9 hides an agent, the next F9 must reopen the SAME one even when a +different agent is ahead of it in `buffer-list'. Falls back to the MRU when +nothing was hidden yet or the remembered buffer was killed." + (cj/test--kill-agent-buffers) + (let ((a1 (get-buffer-create "agent [disp-mru]")) + (a2 (get-buffer-create "agent [disp-shown]")) + (cj/--ai-vterm-last-hidden-buffer nil)) + (unwind-protect + (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-agent-window) + (lambda (&optional _f) nil)) + ((symbol-function 'cj/--ai-vterm-agent-buffers) + (lambda () (list a1 a2)))) ; a1 is the MRU + ;; No memory yet -> falls back to MRU (a1). + (should (equal (cj/--ai-vterm-dispatch) (cons 'redisplay-recent a1))) + ;; Remember a2 as last hidden -> dispatch prefers it. + (setq cj/--ai-vterm-last-hidden-buffer a2) + (should (equal (cj/--ai-vterm-dispatch) (cons 'redisplay-recent a2))) + ;; A killed last-hidden buffer -> falls back to MRU. + (let ((dead (get-buffer-create "agent [disp-dead]"))) + (setq cj/--ai-vterm-last-hidden-buffer dead) + (kill-buffer dead)) + (should (equal (cj/--ai-vterm-dispatch) (cons 'redisplay-recent a1)))) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-vterm--toggle-roundtrip-reopens-same-agent () + "Regression: hide then show brings back the agent that was on screen. +With several agents alive and a different one most-recent in `buffer-list', +F9 off then F9 on restores the SAME agent that was visible -- not a swap to +another. Reproduces the \"the displayed buffer changes\" report." + (cj/test--kill-agent-buffers) + (let ((work (get-buffer-create "*test-roundtrip-work*")) + (a1 (get-buffer-create "agent [rt-1]")) + (a2 (get-buffer-create "agent [rt-2]")) + (cj/--ai-vterm-last-was-bury nil) + (cj/--ai-vterm-last-direction nil) + (cj/--ai-vterm-last-size nil) + (cj/--ai-vterm-last-hidden-buffer nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (set-window-buffer (selected-window) work) + (let ((agent-win (split-window (selected-window) nil 'below))) + ;; a2 is the visible agent; a1 sits ahead of it in buffer-list. + (set-window-buffer agent-win a1) + (bury-buffer a1) ; a1 stays alive, demoted in MRU + (set-window-buffer agent-win a2) + (select-window agent-win) + (should (eq (window-buffer (cj/--ai-vterm-displayed-agent-window)) a2)) + (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) + (cj/test--call-as-gui #'cj/ai-vterm) ; off + (should-not (cj/--ai-vterm-displayed-agent-window)) + (cj/test--call-as-gui #'cj/ai-vterm) ; on -> must be a2 + (should (eq (window-buffer (cj/--ai-vterm-displayed-agent-window)) + a2))))) + (when (get-buffer "*test-roundtrip-work*") (kill-buffer "*test-roundtrip-work*")) + (cj/test--kill-agent-buffers)))) + +(provide 'test-ai-vterm--collapse-split) +;;; test-ai-vterm--collapse-split.el ends here diff --git a/tests/test-ai-vterm--reuse-edge-window.el b/tests/test-ai-vterm--reuse-edge-window.el index 9f621477..eb1b1d75 100644 --- a/tests/test-ai-vterm--reuse-edge-window.el +++ b/tests/test-ai-vterm--reuse-edge-window.el @@ -150,10 +150,12 @@ ends up displayed." (when (get-buffer bottom-name) (kill-buffer bottom-name)) (cj/test--kill-agent-buffers)))) -(ert-deftest test-ai-vterm--reuse-edge-window-toggle-off-restores-displaced () - "Normal: toggle-off after a slot reuse restores the displaced buffer. -=| 1 | 2 |= + show agent -> =| 1 | A |=; toggle off -> =| 1 | 2 |= again, -window count stays 2 (the native `quit-restore-window' puts 2 back)." +(ert-deftest test-ai-vterm--reuse-edge-window-toggle-off-collapses-split () + "Normal: toggle-off after a slot reuse collapses the agent split. +=| 1 | 2 |= + show agent -> =| 1 | A |=; toggle off -> =| 1 |= (one +window). F9 always collapses the agent split back to the working layout +regardless of how the agent window came to be -- it deletes the agent +window rather than restoring the displaced buffer into a kept slot." (cj/test--kill-agent-buffers) (let ((agent-name "agent [edge-restore]") (left-name "*test-restore-left*") @@ -174,22 +176,23 @@ window count stays 2 (the native `quit-restore-window' puts 2 back)." (display-buffer agent-buf) (should (= (count-windows) 2)) (should (member agent-name (cj/test--displayed-buffer-names))) - ;; Toggle off -> the displaced buffer (2) returns to the slot. + ;; Toggle off -> the agent window is deleted, leaving the + ;; working buffer at full frame. (cj/test--call-as-gui #'cj/ai-vterm) - (should (= (count-windows) 2)) + (should (= (count-windows) 1)) (let ((bufs (cj/test--displayed-buffer-names))) - (should (member right-name bufs)) (should (member left-name bufs)) (should-not (member agent-name bufs))))))) (when (get-buffer left-name) (kill-buffer left-name)) (when (get-buffer right-name) (kill-buffer right-name)) (cj/test--kill-agent-buffers)))) -(ert-deftest test-ai-vterm--reuse-edge-window-cycle-keeps-count-and-swaps () - "Normal: on/off/on cycle keeps the window count at 2 and swaps the slot. -=| 1 | 2 |= -> on =| 1 | A |= -> off =| 1 | 2 |= -> on =| 1 | A |=, never -creating or deleting a window, and the agent returns to the same slot at -the same width." +(ert-deftest test-ai-vterm--reuse-edge-window-cycle-collapses-then-resplits () + "Normal: on/off/on cycle collapses on off and re-splits at the same width. +=| 1 | 2 |= -> on =| 1 | A |= (2 windows) -> off =| 1 |= (1 window, +collapsed) -> on =| 1 | A |= (2 windows again), with the agent re-split at +the width captured at toggle-off -- the user's chosen split width is +preserved across the toggle (respect-split-width)." (cj/test--kill-agent-buffers) (let ((agent-name "agent [edge-cycle]") (left-name "*test-cycle-left*") @@ -206,26 +209,24 @@ the same width." slot-width) (set-window-buffer (selected-window) left-buf) (let ((rw (split-window (selected-window) nil 'right))) - (set-window-buffer rw right-buf) - (setq slot-width (window-body-width rw))) + (set-window-buffer rw right-buf)) (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - ;; on + ;; on -- agent takes the existing right slot (display-buffer agent-buf) (should (= (count-windows) 2)) - ;; off + (setq slot-width + (window-body-width (cj/--ai-vterm-displayed-agent-window))) + ;; off -- the split collapses to a single window (cj/test--call-as-gui #'cj/ai-vterm) - (should (= (count-windows) 2)) + (should (= (count-windows) 1)) (should-not (cj/--ai-vterm-displayed-agent-window)) - ;; on again + ;; on again -- re-split at the captured width (cj/test--call-as-gui #'cj/ai-vterm) (should (= (count-windows) 2)) (let ((win (cj/--ai-vterm-displayed-agent-window))) (should (windowp win)) (should (eq (window-buffer win) agent-buf)) - ;; reused the same slot -> same body width as the - ;; original right column - (should (= (window-body-width win) slot-width))) - (should-not (member right-name (cj/test--displayed-buffer-names))))))) + (should (= (window-body-width win) slot-width))))))) (when (get-buffer left-name) (kill-buffer left-name)) (when (get-buffer right-name) (kill-buffer right-name)) (cj/test--kill-agent-buffers)))) @@ -41,6 +41,9 @@ Tags are additive. For example, a small wrong-behavior fix can be =:bug:quick:=, and a feature that requires internal restructuring can be =:feature:refactor:=. * Emacs Open Work +** DONE [#A] f9 should toggle the entire ai-vterm split, not just the buffer +CLOSED: [2026-06-02 Tue] +F9 toggle-off now collapses the agent split (delete-window) instead of quit-restore-window, which went stale across multi-agent slot reuse and surfaced a different agent. Toggle-on reopens the exact agent that was hidden (cj/--ai-vterm-last-hidden-buffer). Sole-window toggle-off returns to the most-recent non-agent buffer. Split width preserved across the toggle. ** DOING [#B] Signal client — forked signel :feature: :PROPERTIES: :LAST_REVIEWED: 2026-05-28 |
