aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/ai-term.el67
-rw-r--r--modules/system-utils.el2
-rw-r--r--modules/term-config.el11
-rw-r--r--tests/test-ai-term--f9-in-term.el18
-rw-r--r--tests/test-ai-term--next-agent-buffer.el73
5 files changed, 149 insertions, 22 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el
index 25e56c508..ff8da0035 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -52,15 +52,19 @@
;; 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"
+;; 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
;; buffer. Its window stays in the layout (swapped to the
;; working buffer), so closing never collapses a split. Confirms
;; first. Targets the current agent, the sole live agent, or
;; prompts among several.
-;; - C-S-F9 `cj/ai-term-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
@@ -181,6 +185,21 @@ 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.
+
+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.
+
+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))))
+
(defun cj/--ai-term-most-recent-non-agent-buffer ()
"Return the most-recently-selected live non-agent buffer, or nil.
@@ -882,7 +901,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-term-pick-project' (C-F9) to force the project picker.
-M-F9 (and C-S-F9) close an agent via `cj/ai-term-close'."
+M-F9 closes an agent via `cj/ai-term-close'."
(interactive "P")
(pcase (cj/--ai-term-dispatch)
(`(toggle-off . ,win)
@@ -952,7 +971,7 @@ buffers; nil when none are alive."
Targets the current agent buffer, the sole live agent, or prompts when
several are alive (see `cj/--ai-term-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>."
+interrupt work in progress. Bound to M-<f9>."
(interactive)
(let ((buffer (cj/--ai-term-close-target)))
(unless buffer
@@ -963,10 +982,42 @@ interrupt work in progress. Bound to M-<f9> (primary) and C-S-<f9>."
(cj/--ai-term-close-buffer buffer)
(message "Closed agent %s." name)))))
+;; ------------------------- Step to the next agent ----------------------------
+
+(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. Signals `user-error' when none are open.
+
+Bound to s-<f9>. Unlike <f9> (toggle the most-recent agent on/off), this
+is the \"switch among existing agents\" surface; C-<f9> opens the project
+picker and M-<f9> closes an agent."
+ (interactive)
+ (let* ((buffers (sort (cj/--ai-term-agent-buffers)
+ (lambda (a b)
+ (string< (buffer-name a) (buffer-name b)))))
+ (win (cj/--ai-term-displayed-agent-window))
+ (current (and win (window-buffer win)))
+ (next (cj/--ai-term-next-agent-buffer current buffers)))
+ (unless next
+ (user-error "No AI-term agent buffers open"))
+ (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))))
+
(keymap-global-set "<f9>" #'cj/ai-term)
(keymap-global-set "C-<f9>" #'cj/ai-term-pick-project)
+(keymap-global-set "s-<f9>" #'cj/ai-term-next)
(keymap-global-set "M-<f9>" #'cj/ai-term-close)
-(keymap-global-set "C-S-<f9>" #'cj/ai-term-close)
;; ghostel's semi-char mode forwards keys not in `ghostel-keymap-exceptions' to
;; the terminal program, so a plain <f9> typed while point is inside an agent
@@ -977,15 +1028,15 @@ interrupt work in progress. Bound to M-<f9> (primary) and C-S-<f9>."
(with-eval-after-load 'ghostel
(keymap-set ghostel-mode-map "<f9>" #'cj/ai-term)
(keymap-set ghostel-mode-map "C-<f9>" #'cj/ai-term-pick-project)
+ (keymap-set ghostel-mode-map "s-<f9>" #'cj/ai-term-next)
(keymap-set ghostel-mode-map "M-<f9>" #'cj/ai-term-close)
- (keymap-set ghostel-mode-map "C-S-<f9>" #'cj/ai-term-close)
;; The bindings above live in `ghostel-mode-map', but in semi-char mode
;; ghostel's own `ghostel-semi-char-mode-map' forwards every key not in
;; `ghostel-keymap-exceptions' to the pty -- and that map outranks the
;; major-mode map, so it would swallow the F9 family before the bindings
;; above fire. Add the family to the exceptions and rebuild the semi-char
;; map so the keys fall through to `ghostel-mode-map' inside agent buffers.
- (dolist (key '("<f9>" "C-<f9>" "M-<f9>" "C-S-<f9>"))
+ (dolist (key '("<f9>" "C-<f9>" "s-<f9>" "M-<f9>"))
(add-to-list 'ghostel-keymap-exceptions key))
(ghostel--rebuild-semi-char-keymap))
diff --git a/modules/system-utils.el b/modules/system-utils.el
index 7cf958674..254a2f502 100644
--- a/modules/system-utils.el
+++ b/modules/system-utils.el
@@ -102,7 +102,7 @@ detached from Emacs."
(interactive)
(save-some-buffers)
(kill-emacs))
-(keymap-global-set "C-<f10>" #'cj/server-shutdown)
+(keymap-global-set "C-x C" #'cj/server-shutdown)
;;; ---------------------------- History Persistence ----------------------------
diff --git a/modules/term-config.el b/modules/term-config.el
index 0a7991409..c1c28911d 100644
--- a/modules/term-config.el
+++ b/modules/term-config.el
@@ -246,12 +246,13 @@ run its own project-named tmux session instead of a bare, auto-named one.
;; rebuild is what actually lets the key through to `ghostel-mode-map' / the
;; global map. C-; and F12 are the prefix + toggle; the modified arrows are
;; windmove (S-arrows, focus) and buffer-move (C-M-arrows, swap), which the
- ;; ai-term workflow expects to work from inside an agent buffer. F8, F10 and
- ;; C-F10 are global bindings (org agenda, music-playlist toggle, server
- ;; shutdown) that reach Emacs by falling through to the global map once the
- ;; semi-char map stops forwarding them.
+ ;; ai-term workflow expects to work from inside an agent buffer. F8 and F10
+ ;; are global bindings (org agenda, music-playlist toggle) that reach Emacs by
+ ;; falling through to the global map once the semi-char map stops forwarding
+ ;; them. (Server shutdown moved off C-F10 to C-x C, which is deliberately
+ ;; left forwarding to the terminal program inside an agent buffer.)
(with-eval-after-load 'ghostel
- (dolist (key '("C-;" "<f8>" "<f12>" "<f10>" "C-<f10>"
+ (dolist (key '("C-;" "<f8>" "<f12>" "<f10>"
"S-<up>" "S-<down>" "S-<left>" "S-<right>"
"C-M-<up>" "C-M-<down>" "C-M-<left>" "C-M-<right>"))
(add-to-list 'ghostel-keymap-exceptions key))
diff --git a/tests/test-ai-term--f9-in-term.el b/tests/test-ai-term--f9-in-term.el
index dad11ffc0..0477f2517 100644
--- a/tests/test-ai-term--f9-in-term.el
+++ b/tests/test-ai-term--f9-in-term.el
@@ -26,27 +26,29 @@
(should (eq (keymap-lookup ghostel-mode-map "<f9>") #'cj/ai-term)))
(ert-deftest test-ai-term-f9-family-bound-in-ghostel-mode-map ()
- "Normal: the C-/M-/C-S- F9 variants are bound in `ghostel-mode-map' too.
-`M-<f9>' and `C-S-<f9>' both close an agent via `cj/ai-term-close'."
+ "Normal: the C-/s-/M- F9 variants are bound in `ghostel-mode-map' too.
+`s-<f9>' steps to the next agent; `M-<f9>' closes an agent via
+`cj/ai-term-close'."
(should (eq (keymap-lookup ghostel-mode-map "C-<f9>") #'cj/ai-term-pick-project))
- (should (eq (keymap-lookup ghostel-mode-map "M-<f9>") #'cj/ai-term-close))
- (should (eq (keymap-lookup ghostel-mode-map "C-S-<f9>") #'cj/ai-term-close)))
+ (should (eq (keymap-lookup ghostel-mode-map "s-<f9>") #'cj/ai-term-next))
+ (should (eq (keymap-lookup ghostel-mode-map "M-<f9>") #'cj/ai-term-close)))
(ert-deftest test-ai-term-f9-still-bound-globally ()
"Normal: the global F9 family bindings are intact.
`<f9>' toggles the ai-term agent window; `C-<f9>' picks a project
-agent; `M-<f9>' and `C-S-<f9>' close an agent via `cj/ai-term-close'."
+agent; `s-<f9>' steps to the next agent; `M-<f9>' closes an agent
+via `cj/ai-term-close'."
(should (eq (lookup-key (current-global-map) (kbd "<f9>")) #'cj/ai-term))
(should (eq (lookup-key (current-global-map) (kbd "C-<f9>")) #'cj/ai-term-pick-project))
- (should (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/ai-term-close))
- (should (eq (lookup-key (current-global-map) (kbd "C-S-<f9>")) #'cj/ai-term-close)))
+ (should (eq (lookup-key (current-global-map) (kbd "s-<f9>")) #'cj/ai-term-next))
+ (should (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/ai-term-close)))
(ert-deftest test-ai-term-f9-family-in-keymap-exceptions ()
"Regression: the F9 family is in `ghostel-keymap-exceptions' so semi-char
mode lets it reach Emacs instead of forwarding it to the terminal program.
Binding in `ghostel-mode-map' alone is not enough -- the semi-char map outranks
it and forwards any key not in the exceptions to the pty."
- (dolist (key '("<f9>" "C-<f9>" "M-<f9>" "C-S-<f9>"))
+ (dolist (key '("<f9>" "C-<f9>" "s-<f9>" "M-<f9>"))
(should (member key ghostel-keymap-exceptions)))
;; The rebuilt semi-char map must no longer forward <f9> to the pty.
(should-not (eq (keymap-lookup ghostel-semi-char-mode-map "<f9>")
diff --git a/tests/test-ai-term--next-agent-buffer.el b/tests/test-ai-term--next-agent-buffer.el
new file mode 100644
index 000000000..330714a92
--- /dev/null
+++ b/tests/test-ai-term--next-agent-buffer.el
@@ -0,0 +1,73 @@
+;;; 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