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.el257
1 files changed, 192 insertions, 65 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el
index ff8da0035..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
@@ -77,6 +79,7 @@
(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))
@@ -185,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.
@@ -987,59 +1009,164 @@ 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. 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."
+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)))
- (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)
-
-;; 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.)
+ (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 ghostel's semi-char mode, keys not in `ghostel-keymap-exceptions' are
+;; forwarded to the pty, and `ghostel-semi-char-mode-map' outranks the major
+;; mode map. M-SPC (swap to the next agent) must reach Emacs from inside an
+;; agent buffer, so add it to the exceptions, rebuild the semi-char map, and
+;; bind it in `ghostel-mode-map'. C-; is already an exception (term-config),
+;; so the C-; a family resolves through the global prefix without extra wiring.
(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)
- ;; 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>" "s-<f9>" "M-<f9>"))
- (add-to-list 'ghostel-keymap-exceptions key))
+ (keymap-set ghostel-mode-map "M-SPC" #'cj/ai-term-next)
+ (add-to-list 'ghostel-keymap-exceptions "M-SPC")
(ghostel--rebuild-semi-char-keymap))
+;; ------------------- 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 ----------
;;
;; `server-start' (in system-defaults.el) leaves `server-window' nil, so