diff options
| -rw-r--r-- | modules/ai-term.el | 103 | ||||
| -rw-r--r-- | tests/test-ai-term--active-agent-dirs.el | 50 | ||||
| -rw-r--r-- | tests/test-ai-term--next-agent-buffer.el | 73 | ||||
| -rw-r--r-- | tests/test-ai-term--next-agent-dir.el | 48 | ||||
| -rw-r--r-- | tests/test-ai-term--next-no-agents.el | 4 |
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)))) |
