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. --- tests/test-ai-vterm--close.el | 86 +++++++++++++++++++++++++++++++++++++ tests/test-ai-vterm--f9-in-vterm.el | 27 +++--------- 2 files changed, 93 insertions(+), 20 deletions(-) create mode 100644 tests/test-ai-vterm--close.el (limited to 'tests') 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 '." + (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 "") #'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-' 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-' and `C-S-' both close an agent via `cj/ai-vterm-close'." (should (eq (keymap-lookup vterm-mode-map "C-") #'cj/ai-vterm-pick-project)) - (should (eq (keymap-lookup vterm-mode-map "M-") #'cj/toggle-gptel))) + (should (eq (keymap-lookup vterm-mode-map "M-") #'cj/ai-vterm-close)) + (should (eq (keymap-lookup vterm-mode-map "C-S-") #'cj/ai-vterm-close))) (ert-deftest test-ai-vterm-f9-not-self-insert-in-vterm () "Boundary: vterm's default -> `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. `' toggles the ai-vterm agent window; `C-' picks a project -agent; `M-' toggles gptel's *AI-Assistant* window (rebound from -the retired `cj/ai-vterm-pick-buffer')." +agent; `M-' and `C-S-' close an agent via `cj/ai-vterm-close'." (should (eq (lookup-key (current-global-map) (kbd "")) #'cj/ai-vterm)) (should (eq (lookup-key (current-global-map) (kbd "C-")) #'cj/ai-vterm-pick-project)) - (should (eq (lookup-key (current-global-map) (kbd "M-")) #'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-")) #'cj/ai-vterm-close)) + (should (eq (lookup-key (current-global-map) (kbd "C-S-")) #'cj/ai-vterm-close))) (provide 'test-ai-vterm--f9-in-vterm) ;;; test-ai-vterm--f9-in-vterm.el ends here -- cgit v1.2.3