diff options
Diffstat (limited to 'modules/ai-term.el')
| -rw-r--r-- | modules/ai-term.el | 192 |
1 files changed, 97 insertions, 95 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el index 3beabe6b5..6dfb669a9 100644 --- a/modules/ai-term.el +++ b/modules/ai-term.el @@ -1,4 +1,4 @@ -;;; ai-term.el --- In-Emacs AI-agent launcher with vertical-split terminal -*- lexical-binding: t; -*- +;;; ai-term.el --- AI-agent terminals backed by EAT and tmux -*- lexical-binding: t; -*- ;; Author: Craig Jennings <c@cjennings.net> @@ -7,70 +7,18 @@ ;; Layer: 3 (Domain Workflow). ;; Category: D. ;; Load shape: eager. -;; Eager reason: registers four global keys for the AI-agent terminal launcher; a -;; command-loaded deferral candidate. -;; Top-level side effects: four global key bindings. -;; Runtime requires: cl-lib, seq, cj-window-geometry-lib, cj-window-toggle-lib, -;; host-environment. +;; Eager reason: binds M-SPC and the C-; a AI-agent prefix. +;; Top-level side effects: global M-SPC binding and C-; a prefix map. +;; Runtime requires: cl-lib, seq, window-toggle/geometry helpers, host-environment. ;; Direct test load: yes. ;; -;; Picks an AI-agent project (a dir under ~/.emacs.d, ~/code/*, or -;; ~/projects/* containing .ai/protocols.org), opens or reuses a terminal -;; buffer named "agent [<basename>]", sends the agent's startup -;; instruction to it, and routes the buffer to a side window via -;; display-buffer-alist. When the frame already has a window forming the -;; half the agent would occupy (a right column on a desktop, a bottom row -;; on a laptop), the agent reuses that slot rather than splitting a third -;; window in; toggling off restores the displaced buffer to the slot. -;; Otherwise placement is a host-aware split: a right-side split at 50% -;; width on a desktop, a bottom split at 75% height on a laptop (see -;; `cj/--ai-term-default-direction'). Multiple -;; projects produce multiple coexisting buffers that share the same -;; slot; switching among them is a buffer-switch, not a -;; kill-and-recreate. +;; Opens project-scoped AI agents in EAT buffers backed by tmux sessions. Project +;; candidates come from configured roots that contain .ai/protocols.org. ;; -;; Each project's agent runs inside a tmux session named -;; "<cj/ai-term-tmux-session-prefix><basename>" (default prefix "aiv-"). -;; The prefix lets `tmux ls' be filtered to AI-term's own sessions, so -;; after an Emacs crash the project picker can match surviving sessions -;; back to their directories: matched projects sort to the top of the -;; picker (flagged "[detached]" -- session alive, no Emacs buffer -- or -;; "[running]" when a live terminal buffer exists), the rest follow in -;; alphabetical order. -;; -;; Four F-key entry points: -;; -;; - F9 `cj/ai-term' -- DWIM dispatch. If an agent buffer is -;; currently displayed in this frame, F9 toggles it off: when it -;; took over an existing window (a reused slot) the buffer it -;; displaced returns to that slot, when it was split into its own -;; window that window is removed, and when it fills the frame it -;; is buried. Otherwise, if exactly one agent buffer is alive, -;; F9 re-displays it; if zero or two-plus are alive, F9 falls -;; through to the project picker. -;; - C-F9 `cj/ai-term-pick-project' -- always show the project -;; 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. -;; -;; Existing windmove (Shift-arrows) handles code <-> agent focus -;; toggling. Buffer-move (C-M-arrows) handles side-swap. Neither -;; needs anything new from this module. +;; Agent display reuses the host-appropriate side slot when possible, otherwise +;; splits right on desktop frames and below on laptop frames. Attached buffers +;; and detached tmux sessions share the same rotation; selecting a detached +;; agent recreates its EAT buffer and attaches to the live session. ;;; Code: @@ -82,6 +30,7 @@ (require 'keybindings) ;; provides cj/register-prefix-map (C-; a) (declare-function eat "eat" (&optional program arg)) +(declare-function cj/make-buffer-pattern-undead "undead-buffers") (defvar eat-buffer-name) (defvar eat-semi-char-mode-map) @@ -215,7 +164,7 @@ which the step materializes by attaching." Walks `buffer-list' (most-recently-selected first) and returns the first buffer that is not an AI-term agent buffer (per `cj/--ai-term-buffer-p') and is not an internal buffer (name starting -with a space). Used by the single-window F9 toggle-off so dismissing a +with a space). Used by the single-window toggle-off so dismissing a full-frame agent returns to the file the user was working in (e.g. todo.org) rather than swapping in another agent." (seq-find (lambda (b) @@ -287,7 +236,7 @@ looked up in SESSIONS, so the lossy whitespace->hyphen transform in (defun cj/--ai-term-launch-command (dir) "Return the shell command line that runs the AI tool in a project tmux session. -Uses `tmux new-session -A' so a second F9 on the same project reattaches +Uses `tmux new-session -A' so a second toggle on the same project reattaches to the running session instead of spawning a new one. The session name comes from `cj/--ai-term-tmux-session-name'; the first window is named `cj/ai-term-tmux-window-name' (default \"ai\") so a later hand-opened @@ -452,7 +401,7 @@ direction applies. Captured at toggle-off by `cj/--ai-term-display-saved'.") (defvar cj/--ai-term-last-was-bury nil - "Non-nil when the last F9 toggle-off used `bury-buffer'. + "Non-nil when the last toggle-off used `bury-buffer'. Set by `cj/ai-term' in its `toggle-off' branch: t when the agent window was the only window in the frame (so toggle-off buried @@ -462,7 +411,7 @@ 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. + "Non-nil when the last 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); @@ -474,7 +423,7 @@ 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. + "The agent buffer hidden by the most recent toggle-off. Captured in `cj/ai-term' just before an agent window is torn down, and consumed by `cj/--ai-term-dispatch' so the next toggle-on reopens the @@ -516,11 +465,24 @@ and a fraction-of-frame produces the wrong size on replay (squeezes the other windows). An integer is unambiguous, at the cost of not auto-scaling if the frame itself resizes.") +(defvar cj/--ai-term-last-fullscreen nil + "Non-nil when the agent window was last seen filling its frame. + +Maintained by `cj/--ai-term-track-geometry' on +`window-configuration-change-hook': set t whenever a live agent window is +the sole window in its frame, cleared when the agent is shown as a split +\(its dock direction and size are captured then instead). Consulted by +`cj/--ai-term-display-saved' so a summon into a single-window frame +restores the agent fullscreen rather than docking it -- the sole-window +state isn't a representable dock size, so this flag is how it round-trips. +Unlike `cj/--ai-term-last-was-bury' it does not depend on a toggle-off, so +it also covers leaving the agent by switching buffers or `C-x 1'.") + (defun cj/--ai-term-capture-state (window) "Capture WINDOW's direction and size into module-level state. Sets `cj/--ai-term-last-direction' and `cj/--ai-term-last-size' -so a subsequent F9 display can restore the user's chosen orientation +so a subsequent display can restore the user's chosen orientation and size. Called at toggle-off (just before the window is torn down). The default direction is host-aware via `cj/--ai-term-default-direction' (used only when WINDOW fills its @@ -532,6 +494,35 @@ is not live." 'cj/--ai-term-last-size '(right below left))) +(defun cj/--ai-term-window-sole-p (window) + "Return non-nil when WINDOW is the only live window in its frame. +A frame's sole window is its root window; once split, the root is an +internal window and no live window equals it." + (and (window-live-p window) + (eq window (frame-root-window (window-frame window))))) + +(defun cj/--ai-term-track-geometry (&rest _) + "Track whether the displayed agent window is fullscreen. + +Run from `window-configuration-change-hook'. Sets +`cj/--ai-term-last-fullscreen' to whether a live agent window is the sole +window in its frame, and leaves it untouched when no agent window is +displayed -- that retained value is the just-left state a later summon +replays. Dock direction and size stay owned by the toggle-off capture +\(`cj/--ai-term-capture-state'); this hook must not re-capture them, or the +repeated capture/replay drifts the dock height a couple rows per cycle." + (let ((win (cj/--ai-term-displayed-agent-window))) + (when (window-live-p win) + (setq cj/--ai-term-last-fullscreen (cj/--ai-term-window-sole-p win))))) + +(add-hook 'window-configuration-change-hook #'cj/--ai-term-track-geometry) + +;; Agent buffers ("agent [<project>]") are buried, not killed, by the +;; kill-all sweep (F1 / `cj/dashboard-only'). Register the family pattern so +;; every agent -- however and whenever created -- survives with its session. +(with-eval-after-load 'undead-buffers + (cj/make-buffer-pattern-undead "\\`agent \\[")) + (defun cj/--ai-term-reuse-existing-agent (buffer _alist) "Display-buffer action: reuse any window in this frame already showing an agent buffer. @@ -544,7 +535,7 @@ action in the chain runs. This is more specific than `display-buffer-use-some-window', which would happily steal any non-selected window (e.g. a code window above the agent split) when the user is focused in agent and -swaps projects via C-F9. The selective lookup here keeps non-agent +swaps projects via C-; a s. The selective lookup here keeps non-agent windows undisturbed and preserves the user's split geometry across project changes." (let ((win (cj/--ai-term-displayed-agent-window))) @@ -592,19 +583,27 @@ keeping the toggle reversible." win)))) (defun cj/--ai-term-display-saved (buffer alist) - "Display-buffer action: split per saved direction and size. + "Display-buffer action: restore fullscreen in a single-window frame, +otherwise split per saved direction and size. -When the prior toggle-off was a bury (single-window state, flagged -via `cj/--ai-term-last-was-bury') and the frame is still single- -window, restore the agent into the selected window in place rather -than splitting -- preserves the user's lone-window layout across -F9 toggles. +When the frame is a single window and the agent was last fullscreen +\(`cj/--ai-term-last-fullscreen', tracked by `cj/--ai-term-track-geometry') +or the prior toggle-off was a single-window bury +\(`cj/--ai-term-last-was-bury'), restore the agent into the selected window +in place rather than splitting. This round-trips a fullscreen agent -- +left by toggle-off, `C-x 1', or switching buffers -- since the sole-window +state isn't a representable dock size. Otherwise delegates to `cj/window-toggle-display-saved' against the -F9 state vars, falling back to the host-aware defaults from +toggle state vars, falling back to the host-aware defaults from `cj/--ai-term-default-direction' and `cj/--ai-term-default-size'." (cond - ((and cj/--ai-term-last-was-bury (one-window-p)) + ;; NOMINI t: don't count an active minibuffer as a second window. A summon + ;; can run with a picker prompt up, and a bare `one-window-p' then returns + ;; nil on a structurally single-window frame, misfiring the fullscreen + ;; restore into a dock -- which clears the fullscreen flag and cascades. + ((and (or cj/--ai-term-last-fullscreen cj/--ai-term-last-was-bury) + (one-window-p t)) (setq cj/--ai-term-last-was-bury nil) (let ((win (selected-window))) (set-window-buffer win buffer) @@ -627,7 +626,7 @@ through four actions in order: 2. `cj/--ai-term-reuse-existing-agent' -- otherwise, if any window in this frame already shows an agent-prefixed buffer, swap its buffer for the new one (preserves geometry across - project changes via C-F9). + project changes via C-; a s). 3. `cj/--ai-term-reuse-edge-window' -- otherwise, if the frame already has a window forming the half the agent would occupy (the right column on a desktop, the bottom row on a laptop), @@ -773,17 +772,17 @@ Signals `user-error' when no candidates exist." (expand-file-name chosen))))) (defun cj/--ai-term-dispatch () - "Compute the F9 (`cj/ai-term') action without performing it. + "Compute the `cj/ai-term' (C-; a a) action without performing it. Returns one of: - (toggle-off . WINDOW) -- agent is displayed in WINDOW; quit it. - (redisplay-recent . BUFFER) -- 1+ alive agent buffers; show MRU. - (pick-project) -- zero alive agent buffers; prompt. -When 2+ agent buffers are alive, F9 redisplays the most-recently- -selected one rather than opening the project picker. C-F9 is the -explicit \"start a different project\" surface; M-F9 is the explicit -\"switch among existing agents\" surface. F9 keeps a single, simple +When 2+ agent buffers are alive, C-; a a redisplays the most-recently- +selected one rather than opening the project picker. C-; a s is the +explicit \"start a different project\" surface; C-; a n is the explicit +\"switch among existing agents\" surface. C-; a a keeps a single, simple job: toggle whichever agent was last in use. A pure-decision helper so the dispatch logic is exercisable in tests @@ -816,7 +815,7 @@ buffers; reinvoking on the same project reuses its existing terminal. With prefix ARG, display the buffer without selecting its window. -Bound to C-F9 -- always shows the project picker, even when an agent +Bound to C-; a s -- always shows the project picker, even when an agent buffer is currently displayed. EAT renders in terminal frames as well as GUI frames, so this @@ -842,7 +841,7 @@ the agent itself." (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. + "Hide the agent shown in WIN for a toggle-off. Always returns nil. Two cases, by window count: @@ -855,7 +854,7 @@ Two cases, by window count: 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 + the working buffer (e.g. todo.org) reclaims the space. The toggle 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 @@ -897,21 +896,21 @@ Two cases, by window count: nil) (defun cj/ai-term (&optional arg) - "Smart F9 dispatch for the AI-term launcher. + "DWIM dispatch for the AI-term launcher. Bound to C-; a a. Behavior depends on the current state: -- If an AI-term buffer is currently displayed in this frame, F9 +- If an AI-term buffer is currently displayed in this frame, it quits its window (toggle off, buffer stays alive). -- Else, if exactly one alive AI-term buffer exists, F9 re-displays +- Else, if exactly one alive AI-term buffer exists, it re-displays it (DWIM -- the obvious next step is to look at it). -- Else (zero or 2+), F9 falls through to `cj/ai-term-pick-project'. +- Else (zero or 2+), it falls through to `cj/ai-term-pick-project'. 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 closes an agent via `cj/ai-term-close'." +See `cj/ai-term-pick-project' (C-; a s) to force the project picker. +C-; a k closes an agent via `cj/ai-term-close'." (interactive "P") (pcase (cj/--ai-term-dispatch) (`(toggle-off . ,win) @@ -945,7 +944,7 @@ Derives the tmux session name from BUFFER's `default-directory' (the project dir the terminal was created in) and kills it so the agent process stops. When BUFFER is shown, swaps its window to a non-agent buffer (the working file) rather than deleting the window -- closing an -agent must not collapse the user's window layout; the F9 hide toggle is +agent must not collapse the user's window layout; the hide toggle is what collapses the split. Then kills BUFFER (suppressing the process-still-running prompt -- the session is already down). No-op when BUFFER isn't an AI-term buffer." @@ -959,6 +958,8 @@ when BUFFER isn't an AI-term buffer." (let ((kill-buffer-query-functions nil)) (kill-buffer buffer)))) +(require 'system-lib) + (defun cj/--ai-term-close-target () "Return the AI-term buffer `cj/ai-term-close' should act on, or nil. @@ -973,7 +974,8 @@ buffers; nil when none are alive." ((null (cdr buffers)) (car buffers)) (t (get-buffer (completing-read "Close AI terminal: " - (mapcar #'buffer-name buffers) nil t)))))))) + (cj/completion-table 'buffer (mapcar #'buffer-name buffers)) + nil t)))))))) (defun cj/ai-term-close () "Gracefully close an AI-term agent: kill its tmux session and buffer. @@ -981,7 +983,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>." +interrupt work in progress. Bound to C-; a k." (interactive) (let ((buffer (cj/--ai-term-close-target))) (unless buffer |
