diff options
Diffstat (limited to 'modules/ai-term.el')
| -rw-r--r-- | modules/ai-term.el | 440 |
1 files changed, 328 insertions, 112 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el index 49d44d25e..b463da90b 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,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)) @@ -181,6 +188,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,21 +432,17 @@ fallback when `cj/--ai-term-last-size' is nil." :type 'number :group 'ai-term) -(defun cj/--ai-term-direction-for-aspect (pixel-width pixel-height) - "Return the space-conserving dock direction for a frame of PIXEL-WIDTH by -PIXEL-HEIGHT. `right' when the frame is wider than tall (dock from the right -edge), `below' when it is square or taller (dock from the bottom)." - (if (> pixel-width pixel-height) 'right 'below)) - (defun cj/--ai-term-default-direction (&optional frame) "Return the default split direction for the agent window. -Chosen at display time from FRAME's pixel aspect ratio (FRAME defaults to the -selected frame): `right' on a landscape frame, `below' on a square or portrait -one -- whichever edge conserves more screen space." +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/--ai-term-direction-for-aspect (frame-pixel-width frame) - (frame-pixel-height frame)))) + (cj/preferred-dock-direction (frame-width frame) + cj/ai-term-desktop-width))) (defun cj/--ai-term-default-size () "Return the default size fraction paired with the chosen direction. @@ -437,6 +474,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. @@ -449,21 +498,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' / @@ -531,14 +587,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. @@ -778,6 +842,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. @@ -793,59 +923,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 @@ -885,10 +967,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)))) @@ -914,7 +993,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 @@ -925,32 +1004,169 @@ 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.) +;; ------------------------- 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 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 "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)) + (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 |
