aboutsummaryrefslogtreecommitdiff
path: root/modules/ai-vterm.el
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 /modules/ai-vterm.el
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.
Diffstat (limited to 'modules/ai-vterm.el')
-rw-r--r--modules/ai-vterm.el360
1 files changed, 334 insertions, 26 deletions
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