diff options
Diffstat (limited to 'modules/ai-term.el')
| -rw-r--r-- | modules/ai-term.el | 521 |
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 ---------- ;; |
