diff options
| -rw-r--r-- | modules/ai-vterm.el | 119 | ||||
| -rw-r--r-- | tests/test-ai-vterm--close.el | 86 | ||||
| -rw-r--r-- | tests/test-ai-vterm--f9-in-vterm.el | 27 |
3 files changed, 184 insertions, 48 deletions
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 "<f9>" #'cj/ai-vterm) -(keymap-global-set "C-<f9>" #'cj/ai-vterm-pick-project) -(keymap-global-set "M-<f9>" #'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-<f9> (primary) and C-S-<f9>." + (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 "<f9>" #'cj/ai-vterm) +(keymap-global-set "C-<f9>" #'cj/ai-vterm-pick-project) +(keymap-global-set "M-<f9>" #'cj/ai-vterm-close) +(keymap-global-set "C-S-<f9>" #'cj/ai-vterm-close) ;; vterm binds <f1>..<f12> to `vterm--self-insert', so a plain <f9> 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-<f9> / M-<f9> 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 "<f9>" #'cj/ai-vterm) - (keymap-set vterm-mode-map "C-<f9>" #'cj/ai-vterm-pick-project) - (keymap-set vterm-mode-map "M-<f9>" #'cj/toggle-gptel)) + (keymap-set vterm-mode-map "<f9>" #'cj/ai-vterm) + (keymap-set vterm-mode-map "C-<f9>" #'cj/ai-vterm-pick-project) + (keymap-set vterm-mode-map "M-<f9>" #'cj/ai-vterm-close) + (keymap-set vterm-mode-map "C-S-<f9>" #'cj/ai-vterm-close)) ;; ---------- emacsclient: keep opened files off the agent vterm ---------- ;; diff --git a/tests/test-ai-vterm--close.el b/tests/test-ai-vterm--close.el new file mode 100644 index 00000000..eb89bcc2 --- /dev/null +++ b/tests/test-ai-vterm--close.el @@ -0,0 +1,86 @@ +;;; test-ai-vterm--close.el --- Tests for graceful agent close -*- lexical-binding: t; -*- + +;;; Commentary: +;; `cj/ai-vterm-close' tears an agent down gracefully: kill its tmux +;; session (stopping the agent process), kill the vterm buffer, and +;; remove its window. These tests cover the pure pieces -- the +;; tmux-kill helper, the per-buffer teardown, and the target selection -- +;; with `process-file' and the prompt mocked at the boundary. + +;;; 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--kill-tmux-session-runs-kill-session () + "Normal: invokes `tmux kill-session -t <session>'." + (let (captured) + (cl-letf (((symbol-function 'process-file) + (lambda (program &rest args) + (setq captured (cons program args)) + 0))) + (cj/--ai-vterm-kill-tmux-session "aiv-foo")) + (should (equal (car captured) "tmux")) + (should (member "kill-session" captured)) + (should (member "-t" captured)) + (should (member "aiv-foo" captured)))) + +(ert-deftest test-ai-vterm--kill-tmux-session-swallows-error () + "Error: returns nil when tmux is unavailable (process-file signals)." + (cl-letf (((symbol-function 'process-file) + (lambda (&rest _) (error "no tmux")))) + (should (null (cj/--ai-vterm-kill-tmux-session "aiv-foo"))))) + +(ert-deftest test-ai-vterm--close-buffer-kills-session-and-buffer () + "Normal: derives the session from default-directory, kills it and the buffer." + (let ((buf (get-buffer-create "agent [foo]")) + captured-session) + (with-current-buffer buf (setq-local default-directory "/tmp/foo/")) + (cl-letf (((symbol-function 'cj/--ai-vterm-kill-tmux-session) + (lambda (s) (setq captured-session s) 0))) + (cj/--ai-vterm-close-buffer buf)) + (should (equal captured-session "aiv-foo")) + (should-not (buffer-live-p buf)))) + +(ert-deftest test-ai-vterm--close-buffer-noop-on-non-agent () + "Boundary: does nothing for a buffer that is not an agent buffer." + (let ((buf (get-buffer-create "*not-an-agent*")) + (called nil)) + (unwind-protect + (progn + (cl-letf (((symbol-function 'cj/--ai-vterm-kill-tmux-session) + (lambda (_s) (setq called t) 0))) + (cj/--ai-vterm-close-buffer buf)) + (should-not called) + (should (buffer-live-p buf))) + (when (buffer-live-p buf) (kill-buffer buf))))) + +(ert-deftest test-ai-vterm--close-target-current-agent-buffer () + "Normal: returns the current buffer when it is an agent buffer." + (let ((buf (get-buffer-create "agent [cur]"))) + (unwind-protect + (with-current-buffer buf + (should (eq (cj/--ai-vterm-close-target) buf))) + (kill-buffer buf)))) + +(ert-deftest test-ai-vterm--close-target-sole-agent () + "Normal: returns the only live agent buffer when current isn't an agent." + (let ((buf (get-buffer-create "agent [only]"))) + (unwind-protect + (with-temp-buffer + (cl-letf (((symbol-function 'cj/--ai-vterm-agent-buffers) + (lambda () (list buf)))) + (should (eq (cj/--ai-vterm-close-target) buf)))) + (kill-buffer buf)))) + +(ert-deftest test-ai-vterm--close-target-none-returns-nil () + "Boundary: nil when current buffer isn't an agent and none are alive." + (with-temp-buffer + (cl-letf (((symbol-function 'cj/--ai-vterm-agent-buffers) (lambda () nil))) + (should (null (cj/--ai-vterm-close-target)))))) + +(provide 'test-ai-vterm--close) +;;; test-ai-vterm--close.el ends here diff --git a/tests/test-ai-vterm--f9-in-vterm.el b/tests/test-ai-vterm--f9-in-vterm.el index 1901127e..ec67ac9b 100644 --- a/tests/test-ai-vterm--f9-in-vterm.el +++ b/tests/test-ai-vterm--f9-in-vterm.el @@ -24,11 +24,11 @@ (should (eq (keymap-lookup vterm-mode-map "<f9>") #'cj/ai-vterm))) (ert-deftest test-ai-vterm-f9-family-bound-in-vterm-mode-map () - "Normal: the C-/M- F9 variants are bound in `vterm-mode-map' too. -`M-<f9>' toggles gptel's *AI-Assistant* window (rebound here from -the old `cj/ai-vterm-pick-buffer' command, which was removed)." + "Normal: the C-/M-/C-S- F9 variants are bound in `vterm-mode-map' too. +`M-<f9>' and `C-S-<f9>' both close an agent via `cj/ai-vterm-close'." (should (eq (keymap-lookup vterm-mode-map "C-<f9>") #'cj/ai-vterm-pick-project)) - (should (eq (keymap-lookup vterm-mode-map "M-<f9>") #'cj/toggle-gptel))) + (should (eq (keymap-lookup vterm-mode-map "M-<f9>") #'cj/ai-vterm-close)) + (should (eq (keymap-lookup vterm-mode-map "C-S-<f9>") #'cj/ai-vterm-close))) (ert-deftest test-ai-vterm-f9-not-self-insert-in-vterm () "Boundary: vterm's default <f9> -> `vterm--self-insert' was overridden." @@ -37,24 +37,11 @@ the old `cj/ai-vterm-pick-buffer' command, which was removed)." (ert-deftest test-ai-vterm-f9-still-bound-globally () "Normal: the global F9 family bindings are intact. `<f9>' toggles the ai-vterm agent window; `C-<f9>' picks a project -agent; `M-<f9>' toggles gptel's *AI-Assistant* window (rebound from -the retired `cj/ai-vterm-pick-buffer')." +agent; `M-<f9>' and `C-S-<f9>' close an agent via `cj/ai-vterm-close'." (should (eq (lookup-key (current-global-map) (kbd "<f9>")) #'cj/ai-vterm)) (should (eq (lookup-key (current-global-map) (kbd "C-<f9>")) #'cj/ai-vterm-pick-project)) - (should (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/toggle-gptel))) - -(ert-deftest test-ai-vterm-toggle-gptel-autoloaded-without-ai-config () - "Regression: loading `ai-vterm.el' must not require `ai-config.el'. -The M-F9 binding targets `cj/toggle-gptel', which lives in -`ai-config.el'. The dependency is declared via `autoload' so that -byte-compiling `ai-vterm.el' does not warn and so that requiring -`ai-vterm' in isolation leaves `cj/toggle-gptel' fboundp as an -autoload sigil pointing at `ai-config'. Without this, ai-vterm -would either need a full `(require 'ai-config)' at load time or -ship a known byte-compile warning." - (should-not (featurep 'ai-config)) - (should (fboundp 'cj/toggle-gptel)) - (should (autoloadp (symbol-function 'cj/toggle-gptel)))) + (should (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/ai-vterm-close)) + (should (eq (lookup-key (current-global-map) (kbd "C-S-<f9>")) #'cj/ai-vterm-close))) (provide 'test-ai-vterm--f9-in-vterm) ;;; test-ai-vterm--f9-in-vterm.el ends here |
