aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/ai-term-sessions.el14
-rw-r--r--modules/ai-term.el33
-rw-r--r--tests/test-ai-term--buffer-name.el21
-rw-r--r--tests/test-ai-term--close.el16
-rw-r--r--tests/test-ai-term--quit.el16
5 files changed, 87 insertions, 13 deletions
diff --git a/modules/ai-term-sessions.el b/modules/ai-term-sessions.el
index fab6b0a6..58532f7e 100644
--- a/modules/ai-term-sessions.el
+++ b/modules/ai-term-sessions.el
@@ -65,6 +65,20 @@ the start so names like \"foo agent [bar]\" do not match."
(buffer-live-p buffer)
(string-prefix-p cj/--ai-term-name-prefix (buffer-name buffer))))
+(defun cj/--ai-term-buffer-basename (buffer)
+ "Return the project basename embedded in BUFFER's AI-term name, or nil.
+
+The buffer name is \"agent [<basename>]\" (see
+`cj/--ai-term-buffer-name') and never changes for the buffer's life,
+unlike `default-directory', which ghostel retargets via OSC 7 every time
+the shell cds. Teardown paths must key tmux-session lookups off this,
+not the directory, or a close after a cd kills the wrong aiv- session.
+Returns nil when BUFFER is not a live AI-term buffer."
+ (when (cj/--ai-term-buffer-p buffer)
+ (let ((name (buffer-name buffer)))
+ (when (string-suffix-p "]" name)
+ (substring name (length cj/--ai-term-name-prefix) -1)))))
+
(defun cj/--ai-term-agent-buffers ()
"Return the live AI-term buffers in `buffer-list' order.
diff --git a/modules/ai-term.el b/modules/ai-term.el
index 9d7be47e..bedc0681 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -400,18 +400,22 @@ C-; a k closes an agent via `cj/ai-term-close'."
(defun cj/--ai-term-close-buffer (buffer)
"Gracefully tear down AI-term BUFFER: tmux session, then buffer.
-Derives the tmux session name from BUFFER's `default-directory' (the
-project dir the terminal was created in) and kills it so the agent
-process stops. When BUFFER is shown, swaps its window to a non-agent
-buffer (the working file) rather than deleting the window -- closing an
-agent must not collapse the user's window layout; the hide toggle is
-what collapses the split. Then kills BUFFER (suppressing the
+Derives the tmux session name from BUFFER's immutable name (\"agent
+[<basename>]\") and kills it so the agent process stops. The name, not
+`default-directory', is the reliable key: ghostel retargets the
+directory via OSC 7 as the shell cds, so a directory-derived name after
+a cd misses the real session (orphaning the agent) or collides with a
+different aiv- session. When BUFFER is shown, swaps its window to a
+non-agent buffer (the working file) rather than deleting the window --
+closing an agent must not collapse the user's window layout; the hide
+toggle is what collapses the split. Then kills BUFFER (suppressing the
process-still-running prompt -- the session is already down). No-op
when BUFFER isn't an AI-term buffer."
(when (cj/--ai-term-buffer-p buffer)
(cj/--ai-term-kill-tmux-session
(cj/--ai-term-tmux-session-name
- (buffer-local-value 'default-directory buffer)))
+ (or (cj/--ai-term-buffer-basename buffer)
+ (buffer-local-value 'default-directory buffer))))
(let ((win (get-buffer-window buffer)))
(when (window-live-p win)
(cj/--ai-term-swap-to-working-buffer win)))
@@ -547,11 +551,16 @@ A defcustom so development and tests can stub it instead of powering off
(defun cj/ai-term-quit (&optional project)
"Tear down PROJECT's AI-term: kill its tmux session, buffer, and restore layout.
PROJECT is a project basename (as the rulesets Stop hook passes) or a directory;
-nil means the current project (`default-directory'). Kills the `aiv-<name>'
-tmux session (taking the agent process with it), then, when the agent buffer is
-live, swaps its window back to the working buffer and kills it. Idempotent and
-safe headless: a session or buffer already gone is a no-op, not an error."
- (let* ((key (or project default-directory))
+nil means the current project -- the current agent buffer's embedded basename
+when called from inside one (immune to the OSC 7 `default-directory' drift a
+cd in the agent shell causes), else `default-directory'. Kills the
+`aiv-<name>' tmux session (taking the agent process with it), then, when the
+agent buffer is live, swaps its window back to the working buffer and kills
+it. Idempotent and safe headless: a session or buffer already gone is a
+no-op, not an error."
+ (let* ((key (or project
+ (cj/--ai-term-buffer-basename (current-buffer))
+ default-directory))
(session (cj/--ai-term-tmux-session-name key))
(buffer (get-buffer (cj/--ai-term-buffer-name key))))
(cj/--ai-term-kill-tmux-session session)
diff --git a/tests/test-ai-term--buffer-name.el b/tests/test-ai-term--buffer-name.el
index b241977d..e728bc82 100644
--- a/tests/test-ai-term--buffer-name.el
+++ b/tests/test-ai-term--buffer-name.el
@@ -38,5 +38,26 @@
(should (equal (cj/--ai-term-buffer-name "/a/b/c/d/e/leaf")
"agent [leaf]")))
+;;; Basename extraction (inverse transform)
+
+(ert-deftest test-ai-term--buffer-basename-normal-round-trip ()
+ "Normal: extracts the basename embedded in an agent buffer's name."
+ (let ((buf (get-buffer-create "agent [proj]")))
+ (unwind-protect
+ (should (equal (cj/--ai-term-buffer-basename buf) "proj"))
+ (kill-buffer buf))))
+
+(ert-deftest test-ai-term--buffer-basename-boundary-dotted ()
+ "Boundary: dotted basenames (.emacs.d) survive extraction intact."
+ (let ((buf (get-buffer-create "agent [.emacs.d]")))
+ (unwind-protect
+ (should (equal (cj/--ai-term-buffer-basename buf) ".emacs.d"))
+ (kill-buffer buf))))
+
+(ert-deftest test-ai-term--buffer-basename-error-non-agent-nil ()
+ "Error: a non-agent buffer yields nil."
+ (with-temp-buffer
+ (should (null (cj/--ai-term-buffer-basename (current-buffer))))))
+
(provide 'test-ai-term--buffer-name)
;;; test-ai-term--buffer-name.el ends here
diff --git a/tests/test-ai-term--close.el b/tests/test-ai-term--close.el
index 242bfd74..8b028351 100644
--- a/tests/test-ai-term--close.el
+++ b/tests/test-ai-term--close.el
@@ -36,8 +36,22 @@
(lambda (&rest _) (error "no tmux"))))
(should (null (cj/--ai-term-kill-tmux-session "aiv-foo")))))
+(ert-deftest test-ai-term--close-buffer-session-from-name-after-cd ()
+ "Regression: the session name comes from the immutable buffer name.
+ghostel retargets `default-directory' via OSC 7 as the shell cds, so
+deriving from it after a cd kills the wrong aiv- session (or misses,
+orphaning the agent). The buffer name's basename never changes."
+ (let ((buf (get-buffer-create "agent [proj]"))
+ captured-session)
+ (with-current-buffer buf (setq-local default-directory "/tmp/elsewhere/"))
+ (cl-letf (((symbol-function 'cj/--ai-term-kill-tmux-session)
+ (lambda (s) (setq captured-session s) 0)))
+ (cj/--ai-term-close-buffer buf))
+ (should (equal captured-session "aiv-proj"))
+ (should-not (buffer-live-p buf))))
+
(ert-deftest test-ai-term--close-buffer-kills-session-and-buffer ()
- "Normal: derives the session from default-directory, kills it and the buffer."
+ "Normal: derives the session from the buffer name, kills it and the buffer."
(let ((buf (get-buffer-create "agent [foo]"))
captured-session)
(with-current-buffer buf (setq-local default-directory "/tmp/foo/"))
diff --git a/tests/test-ai-term--quit.el b/tests/test-ai-term--quit.el
index 55ace81d..64b8a5d4 100644
--- a/tests/test-ai-term--quit.el
+++ b/tests/test-ai-term--quit.el
@@ -43,6 +43,22 @@
(should-not (buffer-live-p buf)))
(when (buffer-live-p buf) (kill-buffer buf)))))
+(ert-deftest test-ai-term-quit-nil-project-from-drifted-agent-buffer ()
+ "Regression: nil PROJECT inside an agent buffer keys off the buffer name.
+After a cd in the agent shell, ghostel's OSC 7 tracking moves the buffer's
+`default-directory' away from the project, so keying off it would kill the
+wrong session and miss the buffer."
+ (let ((buf (get-buffer-create "agent [realproj]"))
+ (calls nil))
+ (unwind-protect
+ (test-ai-term-quit--with-tmux calls
+ (with-current-buffer buf
+ (setq-local default-directory "/tmp/elsewhere/")
+ (cj/ai-term-quit))
+ (should (member '("kill-session" "-t" "aiv-realproj") calls))
+ (should-not (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
(ert-deftest test-ai-term-quit-idempotent-when-gone ()
"Error/Boundary: a second quit (session + buffer already gone) does not error."
(let ((calls nil))