From 47b218ed15acd00c18cbc3bef604c4f2e0050a08 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 7 May 2026 19:25:18 -0500 Subject: 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 []` 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. --- docs/design/ai-vterm.org | 146 ++++++++++++++++++++++++ init.el | 1 + modules/ai-config.el | 4 +- modules/ai-vterm.el | 198 +++++++++++++++++++++++++++++++++ modules/eshell-vterm-config.el | 10 +- tests/test-ai-vterm--buffer-name.el | 42 +++++++ tests/test-ai-vterm--candidates.el | 139 +++++++++++++++++++++++ tests/test-ai-vterm--display-rule.el | 74 ++++++++++++ tests/test-ai-vterm--pick-project.el | 48 ++++++++ tests/test-ai-vterm--show-or-create.el | 119 ++++++++++++++++++++ 10 files changed, 778 insertions(+), 3 deletions(-) create mode 100644 docs/design/ai-vterm.org create mode 100644 modules/ai-vterm.el create mode 100644 tests/test-ai-vterm--buffer-name.el create mode 100644 tests/test-ai-vterm--candidates.el create mode 100644 tests/test-ai-vterm--display-rule.el create mode 100644 tests/test-ai-vterm--pick-project.el create mode 100644 tests/test-ai-vterm--show-or-create.el 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 []=, 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 []=. +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 - (("" . cj/toggle-gptel) + (("C-" . cj/toggle-gptel) :map gptel-mode-map ("C-" . 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 + +;;; 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 []", 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 []\". 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 []\" 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 "" #'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 @@ ("" . vterm-toggle) :config (setq vterm-toggle-fullscreen-p nil) + ;; This rule covers F12 toggle-shells only. AI-vterm buffers are named + ;; "claude []" 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 []". 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 []." + (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 -- cgit v1.2.3