From c38683f13cf361adc93b72c1e87244a0153b2387 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 21 May 2026 20:19:14 -0400 Subject: feat(ai-vterm): add graceful agent close on M-f9 / C-S-f9 cj/ai-vterm-close tears an agent down cleanly: it kills the agent's tmux session (stopping the process), removes the vterm window when it isn't the only one in the frame, then kills the buffer. It targets the current agent buffer, the sole live agent, or prompts among several, and confirms before killing since that interrupts work in progress. I also folded the whole F9 family onto ai-vterm. M-f9 used to run cj/toggle-gptel, but gptel is broken right now (the local fork doesn't load, so gptel-make-anthropic is void), and grouping every ai-vterm command under F9 reads better anyway. M-f9 is the primary close binding. C-S-f9 is a second binding that the Wayland/PGTK layer may swallow on some machines. I covered it with 7 tests over the tmux-kill helper, the per-buffer teardown, and target selection, mocking process-file and the prompt at the boundary. --- modules/ai-vterm.el | 119 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 91 insertions(+), 28 deletions(-) (limited to 'modules') diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el index 266966ea..4306db9a 100644 --- a/modules/ai-vterm.el +++ b/modules/ai-vterm.el @@ -24,21 +24,24 @@ ;; "[running]" when a live vterm buffer exists), the rest follow in ;; alphabetical order. ;; -;; Three F-key entry points: +;; Four F-key entry points: ;; -;; - F9 `cj/ai-vterm' -- DWIM dispatch. If an agent buffer is -;; currently displayed in this frame, F9 quits its window -;; (toggle off). Otherwise, if exactly one agent 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 an agent buffer is currently displayed. -;; Used when the user wants to start a new project session -;; instead of toggling the current one. -;; - M-F9 `cj/toggle-gptel' -- toggle gptel's *AI-Assistant* window. -;; Lives outside this module (defined in `modules/ai-config.el') -;; but the binding is grouped with the other F9-family launchers -;; here so the dispatch shape is visible in one place. +;; - F9 `cj/ai-vterm' -- DWIM dispatch. If an agent buffer is +;; currently displayed in this frame, F9 quits its window +;; (toggle off). Otherwise, if exactly one agent 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 an agent 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-close' -- gracefully close an agent: kill its +;; tmux session (stopping the agent process), then its vterm +;; buffer and window. Confirms first. Targets the current +;; agent, the sole live agent, or prompts among several. +;; - C-S-F9 `cj/ai-vterm-close' -- same close command, second binding. +;; (M-F9 is the primary; C-S-F9 may be swallowed by the +;; Wayland/PGTK layer on some machines.) ;; ;; Existing windmove (Shift-arrows) handles code <-> agent focus ;; toggling. Buffer-move (C-M-arrows) handles side-swap. Neither @@ -57,12 +60,6 @@ (declare-function vterm-send-return "vterm" ()) (defvar vterm-mode-map) -;; `cj/toggle-gptel' lives in ai-config.el. Declaring it as an interactive -;; autoload (rather than `require'ing ai-config here) silences the byte-compile -;; warning at line 685/696 while keeping ai-vterm.el free of a load-time -;; dependency on the full ai-config stack. -(autoload 'cj/toggle-gptel "ai-config" nil t) - (defgroup ai-vterm nil "In-Emacs AI-agent launcher with vertical-split vterm." :group 'tools) @@ -680,8 +677,7 @@ 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. -M-F9 toggles gptel's *AI-Assistant* window (`cj/toggle-gptel', -defined in `modules/ai-config.el')." +M-F9 (and C-S-F9) close an agent via `cj/ai-vterm-close'." (interactive "P") (pcase (cj/--ai-vterm-dispatch) (`(toggle-off . ,win) @@ -728,9 +724,75 @@ defined in `modules/ai-config.el')." (`(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/toggle-gptel) +;; ----------------------------- Close an agent -------------------------------- + +(defun cj/--ai-vterm-kill-tmux-session (session) + "Kill the tmux SESSION via `tmux kill-session -t SESSION'. + +Returns the process exit status (0 on success), or nil when tmux is +unavailable or already gone -- a session that no longer exists is not +an error worth surfacing, since the goal is just to make sure it's +down." + (condition-case nil + (process-file "tmux" nil nil nil "kill-session" "-t" session) + (error nil))) + +(defun cj/--ai-vterm-close-buffer (buffer) + "Gracefully tear down AI-vterm BUFFER: tmux session, window, buffer. + +Derives the tmux session name from BUFFER's `default-directory' (the +project dir the vterm was created in) and kills it so the agent +process stops. Deletes BUFFER's window when it's shown and isn't the +only window in its frame, then kills BUFFER (suppressing the +process-still-running prompt -- the session is already down). No-op +when BUFFER isn't an AI-vterm buffer." + (when (cj/--ai-vterm-buffer-p buffer) + (cj/--ai-vterm-kill-tmux-session + (cj/--ai-vterm-tmux-session-name + (buffer-local-value 'default-directory buffer))) + (let ((win (get-buffer-window buffer))) + (when (and win (> (length (window-list (window-frame win) 'never)) 1)) + (delete-window win))) + (let ((kill-buffer-query-functions nil)) + (kill-buffer buffer)))) + +(defun cj/--ai-vterm-close-target () + "Return the AI-vterm buffer `cj/ai-vterm-close' should act on, or nil. + +The current buffer when it is an agent buffer; else the sole live +agent buffer; else a `completing-read' choice among the live agent +buffers; nil when none are alive." + (cond + ((cj/--ai-vterm-buffer-p (current-buffer)) (current-buffer)) + (t (let ((buffers (cj/--ai-vterm-agent-buffers))) + (cond + ((null buffers) nil) + ((null (cdr buffers)) (car buffers)) + (t (get-buffer + (completing-read "Close AI vterm: " + (mapcar #'buffer-name buffers) nil t)))))))) + +(defun cj/ai-vterm-close () + "Gracefully close an AI-vterm agent: kill its tmux session and buffer. + +Targets the current agent buffer, the sole live agent, or prompts when +several are alive (see `cj/--ai-vterm-close-target'). Asks for +confirmation first -- this kills the running agent process, which can +interrupt work in progress. Bound to M- (primary) and C-S-." + (interactive) + (let ((buffer (cj/--ai-vterm-close-target))) + (unless buffer + (user-error "No AI-vterm agent buffers to close")) + (let ((name (buffer-name buffer))) + (when (y-or-n-p (format "Close agent %s? This kills its tmux session. " + name)) + (cj/--ai-vterm-close-buffer buffer) + (message "Closed agent %s." name))))) + +(keymap-global-set "" #'cj/ai-vterm) +(keymap-global-set "C-" #'cj/ai-vterm-pick-project) +(keymap-global-set "M-" #'cj/ai-vterm-close) +(keymap-global-set "C-S-" #'cj/ai-vterm-close) ;; vterm binds .. to `vterm--self-insert', so a plain typed ;; while point is inside an agent buffer gets sent to the terminal program @@ -739,9 +801,10 @@ defined in `modules/ai-config.el')." ;; the toggle reaches Emacs from there too. (C- / M- aren't in vterm's ;; intercept set, but bind them here as well so the behaviour is uniform.) (with-eval-after-load 'vterm - (keymap-set vterm-mode-map "" #'cj/ai-vterm) - (keymap-set vterm-mode-map "C-" #'cj/ai-vterm-pick-project) - (keymap-set vterm-mode-map "M-" #'cj/toggle-gptel)) + (keymap-set vterm-mode-map "" #'cj/ai-vterm) + (keymap-set vterm-mode-map "C-" #'cj/ai-vterm-pick-project) + (keymap-set vterm-mode-map "M-" #'cj/ai-vterm-close) + (keymap-set vterm-mode-map "C-S-" #'cj/ai-vterm-close)) ;; ---------- emacsclient: keep opened files off the agent vterm ---------- ;; -- cgit v1.2.3