summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/ai-vterm.el105
-rw-r--r--tests/test-ai-vterm--capture-state.el13
-rw-r--r--tests/test-ai-vterm--display-saved.el289
-rw-r--r--tests/test-ai-vterm--window-geometry.el27
4 files changed, 383 insertions, 51 deletions
diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el
index cf375955..670aa43a 100644
--- a/modules/ai-vterm.el
+++ b/modules/ai-vterm.el
@@ -204,12 +204,29 @@ applies. Captured at toggle-off by `cj/--ai-vterm-capture-state'
and consumed by `cj/--ai-vterm-display-saved'.")
(defvar cj/--ai-vterm-last-size nil
- "Last user-chosen size fraction for the AI-vterm display.
-
-Float between 0 and 1, expressed on the axis matching
-`cj/--ai-vterm-last-direction' (width fraction for right/left,
-height fraction for below/above). nil means use the customizable
-default `cj/ai-vterm-window-width'.")
+ "Last user-chosen body size for the AI-vterm display.
+
+Positive integer: body-columns when `cj/--ai-vterm-last-direction'
+is right or left, body-lines when below or above. nil means use
+the customizable default `cj/ai-vterm-window-width' (a float
+fraction).
+
+Body size, not total size, because total-width includes the
+right-edge divider when the window has a right sibling but excludes
+it when the window is at the frame edge. Capturing total-width
+from a rightmost claude (no divider) and replaying into a middle
+position (with divider) leaves the body 1 column short -- visible
+as 1 col of the sibling buffer peeking through where claude should
+have ended. Body-width is divider-independent and matches what the
+user actually sees.
+
+Absolute values rather than fractions because
+`display-buffer-in-direction' interprets a float `window-width' /
+`window-height' as a fraction of the new window's parent in the
+window tree. In a 3+ window layout the parent may be a sub-tree,
+and a fraction-of-frame produces the wrong size on replay
+(squeezes the other windows). An integer is unambiguous, at the
+cost of not auto-scaling if the frame itself resizes.")
(defun cj/--ai-vterm-window-direction (window)
"Return the side WINDOW occupies in its frame.
@@ -239,20 +256,16 @@ fails to span the full height."
((not spans-full-height) (if (= top root-top) 'above 'below))
(t 'right))))
-(defun cj/--ai-vterm-window-fraction (window direction)
- "Return WINDOW's size as a fraction of the frame's root on DIRECTION's axis.
-
-For right/left, returns WINDOW's total-width / root's total-width.
-For below/above, total-height / root's total-height. The root
-window excludes the minibuffer so the fraction matches what
-`display-buffer-in-direction' will use as window-width or
-window-height when re-creating the split."
- (let ((root (frame-root-window (window-frame window))))
- (if (memq direction '(right left))
- (/ (float (window-total-width window))
- (window-total-width root))
- (/ (float (window-total-height window))
- (window-total-height root)))))
+(defun cj/--ai-vterm-window-size (window direction)
+ "Return WINDOW's body size in cols (right/left) or lines (below/above).
+
+Returns body width or body height -- the count of characters
+visible in the text content area, independent of fringes,
+scrollbars, or window dividers. See `cj/--ai-vterm-last-size' for
+why body size, not total size, is the right thing to capture."
+ (if (memq direction '(right left))
+ (window-body-width window)
+ (window-body-height window)))
(defun cj/--ai-vterm-capture-state (window)
"Capture WINDOW's direction and size into module-level state.
@@ -265,9 +278,9 @@ window down).
Does nothing when WINDOW is not live."
(when (window-live-p window)
(let* ((dir (cj/--ai-vterm-window-direction window))
- (frac (cj/--ai-vterm-window-fraction window dir)))
+ (size (cj/--ai-vterm-window-size window dir)))
(setq cj/--ai-vterm-last-direction dir
- cj/--ai-vterm-last-size frac))))
+ cj/--ai-vterm-last-size size))))
(defun cj/--ai-vterm-reuse-existing-claude (buffer _alist)
"Display-buffer action: reuse any window in this frame already showing
@@ -297,23 +310,53 @@ Reads `cj/--ai-vterm-last-direction' and `cj/--ai-vterm-last-size'
delegates to `display-buffer-in-direction' with an alist that carries
the saved values.
+The captured cardinal direction (right/left/below/above) is mapped
+to its frame-edge variant (rightmost/leftmost/bottom/top) so the new
+claude always lands at the same frame edge it came from. This
+means: the new window splits the frame's main window at the
+matching edge, not whatever window happens to be selected when F9
+fires. Without this mapping, a toggle-off-on cycle in a 3+ window
+layout would put claude into a middle position (right of the
+selected window) rather than the edge it lived on before. As a
+side benefit, claude always lands without a sibling on its
+captured-edge side, so its body-width and total-width match -- no
+divider chrome eating 1 col per toggle.
+
+An integer size (the captured absolute body-cols or body-lines) is
+wrapped in a `(body-columns . N)' / `(body-lines . N)' cons so
+`display-buffer-in-direction' sets the body width or body height
+exactly. A float size (the customizable default fallback) passes
+through as a fraction of the new window's parent.
+
Any direction/window-width/window-height entries in ALIST are
stripped so the saved-state values control placement -- callers
shouldn't specify direction or size in the rule when this action is
used."
(let* ((direction (or cj/--ai-vterm-last-direction 'right))
+ (edge-direction (pcase direction
+ ('right 'rightmost)
+ ('left 'leftmost)
+ ('below 'bottom)
+ ('above 'top)
+ (_ 'rightmost)))
(size (or cj/--ai-vterm-last-size cj/ai-vterm-window-width))
(size-key (if (memq direction '(right left))
'window-width
'window-height))
+ (body-tag (if (memq direction '(right left))
+ 'body-columns
+ 'body-lines))
+ (size-value (if (integerp size)
+ (cons body-tag size)
+ size))
(filtered (cl-remove-if
(lambda (cell)
(memq (car-safe cell)
'(direction window-width window-height)))
alist))
(effective (append
- (list (cons 'direction direction)
- (cons size-key size))
+ (list (cons 'direction edge-direction)
+ (cons size-key size-value))
filtered)))
(display-buffer-in-direction buffer effective)))
@@ -530,7 +573,19 @@ AI-vterm buffers without touching the project list."
(pcase (cj/--ai-vterm-dispatch)
(`(toggle-off . ,win)
(cj/--ai-vterm-capture-state win)
- (quit-window nil 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
+ ;; claude 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 claude is the lone window in the frame (delete
+ ;; would leave none); bury in that case.
+ (if (one-window-p)
+ (bury-buffer (window-buffer win))
+ (delete-window win))
nil)
(`(redisplay-single . ,buf)
(display-buffer buf)
diff --git a/tests/test-ai-vterm--capture-state.el b/tests/test-ai-vterm--capture-state.el
index cecb3ab8..88a7784b 100644
--- a/tests/test-ai-vterm--capture-state.el
+++ b/tests/test-ai-vterm--capture-state.el
@@ -15,7 +15,7 @@
(require 'ai-vterm)
(ert-deftest test-ai-vterm--capture-state-right-split-sets-direction ()
- "Normal: right-split window -> direction=right, size in (0.4, 0.6)."
+ "Normal: right-split window -> direction=right, integer body-cols matching window."
(save-window-excursion
(delete-other-windows)
(let ((right (split-window (selected-window) nil 'right))
@@ -23,12 +23,11 @@
(cj/--ai-vterm-last-size nil))
(cj/--ai-vterm-capture-state right)
(should (eq cj/--ai-vterm-last-direction 'right))
- (should (numberp cj/--ai-vterm-last-size))
- (should (and (> cj/--ai-vterm-last-size 0.4)
- (< cj/--ai-vterm-last-size 0.6))))))
+ (should (integerp cj/--ai-vterm-last-size))
+ (should (= cj/--ai-vterm-last-size (window-body-width right))))))
(ert-deftest test-ai-vterm--capture-state-below-split-sets-direction ()
- "Normal: below-split window -> direction=below, size in (0.4, 0.6)."
+ "Normal: below-split window -> direction=below, integer body-lines matching window."
(save-window-excursion
(delete-other-windows)
(let ((below (split-window (selected-window) nil 'below))
@@ -36,8 +35,8 @@
(cj/--ai-vterm-last-size nil))
(cj/--ai-vterm-capture-state below)
(should (eq cj/--ai-vterm-last-direction 'below))
- (should (and (> cj/--ai-vterm-last-size 0.4)
- (< cj/--ai-vterm-last-size 0.6))))))
+ (should (integerp cj/--ai-vterm-last-size))
+ (should (= cj/--ai-vterm-last-size (window-body-height below))))))
(ert-deftest test-ai-vterm--capture-state-noop-on-dead-window ()
"Boundary: nil window -> state remains unchanged."
diff --git a/tests/test-ai-vterm--display-saved.el b/tests/test-ai-vterm--display-saved.el
index 9cb3521c..aa0dc5ee 100644
--- a/tests/test-ai-vterm--display-saved.el
+++ b/tests/test-ai-vterm--display-saved.el
@@ -19,7 +19,10 @@
(require 'ai-vterm)
(ert-deftest test-ai-vterm--display-saved-uses-defaults-when-state-nil ()
- "Normal: nil state -> direction=right, size=cj/ai-vterm-window-width."
+ "Normal: nil state -> direction=rightmost, size=cj/ai-vterm-window-width.
+The cardinal `right' default maps to the frame-edge variant
+`rightmost' so claude lands at the frame's right edge regardless of
+which window is selected."
(let (received-buf received-alist
(cj/--ai-vterm-last-direction nil)
(cj/--ai-vterm-last-size nil)
@@ -30,31 +33,31 @@
'fake-window)))
(cj/--ai-vterm-display-saved 'fake-buf '((inhibit-same-window . t))))
(should (eq received-buf 'fake-buf))
- (should (eq (cdr (assq 'direction received-alist)) 'right))
+ (should (eq (cdr (assq 'direction received-alist)) 'rightmost))
(should (= (cdr (assq 'window-width received-alist)) 0.5))
(should (eq (cdr (assq 'inhibit-same-window received-alist)) t))))
(ert-deftest test-ai-vterm--display-saved-uses-saved-direction-and-size-below ()
- "Normal: saved direction=below, size=0.4 -> below + window-height 0.4."
+ "Normal: saved direction=below maps to bottom edge; size=0.4 passes through."
(let (received-alist
(cj/--ai-vterm-last-direction 'below)
(cj/--ai-vterm-last-size 0.4))
(cl-letf (((symbol-function 'display-buffer-in-direction)
(lambda (_b a) (setq received-alist a) 'fake-window)))
(cj/--ai-vterm-display-saved 'fake-buf nil))
- (should (eq (cdr (assq 'direction received-alist)) 'below))
+ (should (eq (cdr (assq 'direction received-alist)) 'bottom))
(should (= (cdr (assq 'window-height received-alist)) 0.4))
(should-not (assq 'window-width received-alist))))
(ert-deftest test-ai-vterm--display-saved-uses-saved-direction-and-size-right ()
- "Normal: saved direction=right, size=0.7 -> right + window-width 0.7."
+ "Normal: saved direction=right maps to rightmost edge; size=0.7 passes through."
(let (received-alist
(cj/--ai-vterm-last-direction 'right)
(cj/--ai-vterm-last-size 0.7))
(cl-letf (((symbol-function 'display-buffer-in-direction)
(lambda (_b a) (setq received-alist a) 'fake-window)))
(cj/--ai-vterm-display-saved 'fake-buf nil))
- (should (eq (cdr (assq 'direction received-alist)) 'right))
+ (should (eq (cdr (assq 'direction received-alist)) 'rightmost))
(should (= (cdr (assq 'window-width received-alist)) 0.7))
(should-not (assq 'window-height received-alist))))
@@ -71,7 +74,7 @@
(window-width . 0.2)
(window-height . 0.3)
(inhibit-same-window . t))))
- (should (eq (cdr (assq 'direction received-alist)) 'right))
+ (should (eq (cdr (assq 'direction received-alist)) 'rightmost))
(should (= (cdr (assq 'window-width received-alist)) 0.7))
(should (eq (cdr (assq 'inhibit-same-window received-alist)) t))
;; window-height should not be in the alist when direction is right
@@ -91,5 +94,277 @@
(cj/--ai-vterm-display-saved 'sentinel-buffer nil))
(should (eq received-buf 'sentinel-buffer))))
+(defun test-ai-vterm--display-saved-cleanup ()
+ "Kill any leftover claude-prefixed buffers."
+ (dolist (b (buffer-list))
+ (when (string-prefix-p "claude [" (buffer-name b))
+ (kill-buffer b))))
+
+(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 claude 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."
+ (test-ai-vterm--display-saved-cleanup)
+ (let ((claude-name "claude [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))
+ (claude-buf (get-buffer-create claude-name)))
+ ;; Build: left | claude | 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))
+ (claude-win (split-window (selected-window) nil 'right)))
+ (set-window-buffer claude-win claude-buf)
+ ;; Capture claude's state.
+ (cj/--ai-vterm-capture-state claude-win)
+ (let ((captured-size cj/--ai-vterm-last-size)
+ (captured-direction cj/--ai-vterm-last-direction))
+ ;; Simulate quit-window on claude.
+ (delete-window claude-win)
+ ;; Now route a fresh display through the actual rule.
+ (let* ((display-buffer-alist (cj/--ai-vterm-display-rule-list))
+ (new-win (display-buffer claude-buf)))
+ (should (windowp new-win))
+ (should (eq (window-buffer new-win) claude-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))
+ (test-ai-vterm--display-saved-cleanup))))
+
+(ert-deftest test-ai-vterm--display-saved-3window-claude-rightmost-roundtrip ()
+ "Round-trip when claude is the rightmost window (no right divider)."
+ (test-ai-vterm--display-saved-cleanup)
+ (let ((claude-name "claude [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))
+ (claude-buf (get-buffer-create claude-name)))
+ ;; Build: left | mid | claude (claude rightmost)
+ (set-window-buffer (selected-window) left-buf)
+ (let* ((mid-win (split-window (selected-window) nil 'right))
+ (claude-win (split-window mid-win nil 'right)))
+ (set-window-buffer mid-win mid-buf)
+ (set-window-buffer claude-win claude-buf)
+ (cj/--ai-vterm-capture-state claude-win)
+ (let ((captured-size cj/--ai-vterm-last-size))
+ (delete-window claude-win)
+ (let* ((display-buffer-alist (cj/--ai-vterm-display-rule-list))
+ (new-win (display-buffer claude-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))
+ (test-ai-vterm--display-saved-cleanup))))
+
+(ert-deftest test-ai-vterm--display-saved-3window-after-mouse-resize ()
+ "Round-trip after a deliberate mid-window resize (mimics mouse-drag)."
+ (test-ai-vterm--display-saved-cleanup)
+ (let ((claude-name "claude [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))
+ (claude-buf (get-buffer-create claude-name)))
+ (set-window-buffer (selected-window) left-buf)
+ (let* ((right-win (split-window (selected-window) nil 'right))
+ (claude-win (split-window (selected-window) nil 'right)))
+ (set-window-buffer right-win right-buf)
+ (set-window-buffer claude-win claude-buf)
+ ;; Resize claude smaller to mimic the user dragging the
+ ;; divider. Shrink claude by 5 cols, give to left.
+ (let ((delta -5))
+ (when (window--resizable-p claude-win delta t)
+ (window-resize claude-win delta t)))
+ (cj/--ai-vterm-capture-state claude-win)
+ (let ((captured-size cj/--ai-vterm-last-size))
+ (delete-window claude-win)
+ (let* ((display-buffer-alist (cj/--ai-vterm-display-rule-list))
+ (new-win (display-buffer claude-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))
+ (test-ai-vterm--display-saved-cleanup))))
+
+(ert-deftest test-ai-vterm--display-saved-roundtrip-via-cj/ai-vterm-toggle ()
+ "End-to-end: toggle-off via dispatch then redisplay -- preserves size."
+ (test-ai-vterm--display-saved-cleanup)
+ (let ((claude-name "claude [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))
+ (claude-buf (get-buffer-create claude-name)))
+ (set-window-buffer (selected-window) left-buf)
+ (let* ((right-win (split-window (selected-window) nil 'right))
+ (claude-win (split-window (selected-window) nil 'right)))
+ (set-window-buffer right-win right-buf)
+ (set-window-buffer claude-win claude-buf)
+ (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)))
+ ;; Focus claude (mimics `M-x cj/ai-vterm' from inside claude).
+ (select-window claude-win)
+ (let ((before-size (window-body-width claude-win)))
+ ;; Toggle off via the actual command -- captures + quit-window.
+ (cj/ai-vterm)
+ (should-not (cj/--ai-vterm-displayed-claude-window))
+ ;; Toggle on -- single-buffer DWIM redisplay path.
+ (cj/ai-vterm)
+ (let* ((new-win (cj/--ai-vterm-displayed-claude-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))
+ (test-ai-vterm--display-saved-cleanup))))
+
+(ert-deftest test-ai-vterm--display-saved-two-toggle-cycles-stable ()
+ "Two consecutive toggle-off+toggle-on cycles -- no compounding error."
+ (test-ai-vterm--display-saved-cleanup)
+ (let ((claude-name "claude [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))
+ (claude-buf (get-buffer-create claude-name)))
+ (set-window-buffer (selected-window) left-buf)
+ (let* ((right-win (split-window (selected-window) nil 'right))
+ (claude-win (split-window (selected-window) nil 'right)))
+ (set-window-buffer right-win right-buf)
+ (set-window-buffer claude-win claude-buf)
+ (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))
+ (initial-size (window-body-width claude-win)))
+ (select-window claude-win)
+ ;; Cycle 1
+ (cj/ai-vterm) ; off
+ (cj/ai-vterm) ; on
+ (let ((cycle1-size (window-body-width
+ (cj/--ai-vterm-displayed-claude-window))))
+ (should (= cycle1-size initial-size))
+ (select-window (cj/--ai-vterm-displayed-claude-window))
+ ;; Cycle 2
+ (cj/ai-vterm) ; off
+ (cj/ai-vterm) ; on
+ (let ((cycle2-size (window-body-width
+ (cj/--ai-vterm-displayed-claude-window))))
+ (should (= cycle2-size initial-size))))))))
+ (when (get-buffer left-name) (kill-buffer left-name))
+ (when (get-buffer right-name) (kill-buffer right-name))
+ (test-ai-vterm--display-saved-cleanup))))
+
+(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 claude lands at the same total-width it had before."
+ (test-ai-vterm--display-saved-cleanup)
+ (let ((claude-name "claude [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))
+ (claude-buf (get-buffer-create claude-name)))
+ (set-window-buffer (selected-window) dash-buf)
+ (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)))
+ ;; Step 1: F9 displays claude. Layout: dashboard | claude.
+ (let ((claude-win-1 (display-buffer claude-buf)))
+ (should (windowp claude-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 | claude
+ ;; Capture claude's pre-toggle body width for later assertion.
+ (let* ((claude-win-2 (cj/--ai-vterm-displayed-claude-window))
+ (size-before (window-body-width claude-win-2)))
+ ;; Step 3: F9 toggles claude off (selected is dashboard).
+ (cj/ai-vterm)
+ (should-not (cj/--ai-vterm-displayed-claude-window))
+ ;; Step 4: F9 toggles claude on -- redisplay-single path.
+ (cj/ai-vterm)
+ (let* ((claude-win-3 (cj/--ai-vterm-displayed-claude-window))
+ (size-after (window-body-width claude-win-3)))
+ (should (windowp claude-win-3))
+ (should (= size-after size-before)))))))
+ (when (get-buffer dash-name) (kill-buffer dash-name))
+ (test-ai-vterm--display-saved-cleanup))))
+
+(ert-deftest test-ai-vterm--toggle-after-buffer-move-no-extra-window ()
+ "Regression: toggle-off must remove claude's window even when buffer-move
+has cleared its `quit-restore' parameter.
+
+Reproduces Craig's repro from 2026-05-09: 3 windows, user uses
+buffer-move (C-M-arrows) to relocate claude. buffer-move swaps
+buffers between windows and leaves the receiving window with no
+record that it was created for the claude 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 a claude home and creates a fresh one alongside,
+landing the user at N+1 windows instead of N.
+
+Assertion: after toggle-off+toggle-on, the window count is back to
+its pre-cycle value, regardless of `quit-restore' state."
+ (test-ai-vterm--display-saved-cleanup)
+ (let ((claude-name "claude [buffer-move-toggle]")
+ (left-name "*test-bm-left*")
+ (right-name "*test-bm-right*"))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((left-buf (get-buffer-create left-name))
+ (right-buf (get-buffer-create right-name))
+ (claude-buf (get-buffer-create claude-name)))
+ (set-window-buffer (selected-window) left-buf)
+ (let* ((right-win (split-window (selected-window) nil 'right))
+ (claude-win (split-window (selected-window) nil 'right)))
+ (set-window-buffer right-win right-buf)
+ (set-window-buffer claude-win claude-buf)
+ ;; Mimic buffer-move's effect: claude lives in this
+ ;; window but quit-restore says nothing about it.
+ (set-window-parameter claude-win 'quit-restore nil)
+ (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))
+ (window-count-before (count-windows)))
+ (select-window claude-win)
+ (cj/ai-vterm) ; off
+ (cj/ai-vterm) ; on
+ (should (= (count-windows) window-count-before))
+ ;; Claude must be displayed exactly once.
+ (let ((claude-windows
+ (seq-filter
+ (lambda (w)
+ (eq (window-buffer w) claude-buf))
+ (window-list))))
+ (should (= (length claude-windows) 1)))))))
+ (when (get-buffer left-name) (kill-buffer left-name))
+ (when (get-buffer right-name) (kill-buffer right-name))
+ (test-ai-vterm--display-saved-cleanup))))
+
(provide 'test-ai-vterm--display-saved)
;;; test-ai-vterm--display-saved.el ends here
diff --git a/tests/test-ai-vterm--window-geometry.el b/tests/test-ai-vterm--window-geometry.el
index 62b78baf..c2200273 100644
--- a/tests/test-ai-vterm--window-geometry.el
+++ b/tests/test-ai-vterm--window-geometry.el
@@ -55,31 +55,34 @@
(delete-other-windows)
(should (eq (cj/--ai-vterm-window-direction (selected-window)) 'right))))
-(ert-deftest test-ai-vterm--window-fraction-right-split-half ()
- "Normal: right window of equal vertical split -> ~0.5 width fraction."
+(ert-deftest test-ai-vterm--window-size-right-split-returns-body-cols ()
+ "Normal: right window -> integer body-cols matching window-body-width."
(save-window-excursion
(delete-other-windows)
(let* ((right (split-window (selected-window) nil 'right))
- (frac (cj/--ai-vterm-window-fraction right 'right)))
- (should (and (> frac 0.4) (< frac 0.6))))))
+ (size (cj/--ai-vterm-window-size right 'right)))
+ (should (integerp size))
+ (should (= size (window-body-width right))))))
-(ert-deftest test-ai-vterm--window-fraction-below-split-half ()
- "Normal: bottom window of equal horizontal split -> ~0.5 height fraction."
+(ert-deftest test-ai-vterm--window-size-below-split-returns-body-lines ()
+ "Normal: bottom window -> integer body-lines matching window-body-height."
(save-window-excursion
(delete-other-windows)
(let* ((below (split-window (selected-window) nil 'below))
- (frac (cj/--ai-vterm-window-fraction below 'below)))
- (should (and (> frac 0.4) (< frac 0.6))))))
+ (size (cj/--ai-vterm-window-size below 'below)))
+ (should (integerp size))
+ (should (= size (window-body-height below))))))
-(ert-deftest test-ai-vterm--window-fraction-narrow-right-split ()
- "Normal: right window at 1/4 width -> fraction within that range."
+(ert-deftest test-ai-vterm--window-size-narrow-right-split ()
+ "Normal: deliberately narrow right window -> matching body-col count."
(save-window-excursion
(delete-other-windows)
(let* ((frame-w (frame-width))
(target-cols (/ frame-w 4))
(right (split-window (selected-window) (- target-cols) 'right))
- (frac (cj/--ai-vterm-window-fraction right 'right)))
- (should (and (> frac 0.15) (< frac 0.35))))))
+ (size (cj/--ai-vterm-window-size right 'right)))
+ (should (integerp size))
+ (should (= size (window-body-width right))))))
(provide 'test-ai-vterm--window-geometry)
;;; test-ai-vterm--window-geometry.el ends here