diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-07 19:25:18 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-07 19:25:18 -0500 |
| commit | 47b218ed15acd00c18cbc3bef604c4f2e0050a08 (patch) | |
| tree | 98c6541327b707e1e3c1f214f8a6dc7d0135a039 /modules | |
| parent | 3efaf9b5218fa769a297df5821ec89837207e57d (diff) | |
| download | dotemacs-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.
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/ai-config.el | 4 | ||||
| -rw-r--r-- | modules/ai-vterm.el | 198 | ||||
| -rw-r--r-- | modules/eshell-vterm-config.el | 10 |
3 files changed, 209 insertions, 3 deletions
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) |
