aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/ai-term.el103
-rw-r--r--tests/test-ai-term--active-agent-dirs.el50
-rw-r--r--tests/test-ai-term--next-agent-buffer.el73
-rw-r--r--tests/test-ai-term--next-agent-dir.el48
-rw-r--r--tests/test-ai-term--next-no-agents.el4
5 files changed, 166 insertions, 112 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el
index 04ff9f6c7..b463da90b 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -52,12 +52,14 @@
;; 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.
-;; - s-F9 `cj/ai-term-next' -- step to the next open agent in the
-;; queue. The queue is the live agent buffers in buffer-name
-;; order (a stable rotation). When an agent window is on
-;; screen, swap it to the next agent and focus it, wrapping
-;; after the last; when none is shown but agents exist, show
-;; the first. This is the "switch among existing agents"
+;; - s-F9 `cj/ai-term-next' -- step to the next active agent in the
+;; queue. The queue is every active agent in buffer-name order
+;; (a stable rotation): attached agents (a live buffer) and
+;; detached ones (a live tmux session with no Emacs buffer).
+;; Stepping onto a detached agent attaches it. When an agent
+;; window is on screen, swap it to the next agent and focus it,
+;; wrapping after the last; when none is shown but agents exist,
+;; show the first. This is the "switch among existing agents"
;; surface F9 deliberately doesn't provide.
;; - M-F9 `cj/ai-term-close' -- gracefully close an agent: kill its
;; tmux session (stopping the agent process), then its terminal
@@ -186,20 +188,39 @@ recently-selected first. Non-AI-term buffers are filtered out via
`cj/--ai-term-buffer-p'."
(seq-filter #'cj/--ai-term-buffer-p (buffer-list)))
-(defun cj/--ai-term-next-agent-buffer (current buffers)
- "Return the agent buffer after CURRENT in BUFFERS, wrapping to the first.
+(defun cj/--ai-term-next-agent-dir (current dirs)
+ "Return the project dir after CURRENT in DIRS, wrapping to the first.
-BUFFERS is an ordered list of live agent buffers. When CURRENT is the
-last element, wrap to the first. When CURRENT is nil or not a member of
-BUFFERS, return the first buffer. Returns nil when BUFFERS is empty.
+DIRS is an ordered list of active-agent project dirs. When CURRENT is
+the last element, wrap to the first. When CURRENT is nil or not a member
+of DIRS, return the first dir. Returns nil when DIRS is empty. Matches
+with `member' (string equality) since dirs are paths.
Pure decision helper (no buffer or window side effects) so the cycle
-order driving `cj/ai-term-next' (s-F9) is exercisable in tests."
- (when buffers
- (if (memq current buffers)
- (or (cadr (memq current buffers))
- (car buffers))
- (car buffers))))
+order driving `cj/ai-term-next' is exercisable in tests."
+ (when dirs
+ (if (member current dirs)
+ (or (cadr (member current dirs))
+ (car dirs))
+ (car dirs))))
+
+(defun cj/--ai-term-active-agent-dirs ()
+ "Return project dirs that have a live agent buffer or a live tmux session.
+
+Sorted by the agent buffer name, so the rotation is stable and matches
+what the picker shows. This is the queue `cj/ai-term-next' steps through:
+it includes detached sessions (alive in tmux but with no Emacs buffer),
+which the step materializes by attaching."
+ (let* ((sessions (cj/--ai-term-live-tmux-sessions))
+ (live-names (mapcar #'buffer-name (cj/--ai-term-agent-buffers))))
+ (sort
+ (seq-filter
+ (lambda (dir)
+ (or (member (cj/--ai-term-buffer-name dir) live-names)
+ (cj/--ai-term-session-active-p dir sessions)))
+ (cj/--ai-term-candidates))
+ (lambda (a b)
+ (string< (cj/--ai-term-buffer-name a) (cj/--ai-term-buffer-name b))))))
(defun cj/--ai-term-most-recent-non-agent-buffer ()
"Return the most-recently-selected live non-agent buffer, or nil.
@@ -988,35 +1009,43 @@ interrupt work in progress. Bound to M-<f9>."
(defun cj/ai-term-next ()
"Step to the next open AI-term agent in the queue.
-The queue is the live agent buffers ordered by buffer name -- a stable
-rotation, unaffected by which agent was most recently selected. When an
-agent window is on screen, swap it to the next agent in the queue
-\(wrapping after the last) and select it. When no agent is displayed but
-agents exist, show the first. When none are open, open the project picker
-to launch the first agent rather than erroring.
+The queue is every active agent ordered by buffer name -- a stable
+rotation, unaffected by which agent was most recently selected. Active
+means a live agent buffer (attached) OR a live tmux session with no Emacs
+buffer (detached); stepping onto a detached agent attaches it (recreates
+its terminal, which reattaches the session). When an agent window is on
+screen, swap it to the next agent (wrapping after the last) and select it.
+When no agent is displayed but agents exist, show the first. When none
+are open, open the project picker to launch the first agent rather than
+erroring.
Bound to M-SPC. Unlike C-; a a (toggle the most-recent agent on/off), this
is the \"switch among existing agents\" surface; C-; a s opens the project
picker and C-; a k closes an agent."
(interactive)
- (let* ((buffers (sort (cj/--ai-term-agent-buffers)
- (lambda (a b)
- (string< (buffer-name a) (buffer-name b)))))
+ (let* ((dirs (cj/--ai-term-active-agent-dirs))
(win (cj/--ai-term-displayed-agent-window))
- (current (and win (window-buffer win)))
- (next (cj/--ai-term-next-agent-buffer current buffers)))
- (if (not next)
+ (current-name (and win (buffer-name (window-buffer win))))
+ (current-dir (and current-name
+ (seq-find (lambda (d)
+ (equal (cj/--ai-term-buffer-name d) current-name))
+ dirs)))
+ (next-dir (cj/--ai-term-next-agent-dir current-dir dirs)))
+ (if (not next-dir)
;; No agents open: launch the first via the project picker instead of
;; erroring, so the swap key doubles as a "start an agent" key.
(cj/ai-term-pick-project)
- (if win
- (progn
- (set-window-buffer win next)
- (select-window win))
- (display-buffer next)
- (let ((w (get-buffer-window next)))
- (when w (select-window w))))
- (message "Agent: %s" (buffer-name next)))))
+ (let* ((name (cj/--ai-term-buffer-name next-dir))
+ (existing (get-buffer name)))
+ ;; Live agent and an agent window is up: swap it into that window in
+ ;; place (faithful to the prior buffer-only behavior). Detached, or no
+ ;; window yet: show-or-create attaches the tmux session / displays it.
+ (if (and win existing (cj/--ai-term-process-live-p existing))
+ (progn (set-window-buffer win existing) (select-window win))
+ (cj/--ai-term-show-or-create next-dir name)
+ (let ((w (get-buffer-window name)))
+ (when w (select-window w))))
+ (message "Agent: %s" name)))))
;; ai-term lives under the C-; a prefix (vacated when gptel was archived).
;; The frequent "swap to the next agent" also gets M-SPC for a fast chord.
diff --git a/tests/test-ai-term--active-agent-dirs.el b/tests/test-ai-term--active-agent-dirs.el
new file mode 100644
index 000000000..86e557b42
--- /dev/null
+++ b/tests/test-ai-term--active-agent-dirs.el
@@ -0,0 +1,50 @@
+;;; test-ai-term--active-agent-dirs.el --- Tests for cj/--ai-term-active-agent-dirs -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The queue `cj/ai-term-next' steps through: project dirs with an active
+;; agent, which is either a live agent buffer (attached) or a live tmux session
+;; with no Emacs buffer (detached). Folding detached sessions in is what lets
+;; the step key reach and attach a session that isn't currently on screen.
+;; Candidates / buffers / sessions are mocked so the enumeration logic is
+;; exercised without a real tmux server.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(ert-deftest test-ai-term--active-agent-dirs-includes-attached-and-detached ()
+ "Normal: dirs with a live buffer OR a live session are active and sorted by
+name; dirs with neither are excluded."
+ (let ((buf (get-buffer-create (cj/--ai-term-buffer-name "/p/alpha"))))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--ai-term-candidates)
+ (lambda (&rest _) '("/p/alpha" "/p/beta" "/p/gamma" "/p/delta")))
+ ((symbol-function 'cj/--ai-term-agent-buffers)
+ (lambda (&rest _) (list buf)))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda (&rest _) (list (cj/--ai-term-tmux-session-name "/p/gamma")))))
+ ;; alpha attached (buffer), gamma detached (session); beta/delta neither.
+ (should (equal '("/p/alpha" "/p/gamma") (cj/--ai-term-active-agent-dirs))))
+ (kill-buffer buf))))
+
+(ert-deftest test-ai-term--active-agent-dirs-detached-only ()
+ "Normal: a dir with only a live session (no buffer) is included -- the detached case."
+ (cl-letf (((symbol-function 'cj/--ai-term-candidates) (lambda (&rest _) '("/p/solo")))
+ ((symbol-function 'cj/--ai-term-agent-buffers) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda (&rest _) (list (cj/--ai-term-tmux-session-name "/p/solo")))))
+ (should (equal '("/p/solo") (cj/--ai-term-active-agent-dirs)))))
+
+(ert-deftest test-ai-term--active-agent-dirs-empty-when-none-active ()
+ "Boundary: no live buffers and no sessions -> an empty queue."
+ (cl-letf (((symbol-function 'cj/--ai-term-candidates) (lambda (&rest _) '("/p/a" "/p/b")))
+ ((symbol-function 'cj/--ai-term-agent-buffers) (lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions) (lambda (&rest _) nil)))
+ (should (null (cj/--ai-term-active-agent-dirs)))))
+
+(provide 'test-ai-term--active-agent-dirs)
+;;; test-ai-term--active-agent-dirs.el ends here
diff --git a/tests/test-ai-term--next-agent-buffer.el b/tests/test-ai-term--next-agent-buffer.el
deleted file mode 100644
index 330714a92..000000000
--- a/tests/test-ai-term--next-agent-buffer.el
+++ /dev/null
@@ -1,73 +0,0 @@
-;;; test-ai-term--next-agent-buffer.el --- Tests for cj/--ai-term-next-agent-buffer -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; The pure decision helper behind `cj/ai-term-next' (s-F9). Given the
-;; current agent buffer and the ordered list of live agent buffers, it
-;; returns the next buffer in the queue, wrapping after the last. A nil
-;; or non-member CURRENT returns the first; an empty list returns nil.
-;; No buffer or window side effects -- list logic only.
-
-;;; Code:
-
-(require 'ert)
-
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'ai-term)
-
-(ert-deftest test-ai-term--next-agent-buffer-advances-from-first ()
- "Normal: current is the first element -> returns the second."
- (let ((a (get-buffer-create "agent [a]"))
- (b (get-buffer-create "agent [b]"))
- (c (get-buffer-create "agent [c]")))
- (unwind-protect
- (should (eq b (cj/--ai-term-next-agent-buffer a (list a b c))))
- (mapc #'kill-buffer (list a b c)))))
-
-(ert-deftest test-ai-term--next-agent-buffer-advances-from-middle ()
- "Normal: current in the middle -> returns the following element."
- (let ((a (get-buffer-create "agent [a]"))
- (b (get-buffer-create "agent [b]"))
- (c (get-buffer-create "agent [c]")))
- (unwind-protect
- (should (eq c (cj/--ai-term-next-agent-buffer b (list a b c))))
- (mapc #'kill-buffer (list a b c)))))
-
-(ert-deftest test-ai-term--next-agent-buffer-wraps-after-last ()
- "Boundary: current is the last element -> wraps to the first."
- (let ((a (get-buffer-create "agent [a]"))
- (b (get-buffer-create "agent [b]"))
- (c (get-buffer-create "agent [c]")))
- (unwind-protect
- (should (eq a (cj/--ai-term-next-agent-buffer c (list a b c))))
- (mapc #'kill-buffer (list a b c)))))
-
-(ert-deftest test-ai-term--next-agent-buffer-single-element-returns-itself ()
- "Boundary: a one-agent queue wraps current back to itself."
- (let ((a (get-buffer-create "agent [a]")))
- (unwind-protect
- (should (eq a (cj/--ai-term-next-agent-buffer a (list a))))
- (kill-buffer a))))
-
-(ert-deftest test-ai-term--next-agent-buffer-nil-current-returns-first ()
- "Boundary: nil current (no agent displayed) -> returns the first."
- (let ((a (get-buffer-create "agent [a]"))
- (b (get-buffer-create "agent [b]")))
- (unwind-protect
- (should (eq a (cj/--ai-term-next-agent-buffer nil (list a b))))
- (mapc #'kill-buffer (list a b)))))
-
-(ert-deftest test-ai-term--next-agent-buffer-non-member-current-returns-first ()
- "Error: current not in the queue -> returns the first rather than nil."
- (let ((a (get-buffer-create "agent [a]"))
- (b (get-buffer-create "agent [b]"))
- (stray (get-buffer-create "agent [stray]")))
- (unwind-protect
- (should (eq a (cj/--ai-term-next-agent-buffer stray (list a b))))
- (mapc #'kill-buffer (list a b stray)))))
-
-(ert-deftest test-ai-term--next-agent-buffer-empty-queue-returns-nil ()
- "Boundary: an empty queue returns nil (nothing to switch to)."
- (should (null (cj/--ai-term-next-agent-buffer nil '()))))
-
-(provide 'test-ai-term--next-agent-buffer)
-;;; test-ai-term--next-agent-buffer.el ends here
diff --git a/tests/test-ai-term--next-agent-dir.el b/tests/test-ai-term--next-agent-dir.el
new file mode 100644
index 000000000..b5cf1cdf5
--- /dev/null
+++ b/tests/test-ai-term--next-agent-dir.el
@@ -0,0 +1,48 @@
+;;; test-ai-term--next-agent-dir.el --- Tests for cj/--ai-term-next-agent-dir -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The pure decision helper behind `cj/ai-term-next'. Given the current
+;; active-agent project dir and the ordered list of active-agent dirs, it
+;; returns the next dir in the queue, wrapping after the last. A nil or
+;; non-member CURRENT returns the first; an empty list returns nil. Dirs are
+;; matched with `member' (string equality). No side effects -- list logic only.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(defconst test-ai-term--dirs '("/p/a" "/p/b" "/p/c"))
+
+(ert-deftest test-ai-term--next-agent-dir-advances-from-first ()
+ "Normal: current is the first element -> returns the second."
+ (should (equal "/p/b" (cj/--ai-term-next-agent-dir "/p/a" test-ai-term--dirs))))
+
+(ert-deftest test-ai-term--next-agent-dir-advances-from-middle ()
+ "Normal: current in the middle -> returns the following element."
+ (should (equal "/p/c" (cj/--ai-term-next-agent-dir "/p/b" test-ai-term--dirs))))
+
+(ert-deftest test-ai-term--next-agent-dir-wraps-after-last ()
+ "Boundary: current is the last element -> wraps to the first."
+ (should (equal "/p/a" (cj/--ai-term-next-agent-dir "/p/c" test-ai-term--dirs))))
+
+(ert-deftest test-ai-term--next-agent-dir-single-element-returns-itself ()
+ "Boundary: a one-agent queue wraps current back to itself."
+ (should (equal "/p/a" (cj/--ai-term-next-agent-dir "/p/a" '("/p/a")))))
+
+(ert-deftest test-ai-term--next-agent-dir-nil-current-returns-first ()
+ "Boundary: nil current (no agent displayed) -> returns the first."
+ (should (equal "/p/a" (cj/--ai-term-next-agent-dir nil '("/p/a" "/p/b")))))
+
+(ert-deftest test-ai-term--next-agent-dir-non-member-current-returns-first ()
+ "Error: current not in the queue -> returns the first rather than nil."
+ (should (equal "/p/a" (cj/--ai-term-next-agent-dir "/p/stray" '("/p/a" "/p/b")))))
+
+(ert-deftest test-ai-term--next-agent-dir-empty-queue-returns-nil ()
+ "Boundary: an empty queue returns nil (nothing to switch to)."
+ (should (null (cj/--ai-term-next-agent-dir nil '()))))
+
+(provide 'test-ai-term--next-agent-dir)
+;;; test-ai-term--next-agent-dir.el ends here
diff --git a/tests/test-ai-term--next-no-agents.el b/tests/test-ai-term--next-no-agents.el
index ef87d71ee..59132df8e 100644
--- a/tests/test-ai-term--next-no-agents.el
+++ b/tests/test-ai-term--next-no-agents.el
@@ -17,7 +17,7 @@
(ert-deftest test-ai-term-next-no-agents-launches-picker ()
"Error: no agents open -> launches the picker instead of erroring."
(let ((picked 0))
- (cl-letf (((symbol-function 'cj/--ai-term-agent-buffers) (lambda (&rest _) nil))
+ (cl-letf (((symbol-function 'cj/--ai-term-active-agent-dirs) (lambda (&rest _) nil))
((symbol-function 'cj/--ai-term-displayed-agent-window) (lambda (&rest _) nil))
((symbol-function 'cj/ai-term-pick-project) (lambda (&rest _) (setq picked (1+ picked)))))
(cj/ai-term-next)
@@ -25,7 +25,7 @@
(ert-deftest test-ai-term-next-no-agents-does-not-signal ()
"Error: no agents open -> returns normally, no user-error raised."
- (cl-letf (((symbol-function 'cj/--ai-term-agent-buffers) (lambda (&rest _) nil))
+ (cl-letf (((symbol-function 'cj/--ai-term-active-agent-dirs) (lambda (&rest _) nil))
((symbol-function 'cj/--ai-term-displayed-agent-window) (lambda (&rest _) nil))
((symbol-function 'cj/ai-term-pick-project) (lambda (&rest _) nil)))
(should (progn (cj/ai-term-next) t))))