summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-07 19:25:18 -0500
committerCraig Jennings <c@cjennings.net>2026-05-07 19:25:18 -0500
commit47b218ed15acd00c18cbc3bef604c4f2e0050a08 (patch)
tree98c6541327b707e1e3c1f214f8a6dc7d0135a039
parent3efaf9b5218fa769a297df5821ec89837207e57d (diff)
downloaddotemacs-47b218ed15acd00c18cbc3bef604c4f2e0050a08.tar.gz
dotemacs-47b218ed15acd00c18cbc3bef604c4f2e0050a08.zip
feat(ai-vterm): add Claude launcher with vertical-split vterm
The new module picks a Claude-template project from a filtered completing-read list. It scans the same roots the `ai` shell launcher uses, then opens or reuses a vterm buffer named `claude [<repo>]` on the right. F9 launches it. The prior `cj/toggle-gptel` binding moves from F9 to C-F9 so both AI tools share the same physical key. The display rule chains reuse-window -> use-some-window -> in-direction (right). The resulting window isn't dedicated. That matters because side-window dedication was breaking `buffer-move` (C-M-arrows) and `switch-to-buffer` replacement on the claude buffer. I also narrowed `vterm-toggle`'s display rule to skip `claude [` buffers. Otherwise it claimed them first with its bottom-split + dedicated treatment. I added 23 tests across 5 files: the buffer-name transform, candidate walker, show-or-create dispatch, picker, and display rule. Design lives at docs/design/ai-vterm.org.
-rw-r--r--docs/design/ai-vterm.org146
-rw-r--r--init.el1
-rw-r--r--modules/ai-config.el4
-rw-r--r--modules/ai-vterm.el198
-rw-r--r--modules/eshell-vterm-config.el10
-rw-r--r--tests/test-ai-vterm--buffer-name.el42
-rw-r--r--tests/test-ai-vterm--candidates.el139
-rw-r--r--tests/test-ai-vterm--display-rule.el74
-rw-r--r--tests/test-ai-vterm--pick-project.el48
-rw-r--r--tests/test-ai-vterm--show-or-create.el119
10 files changed, 778 insertions, 3 deletions
diff --git a/docs/design/ai-vterm.org b/docs/design/ai-vterm.org
new file mode 100644
index 00000000..62bafbc8
--- /dev/null
+++ b/docs/design/ai-vterm.org
@@ -0,0 +1,146 @@
+#+TITLE: Design: ai-vterm — in-Emacs Claude launcher
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-07
+#+OPTIONS: toc:nil num:nil
+
+* Status
+
+Draft.
+
+* Problem
+
+Claude Code currently launches outside Emacs via the =ai= shell script, which builds candidate projects from =~/.emacs.d=, =~/code/*=, =~/projects/*= (anything with =.ai/protocols.org=), opens each in a tmux window, and runs =claude "Read .ai/protocols.org and follow all instructions."= per project. The shell-out pulls focus to a terminal, and tmux's horizontal split is the wrong shape for a code-on-left, Claude-on-right reading layout.
+
+The in-Emacs alternative today is =vterm-toggle= at F12, which uses a horizontal bottom split via =display-buffer-at-bottom=. No project picker, no per-project session model, no vertical split.
+
+Building this in Emacs eliminates the context switch and gives the side-by-side layout that matches how the work is actually read.
+
+* Non-Goals
+
+- Replicating the =ai= script's git prep / auto-pull. Phase A.0 of the startup workflow already handles pulls at session start.
+- A multi-project session switcher with its own UI. =consult-buffer= and the buffer list already navigate between =claude [...]= buffers.
+- Replacing =vterm-toggle= at F12. The existing bottom-split flow stays for non-AI shells.
+- Tab-bar or frame-per-project layouts.
+- Auto-launching tmux inside the AI vterm. Claude under tmux adds a session-management layer for no benefit here.
+
+* Approaches Considered
+
+** Recommended: wrap =vterm= directly with per-project named buffers + a parallel display rule
+
+A new module =modules/ai-vterm.el= adds a command that picks a Claude-template project, opens (or reuses) a vterm buffer named =claude [<basename>]=, and lets a new =display-buffer-alist= entry route any buffer matching that prefix to a right-side window. Multiple projects produce multiple coexisting buffers, all sharing the same right-side slot. Switching among them is a buffer-switch, not a kill-and-recreate.
+
+Pros:
+- Same package (=vterm=) as the existing config.
+- Per-project buffers run simultaneously without conflict.
+- Right-side placement is one =display-buffer-alist= entry.
+- Existing windmove (Shift-arrows) handles code↔Claude focus toggling. =buffer-move= (C-M-arrows) handles side-swap. Neither needs new bindings.
+
+Cons:
+- Re-implements toggle/show-hide logic that =vterm-toggle= would handle for free. Acceptable because =vterm-toggle= is built around one toggle-able buffer, and the per-project model is what's wanted.
+
+** Rejected: wrap =vterm-toggle=
+
+=vterm-toggle='s contract is one buffer toggled visible/hidden. Per-project buffers running simultaneously is outside that contract. Wrapping it would mean fighting the abstraction.
+
+** Rejected: project-per-tab via =tab-bar-mode=
+
+Each project gets its own tab. Matches the =ai= / tmux model cleanly, but adds tab-bar UI that isn't in current use. Bigger lifestyle change for a one-window task.
+
+** Rejected: frame-per-project
+
+Each Claude session opens in a new Emacs frame. Hyprland-native, clean isolation, but frame creation under Wayland has historical jank, and it breaks the easy windmove flow between code and Claude.
+
+** Rejected: window-configuration-per-project
+
+Save and restore named window configs (code buffers + Claude vterm together). Preserves the surrounding thinking environment, but window configs go stale when buffers die, and it adds a parallel mechanism to project.el. Overkill for v1.
+
+* Design
+
+** Architecture
+
+New module =modules/ai-vterm.el=. Required after =eshell-vterm-config= in =init.el= so =vterm= is loaded.
+
+Components:
+
+| Function | Kind | Responsibility |
+|----------+------+----------------|
+| =cj/--ai-vterm-candidates= | pure | Walks =~/.emacs.d=, =~/code/*=, =~/projects/*=; returns abs paths containing =.ai/protocols.org= |
+| =cj/--ai-vterm-pick-project= | interactive helper | =completing-read= over candidates; returns picked path |
+| =cj/--ai-vterm-buffer-name= | pure | =(format "claude [%s]" basename)= |
+| =cj/--ai-vterm-show-or-create= | internal | Given dir + name: display existing buffer, or create vterm + send claude command |
+| =cj/ai-vterm= | interactive entry | Composes picker + show-or-create |
+
+The =display-buffer-alist= entry is added at module load:
+
+#+begin_src emacs-lisp
+(add-to-list 'display-buffer-alist
+ '("\\`claude \\["
+ (display-buffer-in-side-window)
+ (side . right)
+ (window-width . 0.5)
+ (dedicated . t)))
+#+end_src
+
+** Data Flow
+
+On =M-x cj/ai-vterm=:
+
+1. Pick a project via =completing-read=. Display in =~/relative= form. Return absolute path.
+2. Compute buffer name: =claude [<basename-of-dir>]=.
+3. Branch:
+ - *Buffer exists with live process* → =display-buffer= it. Side-window rule routes it to the right slot.
+ - *Buffer exists, dead process* → kill it (log last 200 chars to =*Messages*=), then fall through to create.
+ - *No buffer* → =let=-bind =default-directory= to picked dir and =vterm-buffer-name= to computed name; call =(vterm)=. After process is live, send =claude "Read .ai/protocols.org and follow all instructions."= via =vterm-send-string= + =vterm-send-return=.
+4. =select-window= on the displayed window so point lands in Claude. =C-u= prefix shows without selecting.
+
+After this, all navigation is handled by existing global bindings: Shift-arrows (windmove) for focus, C-M-arrows (=buffer-move=) for directional side-swap.
+
+** Error Handling
+
+| Case | Response |
+|------+----------|
+| Picker cancelled (=quit=) | Silent no-op |
+| No candidates found | =user-error= naming the search roots |
+| Picked dir disappeared between scan and launch | =user-error= naming the path |
+| Existing buffer with dead process | Kill + recreate; log last 200 chars |
+| Side-window already showing a different =claude [...]= | =display-buffer= swaps which buffer occupies the slot; hidden one keeps running |
+| =vterm= not installed | Module fails to load loudly (no graceful degradation) |
+
+** Tmux Auto-Launch Suppression
+
+Existing =cj/vterm-launch-tmux= on =vterm-mode-hook= types =tmux\n= unconditionally. AI vterms set a buffer-local =cj/--ai-vterm-suppress-tmux= flag before =(vterm)=; the hook checks the flag and skips tmux when set.
+
+** Testing
+
+Pure helpers tested against real inputs:
+
+- =cj/--ai-vterm-buffer-name= — Normal, Boundary (trailing slash, dot-prefix dirs, spaces in basenames), Error (degenerate paths).
+- =cj/--ai-vterm-candidates= — temp directory tree built with =make-temp-file= + =make-directory=, fake =.ai/protocols.org= markers. Assert returned paths, ignored entries.
+
+Internal with mocked boundary:
+
+- =cj/--ai-vterm-show-or-create= — =cl-letf= on =vterm= to skip process spawn; assert buffer name, =default-directory=, claude argv via captured =vterm-send-string= calls. Two branches (exists vs creates) tested with mocked =process-live-p=.
+
+Display rule:
+
+- After =add-to-list=, =display-buffer= on a buffer named =claude [test]= lands in a window with =(window-parameter w 'window-side) = 'right=.
+
+Test files:
+
+- =tests/test-ai-vterm--candidates.el=
+- =tests/test-ai-vterm--buffer-name.el=
+- =tests/test-ai-vterm--show-or-create.el=
+- =tests/test-ai-vterm--display-rule.el=
+
+Smoke test (=:slow= tag, excluded from default suite): launch against a fixture, verify live process.
+
+* Open Questions
+
+- [ ] Default split width — 50/50 vs 60/40 weighted to code. Starting with 50/50.
+- [X] Keybinding — F9. Replaces the prior =cj/toggle-gptel= binding on F9; gptel moves to C-F9.
+
+* Next Steps
+
+- TDD implementation in this order: =buffer-name= → =candidates= → =show-or-create= → display rule → interactive entry.
+- Wire into =init.el= after =eshell-vterm-config=.
+- Pick a keybinding once the command is shipped.
diff --git a/init.el b/init.el
index 4825a330..a070dd79 100644
--- a/init.el
+++ b/init.el
@@ -72,6 +72,7 @@
(require 'erc-config) ;; seamless IRC client
(require 'slack-config) ;; slack client via emacs-slack
(require 'eshell-vterm-config) ;; shell and terminal configuration
+(require 'ai-vterm) ;; in-Emacs Claude launcher (vertical-split vterm)
(require 'help-utils) ;; search: arch-wiki, devdoc, tldr, wikipedia
(require 'help-config) ;; info, man, help config
(require 'tramp-config) ;; remote shell connections
diff --git a/modules/ai-config.el b/modules/ai-config.el
index d3765ab8..779862bc 100644
--- a/modules/ai-config.el
+++ b/modules/ai-config.el
@@ -28,6 +28,8 @@
;;; Code:
+(require 'keybindings) ;; provides cj/custom-keymap
+
(autoload 'cj/gptel-save-conversation "ai-conversations" "Save the AI conversation to a file." t)
(autoload 'cj/gptel-load-conversation "ai-conversations" "Load a saved AI conversation." t)
(autoload 'cj/gptel-delete-conversation "ai-conversations" "Delete a saved AI conversation." t)
@@ -310,7 +312,7 @@ Works for any buffer, whether it's visiting a file or not."
:defer t
:commands (gptel gptel-send gptel-menu)
:bind
- (("<f9>" . cj/toggle-gptel)
+ (("C-<f9>" . cj/toggle-gptel)
:map gptel-mode-map
("C-<return>" . gptel-send))
:custom
diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el
new file mode 100644
index 00000000..afda4f44
--- /dev/null
+++ b/modules/ai-vterm.el
@@ -0,0 +1,198 @@
+;;; ai-vterm.el --- In-Emacs Claude launcher with vertical-split vterm -*- lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; Picks a Claude-template project (a dir under ~/.emacs.d, ~/code/*, or
+;; ~/projects/* containing .ai/protocols.org), opens or reuses a vterm
+;; buffer named "claude [<basename>]", sends Claude Code's startup
+;; instruction to it, and routes the buffer to a right-side window via
+;; display-buffer-alist. Multiple projects produce multiple coexisting
+;; buffers that share the same right-side slot; switching among them is a
+;; buffer-switch, not a kill-and-recreate.
+;;
+;; 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:
+
+(declare-function vterm "vterm" (&optional buffer-name))
+(declare-function vterm-send-string "vterm" (string &optional paste-p))
+(declare-function vterm-send-return "vterm" ())
+
+(defgroup ai-vterm nil
+ "In-Emacs Claude launcher with vertical-split vterm."
+ :group 'tools)
+
+(defcustom cj/ai-vterm-claude-command
+ "claude \"Read .ai/protocols.org and follow all instructions.\""
+ "Shell command sent to a fresh AI-vterm to start Claude Code."
+ :type 'string
+ :group 'ai-vterm)
+
+(defcustom cj/ai-vterm-project-roots
+ (list (expand-file-name "~/.emacs.d"))
+ "Directories that are themselves Claude-template projects.
+Each entry is included as a candidate when it exists and contains
+.ai/protocols.org. Use this for single-project roots like ~/.emacs.d."
+ :type '(repeat directory)
+ :group 'ai-vterm)
+
+(defcustom cj/ai-vterm-container-roots
+ (list (expand-file-name "~/code")
+ (expand-file-name "~/projects"))
+ "Directories whose immediate children are scanned for Claude projects.
+Each entry's child directories are included as candidates when they
+contain .ai/protocols.org. Use this for container dirs like ~/code."
+ :type '(repeat directory)
+ :group 'ai-vterm)
+
+(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]"
+ (file-name-nondirectory (directory-file-name dir))))
+
+(defun cj/--ai-vterm-has-marker-p (dir)
+ "Return non-nil when DIR contains .ai/protocols.org."
+ (file-exists-p (expand-file-name ".ai/protocols.org" dir)))
+
+(defun cj/--ai-vterm-candidates ()
+ "Return the list of Claude-template project paths.
+
+Each entry of `cj/ai-vterm-project-roots' contributes itself when it
+exists and contains .ai/protocols.org. Each entry of
+`cj/ai-vterm-container-roots' contributes its immediate child
+directories that contain .ai/protocols.org.
+
+Returns absolute paths. Nonexistent roots are skipped silently."
+ (let (result)
+ (dolist (root cj/ai-vterm-project-roots)
+ (let ((expanded (expand-file-name root)))
+ (when (and (file-directory-p expanded)
+ (cj/--ai-vterm-has-marker-p expanded))
+ (push expanded result))))
+ (dolist (root cj/ai-vterm-container-roots)
+ (let ((expanded (expand-file-name root)))
+ (when (file-directory-p expanded)
+ (dolist (child (directory-files
+ expanded t directory-files-no-dot-files-regexp t))
+ (when (and (file-directory-p child)
+ (cj/--ai-vterm-has-marker-p child))
+ (push child result))))))
+ (nreverse result)))
+
+(defun cj/--ai-vterm-process-live-p (buffer)
+ "Return non-nil when BUFFER has a live process attached."
+ (let ((proc (get-buffer-process buffer)))
+ (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."
+ :type 'number
+ :group 'ai-vterm)
+
+(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 \\["
+ (display-buffer-reuse-window
+ display-buffer-use-some-window
+ display-buffer-in-direction)
+ (direction . right)
+ (window-width . ,cj/ai-vterm-window-width)
+ (inhibit-same-window . t))))
+
+(dolist (entry (cj/--ai-vterm-display-rule-list))
+ (add-to-list 'display-buffer-alist entry))
+
+(defun cj/--ai-vterm-show-or-create (dir name)
+ "Show or create the AI-vterm buffer for project DIR with buffer NAME.
+
+If a buffer named NAME exists with a live process, display it. If
+the buffer exists but its process is dead, kill it and recreate. If
+no such buffer exists, create a new vterm in DIR and send
+`cj/ai-vterm-claude-command' to it.
+
+Returns the buffer."
+ (let ((existing (get-buffer name)))
+ (cond
+ ((and existing (cj/--ai-vterm-process-live-p existing))
+ (display-buffer existing)
+ existing)
+ (t
+ (when existing
+ (kill-buffer existing))
+ (let ((default-directory dir))
+ (vterm name))
+ (let ((buf (get-buffer name)))
+ (with-current-buffer buf
+ (vterm-send-string cj/ai-vterm-claude-command)
+ (vterm-send-return))
+ (display-buffer buf)
+ buf)))))
+
+(defun cj/--ai-vterm-pick-project ()
+ "Prompt for a Claude-template project; return its absolute path.
+
+Candidates come from `cj/--ai-vterm-candidates'. Display uses
+`abbreviate-file-name' so paths read as ~/code/foo instead of the
+full home-dir form. Signals `user-error' when no candidates exist."
+ (let ((candidates (cj/--ai-vterm-candidates)))
+ (unless candidates
+ (user-error "No Claude-template projects found under %s"
+ (mapconcat #'identity
+ (append cj/ai-vterm-project-roots
+ cj/ai-vterm-container-roots)
+ ", ")))
+ (let* ((display-alist
+ (mapcar (lambda (p) (cons (abbreviate-file-name p) p))
+ candidates))
+ (chosen (completing-read "AI vterm project: "
+ display-alist nil t)))
+ (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.
+
+The project is picked from a filtered completing-read list of dirs
+that contain .ai/protocols.org. The vterm buffer is named
+\"claude [<basename>]\" and is routed to a right-side window via
+`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."
+ (interactive "P")
+ (let* ((dir (cj/--ai-vterm-pick-project))
+ (name (cj/--ai-vterm-buffer-name dir))
+ (buf (cj/--ai-vterm-show-or-create dir name)))
+ (unless arg
+ (let ((win (get-buffer-window buf)))
+ (when win (select-window win))))
+ buf))
+
+(keymap-global-set "<f9>" #'cj/ai-vterm)
+
+(provide 'ai-vterm)
+;;; ai-vterm.el ends here
diff --git a/modules/eshell-vterm-config.el b/modules/eshell-vterm-config.el
index df1f3f77..4c22944b 100644
--- a/modules/eshell-vterm-config.el
+++ b/modules/eshell-vterm-config.el
@@ -231,12 +231,18 @@
("<f12>" . vterm-toggle)
:config
(setq vterm-toggle-fullscreen-p nil)
+ ;; This rule covers F12 toggle-shells only. AI-vterm buffers are named
+ ;; "claude [<repo>]" and have their own display rule in `ai-vterm.el'
+ ;; that puts them in a right-direction window without dedication. The
+ ;; explicit "claude [" exclusion stops this rule from claiming them
+ ;; first when `:defer' makes vterm-toggle's :config run last.
(add-to-list 'display-buffer-alist
'((lambda (buffer-or-name _)
(let ((buffer (get-buffer buffer-or-name)))
(with-current-buffer buffer
- (or (equal major-mode 'vterm-mode)
- (string-prefix-p vterm-buffer-name (buffer-name buffer))))))
+ (and (or (equal major-mode 'vterm-mode)
+ (string-prefix-p vterm-buffer-name (buffer-name buffer)))
+ (not (string-prefix-p "claude [" (buffer-name buffer)))))))
(display-buffer-reuse-window display-buffer-at-bottom)
(dedicated . t) ;dedicated is supported in Emacs 27+
(reusable-frames . visible)
diff --git a/tests/test-ai-vterm--buffer-name.el b/tests/test-ai-vterm--buffer-name.el
new file mode 100644
index 00000000..95c673ba
--- /dev/null
+++ b/tests/test-ai-vterm--buffer-name.el
@@ -0,0 +1,42 @@
+;;; test-ai-vterm--buffer-name.el --- Tests for cj/--ai-vterm-buffer-name -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the buffer-name transform. Given an absolute project
+;; directory, the helper returns "claude [<basename>]". The naming pattern
+;; is what the display-buffer-alist rule keys on, so a regression here
+;; silently breaks routing to the right side-window.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+(ert-deftest test-ai-vterm--buffer-name-normal-project ()
+ "Normal: a typical project path yields claude [<basename>]."
+ (should (equal (cj/--ai-vterm-buffer-name "/home/cjennings/projects/foo")
+ "claude [foo]")))
+
+(ert-deftest test-ai-vterm--buffer-name-trailing-slash ()
+ "Boundary: trailing slash collapses before basename extraction."
+ (should (equal (cj/--ai-vterm-buffer-name "/home/cjennings/projects/foo/")
+ "claude [foo]")))
+
+(ert-deftest test-ai-vterm--buffer-name-dot-prefix-dir ()
+ "Boundary: dot-prefix dirs (.emacs.d) preserve the dot in the basename."
+ (should (equal (cj/--ai-vterm-buffer-name "/home/cjennings/.emacs.d")
+ "claude [.emacs.d]")))
+
+(ert-deftest test-ai-vterm--buffer-name-space-in-basename ()
+ "Boundary: a space in the basename round-trips into the buffer name."
+ (should (equal (cj/--ai-vterm-buffer-name "/tmp/my work")
+ "claude [my work]")))
+
+(ert-deftest test-ai-vterm--buffer-name-deeply-nested ()
+ "Normal: only the last path component is used."
+ (should (equal (cj/--ai-vterm-buffer-name "/a/b/c/d/e/leaf")
+ "claude [leaf]")))
+
+(provide 'test-ai-vterm--buffer-name)
+;;; test-ai-vterm--buffer-name.el ends here
diff --git a/tests/test-ai-vterm--candidates.el b/tests/test-ai-vterm--candidates.el
new file mode 100644
index 00000000..b45888cc
--- /dev/null
+++ b/tests/test-ai-vterm--candidates.el
@@ -0,0 +1,139 @@
+;;; test-ai-vterm--candidates.el --- Tests for cj/--ai-vterm-candidates -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the project-candidate walker. Two kinds of search root:
+;;
+;; - project root (a single project dir, e.g. ~/.emacs.d) -- include if it
+;; itself contains .ai/protocols.org
+;; - container root (e.g. ~/code, ~/projects) -- scan immediate children;
+;; include each child that contains .ai/protocols.org
+;;
+;; Tests build a temp directory tree with fake .ai/protocols.org markers
+;; and let-bind the search-root customs at it.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+(defun test-ai-vterm--make-marker (dir)
+ "Create DIR/.ai/protocols.org so DIR registers as a Claude project."
+ (let ((ai-dir (expand-file-name ".ai" dir)))
+ (make-directory ai-dir t)
+ (write-region "" nil (expand-file-name "protocols.org" ai-dir))))
+
+(defmacro test-ai-vterm--with-fixture (root &rest body)
+ "Bind ROOT to a fresh temp directory; remove on exit; run BODY."
+ (declare (indent 1) (debug t))
+ `(let ((,root (make-temp-file "ai-vterm-test-" t)))
+ (unwind-protect
+ (progn ,@body)
+ (delete-directory ,root t))))
+
+(ert-deftest test-ai-vterm--candidates-project-root-with-marker ()
+ "Normal: a project root containing .ai/protocols.org is included."
+ (test-ai-vterm--with-fixture root
+ (let ((proj (expand-file-name "emacs-d-fake" root)))
+ (make-directory proj)
+ (test-ai-vterm--make-marker proj)
+ (let ((cj/ai-vterm-project-roots (list proj))
+ (cj/ai-vterm-container-roots nil))
+ (should (equal (cj/--ai-vterm-candidates)
+ (list (expand-file-name proj))))))))
+
+(ert-deftest test-ai-vterm--candidates-project-root-without-marker ()
+ "Boundary: a project root without .ai/protocols.org is excluded."
+ (test-ai-vterm--with-fixture root
+ (let ((proj (expand-file-name "no-ai" root)))
+ (make-directory proj)
+ (let ((cj/ai-vterm-project-roots (list proj))
+ (cj/ai-vterm-container-roots nil))
+ (should (null (cj/--ai-vterm-candidates)))))))
+
+(ert-deftest test-ai-vterm--candidates-container-includes-children-with-marker ()
+ "Normal: a container's children with .ai/protocols.org are included."
+ (test-ai-vterm--with-fixture root
+ (let ((container (expand-file-name "code" root))
+ (foo (expand-file-name "code/foo" root))
+ (bar (expand-file-name "code/bar" root)))
+ (make-directory container)
+ (make-directory foo)
+ (make-directory bar)
+ (test-ai-vterm--make-marker foo)
+ (test-ai-vterm--make-marker bar)
+ (let* ((cj/ai-vterm-project-roots nil)
+ (cj/ai-vterm-container-roots (list container))
+ (got (sort (cj/--ai-vterm-candidates) #'string<)))
+ (should (equal got
+ (sort (list (expand-file-name foo)
+ (expand-file-name bar))
+ #'string<)))))))
+
+(ert-deftest test-ai-vterm--candidates-container-skips-children-without-marker ()
+ "Boundary: a container's children without .ai/protocols.org are skipped."
+ (test-ai-vterm--with-fixture root
+ (let ((container (expand-file-name "code" root))
+ (foo (expand-file-name "code/foo" root))
+ (bare (expand-file-name "code/bare" root)))
+ (make-directory container)
+ (make-directory foo)
+ (make-directory bare)
+ (test-ai-vterm--make-marker foo)
+ (let ((cj/ai-vterm-project-roots nil)
+ (cj/ai-vterm-container-roots (list container)))
+ (should (equal (cj/--ai-vterm-candidates)
+ (list (expand-file-name foo))))))))
+
+(ert-deftest test-ai-vterm--candidates-container-skips-non-directory-entries ()
+ "Boundary: a container's non-directory entries are ignored."
+ (test-ai-vterm--with-fixture root
+ (let ((container (expand-file-name "code" root))
+ (foo (expand-file-name "code/foo" root))
+ (stray (expand-file-name "code/README.txt" root)))
+ (make-directory container)
+ (make-directory foo)
+ (test-ai-vterm--make-marker foo)
+ (write-region "" nil stray)
+ (let ((cj/ai-vterm-project-roots nil)
+ (cj/ai-vterm-container-roots (list container)))
+ (should (equal (cj/--ai-vterm-candidates)
+ (list (expand-file-name foo))))))))
+
+(ert-deftest test-ai-vterm--candidates-nonexistent-root-is-skipped ()
+ "Error: a nonexistent search root is skipped silently, no error raised."
+ (test-ai-vterm--with-fixture root
+ (let ((cj/ai-vterm-project-roots
+ (list (expand-file-name "does-not-exist" root)))
+ (cj/ai-vterm-container-roots
+ (list (expand-file-name "also-missing" root))))
+ (should (null (cj/--ai-vterm-candidates))))))
+
+(ert-deftest test-ai-vterm--candidates-empty-roots-yield-empty-list ()
+ "Boundary: nil roots yield nil."
+ (let ((cj/ai-vterm-project-roots nil)
+ (cj/ai-vterm-container-roots nil))
+ (should (null (cj/--ai-vterm-candidates)))))
+
+(ert-deftest test-ai-vterm--candidates-mixed-roots ()
+ "Normal: project + container roots combine in one result list."
+ (test-ai-vterm--with-fixture root
+ (let ((emacs-d (expand-file-name "emacs-d" root))
+ (container (expand-file-name "code" root))
+ (foo (expand-file-name "code/foo" root)))
+ (make-directory emacs-d)
+ (make-directory container)
+ (make-directory foo)
+ (test-ai-vterm--make-marker emacs-d)
+ (test-ai-vterm--make-marker foo)
+ (let* ((cj/ai-vterm-project-roots (list emacs-d))
+ (cj/ai-vterm-container-roots (list container))
+ (got (sort (cj/--ai-vterm-candidates) #'string<)))
+ (should (equal got
+ (sort (list (expand-file-name emacs-d)
+ (expand-file-name foo))
+ #'string<)))))))
+
+(provide 'test-ai-vterm--candidates)
+;;; test-ai-vterm--candidates.el ends here
diff --git a/tests/test-ai-vterm--display-rule.el b/tests/test-ai-vterm--display-rule.el
new file mode 100644
index 00000000..af481eb3
--- /dev/null
+++ b/tests/test-ai-vterm--display-rule.el
@@ -0,0 +1,74 @@
+;;; test-ai-vterm--display-rule.el --- Tests for the AI-vterm display-buffer rule -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The module installs a `display-buffer-alist' entry routing buffers
+;; whose names match "\\`claude \\[" to a right-side window. These
+;; tests verify the rule reaches the right side and ignores buffers
+;; that don't match the prefix.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+(defun test-ai-vterm--cleanup (name)
+ "Kill buffer NAME if it exists."
+ (when (get-buffer name)
+ (kill-buffer name)))
+
+(defmacro test-ai-vterm--with-clean-frame (&rest body)
+ "Run BODY in a context with one window and the AI-vterm rule loaded."
+ (declare (indent 0) (debug t))
+ `(save-window-excursion
+ (delete-other-windows)
+ (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)))
+ ,@body)))
+
+(ert-deftest test-ai-vterm--display-rule-routes-claude-buffer-to-right ()
+ "Normal: a buffer named \"claude [foo]\" lands in a window to the right.
+
+The rule uses `display-buffer-in-direction' with `(direction . right)',
+which splits the current window so the new window's left edge sits at
+a positive column. The buffer winds up in that new window."
+ (let ((name "claude [display-rule-test]"))
+ (test-ai-vterm--cleanup name)
+ (unwind-protect
+ (test-ai-vterm--with-clean-frame
+ (let* ((buf (get-buffer-create name))
+ (win (display-buffer buf)))
+ (should (windowp win))
+ (should (> (window-left-column win) 0))))
+ (test-ai-vterm--cleanup name))))
+
+(ert-deftest test-ai-vterm--display-rule-skips-non-matching-buffer ()
+ "Boundary: a buffer not named \"claude [...]\" does not match the rule.
+
+The rule's regex doesn't fire, so `display-buffer' falls back to the
+default action -- reuse the current window -- and no rightward split
+occurs."
+ (let ((name "scratch-buffer-no-match"))
+ (test-ai-vterm--cleanup name)
+ (unwind-protect
+ (test-ai-vterm--with-clean-frame
+ (let* ((buf (get-buffer-create name))
+ (win (display-buffer buf)))
+ (should (windowp win))
+ (should (= (window-left-column win) 0))))
+ (test-ai-vterm--cleanup name))))
+
+(ert-deftest test-ai-vterm--display-rule-prefix-not-substring ()
+ "Boundary: \"foo claude [bar]\" does not match -- the rule anchors at start."
+ (let ((name "foo claude [substring-test]"))
+ (test-ai-vterm--cleanup name)
+ (unwind-protect
+ (test-ai-vterm--with-clean-frame
+ (let* ((buf (get-buffer-create name))
+ (win (display-buffer buf)))
+ (should (windowp win))
+ (should (= (window-left-column win) 0))))
+ (test-ai-vterm--cleanup name))))
+
+(provide 'test-ai-vterm--display-rule)
+;;; test-ai-vterm--display-rule.el ends here
diff --git a/tests/test-ai-vterm--pick-project.el b/tests/test-ai-vterm--pick-project.el
new file mode 100644
index 00000000..6fa2d185
--- /dev/null
+++ b/tests/test-ai-vterm--pick-project.el
@@ -0,0 +1,48 @@
+;;; test-ai-vterm--pick-project.el --- Tests for cj/--ai-vterm-pick-project -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The picker presents abbreviated paths to `completing-read', then
+;; returns the absolute path corresponding to the user's choice. Empty
+;; candidate set raises a `user-error' rather than offering an empty
+;; prompt.
+
+;;; 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--pick-project-returns-absolute-path-of-choice ()
+ "Normal: user picks a candidate, picker returns its absolute path."
+ (cl-letf (((symbol-function 'cj/--ai-vterm-candidates)
+ (lambda () '("/home/u/code/foo" "/home/u/code/bar")))
+ ((symbol-function 'completing-read)
+ (lambda (_p collection &rest _)
+ ;; Pick the one whose display form matches ~/code/bar
+ ;; (collection is alist of display . abs)
+ (car (cl-find-if
+ (lambda (cell) (string-match-p "bar" (car cell)))
+ collection)))))
+ (should (equal (cj/--ai-vterm-pick-project) "/home/u/code/bar"))))
+
+(ert-deftest test-ai-vterm--pick-project-empty-candidates-raises-user-error ()
+ "Error: no candidates -> user-error rather than empty prompt."
+ (cl-letf (((symbol-function 'cj/--ai-vterm-candidates) (lambda () nil)))
+ (should-error (cj/--ai-vterm-pick-project) :type 'user-error)))
+
+(ert-deftest test-ai-vterm--pick-project-presents-abbreviated-paths ()
+ "Normal: the completing-read collection holds abbreviated display forms."
+ (let (received-collection)
+ (cl-letf (((symbol-function 'cj/--ai-vterm-candidates)
+ (lambda () (list (expand-file-name "~/code/foo"))))
+ ((symbol-function 'completing-read)
+ (lambda (_p collection &rest _)
+ (setq received-collection collection)
+ (caar collection))))
+ (cj/--ai-vterm-pick-project)
+ (should (equal (caar received-collection) "~/code/foo")))))
+
+(provide 'test-ai-vterm--pick-project)
+;;; test-ai-vterm--pick-project.el ends here
diff --git a/tests/test-ai-vterm--show-or-create.el b/tests/test-ai-vterm--show-or-create.el
new file mode 100644
index 00000000..28e0faeb
--- /dev/null
+++ b/tests/test-ai-vterm--show-or-create.el
@@ -0,0 +1,119 @@
+;;; test-ai-vterm--show-or-create.el --- Tests for cj/--ai-vterm-show-or-create -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests the show-or-create branching:
+;;
+;; - buffer absent -> vterm called, claude command sent
+;; - buffer present, live -> vterm not called, buffer displayed
+;; - buffer present, dead -> old buffer killed, vterm recreates
+;;
+;; vterm functions are stubbed so the test does no process spawning.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+;; vterm isn't loaded in batch -- provide stubs so cl-letf has overrides.
+(unless (fboundp 'vterm)
+ (defun vterm (&optional _name) nil))
+(unless (fboundp 'vterm-send-string)
+ (defun vterm-send-string (_s &optional _) nil))
+(unless (fboundp 'vterm-send-return)
+ (defun vterm-send-return () nil))
+
+(defmacro test-ai-vterm--with-mock-vterm (vars &rest body)
+ "Run BODY with vterm + send-string + send-return mocked.
+
+VARS is a plist of capture variable names: :calls, :strings, :returns,
+:default-dir. The test references these names directly inside BODY."
+ (declare (indent 1) (debug t))
+ (let ((calls (plist-get vars :calls))
+ (strings (plist-get vars :strings))
+ (returns (plist-get vars :returns))
+ (ddir (plist-get vars :default-dir)))
+ `(let ((,calls '())
+ (,strings '())
+ (,returns 0)
+ (,ddir nil))
+ (cl-letf (((symbol-function 'vterm)
+ (lambda (&optional name)
+ (push name ,calls)
+ (setq ,ddir default-directory)
+ (with-current-buffer (get-buffer-create name)
+ (current-buffer))))
+ ((symbol-function 'vterm-send-string)
+ (lambda (s &optional _) (push s ,strings)))
+ ((symbol-function 'vterm-send-return)
+ (lambda () (cl-incf ,returns))))
+ ,@body))))
+
+(defun test-ai-vterm--cleanup (name)
+ "Kill buffer NAME if it exists."
+ (when (get-buffer name)
+ (kill-buffer name)))
+
+(ert-deftest test-ai-vterm--show-or-create-creates-when-buffer-missing ()
+ "Normal: no existing buffer -> vterm called once, claude cmd sent."
+ (let ((name "claude [normal-create-test]"))
+ (test-ai-vterm--cleanup name)
+ (unwind-protect
+ (test-ai-vterm--with-mock-vterm (:calls calls :strings strings
+ :returns returns :default-dir ddir)
+ (cj/--ai-vterm-show-or-create "/tmp/some-project" name)
+ (should (equal calls (list name)))
+ (should (equal strings (list cj/ai-vterm-claude-command)))
+ (should (= returns 1))
+ (should (equal ddir "/tmp/some-project")))
+ (test-ai-vterm--cleanup name))))
+
+(ert-deftest test-ai-vterm--show-or-create-displays-existing-when-process-live ()
+ "Normal: buffer exists with live process -> vterm not called."
+ (let ((name "claude [reuse-test]"))
+ (test-ai-vterm--cleanup name)
+ (unwind-protect
+ (let ((buf (get-buffer-create name)))
+ (cl-letf (((symbol-function 'cj/--ai-vterm-process-live-p)
+ (lambda (b) (and (eq b buf) t))))
+ (test-ai-vterm--with-mock-vterm (:calls calls :strings strings
+ :returns returns :default-dir _ddir)
+ (cj/--ai-vterm-show-or-create "/tmp/reuse" name)
+ (should (null calls))
+ (should (null strings))
+ (should (= returns 0)))))
+ (test-ai-vterm--cleanup name))))
+
+(ert-deftest test-ai-vterm--show-or-create-recreates-when-process-dead ()
+ "Boundary: buffer exists with dead process -> killed and recreated."
+ (let ((name "claude [dead-test]"))
+ (test-ai-vterm--cleanup name)
+ (unwind-protect
+ (let ((stale (get-buffer-create name)))
+ (cl-letf (((symbol-function 'cj/--ai-vterm-process-live-p)
+ (lambda (_b) nil)))
+ (test-ai-vterm--with-mock-vterm (:calls calls :strings strings
+ :returns returns :default-dir _ddir)
+ (cj/--ai-vterm-show-or-create "/tmp/dead" name)
+ (should (equal calls (list name)))
+ (should (equal strings (list cj/ai-vterm-claude-command)))
+ (should (= returns 1))
+ (should-not (buffer-live-p stale)))))
+ (test-ai-vterm--cleanup name))))
+
+(ert-deftest test-ai-vterm--show-or-create-returns-buffer ()
+ "Normal: return value is the vterm buffer."
+ (let ((name "claude [return-test]"))
+ (test-ai-vterm--cleanup name)
+ (unwind-protect
+ (test-ai-vterm--with-mock-vterm (:calls _c :strings _s
+ :returns _r :default-dir _d)
+ (let ((result (cj/--ai-vterm-show-or-create "/tmp/return" name)))
+ (should (bufferp result))
+ (should (equal (buffer-name result) name))))
+ (test-ai-vterm--cleanup name))))
+
+(provide 'test-ai-vterm--show-or-create)
+;;; test-ai-vterm--show-or-create.el ends here