aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-08 19:21:26 -0500
committerCraig Jennings <c@cjennings.net>2026-05-08 19:21:26 -0500
commiteab070e5b542f525340ee7f07ea0560944639721 (patch)
tree09a0ce76e38821ecfa2ed8bfcdf50057096fe794
parent1d93e1a6569e4193c2b078a3d5df0bf47eeba9df (diff)
downloaddotemacs-eab070e5b542f525340ee7f07ea0560944639721.tar.gz
dotemacs-eab070e5b542f525340ee7f07ea0560944639721.zip
feat(ai-vterm): F9 toggle/redisplay/pick + persistent split geometry
F9 was a single command that always opened the project picker. Three small frustrations stacked up. With one claude buffer open and not visible, F9 was a redundant prompt to pick a project that already had a session. With claude visible, there was no way to bury it without M-x quit-window. With two projects' buffers alive, swapping between them was a buffer-switch chore. F9 is now a dispatch: - Claude visible in this frame: quit the window (toggle off) and capture the geometry first. - Exactly one claude buffer alive but hidden: re-display it (DWIM single-buffer case). - Zero or two-plus alive: fall through to the project picker. C-F9 is the always-pick-project entry point for explicit project switches. M-F9 is a buffer picker over the alive claude buffers. If a claude window is currently shown, the picked buffer replaces it in that window so the split orientation and size carry over. The shown buffer sorts last in the picker with a [shown] marker so RET picks "the other one." Split geometry persists across toggles. Two module-level vars (cj/--ai-vterm-last-direction, cj/--ai-vterm-last-size) capture at toggle-off and feed a custom display action. After M-S-t flips claude from right to bottom, F9 toggle-off-then-on returns it at the bottom. After a mouse resize, the next toggle restores that fraction. State is per-session. Restarts reset to default right/0.5. Two display-buffer fixes came out of testing: - save-window-excursion around (vterm name) keeps the dashboard from being buried on a fresh F9 at startup. vterm calls pop-to-buffer-same-window internally, which would otherwise replace the selected window's buffer before the alist could route the new one. - The action chain swaps display-buffer-use-some-window for a more specific cj/--ai-vterm-reuse-existing-claude. The generic version stole non-claude windows on C-F9 when the user was focused inside claude (claude on bottom, code on top -> new project landed in the code window). The specific version only reuses windows that already show a claude buffer. I reclaimed C-F9 from the gptel toggle in ai-config.el. C-; a t still binds gptel. I added eight new test files (claude-buffers, displayed-claude-window, dispatch, pick-buffer-candidates, window-geometry, capture-state, display-saved, reuse-existing-claude) plus a regression test on cj/--ai-vterm-show-or-create for the dashboard-preservation fix. All 73 ai-vterm tests pass and the full make test suite is green.
-rw-r--r--modules/ai-config.el9
-rw-r--r--modules/ai-vterm.el360
-rw-r--r--tests/test-ai-vterm--capture-state.el64
-rw-r--r--tests/test-ai-vterm--claude-buffers.el63
-rw-r--r--tests/test-ai-vterm--dispatch.el70
-rw-r--r--tests/test-ai-vterm--display-saved.el95
-rw-r--r--tests/test-ai-vterm--displayed-claude-window.el64
-rw-r--r--tests/test-ai-vterm--pick-buffer-candidates.el84
-rw-r--r--tests/test-ai-vterm--reuse-existing-claude.el103
-rw-r--r--tests/test-ai-vterm--show-or-create.el39
-rw-r--r--tests/test-ai-vterm--window-geometry.el85
11 files changed, 1005 insertions, 31 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el
index 779862bc..3b0b6e20 100644
--- a/modules/ai-config.el
+++ b/modules/ai-config.el
@@ -5,7 +5,7 @@
;; Configuration for AI integrations in Emacs, focused on GPTel.
;;
;; Main Features:
-;; - Quick toggle for AI assistant window (F9 or C-; a t)
+;; - Quick toggle for AI assistant window (C-; a t)
;; - Custom keymap (C-; a prefix) for AI-related commands.
;; - Enhanced org-mode conversation formatting with timestamps
;; allows switching models and easily compare and track responses.
@@ -17,7 +17,7 @@
;; Basic Workflow
;;
;; Using a side-chat window:
-;; - Launch GPTel via F9 or C-; a t, and chat in the AI-Assistant side window (C-<return> to send)
+;; - Launch GPTel via C-; a t, and chat in the AI-Assistant side window (C-<return> to send)
;; - Change system prompt (expertise, personalities) with C-; a p
;; - Add context from files (C-; a f) or current buffer (C-; a .)
;; - Save conversations with C-; a s, load previous ones with C-; a l
@@ -312,9 +312,8 @@ Works for any buffer, whether it's visiting a file or not."
:defer t
:commands (gptel gptel-send gptel-menu)
:bind
- (("C-<f9>" . cj/toggle-gptel)
- :map gptel-mode-map
- ("C-<return>" . gptel-send))
+ (:map gptel-mode-map
+ ("C-<return>" . gptel-send))
:custom
(gptel-default-mode 'org-mode)
(gptel-expert-commands t)
diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el
index 4d83127a..cf375955 100644
--- a/modules/ai-vterm.el
+++ b/modules/ai-vterm.el
@@ -12,12 +12,31 @@
;; buffers that share the same right-side slot; switching among them is a
;; buffer-switch, not a kill-and-recreate.
;;
+;; Three F-key entry points:
+;;
+;; - F9 `cj/ai-vterm' -- DWIM dispatch. If a claude buffer is
+;; currently displayed in this frame, F9 quits its window
+;; (toggle off). Otherwise, if exactly one claude 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 a claude buffer is currently displayed.
+;; Used when the user wants to start a new project session
+;; instead of toggling the current one.
+;; - M-F9 `cj/ai-vterm-pick-buffer' -- pick from the alive claude
+;; buffers (no project candidates, no creation). When a claude
+;; buffer is currently displayed, the picked buffer replaces it
+;; in that window so orientation and size are preserved.
+;;
;; Existing windmove (Shift-arrows) handles code <-> Claude focus
;; toggling. Buffer-move (C-M-arrows) handles side-swap. Neither
;; needs anything new from this module.
;;; Code:
+(require 'cl-lib)
+(require 'seq)
+
(declare-function vterm "vterm" (&optional buffer-name))
(declare-function vterm-send-string "vterm" (string &optional paste-p))
(declare-function vterm-send-return "vterm" ())
@@ -58,15 +77,52 @@ contain .ai/protocols.org. Use this for container dirs like ~/code."
:type '(repeat directory)
:group 'ai-vterm)
+(defconst cj/--ai-vterm-name-prefix "claude ["
+ "Buffer-name prefix shared by all AI-vterm buffers.
+
+Single source of truth for both buffer construction in
+`cj/--ai-vterm-buffer-name' and detection in
+`cj/--ai-vterm-buffer-p'. The display-buffer-alist rule keys on the
+escaped form \"\\\\`claude \\\\[\" -- they must stay in sync.")
+
(defun cj/--ai-vterm-buffer-name (dir)
"Return the AI-vterm buffer name for project directory DIR.
The name pattern is \"claude [<basename>]\". The display-buffer-alist
rule keys on the literal prefix \"claude [\", so changing the format
breaks routing to the right-side window."
- (format "claude [%s]"
+ (format "%s%s]"
+ cj/--ai-vterm-name-prefix
(file-name-nondirectory (directory-file-name dir))))
+(defun cj/--ai-vterm-buffer-p (buffer)
+ "Return non-nil when BUFFER is an AI-vterm buffer.
+
+A buffer qualifies when its name starts with the literal prefix in
+`cj/--ai-vterm-name-prefix' (\"claude [\"). The check is anchored at
+the start so names like \"foo claude [bar]\" do not match."
+ (and (bufferp buffer)
+ (buffer-live-p buffer)
+ (string-prefix-p cj/--ai-vterm-name-prefix (buffer-name buffer))))
+
+(defun cj/--ai-vterm-claude-buffers ()
+ "Return the live AI-vterm buffers in `buffer-list' order.
+
+Order matches `buffer-list' on the selected frame, which is most-
+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-displayed-claude-window (&optional frame)
+ "Return a window in FRAME currently displaying an AI-vterm buffer, or nil.
+
+FRAME defaults to the selected frame. When more than one window in
+the frame shows a claude buffer, the first one in `window-list' order
+is returned. The minibuffer is excluded from the search."
+ (seq-find (lambda (w)
+ (cj/--ai-vterm-buffer-p (window-buffer w)))
+ (window-list (or frame (selected-frame)) 'never)))
+
(defun cj/--ai-vterm-tmux-session-name (dir)
"Return the tmux name derived from project directory DIR.
@@ -129,34 +185,167 @@ Returns absolute paths. Nonexistent roots are skipped silently."
(and proc (process-live-p proc))))
(defcustom cj/ai-vterm-window-width 0.5
- "Fraction of frame width allocated to the AI-vterm side window."
+ "Default fraction of frame allocated to the AI-vterm window.
+
+Used by `cj/--ai-vterm-display-saved' as the size fallback when
+`cj/--ai-vterm-last-size' is nil (i.e. the user hasn't yet toggled
+off a claude window in this session). Applies to both width and
+height axes -- the same fallback fraction is used for either default
+direction."
:type 'number
:group 'ai-vterm)
+(defvar cj/--ai-vterm-last-direction nil
+ "Last user-chosen direction for the AI-vterm display.
+
+Symbol: right, below, left, or above. nil means no claude window
+has been toggled off yet this session, so the default direction
+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'.")
+
+(defun cj/--ai-vterm-window-direction (window)
+ "Return the side WINDOW occupies in its frame.
+
+Returns one of right, below, left, above. Falls back to right when
+WINDOW fills its frame's root area (single-window or atypical
+layout), since right is the module's default split direction.
+
+Comparison uses `frame-root-window' edges rather than frame edges so
+the minibuffer doesn't make every full-area window look like it
+fails to span the full height."
+ (let* ((root (frame-root-window (window-frame window)))
+ (edges (window-edges window))
+ (root-edges (window-edges root))
+ (left (nth 0 edges))
+ (top (nth 1 edges))
+ (right (nth 2 edges))
+ (bottom (nth 3 edges))
+ (root-left (nth 0 root-edges))
+ (root-top (nth 1 root-edges))
+ (root-right (nth 2 root-edges))
+ (root-bottom (nth 3 root-edges))
+ (spans-full-width (and (= left root-left) (= right root-right)))
+ (spans-full-height (and (= top root-top) (= bottom root-bottom))))
+ (cond
+ ((not spans-full-width) (if (= left root-left) 'left 'right))
+ ((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-capture-state (window)
+ "Capture WINDOW's direction and size into module-level state.
+
+Sets `cj/--ai-vterm-last-direction' and `cj/--ai-vterm-last-size'
+so a subsequent F9 display can restore the user's chosen orientation
+and size. Called at toggle-off (just before `quit-window' tears the
+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)))
+ (setq cj/--ai-vterm-last-direction dir
+ cj/--ai-vterm-last-size frac))))
+
+(defun cj/--ai-vterm-reuse-existing-claude (buffer _alist)
+ "Display-buffer action: reuse any window in this frame already showing
+a claude buffer.
+
+Looks up `cj/--ai-vterm-displayed-claude-window' on the selected
+frame. When a claude window exists, replaces its buffer with BUFFER
+and returns the window. When none exists, returns nil so the next
+action in the chain runs.
+
+This is more specific than `display-buffer-use-some-window', which
+would happily steal any non-selected window (e.g. a code window
+above the claude split) when the user is focused in claude and
+swaps projects via C-F9. The selective lookup here keeps non-claude
+windows undisturbed and preserves the user's split geometry across
+project changes."
+ (let ((win (cj/--ai-vterm-displayed-claude-window)))
+ (when win
+ (set-window-buffer win buffer)
+ win)))
+
+(defun cj/--ai-vterm-display-saved (buffer alist)
+ "Display-buffer action: split per saved direction and size.
+
+Reads `cj/--ai-vterm-last-direction' and `cj/--ai-vterm-last-size'
+(falling back to right and `cj/ai-vterm-window-width' when nil) and
+delegates to `display-buffer-in-direction' with an alist that carries
+the saved values.
+
+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))
+ (size (or cj/--ai-vterm-last-size cj/ai-vterm-window-width))
+ (size-key (if (memq direction '(right left))
+ 'window-width
+ 'window-height))
+ (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))
+ filtered)))
+ (display-buffer-in-direction buffer effective)))
+
(defun cj/--ai-vterm-display-rule-list ()
"Return the `display-buffer-alist' entry list installed by this module.
The single rule routes any buffer whose name starts with \"claude [\"
through three actions in order:
-1. `display-buffer-reuse-window' -- if the buffer is already visible
- in any window, focus that one.
-2. `display-buffer-use-some-window' -- otherwise, reuse an existing
- non-selected window (the right window of a left/right split, in
- the typical layout).
-3. `display-buffer-in-direction' -- otherwise, split the selected
- window to the right at width `cj/ai-vterm-window-width'.
-
-`display-buffer-in-side-window' is avoided deliberately. Side windows
-enforce dedication, which breaks `buffer-move' (C-M-arrows) and
-`switch-to-buffer' replacement. The chain above keeps the resulting
-window an ordinary window so all the standard window commands work."
- `(("\\`claude \\["
+1. `display-buffer-reuse-window' -- if the same buffer is already
+ visible in any window, focus that one.
+2. `cj/--ai-vterm-reuse-existing-claude' -- otherwise, if any
+ window in this frame already shows a claude-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
+ direction + size from the last toggle-off (or defaults when no
+ capture has happened this session).
+
+`display-buffer-in-side-window' is avoided deliberately. Side
+windows enforce dedication, which breaks `buffer-move' (C-M-arrows)
+and `switch-to-buffer' replacement. The chain above keeps the
+resulting window an ordinary window so all standard window commands
+work.
+
+`display-buffer-use-some-window' is also avoided -- it would happily
+steal any non-selected window (e.g. a code window above a claude
+split) when the user is focused in claude and switches projects."
+ '(("\\`claude \\["
(display-buffer-reuse-window
- display-buffer-use-some-window
- display-buffer-in-direction)
- (direction . right)
- (window-width . ,cj/ai-vterm-window-width)
+ cj/--ai-vterm-reuse-existing-claude
+ cj/--ai-vterm-display-saved)
(inhibit-same-window . t))))
(dolist (entry (cj/--ai-vterm-display-rule-list))
@@ -185,9 +374,16 @@ Returns the buffer."
(t
(when existing
(kill-buffer existing))
- (let ((default-directory dir)
- (cj/--ai-vterm-suppress-tmux t))
- (vterm name))
+ ;; `vterm' calls pop-to-buffer-same-window internally, which
+ ;; replaces the selected window's buffer (e.g. the dashboard at
+ ;; fresh startup) before our display-buffer-alist rule has a
+ ;; chance to route it. `save-window-excursion' reverts that
+ ;; side-effect; the explicit display-buffer call below then
+ ;; routes the buffer through the alist into a right-side split.
+ (save-window-excursion
+ (let ((default-directory dir)
+ (cj/--ai-vterm-suppress-tmux t))
+ (vterm name)))
(let ((buf (get-buffer name)))
(with-current-buffer buf
(vterm-send-string (cj/--ai-vterm-launch-command dir))
@@ -216,8 +412,48 @@ full home-dir form. Signals `user-error' when no candidates exist."
(or (cdr (assoc chosen display-alist))
(expand-file-name chosen)))))
-(defun cj/ai-vterm (&optional arg)
- "Open or reuse a Claude-running vterm for a chosen project.
+(defun cj/--ai-vterm-dispatch ()
+ "Compute the F9 (`cj/ai-vterm') action without performing it.
+
+Returns one of:
+- (toggle-off . WINDOW) -- claude is displayed in WINDOW; quit it.
+- (redisplay-single . BUFFER) -- exactly one alive claude buffer; show it.
+- (pick-project) -- zero or 2+ alive claude buffers; prompt.
+
+A pure-decision helper so the dispatch logic is exercisable in tests
+without firing real `display-buffer' or `quit-window' calls."
+ (let ((win (cj/--ai-vterm-displayed-claude-window)))
+ (cond
+ (win (cons 'toggle-off win))
+ (t
+ (let ((buffers (cj/--ai-vterm-claude-buffers)))
+ (cond
+ ((= (length buffers) 1) (cons 'redisplay-single (car buffers)))
+ (t '(pick-project))))))))
+
+(defun cj/--ai-vterm-pick-buffer-candidates (buffers shown-buffer)
+ "Build the M-F9 picker alist.
+
+BUFFERS is an MRU-ordered list of alive AI-vterm buffers.
+SHOWN-BUFFER is the AI-vterm buffer currently displayed in this frame,
+or nil.
+
+When SHOWN-BUFFER is one of BUFFERS, it sorts last with a
+\" [shown]\" suffix so the default `completing-read' selection lands
+on a non-shown candidate (i.e. RET picks \"the other one\"). When
+SHOWN-BUFFER is not in BUFFERS (a stale window state), every entry is
+treated as non-shown.
+
+Each cell is (DISPLAY-NAME . BUFFER)."
+ (let ((non-shown (seq-remove (lambda (b) (eq b shown-buffer)) buffers))
+ (shown (when (memq shown-buffer buffers) shown-buffer)))
+ (append
+ (mapcar (lambda (b) (cons (buffer-name b) b)) non-shown)
+ (when shown
+ (list (cons (format "%s [shown]" (buffer-name shown)) shown))))))
+
+(defun cj/ai-vterm-pick-project (&optional arg)
+ "Pick a Claude-template project and open or reuse its vterm.
The project is picked from a filtered completing-read list of dirs
that contain .ai/protocols.org. The vterm buffer is named
@@ -225,7 +461,10 @@ that contain .ai/protocols.org. The vterm buffer is named
`display-buffer-alist'. Multiple projects coexist as separate
buffers; reinvoking on the same project reuses its existing vterm.
-With prefix ARG, display the buffer without selecting its window."
+With prefix ARG, display the buffer without selecting its window.
+
+Bound to C-F9 -- always shows the project picker, even when a claude
+buffer is currently displayed."
(interactive "P")
(let* ((dir (cj/--ai-vterm-pick-project))
(name (cj/--ai-vterm-buffer-name dir))
@@ -235,7 +474,76 @@ With prefix ARG, display the buffer without selecting its window."
(when win (select-window win))))
buf))
-(keymap-global-set "<f9>" #'cj/ai-vterm)
+(defun cj/ai-vterm-pick-buffer ()
+ "Pick from the alive AI-vterm buffers; switch to the chosen one.
+
+When an AI-vterm buffer is currently displayed in this frame, the
+picked buffer replaces it in the same window via `set-window-buffer'.
+Orientation and size are preserved so the user's split layout doesn't
+change. When no AI-vterm buffer is displayed, default placement
+applies via `display-buffer'.
+
+The currently-displayed buffer (if any) is sorted last in the picker
+with a \" [shown]\" suffix; the default selection lands on a
+non-shown candidate so RET means \"give me the other one\".
+
+Signals `user-error' when no AI-vterm buffers exist.
+
+Bound to M-F9."
+ (interactive)
+ (let ((buffers (cj/--ai-vterm-claude-buffers)))
+ (unless buffers
+ (user-error "No Claude buffers"))
+ (let* ((shown-win (cj/--ai-vterm-displayed-claude-window))
+ (shown-buf (and shown-win (window-buffer shown-win)))
+ (alist (cj/--ai-vterm-pick-buffer-candidates buffers shown-buf))
+ (chosen (completing-read "AI vterm buffer: " alist nil t))
+ (buf (cdr (assoc chosen alist))))
+ (cond
+ ((and shown-win (window-live-p shown-win))
+ (set-window-buffer shown-win buf)
+ (select-window shown-win))
+ (t
+ (display-buffer buf)
+ (let ((w (get-buffer-window buf)))
+ (when w (select-window w)))))
+ buf)))
+
+(defun cj/ai-vterm (&optional arg)
+ "Smart F9 dispatch for the AI-vterm launcher.
+
+Behavior depends on the current state:
+
+- If an AI-vterm buffer is currently displayed in this frame, F9
+ quits its window (toggle off, buffer stays alive).
+- Else, if exactly one alive AI-vterm buffer exists, F9 re-displays
+ it (DWIM -- the obvious next step is to look at it).
+- Else (zero or 2+), F9 falls through to `cj/ai-vterm-pick-project'.
+
+With prefix ARG, display the buffer without selecting its window
+when a buffer is being shown (no effect on the toggle-off branch).
+
+See `cj/ai-vterm-pick-project' (C-F9) to force the project picker
+and `cj/ai-vterm-pick-buffer' (M-F9) to switch among existing
+AI-vterm buffers without touching the project list."
+ (interactive "P")
+ (pcase (cj/--ai-vterm-dispatch)
+ (`(toggle-off . ,win)
+ (cj/--ai-vterm-capture-state win)
+ (quit-window nil win)
+ nil)
+ (`(redisplay-single . ,buf)
+ (display-buffer buf)
+ (unless arg
+ (let ((w (get-buffer-window buf)))
+ (when w (select-window w))))
+ buf)
+ (`(pick-project)
+ (cj/ai-vterm-pick-project arg))))
+
+(keymap-global-set "<f9>" #'cj/ai-vterm)
+(keymap-global-set "C-<f9>" #'cj/ai-vterm-pick-project)
+(keymap-global-set "M-<f9>" #'cj/ai-vterm-pick-buffer)
(provide 'ai-vterm)
;;; ai-vterm.el ends here
diff --git a/tests/test-ai-vterm--capture-state.el b/tests/test-ai-vterm--capture-state.el
new file mode 100644
index 00000000..cecb3ab8
--- /dev/null
+++ b/tests/test-ai-vterm--capture-state.el
@@ -0,0 +1,64 @@
+;;; test-ai-vterm--capture-state.el --- Tests for cj/--ai-vterm-capture-state -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The capture helper writes WINDOW's direction and size to module-
+;; level state vars `cj/--ai-vterm-last-direction' and
+;; `cj/--ai-vterm-last-size'. Called from `cj/ai-vterm''s toggle-off
+;; branch so the next F9 display can restore the user's chosen
+;; orientation and size. No-op on a dead window.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(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)."
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((right (split-window (selected-window) nil 'right))
+ (cj/--ai-vterm-last-direction nil)
+ (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))))))
+
+(ert-deftest test-ai-vterm--capture-state-below-split-sets-direction ()
+ "Normal: below-split window -> direction=below, size in (0.4, 0.6)."
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((below (split-window (selected-window) nil 'below))
+ (cj/--ai-vterm-last-direction nil)
+ (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))))))
+
+(ert-deftest test-ai-vterm--capture-state-noop-on-dead-window ()
+ "Boundary: nil window -> state remains unchanged."
+ (let ((cj/--ai-vterm-last-direction 'sentinel-dir)
+ (cj/--ai-vterm-last-size 0.123))
+ (cj/--ai-vterm-capture-state nil)
+ (should (eq cj/--ai-vterm-last-direction 'sentinel-dir))
+ (should (= cj/--ai-vterm-last-size 0.123))))
+
+(ert-deftest test-ai-vterm--capture-state-noop-on-deleted-window ()
+ "Boundary: deleted window -> state remains unchanged."
+ (let ((cj/--ai-vterm-last-direction 'sentinel-dir)
+ (cj/--ai-vterm-last-size 0.123)
+ (dead-win (save-window-excursion
+ (delete-other-windows)
+ (let ((w (split-window (selected-window) nil 'right)))
+ (delete-window w)
+ w))))
+ (cj/--ai-vterm-capture-state dead-win)
+ (should (eq cj/--ai-vterm-last-direction 'sentinel-dir))
+ (should (= cj/--ai-vterm-last-size 0.123))))
+
+(provide 'test-ai-vterm--capture-state)
+;;; test-ai-vterm--capture-state.el ends here
diff --git a/tests/test-ai-vterm--claude-buffers.el b/tests/test-ai-vterm--claude-buffers.el
new file mode 100644
index 00000000..56668ca1
--- /dev/null
+++ b/tests/test-ai-vterm--claude-buffers.el
@@ -0,0 +1,63 @@
+;;; test-ai-vterm--claude-buffers.el --- Tests for cj/--ai-vterm-claude-buffers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The helper returns the list of buffers whose names start with the
+;; literal prefix "claude [". Order is the same order `buffer-list'
+;; gives them (most-recently-selected first). Non-claude buffers and
+;; buffers whose names merely contain the prefix as a substring are
+;; excluded.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+(defun test-ai-vterm--claude-buffers-cleanup ()
+ "Kill any leftover claude-prefixed buffers before/after a test."
+ (dolist (b (buffer-list))
+ (when (string-prefix-p "claude [" (buffer-name b))
+ (kill-buffer b))))
+
+(ert-deftest test-ai-vterm--claude-buffers-empty-when-none-exist ()
+ "Boundary: no claude-prefixed buffers anywhere -> empty list."
+ (test-ai-vterm--claude-buffers-cleanup)
+ (unwind-protect
+ (should (null (cj/--ai-vterm-claude-buffers)))
+ (test-ai-vterm--claude-buffers-cleanup)))
+
+(ert-deftest test-ai-vterm--claude-buffers-returns-only-claude-buffers ()
+ "Normal: filters to only claude-prefixed buffers, leaves others alone."
+ (test-ai-vterm--claude-buffers-cleanup)
+ (let ((c1 (get-buffer-create "claude [a]"))
+ (c2 (get-buffer-create "claude [b]"))
+ (other (get-buffer-create "regular-buffer")))
+ (unwind-protect
+ (let ((result (cj/--ai-vterm-claude-buffers)))
+ (should (memq c1 result))
+ (should (memq c2 result))
+ (should-not (memq other result))
+ (should (= (length result) 2)))
+ (kill-buffer c1)
+ (kill-buffer c2)
+ (kill-buffer other))))
+
+(ert-deftest test-ai-vterm--claude-buffers-anchors-prefix-not-substring ()
+ "Boundary: 'foo claude [bar]' is not a claude buffer -- prefix anchored."
+ (test-ai-vterm--claude-buffers-cleanup)
+ (let ((not-claude (get-buffer-create "foo claude [bar]")))
+ (unwind-protect
+ (should-not (memq not-claude (cj/--ai-vterm-claude-buffers)))
+ (kill-buffer not-claude))))
+
+(ert-deftest test-ai-vterm--claude-buffers-bare-claude-not-included ()
+ "Boundary: 'claude' alone (no bracket) doesn't match the 'claude [' prefix."
+ (test-ai-vterm--claude-buffers-cleanup)
+ (let ((bare (get-buffer-create "claude")))
+ (unwind-protect
+ (should-not (memq bare (cj/--ai-vterm-claude-buffers)))
+ (kill-buffer bare))))
+
+(provide 'test-ai-vterm--claude-buffers)
+;;; test-ai-vterm--claude-buffers.el ends here
diff --git a/tests/test-ai-vterm--dispatch.el b/tests/test-ai-vterm--dispatch.el
new file mode 100644
index 00000000..3c0ae766
--- /dev/null
+++ b/tests/test-ai-vterm--dispatch.el
@@ -0,0 +1,70 @@
+;;; test-ai-vterm--dispatch.el --- Tests for cj/--ai-vterm-dispatch -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The dispatch helper is a pure decision function used by F9.
+;; Returns one of (toggle-off . WIN), (redisplay-single . BUF),
+;; or (pick-project) based on whether a claude buffer is currently
+;; displayed and how many alive claude buffers exist. Tests mock the
+;; two underlying helpers so the dispatch logic can be exercised
+;; without touching real windows.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+(defun test-ai-vterm--dispatch-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--dispatch-window-displayed-returns-toggle-off ()
+ "Normal: displayed claude window -> (toggle-off . WIN)."
+ (let ((sentinel-win 'fake-window))
+ (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-claude-window)
+ (lambda (&optional _frame) sentinel-win)))
+ (should (equal (cj/--ai-vterm-dispatch)
+ (cons 'toggle-off sentinel-win))))))
+
+(ert-deftest test-ai-vterm--dispatch-no-window-single-buffer-returns-redisplay ()
+ "Normal: no displayed claude, exactly one alive buffer -> redisplay-single."
+ (test-ai-vterm--dispatch-cleanup)
+ (let ((b1 (get-buffer-create "claude [single]")))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-claude-window)
+ (lambda (&optional _frame) nil))
+ ((symbol-function 'cj/--ai-vterm-claude-buffers)
+ (lambda () (list b1))))
+ (should (equal (cj/--ai-vterm-dispatch)
+ (cons 'redisplay-single b1))))
+ (kill-buffer b1))))
+
+(ert-deftest test-ai-vterm--dispatch-no-window-multiple-buffers-returns-pick-project ()
+ "Normal: no displayed claude, 2+ alive buffers -> pick-project."
+ (test-ai-vterm--dispatch-cleanup)
+ (let ((b1 (get-buffer-create "claude [a]"))
+ (b2 (get-buffer-create "claude [b]")))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-claude-window)
+ (lambda (&optional _frame) nil))
+ ((symbol-function 'cj/--ai-vterm-claude-buffers)
+ (lambda () (list b1 b2))))
+ (should (equal (cj/--ai-vterm-dispatch) '(pick-project))))
+ (kill-buffer b1)
+ (kill-buffer b2))))
+
+(ert-deftest test-ai-vterm--dispatch-no-window-zero-buffers-returns-pick-project ()
+ "Boundary: no displayed claude, zero alive buffers -> pick-project."
+ (test-ai-vterm--dispatch-cleanup)
+ (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-claude-window)
+ (lambda (&optional _frame) nil))
+ ((symbol-function 'cj/--ai-vterm-claude-buffers)
+ (lambda () nil)))
+ (should (equal (cj/--ai-vterm-dispatch) '(pick-project)))))
+
+(provide 'test-ai-vterm--dispatch)
+;;; test-ai-vterm--dispatch.el ends here
diff --git a/tests/test-ai-vterm--display-saved.el b/tests/test-ai-vterm--display-saved.el
new file mode 100644
index 00000000..9cb3521c
--- /dev/null
+++ b/tests/test-ai-vterm--display-saved.el
@@ -0,0 +1,95 @@
+;;; 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'.
+;;
+;; Tests stub `display-buffer-in-direction' to capture the alist
+;; that would have reached it.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(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."
+ (let (received-buf received-alist
+ (cj/--ai-vterm-last-direction nil)
+ (cj/--ai-vterm-last-size nil)
+ (cj/ai-vterm-window-width 0.5))
+ (cl-letf (((symbol-function 'display-buffer-in-direction)
+ (lambda (b a)
+ (setq received-buf b received-alist a)
+ '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 (= (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."
+ (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 (= (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."
+ (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 (= (cdr (assq 'window-width received-alist)) 0.7))
+ (should-not (assq 'window-height received-alist))))
+
+(ert-deftest test-ai-vterm--display-saved-strips-conflicting-alist-entries ()
+ "Boundary: caller-supplied direction/size are stripped, saved values win."
+ (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
+ '((direction . below)
+ (window-width . 0.2)
+ (window-height . 0.3)
+ (inhibit-same-window . t))))
+ (should (eq (cdr (assq 'direction received-alist)) 'right))
+ (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
+ ;; -- the action picks the matching size key based on direction.
+ (let ((wh-cells (cl-remove-if-not
+ (lambda (cell) (eq (car-safe cell) 'window-height))
+ received-alist)))
+ (should (null wh-cells)))))
+
+(ert-deftest test-ai-vterm--display-saved-passes-buffer-through ()
+ "Normal: BUFFER argument reaches display-buffer-in-direction unchanged."
+ (let (received-buf
+ (cj/--ai-vterm-last-direction 'right)
+ (cj/--ai-vterm-last-size 0.5))
+ (cl-letf (((symbol-function 'display-buffer-in-direction)
+ (lambda (b _a) (setq received-buf b) 'fake-window)))
+ (cj/--ai-vterm-display-saved 'sentinel-buffer nil))
+ (should (eq received-buf 'sentinel-buffer))))
+
+(provide 'test-ai-vterm--display-saved)
+;;; test-ai-vterm--display-saved.el ends here
diff --git a/tests/test-ai-vterm--displayed-claude-window.el b/tests/test-ai-vterm--displayed-claude-window.el
new file mode 100644
index 00000000..283a1b3c
--- /dev/null
+++ b/tests/test-ai-vterm--displayed-claude-window.el
@@ -0,0 +1,64 @@
+;;; test-ai-vterm--displayed-claude-window.el --- Tests for the displayed-window helper -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The helper returns a window in the selected frame whose buffer
+;; satisfies `cj/--ai-vterm-buffer-p', or nil when no such window
+;; exists. Used by F9 dispatch and M-F9 in-place replacement.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+(defun test-ai-vterm--displayed-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--displayed-claude-window-no-buffers-returns-nil ()
+ "Boundary: no claude buffers anywhere -> nil."
+ (test-ai-vterm--displayed-cleanup)
+ (save-window-excursion
+ (delete-other-windows)
+ (should-not (cj/--ai-vterm-displayed-claude-window))))
+
+(ert-deftest test-ai-vterm--displayed-claude-window-not-displayed-returns-nil ()
+ "Boundary: claude buffer exists but not in any window -> nil."
+ (test-ai-vterm--displayed-cleanup)
+ (let ((b1 (get-buffer-create "claude [hidden]")))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (should-not (cj/--ai-vterm-displayed-claude-window)))
+ (kill-buffer b1))))
+
+(ert-deftest test-ai-vterm--displayed-claude-window-returns-window-when-displayed ()
+ "Normal: claude buffer in a window -> returns that window."
+ (test-ai-vterm--displayed-cleanup)
+ (let ((b1 (get-buffer-create "claude [shown]")))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((win (split-window-right)))
+ (set-window-buffer win b1)
+ (let ((result (cj/--ai-vterm-displayed-claude-window)))
+ (should (windowp result))
+ (should (eq (window-buffer result) b1)))))
+ (kill-buffer b1))))
+
+(ert-deftest test-ai-vterm--displayed-claude-window-ignores-non-claude-windows ()
+ "Boundary: only a non-claude buffer is displayed -> nil."
+ (test-ai-vterm--displayed-cleanup)
+ (let ((other (get-buffer-create "regular-displayed-buffer")))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (set-window-buffer (selected-window) other)
+ (should-not (cj/--ai-vterm-displayed-claude-window)))
+ (kill-buffer other))))
+
+(provide 'test-ai-vterm--displayed-claude-window)
+;;; test-ai-vterm--displayed-claude-window.el ends here
diff --git a/tests/test-ai-vterm--pick-buffer-candidates.el b/tests/test-ai-vterm--pick-buffer-candidates.el
new file mode 100644
index 00000000..99ef7325
--- /dev/null
+++ b/tests/test-ai-vterm--pick-buffer-candidates.el
@@ -0,0 +1,84 @@
+;;; test-ai-vterm--pick-buffer-candidates.el --- Tests for the M-F9 candidate builder -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The candidate builder is a pure function: given an MRU list of
+;; alive AI-vterm buffers and the currently-displayed buffer (or
+;; nil), it returns an alist of (DISPLAY-NAME . BUFFER) cells.
+;;
+;; Sort rule: non-shown buffers come first in their input order,
+;; then the shown buffer (if it's in the list) appears last with a
+;; \" [shown]\" suffix. The intent is that the default `completing-
+;; read' selection lands on a non-shown candidate so RET means
+;; \"give me the other one\".
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+(defun test-ai-vterm--pbc-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--pick-buffer-candidates-empty-buffers ()
+ "Boundary: empty buffer list -> empty alist regardless of shown."
+ (test-ai-vterm--pbc-cleanup)
+ (should (null (cj/--ai-vterm-pick-buffer-candidates nil nil)))
+ (should (null (cj/--ai-vterm-pick-buffer-candidates nil 'sentinel))))
+
+(ert-deftest test-ai-vterm--pick-buffer-candidates-shown-nil ()
+ "Normal: shown is nil -> straight alist in input order, no marker."
+ (test-ai-vterm--pbc-cleanup)
+ (let ((b1 (get-buffer-create "claude [a]"))
+ (b2 (get-buffer-create "claude [b]")))
+ (unwind-protect
+ (let ((result (cj/--ai-vterm-pick-buffer-candidates (list b1 b2) nil)))
+ (should (equal result `(("claude [a]" . ,b1)
+ ("claude [b]" . ,b2)))))
+ (kill-buffer b1)
+ (kill-buffer b2))))
+
+(ert-deftest test-ai-vterm--pick-buffer-candidates-shown-promotes-non-shown ()
+ "Normal: shown buffer sorts last with [shown] suffix; others first."
+ (test-ai-vterm--pbc-cleanup)
+ (let ((b1 (get-buffer-create "claude [a]"))
+ (b2 (get-buffer-create "claude [b]"))
+ (b3 (get-buffer-create "claude [c]")))
+ (unwind-protect
+ (let ((result (cj/--ai-vterm-pick-buffer-candidates
+ (list b1 b2 b3) b1)))
+ (should (equal result
+ `(("claude [b]" . ,b2)
+ ("claude [c]" . ,b3)
+ ("claude [a] [shown]" . ,b1)))))
+ (kill-buffer b1)
+ (kill-buffer b2)
+ (kill-buffer b3))))
+
+(ert-deftest test-ai-vterm--pick-buffer-candidates-shown-only-buffer ()
+ "Boundary: shown is the only entry -> single cell with [shown] marker."
+ (test-ai-vterm--pbc-cleanup)
+ (let ((b1 (get-buffer-create "claude [a]")))
+ (unwind-protect
+ (let ((result (cj/--ai-vterm-pick-buffer-candidates (list b1) b1)))
+ (should (equal result `(("claude [a] [shown]" . ,b1)))))
+ (kill-buffer b1))))
+
+(ert-deftest test-ai-vterm--pick-buffer-candidates-shown-not-in-buffers ()
+ "Boundary: stale shown buffer not in list -> all cells are non-shown."
+ (test-ai-vterm--pbc-cleanup)
+ (let ((b1 (get-buffer-create "claude [a]"))
+ (b-stale (get-buffer-create "claude [stale]")))
+ (unwind-protect
+ (let ((result (cj/--ai-vterm-pick-buffer-candidates
+ (list b1) b-stale)))
+ (should (equal result `(("claude [a]" . ,b1)))))
+ (kill-buffer b1)
+ (kill-buffer b-stale))))
+
+(provide 'test-ai-vterm--pick-buffer-candidates)
+;;; test-ai-vterm--pick-buffer-candidates.el ends here
diff --git a/tests/test-ai-vterm--reuse-existing-claude.el b/tests/test-ai-vterm--reuse-existing-claude.el
new file mode 100644
index 00000000..4668188d
--- /dev/null
+++ b/tests/test-ai-vterm--reuse-existing-claude.el
@@ -0,0 +1,103 @@
+;;; test-ai-vterm--reuse-existing-claude.el --- Tests for reuse-existing-claude action -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The action looks for any window in the selected frame whose buffer
+;; satisfies `cj/--ai-vterm-buffer-p'. When found, swaps that
+;; window's buffer for the one being displayed and returns the
+;; window. When not found, returns nil so the next action in the
+;; chain runs.
+;;
+;; This is the action that keeps C-F9 (project-switch) from stealing
+;; a non-claude window when the user is focused inside claude.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+(defun test-ai-vterm--reuse-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--reuse-existing-claude-swaps-buffer-when-window-exists ()
+ "Normal: a claude window exists -> swap its buffer, return the window."
+ (test-ai-vterm--reuse-cleanup)
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((existing (get-buffer-create "claude [existing]"))
+ (new-buf (get-buffer-create "claude [new]"))
+ (split (split-window (selected-window) nil 'right)))
+ (unwind-protect
+ (progn
+ (set-window-buffer split existing)
+ (let ((result (cj/--ai-vterm-reuse-existing-claude new-buf nil)))
+ (should (eq result split))
+ (should (eq (window-buffer split) new-buf))))
+ (kill-buffer existing)
+ (kill-buffer new-buf)))))
+
+(ert-deftest test-ai-vterm--reuse-existing-claude-returns-nil-when-no-claude-window ()
+ "Boundary: no claude window in frame -> nil (chain continues to next action)."
+ (test-ai-vterm--reuse-cleanup)
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((new-buf (get-buffer-create "claude [no-existing]")))
+ (unwind-protect
+ (should (null (cj/--ai-vterm-reuse-existing-claude new-buf nil)))
+ (kill-buffer new-buf)))))
+
+(ert-deftest test-ai-vterm--reuse-existing-claude-leaves-non-claude-windows-alone ()
+ "Boundary: only non-claude windows in frame -> nil; other windows untouched."
+ (test-ai-vterm--reuse-cleanup)
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((code-buf (get-buffer-create "*test-code-buffer*"))
+ (new-claude (get-buffer-create "claude [new-here]"))
+ (other-win (split-window (selected-window) nil 'right)))
+ (unwind-protect
+ (progn
+ (set-window-buffer (selected-window) code-buf)
+ (set-window-buffer other-win code-buf)
+ (let ((result (cj/--ai-vterm-reuse-existing-claude
+ new-claude nil)))
+ (should (null result))
+ (should (eq (window-buffer (selected-window)) code-buf))
+ (should (eq (window-buffer other-win) code-buf))))
+ (kill-buffer code-buf)
+ (kill-buffer new-claude)))))
+
+(ert-deftest test-ai-vterm--reuse-existing-claude-preserves-non-claude-window-when-swapping ()
+ "Normal: swap claude window only; the other window keeps its buffer.
+
+This is the C-F9-from-claude regression: with claude at the bottom
+and code on top, switching projects must replace the bottom window's
+buffer, not the top window's."
+ (test-ai-vterm--reuse-cleanup)
+ (save-window-excursion
+ (delete-other-windows)
+ (let* ((code-buf (get-buffer-create "*test-code-top*"))
+ (claude-a (get-buffer-create "claude [a]"))
+ (claude-b (get-buffer-create "claude [b]"))
+ (top-win (selected-window))
+ (bottom-win (split-window top-win nil 'below)))
+ (unwind-protect
+ (progn
+ (set-window-buffer top-win code-buf)
+ (set-window-buffer bottom-win claude-a)
+ ;; Focus the claude window -- this is the regression scenario.
+ (select-window bottom-win)
+ (let ((result (cj/--ai-vterm-reuse-existing-claude
+ claude-b nil)))
+ (should (eq result bottom-win))
+ (should (eq (window-buffer bottom-win) claude-b))
+ (should (eq (window-buffer top-win) code-buf))))
+ (kill-buffer code-buf)
+ (kill-buffer claude-a)
+ (kill-buffer claude-b)))))
+
+(provide 'test-ai-vterm--reuse-existing-claude)
+;;; test-ai-vterm--reuse-existing-claude.el ends here
diff --git a/tests/test-ai-vterm--show-or-create.el b/tests/test-ai-vterm--show-or-create.el
index 3faf5f03..3fee4883 100644
--- a/tests/test-ai-vterm--show-or-create.el
+++ b/tests/test-ai-vterm--show-or-create.el
@@ -105,6 +105,45 @@ VARS is a plist of capture variable names: :calls, :strings, :returns,
(should-not (buffer-live-p stale)))))
(test-ai-vterm--cleanup name))))
+(ert-deftest test-ai-vterm--show-or-create-preserves-selected-window ()
+ "Regression: vterm's pop-to-buffer-same-window must not bury the dashboard.
+
+Real `vterm' replaces the selected window's buffer as a side-effect of
+construction. On a fresh-boot frame (one window showing the dashboard),
+that side-effect previously left the original window pointing at the new
+claude buffer; the dashboard was buried, the alist-routed split then
+created a second window also showing claude. The wrapper must restore
+the original window state before `display-buffer' fires so dashboard
+stays put and the alist places claude into a fresh right-side split.
+
+This test stubs `vterm' to mimic the pop-to-buffer-same-window side-effect
+and asserts the originally-selected window still shows its original buffer
+after `cj/--ai-vterm-show-or-create' returns."
+ (let ((claude-name "claude [preserve-window-test]")
+ (orig-name "*test-original-buffer*"))
+ (test-ai-vterm--cleanup claude-name)
+ (when (get-buffer orig-name) (kill-buffer orig-name))
+ (unwind-protect
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((orig-buf (get-buffer-create orig-name))
+ (orig-win (selected-window)))
+ (set-window-buffer orig-win orig-buf)
+ (cl-letf
+ (((symbol-function 'vterm)
+ (lambda (&optional name)
+ (let ((buf (get-buffer-create name)))
+ (set-window-buffer (selected-window) buf)
+ buf)))
+ ((symbol-function 'vterm-send-string)
+ (lambda (_s &optional _) nil))
+ ((symbol-function 'vterm-send-return)
+ (lambda () nil)))
+ (cj/--ai-vterm-show-or-create "/tmp/preserve" claude-name)
+ (should (eq (window-buffer orig-win) orig-buf)))))
+ (test-ai-vterm--cleanup claude-name)
+ (when (get-buffer orig-name) (kill-buffer orig-name)))))
+
(ert-deftest test-ai-vterm--show-or-create-returns-buffer ()
"Normal: return value is the vterm buffer."
(let ((name "claude [return-test]"))
diff --git a/tests/test-ai-vterm--window-geometry.el b/tests/test-ai-vterm--window-geometry.el
new file mode 100644
index 00000000..62b78baf
--- /dev/null
+++ b/tests/test-ai-vterm--window-geometry.el
@@ -0,0 +1,85 @@
+;;; test-ai-vterm--window-geometry.el --- Tests for direction + fraction helpers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Two pure helpers used by F9's geometry-preservation feature:
+;;
+;; - `cj/--ai-vterm-window-direction' classifies a window's position
+;; relative to its frame as right / below / left / above (with a
+;; right fallback when the window fills the frame).
+;;
+;; - `cj/--ai-vterm-window-fraction' returns the window's size on
+;; the matching axis as a fraction of the frame.
+;;
+;; Tests use real window splits in `save-window-excursion' rather
+;; than mocking, since the helpers consume `window-edges' and
+;; `frame-width' / `frame-height' directly.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+(ert-deftest test-ai-vterm--window-direction-right-split ()
+ "Normal: 2-window vertical split, right-side window -> right."
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((right (split-window (selected-window) nil 'right)))
+ (should (eq (cj/--ai-vterm-window-direction right) 'right)))))
+
+(ert-deftest test-ai-vterm--window-direction-left-split ()
+ "Normal: 2-window vertical split, left-side window -> left."
+ (save-window-excursion
+ (delete-other-windows)
+ (split-window (selected-window) nil 'right)
+ (should (eq (cj/--ai-vterm-window-direction (selected-window)) 'left))))
+
+(ert-deftest test-ai-vterm--window-direction-below-split ()
+ "Normal: 2-window horizontal split, bottom window -> below."
+ (save-window-excursion
+ (delete-other-windows)
+ (let ((below (split-window (selected-window) nil 'below)))
+ (should (eq (cj/--ai-vterm-window-direction below) 'below)))))
+
+(ert-deftest test-ai-vterm--window-direction-above-split ()
+ "Normal: 2-window horizontal split, top window -> above."
+ (save-window-excursion
+ (delete-other-windows)
+ (split-window (selected-window) nil 'below)
+ (should (eq (cj/--ai-vterm-window-direction (selected-window)) 'above))))
+
+(ert-deftest test-ai-vterm--window-direction-single-window-fallback ()
+ "Boundary: single-window frame -> default right."
+ (save-window-excursion
+ (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."
+ (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))))))
+
+(ert-deftest test-ai-vterm--window-fraction-below-split-half ()
+ "Normal: bottom window of equal horizontal split -> ~0.5 height fraction."
+ (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))))))
+
+(ert-deftest test-ai-vterm--window-fraction-narrow-right-split ()
+ "Normal: right window at 1/4 width -> fraction within that range."
+ (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))))))
+
+(provide 'test-ai-vterm--window-geometry)
+;;; test-ai-vterm--window-geometry.el ends here