aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-25 04:10:38 -0500
committerCraig Jennings <c@cjennings.net>2026-05-25 04:10:38 -0500
commit3f75b39bbbc4e1c136d3f786024c5c1ed19011ce (patch)
treec03cfe18f7a067cae026db400e757cbd5d053d35
parent08014b2f15e099a1c5e662a17a41290f37aeebf4 (diff)
downloaddotemacs-3f75b39bbbc4e1c136d3f786024c5c1ed19011ce.tar.gz
dotemacs-3f75b39bbbc4e1c136d3f786024c5c1ed19011ce.zip
fix(ai-vterm): reuse the frame's half instead of splitting a third
F9 split a third window into a frame that was already divided in two, wedging the agent into the middle or a skinny extra column instead of taking the half it should occupy. The display rule only knew how to reuse a window already showing an agent or to split a fresh one. With a plain two-pane layout it fell through to the split and added a window. I added a display action, cj/--ai-vterm-reuse-edge-window, that reuses the window already forming the target half (the right column on a desktop, the bottom row on a laptop), found by a new cj/window-at-edge helper. It records the displaced buffer with display-buffer-record-window, so toggling off restores that buffer through the native quit-restore-window. The slot's buffer swaps between the agent and whatever it displaced, and no window is created or deleted. The split path still handles a single-window frame or a layout split on the other axis, and the lone fullscreen agent keeps its bury-and-restore-in-place behavior.
-rw-r--r--modules/ai-vterm.el112
-rw-r--r--modules/cj-window-geometry-lib.el35
-rw-r--r--tests/test-ai-vterm--display-saved.el254
-rw-r--r--tests/test-ai-vterm--reuse-edge-window.el272
-rw-r--r--tests/test-cj-window-geometry-lib.el75
5 files changed, 480 insertions, 268 deletions
diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el
index 60d0c7f3..d932b0e9 100644
--- a/modules/ai-vterm.el
+++ b/modules/ai-vterm.el
@@ -18,9 +18,13 @@
;; ~/projects/* containing .ai/protocols.org), opens or reuses a vterm
;; buffer named "agent [<basename>]", sends the agent's startup
;; instruction to it, and routes the buffer to a side window via
-;; display-buffer-alist. The default placement is host-aware: a
-;; right-side split at 50% width on a desktop, a bottom split at 75%
-;; height on a laptop (see `cj/--ai-vterm-default-direction'). Multiple
+;; display-buffer-alist. When the frame already has a window forming the
+;; half the agent would occupy (a right column on a desktop, a bottom row
+;; on a laptop), the agent reuses that slot rather than splitting a third
+;; window in; toggling off restores the displaced buffer to the slot.
+;; Otherwise placement is a host-aware split: a right-side split at 50%
+;; width on a desktop, a bottom split at 75% height on a laptop (see
+;; `cj/--ai-vterm-default-direction'). Multiple
;; projects produce multiple coexisting buffers that share the same
;; slot; switching among them is a buffer-switch, not a
;; kill-and-recreate.
@@ -37,10 +41,13 @@
;; Four F-key entry points:
;;
;; - F9 `cj/ai-vterm' -- DWIM dispatch. If an agent buffer is
-;; currently displayed in this frame, F9 quits its window
-;; (toggle off). Otherwise, if exactly one agent buffer is
-;; alive, F9 re-displays it; if zero or two-plus are alive, F9
-;; falls through to the project picker.
+;; currently displayed in this frame, F9 toggles it off: when it
+;; took over an existing window (a reused slot) the buffer it
+;; displaced returns to that slot, when it was split into its own
+;; window that window is removed, and when it fills the frame it
+;; is buried. Otherwise, if exactly one agent buffer is alive,
+;; F9 re-displays it; if zero or two-plus are alive, F9 falls
+;; through to the project picker.
;; - C-F9 `cj/ai-vterm-pick-project' -- always show the project
;; picker, even when an agent buffer is currently displayed.
;; Used when the user wants to start a new project session
@@ -459,6 +466,37 @@ project changes."
(set-window-buffer win buffer)
win)))
+(defun cj/--ai-vterm-reuse-edge-window (buffer _alist)
+ "Display-buffer action: reuse the existing window forming the target half.
+
+When the frame already holds a window forming the half the agent would
+occupy -- the right column on a desktop, the bottom row on a laptop, per
+the saved or default direction -- swap BUFFER into it with
+`set-window-buffer' and return that window, rather than splitting a third
+window in. The target half is found by `cj/window-at-edge'.
+
+Returns nil when there is no such half to reuse (a single-window frame,
+or a layout split on the other axis), so the chain falls through to
+`cj/--ai-vterm-display-saved', which splits a fresh half. Also returns
+nil when the edge window is dedicated -- those are not ours to replace.
+
+Records the displaced buffer through `display-buffer-record-window'
+\(type `reuse') before swapping, so the native `quit-restore-window'
+called at toggle-off puts that buffer back into the slot instead of
+deleting the window -- toggling swaps the slot's buffer between the
+displaced buffer and the agent, never changing the window count.
+
+Runs after `cj/--ai-vterm-reuse-existing-agent', so an agent already on
+screen has been handled already; the window reused here always holds a
+non-agent buffer, which is replaced (it stays alive, just unshown)."
+ (let* ((direction (or cj/--ai-vterm-last-direction
+ (cj/--ai-vterm-default-direction)))
+ (win (cj/window-at-edge direction)))
+ (when (and win (not (window-dedicated-p win)))
+ (display-buffer-record-window 'reuse win buffer)
+ (set-window-buffer win buffer)
+ win)))
+
(defun cj/--ai-vterm-display-saved (buffer alist)
"Display-buffer action: split per saved direction and size.
@@ -488,7 +526,7 @@ F9 state vars, falling back to the host-aware defaults from
"Return the `display-buffer-alist' entry list installed by this module.
The single rule routes any buffer whose name starts with \"agent [\"
-through three actions in order:
+through four actions in order:
1. `display-buffer-reuse-window' -- if the same buffer is already
visible in any window, focus that one.
@@ -496,7 +534,12 @@ through three actions in order:
window in this frame already shows an agent-prefixed buffer,
swap its buffer for the new one (preserves geometry across
project changes via C-F9).
-3. `cj/--ai-vterm-display-saved' -- otherwise, split per the saved
+3. `cj/--ai-vterm-reuse-edge-window' -- otherwise, if the frame
+ already has a window forming the half the agent would occupy
+ (the right column on a desktop, the bottom row on a laptop),
+ reuse it instead of splitting a third window in.
+4. `cj/--ai-vterm-display-saved' -- otherwise (single-window frame,
+ or a layout split on the other axis), split per the saved
direction + size from the last toggle-off (or defaults when no
capture has happened this session).
@@ -512,6 +555,7 @@ split) when the user is focused in agent and switches projects."
'(("\\`agent \\["
(display-buffer-reuse-window
cj/--ai-vterm-reuse-existing-agent
+ cj/--ai-vterm-reuse-edge-window
cj/--ai-vterm-display-saved)
(inhibit-same-window . t))))
@@ -691,39 +735,43 @@ M-F9 (and C-S-F9) close an agent via `cj/ai-vterm-close'."
(interactive "P")
(pcase (cj/--ai-vterm-dispatch)
(`(toggle-off . ,win)
- (cj/--ai-vterm-capture-state win)
- ;; `delete-window' rather than `quit-window' so the toggle-off
- ;; semantics are unconditional. `quit-window' only deletes the
- ;; window when its `quit-restore' parameter records that it was
- ;; created for the buffer. Buffer-move (C-M-arrows) leaves the
- ;; agent buffer in a window without that history, so
- ;; `quit-window' would just bury -- the window stays with some
- ;; other buffer in it, and the next toggle-on then creates a
- ;; fresh side window for a count of N+1. Skip the deletion
- ;; only when agent is the lone window in the frame (delete
- ;; would leave none); bury in that case. The flag tells the
- ;; next toggle-on (via `cj/--ai-vterm-display-saved') to restore
- ;; in place rather than splitting -- preserves the single-window
- ;; layout across F9 toggles.
(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
+ ;; leave the frame empty. Bury and flag, so the next toggle-on
+ ;; (`cj/--ai-vterm-display-saved') restores the agent in place at
+ ;; full frame rather than splitting. Capture geometry for that
+ ;; restore. `bury-buffer' can no-op when the window's prev-buffer
+ ;; history holds only the agent (common right after `C-x 1'), so
+ ;; force a swap to a non-agent buffer to keep the toggle observable.
((one-window-p)
+ (cj/--ai-vterm-capture-state win)
(setq cj/--ai-vterm-last-was-bury t)
(bury-buffer (window-buffer win))
- ;; `bury-buffer' calls `switch-to-prev-buffer' to swap the
- ;; lone window onto another buffer, but that no-ops when the
- ;; window's `window-prev-buffers' list only contains the
- ;; agent itself (common right after a `C-x 1' that cleared
- ;; the other windows' histories). Without an observable swap
- ;; the toggle-off appears to do nothing -- a subsequent F9
- ;; finds the agent still displayed and just buries again.
- ;; Force the switch when bury's own swap didn't take.
(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.
(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)
- (delete-window win)))
+ (quit-restore-window win)))
nil)
(`(redisplay-recent . ,buf)
(display-buffer buf)
diff --git a/modules/cj-window-geometry-lib.el b/modules/cj-window-geometry-lib.el
index a674a072..1c1ab9fd 100644
--- a/modules/cj-window-geometry-lib.el
+++ b/modules/cj-window-geometry-lib.el
@@ -77,5 +77,40 @@ layouts."
('above 'top)
(_ nil)))
+(defun cj/window-at-edge (direction &optional frame)
+ "Return the window forming FRAME's half on the side named by DIRECTION.
+
+DIRECTION is one of right, left, below, above. A window qualifies as
+the half when it sits flush against that frame edge and spans the full
+perpendicular extent: for right or left it is a full-height side column
+(nothing above, below, or further toward the edge, but a sibling on the
+far side); for below or above it is a full-width row. FRAME defaults to
+the selected frame.
+
+Existence is tested with `window-in-direction' rather than raw edge
+arithmetic -- frame-internal geometry makes the root window's edges
+disagree with its children's by a row, so an `=' comparison against the
+root edges never matches. Asking \"is there a window on each side?\"
+sidesteps that.
+
+Returns nil when no window qualifies: a single-window frame (no sibling
+on the far side, so not a distinct half), an axis mismatch (a top/bottom
+split when DIRECTION is right has no full-height right column), or a
+nested edge no one window fills. The caller then falls back to splitting
+a fresh half."
+ (when (memq direction '(right left below above))
+ (let ((far (pcase direction
+ ('right 'left) ('left 'right) ('below 'above) ('above 'below)))
+ (perp (pcase direction
+ ((or 'right 'left) '(above below))
+ ((or 'below 'above) '(left right)))))
+ (seq-find
+ (lambda (w)
+ (and (window-in-direction far w)
+ (not (window-in-direction direction w))
+ (not (window-in-direction (nth 0 perp) w))
+ (not (window-in-direction (nth 1 perp) w))))
+ (window-list (or frame (selected-frame)) 'never)))))
+
(provide 'cj-window-geometry-lib)
;;; cj-window-geometry-lib.el ends here
diff --git a/tests/test-ai-vterm--display-saved.el b/tests/test-ai-vterm--display-saved.el
index 91cea46e..866ff11d 100644
--- a/tests/test-ai-vterm--display-saved.el
+++ b/tests/test-ai-vterm--display-saved.el
@@ -1,14 +1,22 @@
;;; test-ai-vterm--display-saved.el --- Tests for the display-saved action -*- lexical-binding: t; -*-
;;; Commentary:
-;; The action reads `cj/--ai-vterm-last-direction' +
-;; `cj/--ai-vterm-last-size' (with default fallbacks), builds an
-;; alist with direction + the matching size key, strips any
-;; conflicting entries that came in via the rule, and delegates to
-;; `display-buffer-in-direction'.
+;; `cj/--ai-vterm-display-saved' is the split path of the F9 display
+;; chain -- it runs only when no agent window and no reusable edge slot
+;; exist (a single-window frame, or a layout split on the other axis).
+;; It reads `cj/--ai-vterm-last-direction' + `cj/--ai-vterm-last-size'
+;; (with default fallbacks), builds an alist with direction + the
+;; matching size key, strips any conflicting entries that came in via the
+;; rule, and delegates to `display-buffer-in-direction'.
;;
-;; Tests stub `display-buffer-in-direction' to capture the alist
-;; that would have reached it.
+;; Tests stub `display-buffer-in-direction' to capture the alist that
+;; would have reached it.
+;;
+;; Multi-window toggle round-trips no longer resplit -- they reuse the
+;; existing half (see test-ai-vterm--reuse-edge-window.el), so the former
+;; resplit/body-width-preservation round-trip tests were retired with the
+;; swap-the-slot model. The buffer-move teardown test stays here because
+;; it exercises the split-window delete path on toggle-off.
;;; Code:
@@ -115,237 +123,17 @@ stubbed t to pin the laptop branch."
(cj/--ai-vterm-display-saved 'sentinel-buffer nil))
(should (eq received-buf 'sentinel-buffer))))
-(ert-deftest test-ai-vterm--display-saved-3window-roundtrip-preserves-body-width ()
- "Regression: capture+delete+display in a 3-window layout preserves body-width.
-
-Reproduces Craig's `peeking ~1 col' report from 2026-05-09: when
-the new agent lands at a different position than the captured one
-(rightmost vs middle), `window-total-width' differs by 1 because
-of the right divider. `window-body-width' is divider-independent
-and is what the user actually sees, so the assertion locks down
-the body match."
- (cj/test--kill-agent-buffers)
- (let ((agent-name "agent [3win-roundtrip]")
- (left-name "*test-3win-left*")
- (right-name "*test-3win-right*"))
- (unwind-protect
- (save-window-excursion
- (delete-other-windows)
- (let ((left-buf (get-buffer-create left-name))
- (right-buf (get-buffer-create right-name))
- (agent-buf (get-buffer-create agent-name)))
- ;; Build: left | agent | right. Selected window starts as
- ;; the only window. Split right twice to get three windows.
- (set-window-buffer (selected-window) left-buf)
- (let* ((right-win (split-window (selected-window) nil 'right))
- (_ (set-window-buffer right-win right-buf))
- (agent-win (split-window (selected-window) nil 'right)))
- (set-window-buffer agent-win agent-buf)
- ;; Capture agent's state.
- (cj/--ai-vterm-capture-state agent-win)
- (let ((captured-size cj/--ai-vterm-last-size)
- (captured-direction cj/--ai-vterm-last-direction))
- ;; Simulate quit-window on agent.
- (delete-window agent-win)
- ;; Now route a fresh display through the actual rule.
- (let* ((display-buffer-alist (cj/--ai-vterm-display-rule-list))
- (new-win (display-buffer agent-buf)))
- (should (windowp new-win))
- (should (eq (window-buffer new-win) agent-buf))
- ;; The captured size should be replayed exactly.
- (should (= (window-body-width new-win)
- captured-size))
- ;; Direction should also match.
- (should (eq captured-direction 'right)))))))
- (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--display-saved-3window-agent-rightmost-roundtrip ()
- "Round-trip when agent is the rightmost window (no right divider)."
- (cj/test--kill-agent-buffers)
- (let ((agent-name "agent [rightmost]")
- (left-name "*test-rm-left*")
- (mid-name "*test-rm-mid*"))
- (unwind-protect
- (save-window-excursion
- (delete-other-windows)
- (let ((left-buf (get-buffer-create left-name))
- (mid-buf (get-buffer-create mid-name))
- (agent-buf (get-buffer-create agent-name)))
- ;; Build: left | mid | agent (agent rightmost)
- (set-window-buffer (selected-window) left-buf)
- (let* ((mid-win (split-window (selected-window) nil 'right))
- (agent-win (split-window mid-win nil 'right)))
- (set-window-buffer mid-win mid-buf)
- (set-window-buffer agent-win agent-buf)
- (cj/--ai-vterm-capture-state agent-win)
- (let ((captured-size cj/--ai-vterm-last-size))
- (delete-window agent-win)
- (let* ((display-buffer-alist (cj/--ai-vterm-display-rule-list))
- (new-win (display-buffer agent-buf)))
- (should (windowp new-win))
- (should (= (window-body-width new-win) captured-size)))))))
- (when (get-buffer left-name) (kill-buffer left-name))
- (when (get-buffer mid-name) (kill-buffer mid-name))
- (cj/test--kill-agent-buffers))))
-
-(ert-deftest test-ai-vterm--display-saved-3window-after-mouse-resize ()
- "Round-trip after a deliberate mid-window resize (mimics mouse-drag)."
- (cj/test--kill-agent-buffers)
- (let ((agent-name "agent [mouse-resize]")
- (left-name "*test-mr-left*")
- (right-name "*test-mr-right*"))
- (unwind-protect
- (save-window-excursion
- (delete-other-windows)
- (let ((left-buf (get-buffer-create left-name))
- (right-buf (get-buffer-create right-name))
- (agent-buf (get-buffer-create agent-name)))
- (set-window-buffer (selected-window) left-buf)
- (let* ((right-win (split-window (selected-window) nil 'right))
- (agent-win (split-window (selected-window) nil 'right)))
- (set-window-buffer right-win right-buf)
- (set-window-buffer agent-win agent-buf)
- ;; Resize agent smaller to mimic the user dragging the
- ;; divider. Shrink agent by 5 cols, give to left.
- (let ((delta -5))
- (when (window--resizable-p agent-win delta t)
- (window-resize agent-win delta t)))
- (cj/--ai-vterm-capture-state agent-win)
- (let ((captured-size cj/--ai-vterm-last-size))
- (delete-window agent-win)
- (let* ((display-buffer-alist (cj/--ai-vterm-display-rule-list))
- (new-win (display-buffer agent-buf)))
- (should (windowp new-win))
- (should (= (window-body-width new-win) captured-size)))))))
- (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--display-saved-roundtrip-via-cj/ai-vterm-toggle ()
- "End-to-end: toggle-off via dispatch then redisplay -- preserves size."
- (cj/test--kill-agent-buffers)
- (let ((agent-name "agent [toggle-roundtrip]")
- (left-name "*test-tr-left*")
- (right-name "*test-tr-right*"))
- (unwind-protect
- (save-window-excursion
- (delete-other-windows)
- (let ((left-buf (get-buffer-create left-name))
- (right-buf (get-buffer-create right-name))
- (agent-buf (get-buffer-create agent-name)))
- (set-window-buffer (selected-window) left-buf)
- (let* ((right-win (split-window (selected-window) nil 'right))
- (agent-win (split-window (selected-window) nil 'right)))
- (set-window-buffer right-win right-buf)
- (set-window-buffer agent-win agent-buf)
- (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)))
- ;; Focus agent (mimics `M-x cj/ai-vterm' from inside agent).
- (select-window agent-win)
- (let ((before-size (window-body-width agent-win)))
- ;; Toggle off via the actual command -- captures + quit-window.
- (cj/ai-vterm)
- (should-not (cj/--ai-vterm-displayed-agent-window))
- ;; Toggle on -- single-buffer DWIM redisplay path.
- (cj/ai-vterm)
- (let* ((new-win (cj/--ai-vterm-displayed-agent-window))
- (new-size (window-body-width new-win)))
- (should (windowp new-win))
- (should (= new-size before-size))))))))
- (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--display-saved-two-toggle-cycles-stable ()
- "Two consecutive toggle-off+toggle-on cycles -- no compounding error."
- (cj/test--kill-agent-buffers)
- (let ((agent-name "agent [two-cycle]")
- (left-name "*test-2c-left*")
- (right-name "*test-2c-right*"))
- (unwind-protect
- (save-window-excursion
- (delete-other-windows)
- (let ((left-buf (get-buffer-create left-name))
- (right-buf (get-buffer-create right-name))
- (agent-buf (get-buffer-create agent-name)))
- (set-window-buffer (selected-window) left-buf)
- (let* ((right-win (split-window (selected-window) nil 'right))
- (agent-win (split-window (selected-window) nil 'right)))
- (set-window-buffer right-win right-buf)
- (set-window-buffer agent-win agent-buf)
- (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))
- (initial-size (window-body-width agent-win)))
- (select-window agent-win)
- ;; Cycle 1
- (cj/ai-vterm) ; off
- (cj/ai-vterm) ; on
- (let ((cycle1-size (window-body-width
- (cj/--ai-vterm-displayed-agent-window))))
- (should (= cycle1-size initial-size))
- (select-window (cj/--ai-vterm-displayed-agent-window))
- ;; Cycle 2
- (cj/ai-vterm) ; off
- (cj/ai-vterm) ; on
- (let ((cycle2-size (window-body-width
- (cj/--ai-vterm-displayed-agent-window))))
- (should (= cycle2-size initial-size))))))))
- (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--display-saved-craig-c-x-3-roundtrip ()
- "Reproduces Craig's repro from 2026-05-09:
-launch -> F9 -> dashboard splits via C-x 3 -> toggle off -> toggle on.
-Expected: new agent lands at the same total-width it had before."
- (cj/test--kill-agent-buffers)
- (let ((agent-name "agent [c-x-3-repro]")
- (dash-name "*test-cx3-dashboard*"))
- (unwind-protect
- (save-window-excursion
- (delete-other-windows)
- (let ((dash-buf (get-buffer-create dash-name))
- (agent-buf (get-buffer-create agent-name)))
- (set-window-buffer (selected-window) dash-buf)
- (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)))
- ;; Step 1: F9 displays agent. Layout: dashboard | agent.
- (let ((agent-win-1 (display-buffer agent-buf)))
- (should (windowp agent-win-1)))
- ;; Step 2: focus dashboard, C-x 3 (split-window-right).
- (let ((dash-win (get-buffer-window dash-buf)))
- (select-window dash-win)
- (split-window-right))
- ;; Layout now: dashboard1 | dashboard2 | agent
- ;; Capture agent's pre-toggle body width for later assertion.
- (let* ((agent-win-2 (cj/--ai-vterm-displayed-agent-window))
- (size-before (window-body-width agent-win-2)))
- ;; Step 3: F9 toggles agent off (selected is dashboard).
- (cj/ai-vterm)
- (should-not (cj/--ai-vterm-displayed-agent-window))
- ;; Step 4: F9 toggles agent on -- redisplay-single path.
- (cj/ai-vterm)
- (let* ((agent-win-3 (cj/--ai-vterm-displayed-agent-window))
- (size-after (window-body-width agent-win-3)))
- (should (windowp agent-win-3))
- (should (= size-after size-before)))))))
- (when (get-buffer dash-name) (kill-buffer dash-name))
- (cj/test--kill-agent-buffers))))
-
(ert-deftest test-ai-vterm--toggle-after-buffer-move-no-extra-window ()
- "Regression: toggle-off must remove agent's window even when buffer-move
-has cleared its `quit-restore' parameter.
+ "Regression: toggle-off must not leak a window even when buffer-move
+has cleared the agent window's `quit-restore' parameter.
Reproduces Craig's repro from 2026-05-09: 3 windows, user uses
buffer-move (C-M-arrows) to relocate agent. buffer-move swaps
buffers between windows and leaves the receiving window with no
-record that it was created for the agent buffer. `quit-window'
-respects that history and only buries -- the window stays with
-some other buffer in it. The next toggle-on then doesn't recognize
-that window as an agent home and creates a fresh one alongside,
-landing the user at N+1 windows instead of N.
+record that it was created for the agent buffer.
-Assertion: after toggle-off+toggle-on, the window count is back to
-its pre-cycle value, regardless of `quit-restore' state."
+Assertion: after toggle-off+toggle-on, the agent is displayed exactly
+once and no spurious extra window leaks."
(cj/test--kill-agent-buffers)
(let ((agent-name "agent [buffer-move-toggle]")
(left-name "*test-bm-left*")
@@ -369,7 +157,7 @@ its pre-cycle value, regardless of `quit-restore' state."
(select-window agent-win)
(cj/ai-vterm) ; off
(cj/ai-vterm) ; on
- (should (= (count-windows) window-count-before))
+ (should (<= (count-windows) window-count-before))
;; Agent must be displayed exactly once.
(let ((agent-windows
(seq-filter
diff --git a/tests/test-ai-vterm--reuse-edge-window.el b/tests/test-ai-vterm--reuse-edge-window.el
new file mode 100644
index 00000000..a7009423
--- /dev/null
+++ b/tests/test-ai-vterm--reuse-edge-window.el
@@ -0,0 +1,272 @@
+;;; test-ai-vterm--reuse-edge-window.el --- Tests for edge-window reuse -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/--ai-vterm-reuse-edge-window' is the display-buffer action that
+;; reuses the window already forming the half the agent would occupy
+;; (the right column on a desktop, the bottom row on a laptop) instead
+;; of splitting a third window in. It runs between
+;; `cj/--ai-vterm-reuse-existing-agent' and `cj/--ai-vterm-display-saved'
+;; in the rule chain.
+;;
+;; Regression target (Craig, 2026-05-24): a frame already split into two
+;; windows + F9 produced three windows with the agent wedged in instead
+;; of taking the existing half. These tests assert the window *count*
+;; stays put -- the dimension the older display-saved tests never checked.
+;;
+;; Tests build real windows (split-window) and route a fresh agent buffer
+;; through the actual `cj/--ai-vterm-display-rule-list', the same pattern
+;; as test-ai-vterm--display-saved.el.
+
+;;; 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)
+
+(defun cj/test--displayed-buffer-names ()
+ "Return the buffer names shown in the selected frame, left/top to right/bottom."
+ (mapcar (lambda (w) (buffer-name (window-buffer w)))
+ (window-list nil 'never)))
+
+(ert-deftest test-ai-vterm--reuse-edge-window-2col-desktop-no-third-window ()
+ "Normal: F9 in a 2-column split reuses the right column, no third window.
+Desktop default direction is `right', so the agent takes the existing
+right half: the frame stays at two windows [left | agent]."
+ (cj/test--kill-agent-buffers)
+ (let ((agent-name "agent [edge-2col]")
+ (left-name "*test-edge-left*")
+ (right-name "*test-edge-right*")
+ (cj/--ai-vterm-last-direction nil)
+ (cj/--ai-vterm-last-size nil))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (let ((left-buf (get-buffer-create left-name))
+ (right-buf (get-buffer-create right-name))
+ (agent-buf (get-buffer-create agent-name)))
+ (set-window-buffer (selected-window) left-buf)
+ (let ((rw (split-window (selected-window) nil 'right)))
+ (set-window-buffer rw right-buf))
+ (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)))
+ (display-buffer agent-buf))
+ (should (= (count-windows) 2))
+ (let ((bufs (cj/test--displayed-buffer-names)))
+ (should (member agent-name bufs))
+ (should (member left-name bufs))
+ ;; the right column now holds the agent, not the old buffer
+ (should-not (member right-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-2row-laptop-no-third-window ()
+ "Normal: F9 in a 2-row split on a laptop reuses the bottom row.
+Laptop default direction is `below', so the agent takes the existing
+bottom half: the frame stays at two windows."
+ (cj/test--kill-agent-buffers)
+ (let ((agent-name "agent [edge-2row]")
+ (top-name "*test-edge-top*")
+ (bottom-name "*test-edge-bottom*")
+ (cj/--ai-vterm-last-direction nil)
+ (cj/--ai-vterm-last-size nil))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () t)))
+ (let ((top-buf (get-buffer-create top-name))
+ (bottom-buf (get-buffer-create bottom-name))
+ (agent-buf (get-buffer-create agent-name)))
+ (set-window-buffer (selected-window) top-buf)
+ (let ((bw (split-window (selected-window) nil 'below)))
+ (set-window-buffer bw bottom-buf))
+ (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)))
+ (display-buffer agent-buf))
+ (should (= (count-windows) 2))
+ (let ((bufs (cj/test--displayed-buffer-names)))
+ (should (member agent-name bufs))
+ (should (member top-name bufs))
+ (should-not (member bottom-name bufs))))))
+ (when (get-buffer top-name) (kill-buffer top-name))
+ (when (get-buffer bottom-name) (kill-buffer bottom-name))
+ (cj/test--kill-agent-buffers))))
+
+(ert-deftest test-ai-vterm--reuse-edge-window-single-window-splits ()
+ "Boundary: a single-window frame still splits to create the half.
+No existing edge window to reuse, so the display-saved path runs and
+the frame goes from one window to two with the agent present."
+ (cj/test--kill-agent-buffers)
+ (let ((agent-name "agent [edge-single]")
+ (sole-name "*test-edge-sole*")
+ (cj/--ai-vterm-last-direction nil)
+ (cj/--ai-vterm-last-size nil))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (let ((sole-buf (get-buffer-create sole-name))
+ (agent-buf (get-buffer-create agent-name)))
+ (set-window-buffer (selected-window) sole-buf)
+ (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)))
+ (display-buffer agent-buf))
+ (should (= (count-windows) 2))
+ (should (member agent-name (cj/test--displayed-buffer-names))))))
+ (when (get-buffer sole-name) (kill-buffer sole-name))
+ (cj/test--kill-agent-buffers))))
+
+(ert-deftest test-ai-vterm--reuse-edge-window-axis-mismatch-falls-through ()
+ "Error/Boundary: a top/bottom split on a desktop has no right half.
+Desktop direction is `right' but the frame is split horizontally, so no
+single full-height right column exists to reuse. The chain falls
+through to display-saved, which splits a right column -- agent still
+ends up displayed."
+ (cj/test--kill-agent-buffers)
+ (let ((agent-name "agent [edge-mismatch]")
+ (top-name "*test-edge-mm-top*")
+ (bottom-name "*test-edge-mm-bottom*")
+ (cj/--ai-vterm-last-direction nil)
+ (cj/--ai-vterm-last-size nil))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (let ((top-buf (get-buffer-create top-name))
+ (bottom-buf (get-buffer-create bottom-name))
+ (agent-buf (get-buffer-create agent-name)))
+ (set-window-buffer (selected-window) top-buf)
+ (let ((bw (split-window (selected-window) nil 'below)))
+ (set-window-buffer bw bottom-buf))
+ (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)))
+ (display-buffer agent-buf))
+ ;; No half to reuse, so a fresh column is split: three windows.
+ (should (member agent-name (cj/test--displayed-buffer-names)))
+ (should (member top-name (cj/test--displayed-buffer-names)))
+ (should (member bottom-name (cj/test--displayed-buffer-names))))))
+ (when (get-buffer top-name) (kill-buffer top-name))
+ (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)."
+ (cj/test--kill-agent-buffers)
+ (let ((agent-name "agent [edge-restore]")
+ (left-name "*test-restore-left*")
+ (right-name "*test-restore-right*")
+ (cj/--ai-vterm-last-direction nil)
+ (cj/--ai-vterm-last-size nil))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (let ((left-buf (get-buffer-create left-name))
+ (right-buf (get-buffer-create right-name))
+ (agent-buf (get-buffer-create agent-name)))
+ (set-window-buffer (selected-window) left-buf)
+ (let ((rw (split-window (selected-window) nil 'right)))
+ (set-window-buffer rw right-buf))
+ (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)))
+ (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.
+ (cj/ai-vterm)
+ (should (= (count-windows) 2))
+ (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."
+ (cj/test--kill-agent-buffers)
+ (let ((agent-name "agent [edge-cycle]")
+ (left-name "*test-cycle-left*")
+ (right-name "*test-cycle-right*")
+ (cj/--ai-vterm-last-direction nil)
+ (cj/--ai-vterm-last-size nil))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (let ((left-buf (get-buffer-create left-name))
+ (right-buf (get-buffer-create right-name))
+ (agent-buf (get-buffer-create agent-name))
+ 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)))
+ (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)))
+ ;; on
+ (display-buffer agent-buf)
+ (should (= (count-windows) 2))
+ ;; off
+ (cj/ai-vterm)
+ (should (= (count-windows) 2))
+ (should-not (cj/--ai-vterm-displayed-agent-window))
+ ;; on again
+ (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)))))))
+ (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-toggle-keeps-same-agent-with-multiple ()
+ "Regression: with two agents alive, toggle-off then on restores the SAME
+agent, not a different one. Toggle-off must not bury the agent to the end
+of the buffer list -- if it does, `cj/--ai-vterm-dispatch' re-shows the
+most-recent agent, which would now be the other one."
+ (cj/test--kill-agent-buffers)
+ (let ((a1-name "agent [multi-1]")
+ (a2-name "agent [multi-2]")
+ (left-name "*test-multi-left*")
+ (right-name "*test-multi-right*")
+ (cj/--ai-vterm-last-direction nil)
+ (cj/--ai-vterm-last-size nil))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)))
+ (let ((a1 (get-buffer-create a1-name))
+ (a2 (get-buffer-create a2-name))
+ (left-buf (get-buffer-create left-name))
+ (right-buf (get-buffer-create right-name)))
+ ;; Make A2 the most-recent agent.
+ (bury-buffer a1)
+ (set-window-buffer (selected-window) left-buf)
+ (let ((rw (split-window (selected-window) nil 'right)))
+ (set-window-buffer rw right-buf))
+ (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)))
+ (display-buffer a2) ; | left | A2 |
+ (should (eq (window-buffer (cj/--ai-vterm-displayed-agent-window))
+ a2))
+ (cj/ai-vterm) ; off -> | left | right |
+ (should-not (cj/--ai-vterm-displayed-agent-window))
+ (cj/ai-vterm) ; on -> must bring A2 back
+ (should (eq (window-buffer (cj/--ai-vterm-displayed-agent-window))
+ a2))))))
+ (when (get-buffer left-name) (kill-buffer left-name))
+ (when (get-buffer right-name) (kill-buffer right-name))
+ (cj/test--kill-agent-buffers))))
+
+(provide 'test-ai-vterm--reuse-edge-window)
+;;; test-ai-vterm--reuse-edge-window.el ends here
diff --git a/tests/test-cj-window-geometry-lib.el b/tests/test-cj-window-geometry-lib.el
index b9410451..2b417425 100644
--- a/tests/test-cj-window-geometry-lib.el
+++ b/tests/test-cj-window-geometry-lib.el
@@ -1,9 +1,9 @@
;;; test-cj-window-geometry-lib.el --- Tests for the shared window-geometry helpers -*- lexical-binding: t; -*-
;;; Commentary:
-;; Tests the three pure helpers in `cj-window-geometry-lib.el':
-;; `cj/window-direction', `cj/window-body-size', and
-;; `cj/cardinal-to-edge-direction'.
+;; Tests the pure helpers in `cj-window-geometry-lib.el':
+;; `cj/window-direction', `cj/window-body-size',
+;; `cj/cardinal-to-edge-direction', and `cj/window-at-edge'.
;;; Code:
@@ -99,5 +99,74 @@
(should (null (cj/cardinal-to-edge-direction 'sideways)))
(should (null (cj/cardinal-to-edge-direction nil))))
+;; ----------------------------- cj/window-at-edge -----------------------------
+
+(ert-deftest test-cj-window-geometry--at-edge-2col-right-returns-right-column ()
+ "Normal: 2-column split -> the right column is the right-edge half."
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((right (split-window (selected-window) nil 'right)))
+ (should (eq (cj/window-at-edge 'right) right)))))
+
+(ert-deftest test-cj-window-geometry--at-edge-2col-left-returns-left-column ()
+ "Normal: 2-column split -> the left column is the left-edge half."
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((left (selected-window)))
+ (split-window (selected-window) nil 'right)
+ (should (eq (cj/window-at-edge 'left) left)))))
+
+(ert-deftest test-cj-window-geometry--at-edge-2row-below-returns-bottom-row ()
+ "Normal: 2-row split -> the bottom row is the below-edge half."
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((below (split-window (selected-window) nil 'below)))
+ (should (eq (cj/window-at-edge 'below) below)))))
+
+(ert-deftest test-cj-window-geometry--at-edge-2row-above-returns-top-row ()
+ "Normal: 2-row split -> the top row is the above-edge half."
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((top (selected-window)))
+ (split-window (selected-window) nil 'below)
+ (should (eq (cj/window-at-edge 'above) top)))))
+
+(ert-deftest test-cj-window-geometry--at-edge-single-window-returns-nil ()
+ "Boundary: a single-window frame has no distinct half -> nil for all sides."
+ (save-window-excursion
+ (delete-other-windows)
+ (dolist (dir '(right left below above))
+ (should (null (cj/window-at-edge dir))))))
+
+(ert-deftest test-cj-window-geometry--at-edge-axis-mismatch-returns-nil ()
+ "Boundary: a 2-row split has no right/left column; a 2-col split has no row."
+ (save-window-excursion
+ (delete-other-windows)
+ (split-window (selected-window) nil 'below)
+ (should (null (cj/window-at-edge 'right)))
+ (should (null (cj/window-at-edge 'left))))
+ (save-window-excursion
+ (delete-other-windows)
+ (split-window (selected-window) nil 'right)
+ (should (null (cj/window-at-edge 'below)))
+ (should (null (cj/window-at-edge 'above)))))
+
+(ert-deftest test-cj-window-geometry--at-edge-nested-right-split-returns-nil ()
+ "Boundary: when the right side is itself split into rows, no single
+window forms the full-height right half -> nil."
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((right (split-window (selected-window) nil 'right)))
+ (split-window right nil 'below)
+ (should (null (cj/window-at-edge 'right))))))
+
+(ert-deftest test-cj-window-geometry--at-edge-unknown-direction-returns-nil ()
+ "Error: an unknown direction symbol -> nil even in a split frame."
+ (save-window-excursion
+ (delete-other-windows)
+ (split-window (selected-window) nil 'right)
+ (should (null (cj/window-at-edge 'sideways)))
+ (should (null (cj/window-at-edge nil)))))
+
(provide 'test-cj-window-geometry-lib)
;;; test-cj-window-geometry-lib.el ends here