aboutsummaryrefslogtreecommitdiff
path: root/modules/ai-term.el
diff options
context:
space:
mode:
Diffstat (limited to 'modules/ai-term.el')
-rw-r--r--modules/ai-term.el521
1 files changed, 365 insertions, 156 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el
index baf752fe7..3beabe6b5 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -52,15 +52,21 @@
;; 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 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
;; 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
@@ -73,17 +79,14 @@
(require 'cj-window-geometry-lib)
(require 'cj-window-toggle-lib)
(require 'host-environment)
+(require 'keybindings) ;; provides cj/register-prefix-map (C-; a)
-(declare-function ghostel "ghostel" (&optional arg))
-(declare-function ghostel-send-string "ghostel" (string))
-(declare-function ghostel--rebuild-semi-char-keymap "ghostel" ())
-(defvar ghostel-keymap-exceptions)
-(defvar ghostel-mode-map)
-(defvar ghostel-buffer-name)
-(defvar ghostel-buffer-name-function)
+(declare-function eat "eat" (&optional program arg))
+(defvar eat-buffer-name)
+(defvar eat-semi-char-mode-map)
(defgroup ai-term nil
- "In-Emacs AI-agent launcher with a vertical-split ghostel terminal."
+ "In-Emacs AI-agent launcher with a vertical-split EAT terminal."
:group 'tools)
(defcustom cj/ai-term-agent-command
@@ -95,15 +98,6 @@ agent you run (aider, an open-source LLM TUI, etc.)."
:type 'string
:group 'ai-term)
-(defvar cj/--ai-term-suppress-tmux nil
- "When non-nil, the generic ghostel tmux-launch hook skips its auto-tmux step.
-
-ai-term dynamically binds this around `(ghostel)' so the hook in
-term-config.el doesn't send a bare \"tmux\\n\" before the named
-session launch command runs. The hook reads the variable via
-`bound-and-true-p' so loading order between the two modules doesn't
-matter.")
-
(defcustom cj/ai-term-project-roots
(list (expand-file-name "~/.emacs.d"))
"Directories that are themselves AI-agent projects.
@@ -181,6 +175,40 @@ 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-dir (current dirs)
+ "Return the project dir after CURRENT in DIRS, wrapping to the first.
+
+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' 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.
@@ -391,22 +419,26 @@ fallback when `cj/--ai-term-last-size' is nil."
:type 'number
:group 'ai-term)
-(defun cj/--ai-term-default-direction ()
- "Return the host-appropriate default split direction for the agent window.
+(defun cj/--ai-term-default-direction (&optional frame)
+ "Return the default split direction for the agent window.
-`below' on a laptop (bottom horizontal split), `right' on a desktop
-(right-side vertical split). Detected via `env-laptop-p'."
- (if (env-laptop-p) 'below 'right))
+Chosen at display time from FRAME's column width (FRAME defaults to the
+selected frame): `right' when a side-by-side split would leave both the
+agent and the main window at least `cj/window-dock-min-columns' wide,
+`below' otherwise. The agent's share of the width is
+`cj/ai-term-desktop-width'. See `cj/preferred-dock-direction'."
+ (let ((frame (or frame (selected-frame))))
+ (cj/preferred-dock-direction (frame-width frame)
+ cj/ai-term-desktop-width)))
(defun cj/--ai-term-default-size ()
- "Return the host-appropriate default size fraction for the agent window.
+ "Return the default size fraction paired with the chosen direction.
-`cj/ai-term-laptop-height' on a laptop, `cj/ai-term-desktop-width'
-on a desktop -- pairing with the axis chosen by
-`cj/--ai-term-default-direction'."
- (if (env-laptop-p)
- cj/ai-term-laptop-height
- cj/ai-term-desktop-width))
+`cj/ai-term-desktop-width' (a width fraction) when the default direction is
+`right', `cj/ai-term-laptop-height' (a height fraction) when it is `below'."
+ (if (eq (cj/--ai-term-default-direction) 'right)
+ cj/ai-term-desktop-width
+ cj/ai-term-laptop-height))
(defvar cj/--ai-term-last-direction nil
"Last user-chosen direction for the AI-term display.
@@ -429,6 +461,18 @@ without deleting), nil when the window was deleted. Consumed by
buried agent in the current window (the only one) or splitting per
the saved direction.")
+(defvar cj/--ai-term-last-toggle-deleted-split nil
+ "Non-nil when the last F9 toggle-off deleted the agent's own split window.
+
+Set t by `cj/--ai-term-toggle-off' only when it actually `delete-window's
+the agent (a multi-window layout where the agent had its own window);
+nil for a bury or a degenerate swap. Consumed by
+`cj/--ai-term-reuse-edge-window': when set, the next toggle-on re-splits a
+fresh agent window instead of reusing a window at the edge. Without this,
+toggling the agent off and on in a 3+ window layout would reuse the user's
+working window at the edge, displacing its buffer and collapsing the layout
+-- the toggle must be reversible (off then on returns the same windows).")
+
(defvar cj/--ai-term-last-hidden-buffer nil
"The agent buffer hidden by the most recent F9 toggle-off.
@@ -441,21 +485,28 @@ the \"the displayed buffer changes\" bug. Falls back to the buffer-list
MRU when nil or when the remembered buffer has been killed.")
(defvar cj/--ai-term-last-size nil
- "Last user-chosen body size for the AI-term display.
+ "Last user-chosen size for the AI-term display.
Positive integer: body-columns when `cj/--ai-term-last-direction'
-is right or left, body-lines when below or above. nil means use
+is right or left, total-lines when below or above. nil means use
the host-aware default from `cj/--ai-term-default-size' (a float
-fraction).
-
-Body size, not total size, because total-width includes the
-right-edge divider when the window has a right sibling but excludes
-it when the window is at the frame edge. Capturing total-width
-from a rightmost agent (no divider) and replaying into a middle
-position (with divider) leaves the body 1 column short -- visible
-as 1 col of the sibling buffer peeking through where agent should
-have ended. Body-width is divider-independent and matches what the
-user actually sees.
+fraction). See `cj/window-replay-size' for the per-axis capture.
+
+The axis choice is asymmetric. Width captures body-width, not
+total-width: total-width includes the right-edge divider when the
+window has a right sibling but excludes it at the frame edge, so
+capturing total-width from a rightmost agent (no divider) and
+replaying into a middle position (with divider) leaves the body 1
+column short. Body-width is divider-independent.
+
+Height captures total-height, not body-height: every window has
+exactly one mode line regardless of position, so total-height has
+no divider-position problem, and total-height is the same whether
+the window is active or inactive. Body-height would subtract the
+mode line's pixel height, which differs between an active and an
+inactive (theme-shrunk) mode line -- capturing body-height active
+and replaying it inactive then re-measuring active drifts the
+window down by ~1 line per toggle (the F9 shrink bug, 2026-06-20).
Absolute values rather than fractions because
`display-buffer-in-direction' interprets a float `window-width' /
@@ -523,14 +574,22 @@ displaced buffer and the agent, never changing the window count.
Runs after `cj/--ai-term-reuse-existing-agent', so an agent already on
screen has been handled already; the window reused here always holds a
-non-agent buffer, which is replaced (it stays alive, just unshown)."
- (let* ((direction (or cj/--ai-term-last-direction
- (cj/--ai-term-default-direction)))
- (win (cj/window-at-edge direction)))
- (when (and win (not (window-dedicated-p win)))
- (display-buffer-record-window 'reuse win buffer)
- (set-window-buffer win buffer)
- win)))
+non-agent buffer, which is replaced (it stays alive, just unshown).
+
+Skipped entirely when the prior toggle-off deleted the agent's own split
+window (`cj/--ai-term-last-toggle-deleted-split'): re-showing then reuses a
+working window at the edge and collapses the layout. Consume the flag and
+return nil so `cj/--ai-term-display-saved' re-splits a fresh agent window,
+keeping the toggle reversible."
+ (if cj/--ai-term-last-toggle-deleted-split
+ (progn (setq cj/--ai-term-last-toggle-deleted-split nil) nil)
+ (let* ((direction (or cj/--ai-term-last-direction
+ (cj/--ai-term-default-direction)))
+ (win (cj/window-at-edge direction)))
+ (when (and win (not (window-dedicated-p win)))
+ (display-buffer-record-window 'reuse win buffer)
+ (set-window-buffer win buffer)
+ win))))
(defun cj/--ai-term-display-saved (buffer alist)
"Display-buffer action: split per saved direction and size.
@@ -597,19 +656,26 @@ split) when the user is focused in agent and switches projects."
(dolist (entry (cj/--ai-term-display-rule-list))
(add-to-list 'display-buffer-alist entry))
+(defun cj/--ai-term-send-string (buffer string)
+ "Send STRING to BUFFER's terminal process (the agent's shell).
+Sends to the pty directly so the launch command reaches the shell EAT runs."
+ (let ((proc (get-buffer-process buffer)))
+ (when (process-live-p proc)
+ (process-send-string proc string))))
+
(defun cj/--ai-term-show-or-create (dir name)
"Show or create the AI-term buffer for project DIR with buffer NAME.
If a buffer named NAME exists with a live process, display it. If
the buffer exists but its process is dead, kill it and recreate. If
-no such buffer exists, create a new ghostel terminal in DIR and send
+no such buffer exists, create a new EAT terminal in DIR and send
the project's tmux launch command (see `cj/--ai-term-launch-command') so
the same project basename reattaches across Emacs restarts.
-The dynamic binding of `cj/--ai-term-suppress-tmux' around `(ghostel)'
-suppresses the generic tmux-launch hook in term-config.el so
-it doesn't fire a bare \"tmux\\n\" before the project-named launch
-command runs.
+EAT runs a plain shell with no auto-tmux hook, so the named
+`tmux new-session -A' launch command is the only thing that starts the
+session -- the spike confirmed EAT + tmux detach and reattach exactly
+like ghostel + tmux did.
Records DIR in `cj/--ai-term-mru' (whichever branch runs) so the
project picker can list recently-opened projects first. Returns the
@@ -623,28 +689,22 @@ buffer."
(t
(when existing
(kill-buffer existing))
- ;; `ghostel' switches to its buffer in the selected window before our
+ ;; `eat' switches to its buffer in the selected window before our
;; display-buffer-alist rule can route it; `save-window-excursion'
;; reverts that, and the explicit display-buffer below routes the buffer
- ;; through the alist into the agent slot. `ghostel-buffer-name' is bound
- ;; to NAME so the terminal is created under the agent name, and
- ;; `ghostel-buffer-name-function' is pinned nil (dynamically during
- ;; creation, then buffer-locally) so OSC title escapes from the agent
- ;; don't rename it out from under the "agent [" prefix that buffer
- ;; detection and the display rule key on.
+ ;; through the alist into the agent slot. `eat-buffer-name' is bound to
+ ;; NAME so the terminal is created under the agent name; EAT (unlike
+ ;; ghostel) does not rename the buffer from the terminal's OSC title, so
+ ;; the "agent [" prefix that buffer detection and the display rule key on
+ ;; stays put.
(save-window-excursion
(let ((default-directory dir)
- (ghostel-buffer-name name)
- (ghostel-buffer-name-function nil)
- (cj/--ai-term-suppress-tmux t))
- (let ((buf (ghostel)))
- (when (buffer-live-p buf)
- (with-current-buffer buf
- (setq-local ghostel-buffer-name-function nil))))))
+ (eat-buffer-name name))
+ (eat)))
(let ((buf (get-buffer name)))
(with-current-buffer buf
- (ghostel-send-string (cj/--ai-term-launch-command dir))
- (ghostel-send-string "\n"))
+ (cj/--ai-term-send-string
+ buf (concat (cj/--ai-term-launch-command dir) "\n")))
(display-buffer buf)
buf)))))
@@ -746,7 +806,7 @@ without firing real `display-buffer' or `quit-window' calls."
(t '(pick-project))))))))
(defun cj/ai-term-pick-project (&optional arg)
- "Pick an AI-agent project and open or reuse its ghostel terminal.
+ "Pick an AI-agent project and open or reuse its EAT terminal.
The project is picked from a filtered completing-read list of dirs
that contain .ai/protocols.org. The terminal buffer is named
@@ -759,8 +819,8 @@ With prefix ARG, display the buffer without selecting its window.
Bound to C-F9 -- always shows the project picker, even when an agent
buffer is currently displayed.
-ghostel renders in terminal frames as well as GUI frames, so this
-launches from either (only kitty inline-graphics degrade in a TTY)."
+EAT renders in terminal frames as well as GUI frames, so this
+launches from either."
(interactive "P")
(let* ((dir (cj/--ai-term-pick-project))
(name (cj/--ai-term-buffer-name dir))
@@ -770,6 +830,72 @@ launches from either (only kitty inline-graphics degrade in a TTY)."
(when win (select-window win))))
buf))
+(defun cj/--ai-term-swap-to-working-buffer (win)
+ "In WIN, switch to the most-recent non-agent buffer (a working file).
+Falls back to `other-buffer' (excluding WIN's current agent buffer) when no
+non-agent buffer is on record. Used at toggle-off and close so dismissing an
+agent surfaces the file the user was working on rather than another agent or
+the agent itself."
+ (with-selected-window win
+ (switch-to-buffer
+ (or (cj/--ai-term-most-recent-non-agent-buffer)
+ (other-buffer (window-buffer win) t)))))
+
+(defun cj/--ai-term-toggle-off (win)
+ "Hide the agent shown in WIN for an F9 toggle-off. Always returns nil.
+
+Two cases, by window count:
+
+- Lone fullscreen agent (e.g. after `C-x 1' inside it): there is no prior
+ layout for the native undo to restore and deleting would leave the frame
+ empty. Bury and flag, so the next toggle-on (`cj/--ai-term-display-saved')
+ restores the agent in place at full frame rather than splitting. Capture
+ geometry for that restore. `bury-buffer' can no-op when the window's
+ prev-buffer history holds only the agent (common right after `C-x 1'), so
+ force a swap to a non-agent buffer to keep the toggle observable.
+
+- Multi-window: collapse the agent split outright by deleting its window, so
+ the working buffer (e.g. todo.org) reclaims the space. F9 is a pure
+ show/hide toggle of THE agent split -- it must never surface a different
+ agent. `quit-restore-window' can't guarantee that here: switching among
+ several agents reuses the one slot via `set-window-buffer' (see
+ `cj/--ai-term-reuse-existing-agent'), which leaves the window's
+ `quit-restore' parameter pointing at the FIRST agent shown. Once it's
+ stale, `quit-restore-window' falls back to `switch-to-prev-buffer' and
+ surfaces another agent instead of removing the window -- exactly the \"F9
+ shows another agent\" bug. `delete-window' is unconditional and
+ slot-history-independent. Capture geometry first so the next toggle-on
+ splits at the same size (the user's chosen split width is preserved)."
+ ;; Remember which agent we're hiding so the next toggle-on reopens this
+ ;; same one, not whichever agent is most-recent in `buffer-list'.
+ (setq cj/--ai-term-last-hidden-buffer (window-buffer win))
+ (cond
+ ((one-window-p)
+ (cj/--ai-term-capture-state win)
+ (setq cj/--ai-term-last-was-bury t)
+ (setq cj/--ai-term-last-toggle-deleted-split nil)
+ (bury-buffer (window-buffer win))
+ (when (and (window-live-p win)
+ (cj/--ai-term-buffer-p (window-buffer win)))
+ (cj/--ai-term-swap-to-working-buffer win)))
+ (t
+ (cj/--ai-term-capture-state win)
+ (setq cj/--ai-term-last-was-bury nil)
+ (if (and (window-live-p win)
+ (> (length (window-list (window-frame win) 'never)) 1))
+ (progn
+ (delete-window win)
+ ;; The agent had its own window in a multi-window layout, now gone:
+ ;; the next toggle-on must re-split it rather than reuse a working
+ ;; window at the edge (see `cj/--ai-term-reuse-edge-window').
+ (setq cj/--ai-term-last-toggle-deleted-split t))
+ ;; Degenerate fallback (window became sole between dispatch and
+ ;; here): swap to a non-agent buffer rather than leave the agent up.
+ (setq cj/--ai-term-last-toggle-deleted-split nil)
+ (when (window-live-p win)
+ (cj/--ai-term-swap-to-working-buffer win)))))
+ nil)
+
(defun cj/ai-term (&optional arg)
"Smart F9 dispatch for the AI-term launcher.
@@ -785,59 +911,11 @@ 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)
- ;; Remember which agent we're hiding so the next toggle-on reopens this
- ;; same one, not whichever agent is most-recent in `buffer-list'.
- (setq cj/--ai-term-last-hidden-buffer (window-buffer win))
- (cond
- ;; Lone fullscreen agent (e.g. after `C-x 1' inside it): there is no
- ;; prior layout for the native undo to restore and deleting would
- ;; leave the frame empty. Bury and flag, so the next toggle-on
- ;; (`cj/--ai-term-display-saved') restores the agent in place at
- ;; full frame rather than splitting. Capture geometry for that
- ;; restore. `bury-buffer' can no-op when the window's prev-buffer
- ;; history holds only the agent (common right after `C-x 1'), so
- ;; force a swap to a non-agent buffer to keep the toggle observable.
- ((one-window-p)
- (cj/--ai-term-capture-state win)
- (setq cj/--ai-term-last-was-bury t)
- (bury-buffer (window-buffer win))
- (when (and (window-live-p win)
- (cj/--ai-term-buffer-p (window-buffer win)))
- (with-selected-window win
- (switch-to-buffer
- (or (cj/--ai-term-most-recent-non-agent-buffer)
- (other-buffer (window-buffer win) t))))))
- ;; Multi-window: collapse the agent split outright by deleting its
- ;; window, so the working buffer (e.g. todo.org) reclaims the space.
- ;; F9 is a pure show/hide toggle of THE agent split -- it must never
- ;; surface a different agent. `quit-restore-window' can't guarantee
- ;; that here: switching among several agents reuses the one slot via
- ;; `set-window-buffer' (see `cj/--ai-term-reuse-existing-agent'),
- ;; which leaves the window's `quit-restore' parameter pointing at the
- ;; FIRST agent shown. Once it's stale, `quit-restore-window' falls
- ;; back to `switch-to-prev-buffer' and surfaces another agent instead
- ;; of removing the window -- exactly the "F9 shows another agent"
- ;; bug. `delete-window' is unconditional and slot-history-independent.
- ;; Capture geometry first so the next toggle-on splits at the same
- ;; size (the user's chosen split width is preserved across the toggle).
- (t
- (cj/--ai-term-capture-state win)
- (setq cj/--ai-term-last-was-bury nil)
- (if (and (window-live-p win)
- (> (length (window-list (window-frame win) 'never)) 1))
- (delete-window win)
- ;; Degenerate fallback (window became sole between dispatch and
- ;; here): swap to a non-agent buffer rather than leave the agent up.
- (when (window-live-p win)
- (with-selected-window win
- (switch-to-buffer
- (or (cj/--ai-term-most-recent-non-agent-buffer)
- (other-buffer (window-buffer win) t))))))))
- nil)
+ (cj/--ai-term-toggle-off win))
(`(redisplay-recent . ,buf)
(display-buffer buf)
(unless arg
@@ -877,10 +955,7 @@ when BUFFER isn't an AI-term buffer."
(buffer-local-value 'default-directory buffer)))
(let ((win (get-buffer-window buffer)))
(when (window-live-p win)
- (with-selected-window win
- (switch-to-buffer
- (or (cj/--ai-term-most-recent-non-agent-buffer)
- (other-buffer buffer t))))))
+ (cj/--ai-term-swap-to-working-buffer win)))
(let ((kill-buffer-query-functions nil))
(kill-buffer buffer))))
@@ -906,7 +981,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
@@ -917,31 +992,165 @@ interrupt work in progress. Bound to M-<f9> (primary) and C-S-<f9>."
(cj/--ai-term-close-buffer buffer)
(message "Closed agent %s." name)))))
-(keymap-global-set "<f9>" #'cj/ai-term)
-(keymap-global-set "C-<f9>" #'cj/ai-term-pick-project)
-(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
-;; buffer would be sent to the program instead of toggling the agent -- which
-;; bites hard when the agent buffer is the only window in the frame. Re-bind
-;; the F9 family in `ghostel-mode-map' so the toggle reaches Emacs from there
-;; too. (C-<f9> / M-<f9> are bound here as well so the behaviour is uniform.)
-(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 "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>"))
- (add-to-list 'ghostel-keymap-exceptions key))
- (ghostel--rebuild-semi-char-keymap))
+;; ------------------------- Step to the next agent ----------------------------
+
+(defun cj/ai-term-next ()
+ "Step to the next open AI-term agent in the queue.
+
+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* ((dirs (cj/--ai-term-active-agent-dirs))
+ (win (cj/--ai-term-displayed-agent-window))
+ (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)
+ (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.
+(defvar-keymap cj/ai-term-keymap
+ :doc "Keymap for ai-term agent commands (C-; a)."
+ "a" #'cj/ai-term ;; toggle the most-recent agent on/off
+ "s" #'cj/ai-term-pick-project ;; select / launch via the project picker
+ "n" #'cj/ai-term-next ;; swap to the next open agent
+ "k" #'cj/ai-term-close) ;; kill the current agent
+(cj/register-prefix-map "a" cj/ai-term-keymap "ai-term")
+(keymap-global-set "M-SPC" #'cj/ai-term-next)
+
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-; a" "ai-term menu"
+ "C-; a a" "toggle agent"
+ "C-; a s" "select / launch"
+ "C-; a n" "next agent"
+ "C-; a k" "kill agent"
+ "M-SPC" "ai-term: next agent"))
+
+;; In EAT's semi-char mode, keys not bound in `eat-semi-char-mode-map' are
+;; forwarded to the pty. M-SPC (swap to the next agent) must reach Emacs from
+;; inside an agent buffer, so bind it in that map -- no exception-list or rebuild
+;; dance like ghostel needed. C-; is already bound there (eat-config), so the
+;; C-; a family resolves through the global prefix without extra wiring.
+(with-eval-after-load 'eat
+ (keymap-set eat-semi-char-mode-map "M-SPC" #'cj/ai-term-next))
+
+;; ------------------- Wrap-it-up teardown + shutdown -------------------------
+;;
+;; Headless entry points the rulesets wrap-it-up workflow calls via
+;; `emacsclient -e' (its Stop hook ~/.claude/hooks/ai-wrap-teardown.sh). All
+;; three must work with no interactive frame guaranteed. rulesets owns the
+;; workflow + hook that call these; this module owns the aiv- session naming,
+;; the agent buffer, and the geometry restore, so the functions live here.
+;; See docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org (rulesets).
+
+(defcustom cj/ai-term-shutdown-command "sudo shutdown now"
+ "Shell command run when the shutdown countdown completes uncancelled.
+A defcustom so development and tests can stub it instead of powering off
+\(sudo is NOPASSWD on Craig's machines, so the default really shuts down)."
+ :type 'string
+ :group 'cj)
+
+(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))
+ (session (cj/--ai-term-tmux-session-name key))
+ (buffer (get-buffer (cj/--ai-term-buffer-name key))))
+ (cj/--ai-term-kill-tmux-session session)
+ (when (cj/--ai-term-buffer-p buffer)
+ (let ((win (get-buffer-window buffer)))
+ (when (window-live-p win)
+ (cj/--ai-term-swap-to-working-buffer win)))
+ (let ((kill-buffer-query-functions nil))
+ (kill-buffer buffer)))
+ session))
+
+(defun cj/ai-term-live-count ()
+ "Return the integer count of live AI-term (aiv-*) tmux sessions.
+0 when tmux has no server or no AI-term sessions. The shutdown safety gate:
+`emacsclient -e (cj/ai-term-live-count)' prints the integer for the hook."
+ (length (cj/--ai-term-live-tmux-sessions)))
+
+(defvar cj/--ai-term-shutdown-timer nil
+ "The active shutdown-countdown repeating timer, or nil when none is running.")
+
+(defun cj/--ai-term-shutdown-clear-timer ()
+ "Cancel and forget the shutdown-countdown timer, if any."
+ (when (timerp cj/--ai-term-shutdown-timer)
+ (cancel-timer cj/--ai-term-shutdown-timer))
+ (setq cj/--ai-term-shutdown-timer nil))
+
+(defun cj/ai-term-shutdown-cancel ()
+ "Cancel an in-progress AI-term shutdown countdown."
+ (interactive)
+ (when cj/--ai-term-shutdown-timer
+ (cj/--ai-term-shutdown-clear-timer)
+ (message "Shutdown cancelled.")))
+
+(defun cj/ai-term-shutdown-countdown (&optional seconds)
+ "Count down SECONDS (default 10) in the echo area, then shut the machine down.
+Re-checks the safety gate first (a TOCTOU guard against the workflow's earlier
+check): aborts with a message when more than one `aiv-*' session is live. The
+countdown is an abort-able `run-at-time' timer -- `C-g' (while the countdown
+owns the keymap) or \\[cj/ai-term-shutdown-cancel] stops it. On reaching zero
+uncancelled it runs `cj/ai-term-shutdown-command'. Returns immediately so the
+Stop hook does not block; the daemon ticks the timer asynchronously."
+ (if (> (cj/ai-term-live-count) 1)
+ (progn
+ (message "Shutdown aborted: %d AI-term sessions still live."
+ (cj/ai-term-live-count))
+ nil)
+ (cj/--ai-term-shutdown-clear-timer)
+ (let ((remaining (or seconds 10)))
+ (set-transient-map
+ (let ((m (make-sparse-keymap)))
+ (define-key m (kbd "C-g") #'cj/ai-term-shutdown-cancel)
+ m)
+ (lambda () (and cj/--ai-term-shutdown-timer t)))
+ (setq cj/--ai-term-shutdown-timer
+ (run-at-time
+ 0 1
+ (lambda ()
+ (if (<= remaining 0)
+ (progn
+ (cj/--ai-term-shutdown-clear-timer)
+ (shell-command cj/ai-term-shutdown-command))
+ (message "Shutting down in %d… (C-g to cancel)" remaining)
+ (setq remaining (1- remaining))))))
+ nil)))
;; ---------- emacsclient: keep opened files off the agent terminal ----------
;;