From eab070e5b542f525340ee7f07ea0560944639721 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 8 May 2026 19:21:26 -0500 Subject: 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. --- modules/ai-config.el | 9 +- modules/ai-vterm.el | 360 +++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 338 insertions(+), 31 deletions(-) (limited to 'modules') diff --git a/modules/ai-config.el b/modules/ai-config.el index 779862bc..3b0b6e20 100644 --- a/modules/ai-config.el +++ b/modules/ai-config.el @@ -5,7 +5,7 @@ ;; Configuration for AI integrations in Emacs, focused on GPTel. ;; ;; Main Features: -;; - Quick toggle for AI assistant window (F9 or C-; a t) +;; - Quick toggle for AI assistant window (C-; a t) ;; - Custom keymap (C-; a prefix) for AI-related commands. ;; - Enhanced org-mode conversation formatting with timestamps ;; allows switching models and easily compare and track responses. @@ -17,7 +17,7 @@ ;; Basic Workflow ;; ;; Using a side-chat window: -;; - Launch GPTel via F9 or C-; a t, and chat in the AI-Assistant side window (C- to send) +;; - Launch GPTel via C-; a t, and chat in the AI-Assistant side window (C- to send) ;; - Change system prompt (expertise, personalities) with C-; a p ;; - Add context from files (C-; a f) or current buffer (C-; a .) ;; - Save conversations with C-; a s, load previous ones with C-; a l @@ -312,9 +312,8 @@ Works for any buffer, whether it's visiting a file or not." :defer t :commands (gptel gptel-send gptel-menu) :bind - (("C-" . cj/toggle-gptel) - :map gptel-mode-map - ("C-" . gptel-send)) + (:map gptel-mode-map + ("C-" . gptel-send)) :custom (gptel-default-mode 'org-mode) (gptel-expert-commands t) diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el index 4d83127a..cf375955 100644 --- a/modules/ai-vterm.el +++ b/modules/ai-vterm.el @@ -12,12 +12,31 @@ ;; buffers that share the same right-side slot; switching among them is a ;; buffer-switch, not a kill-and-recreate. ;; +;; Three F-key entry points: +;; +;; - F9 `cj/ai-vterm' -- DWIM dispatch. If a claude buffer is +;; currently displayed in this frame, F9 quits its window +;; (toggle off). Otherwise, if exactly one claude buffer is +;; alive, F9 re-displays it; if zero or two-plus are alive, F9 +;; falls through to the project picker. +;; - C-F9 `cj/ai-vterm-pick-project' -- always show the project +;; picker, even when a claude buffer is currently displayed. +;; Used when the user wants to start a new project session +;; instead of toggling the current one. +;; - M-F9 `cj/ai-vterm-pick-buffer' -- pick from the alive claude +;; buffers (no project candidates, no creation). When a claude +;; buffer is currently displayed, the picked buffer replaces it +;; in that window so orientation and size are preserved. +;; ;; Existing windmove (Shift-arrows) handles code <-> Claude focus ;; toggling. Buffer-move (C-M-arrows) handles side-swap. Neither ;; needs anything new from this module. ;;; Code: +(require 'cl-lib) +(require 'seq) + (declare-function vterm "vterm" (&optional buffer-name)) (declare-function vterm-send-string "vterm" (string &optional paste-p)) (declare-function vterm-send-return "vterm" ()) @@ -58,15 +77,52 @@ contain .ai/protocols.org. Use this for container dirs like ~/code." :type '(repeat directory) :group 'ai-vterm) +(defconst cj/--ai-vterm-name-prefix "claude [" + "Buffer-name prefix shared by all AI-vterm buffers. + +Single source of truth for both buffer construction in +`cj/--ai-vterm-buffer-name' and detection in +`cj/--ai-vterm-buffer-p'. The display-buffer-alist rule keys on the +escaped form \"\\\\`claude \\\\[\" -- they must stay in sync.") + (defun cj/--ai-vterm-buffer-name (dir) "Return the AI-vterm buffer name for project directory DIR. The name pattern is \"claude []\". 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 "" #'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 "" #'cj/ai-vterm) +(keymap-global-set "C-" #'cj/ai-vterm-pick-project) +(keymap-global-set "M-" #'cj/ai-vterm-pick-buffer) (provide 'ai-vterm) ;;; ai-vterm.el ends here -- cgit v1.2.3