diff options
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/ai-term-backend-eat.el | 93 | ||||
| -rw-r--r-- | modules/ai-term-display.el | 460 | ||||
| -rw-r--r-- | modules/ai-term-sessions.el | 350 | ||||
| -rw-r--r-- | modules/ai-term.el | 812 |
4 files changed, 918 insertions, 797 deletions
diff --git a/modules/ai-term-backend-eat.el b/modules/ai-term-backend-eat.el new file mode 100644 index 00000000..6d512831 --- /dev/null +++ b/modules/ai-term-backend-eat.el @@ -0,0 +1,93 @@ +;;; ai-term-backend-eat.el --- EAT terminal backend for ai-term -*- lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; Layer: 3 (Domain Workflow). +;; Category: D. +;; Load shape: library. +;; Top-level side effects: binds M-SPC in `eat-semi-char-mode-map' after EAT +;; loads. +;; Runtime requires: ai-term-sessions. +;; Direct test load: yes. +;; +;; EAT backend of ai-term: terminal buffer creation and tmux reattach, the +;; pty send helper, and the EAT keymap integration. The name is +;; backend-specific on purpose -- a future terminal backend lands as a +;; sibling ai-term-backend-<name>.el and everything above this layer stays +;; put. The display routing the created buffer goes through lives in +;; ai-term-display (the display-buffer-alist rule); this module only calls +;; `display-buffer'. + +;;; Code: + +(require 'ai-term-sessions) + +(declare-function eat "eat" (&optional program arg)) +(declare-function cj/ai-term-next "ai-term" ()) +(defvar eat-buffer-name) +(defvar eat-semi-char-mode-map) + +(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 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. + +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 +buffer." + (cj/--ai-term-record-mru dir) + (let ((existing (get-buffer name))) + (cond + ((and existing (cj/--ai-term-process-live-p existing)) + (display-buffer existing) + existing) + (t + (when existing + (kill-buffer existing)) + ;; `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. `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) + (eat-buffer-name name)) + (eat))) + (let ((buf (get-buffer name))) + (with-current-buffer buf + (cj/--ai-term-send-string + buf (concat (cj/--ai-term-launch-command dir) "\n"))) + (display-buffer buf) + buf))))) + +;; 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)) + +(provide 'ai-term-backend-eat) +;;; ai-term-backend-eat.el ends here diff --git a/modules/ai-term-display.el b/modules/ai-term-display.el new file mode 100644 index 00000000..b78a2638 --- /dev/null +++ b/modules/ai-term-display.el @@ -0,0 +1,460 @@ +;;; ai-term-display.el --- AI-term window and display policy -*- lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; Layer: 3 (Domain Workflow). +;; Category: D. +;; Load shape: library. +;; Top-level side effects: installs the agent display-buffer-alist rule, adds +;; the geometry tracker to window-configuration-change-hook, and wires +;; `server-window' after server loads. +;; Runtime requires: seq, cj-window-geometry-lib, cj-window-toggle-lib, +;; ai-term-sessions. +;; Direct test load: yes. +;; +;; Display/window layer of ai-term: the display-buffer action chain and its +;; alist rule, the toggle capture/restore state and geometry tracking, the +;; toggle-off teardown, the working-buffer swap, and the server-window +;; routing that keeps emacsclient-opened files off the agent terminal. +;; No EAT and no tmux -- window behavior here is exercisable with plain +;; buffers and stubbed session/backend functions. +;; +;; The size defaults this layer reads (`cj/ai-term-desktop-width', +;; `cj/ai-term-laptop-height') are owned by ai-term.el, the public face, +;; which requires this module before defining them; they are +;; forward-declared here so this module compiles and reads them without a +;; cycle. + +;;; Code: + +(require 'seq) +(require 'cj-window-geometry-lib) +(require 'cj-window-toggle-lib) +(require 'ai-term-sessions) + +;; Owned by ai-term.el (the public face's defcustoms); forward-declared so +;; this module compiles and reads them without a cycle. +(defvar cj/ai-term-desktop-width) +(defvar cj/ai-term-laptop-height) + +(defun cj/--ai-term-displayed-agent-window (&optional frame) + "Return a window in FRAME currently displaying an AI-term buffer, or nil. + +FRAME defaults to the selected frame. When more than one window in +the frame shows an agent buffer, the first one in `window-list' order +is returned. The minibuffer is excluded from the search." + (seq-find (lambda (w) + (cj/--ai-term-buffer-p (window-buffer w))) + (window-list (or frame (selected-frame)) 'never))) + +(defun cj/--ai-term-most-recent-non-agent-buffer () + "Return the most-recently-selected live non-agent buffer, or nil. + +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 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) + (and (buffer-live-p b) + (not (cj/--ai-term-buffer-p b)) + (not (string-prefix-p " " (buffer-name b))))) + (buffer-list))) + +(defun cj/--ai-term-default-direction (&optional frame) + "Return the default split direction for the agent window. + +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 default size fraction paired with the chosen direction. + +`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. + +Symbol: right, below, or left. `above' is never stored -- the agent +window must not be remembered at the top of the frame, so a top +placement falls back to the host default at capture time. nil means no +agent window has been toggled off yet this session, so the default +direction applies. Captured at toggle-off by +`cj/--ai-term-capture-state' and consumed by +`cj/--ai-term-display-saved'.") + +(defvar cj/--ai-term-last-was-bury nil + "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 +without deleting), nil when the window was deleted. Consumed by +`cj/--ai-term-display-saved' to decide between restoring the +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 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 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 +SAME agent that was on screen rather than whichever agent happens to be +most-recent in `buffer-list'. Without this, hiding one agent and +reopening could surface a different one when several agents are alive -- +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 size for the AI-term display. + +Positive integer: body-columns when `cj/--ai-term-last-direction' +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). 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' / +`window-height' as a fraction of the new window's parent in the +window tree. In a 3+ window layout the parent may be a sub-tree, +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 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 +frame and no direction can be inferred). Does nothing when WINDOW +is not live." + (cj/window-toggle-capture-state + window (cj/--ai-term-default-direction) + 'cj/--ai-term-last-direction + '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) + +(defun cj/--ai-term-reuse-existing-agent (buffer _alist) + "Display-buffer action: reuse any window in this frame already showing +an agent buffer. + +Looks up `cj/--ai-term-displayed-agent-window' on the selected +frame. When an agent window exists, replaces its buffer with BUFFER +and returns the window. When none exists, returns nil so the next +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-; 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))) + (when win + (set-window-buffer win buffer) + win))) + +(defun cj/--ai-term-reuse-edge-window (buffer _alist) + "Display-buffer action: reuse the existing window forming the target half. + +When the frame already holds a window forming the half the agent would +occupy -- the right column on a desktop, the bottom row on a laptop, per +the saved or default direction -- swap BUFFER into it with +`set-window-buffer' and return that window, rather than splitting a third +window in. The target half is found by `cj/window-at-edge'. + +Returns nil when there is no such half to reuse (a single-window frame, +or a layout split on the other axis), so the chain falls through to +`cj/--ai-term-display-saved', which splits a fresh half. Also returns +nil when the edge window is dedicated -- those are not ours to replace. + +Records the displaced buffer through `display-buffer-record-window' +\(type `reuse') before swapping, so the native `quit-restore-window' +called at toggle-off puts that buffer back into the slot instead of +deleting the window -- toggling swaps the slot's buffer between the +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). + +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: restore fullscreen in a single-window frame, +otherwise split per saved direction and size. + +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 +toggle state vars, falling back to the host-aware defaults from +`cj/--ai-term-default-direction' and `cj/--ai-term-default-size'." + (cond + ;; 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) + win)) + (t + (setq cj/--ai-term-last-was-bury nil) + (cj/window-toggle-display-saved + buffer alist + 'cj/--ai-term-last-direction (cj/--ai-term-default-direction) + 'cj/--ai-term-last-size (cj/--ai-term-default-size))))) + +(defun cj/--ai-term-display-rule-list () + "Return the `display-buffer-alist' entry list installed by this module. + +The single rule routes any buffer whose name starts with \"agent [\" +through four actions in order: + +1. `display-buffer-reuse-window' -- if the same buffer is already + visible in any window, focus that one. +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-; 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), + reuse it instead of splitting a third window in. +4. `cj/--ai-term-display-saved' -- otherwise (single-window frame, + or a layout split on the other axis), split per the saved + direction + size from the last toggle-off (or defaults when no + capture has happened this session). + +`display-buffer-in-side-window' is avoided deliberately. Side +windows enforce dedication, which breaks `buffer-move' (C-M-arrows) +and `switch-to-buffer' replacement. The chain above keeps the +resulting window an ordinary window so all standard window commands +work. + +`display-buffer-use-some-window' is also avoided -- it would happily +steal any non-selected window (e.g. a code window above an agent +split) when the user is focused in agent and switches projects." + '(("\\`agent \\[" + (display-buffer-reuse-window + cj/--ai-term-reuse-existing-agent + cj/--ai-term-reuse-edge-window + cj/--ai-term-display-saved) + (inhibit-same-window . t)))) + +(dolist (entry (cj/--ai-term-display-rule-list)) + (add-to-list 'display-buffer-alist entry)) + +(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 a 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. 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 + `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) + +;; ---------- emacsclient: keep opened files off the agent terminal ---------- +;; +;; `server-start' (in system-defaults.el) leaves `server-window' nil, so +;; `server-switch-buffer' opens an `emacsclient -n' file in the *selected* +;; window. When the user is typing in the agent terminal, that's the agent +;; window -- so "tell the agent to open X" would replace the agent buffer +;; with X. The function below, wired as `server-window', routes such files +;; into a non-agent window instead (splitting one off the agent when the +;; agent is the only window). emacsclient invocations from anywhere else +;; fall through to `pop-to-buffer' and behave as before. + +(defun cj/--ai-term-non-agent-window (&optional exclude) + "Return a window in the selected frame fit to show a non-agent buffer. + +Skips the minibuffer, the EXCLUDE window, dedicated windows, and any +window already showing an AI-term agent buffer. Returns nil when no +such window exists." + (seq-find (lambda (w) + (and (not (eq w exclude)) + (not (window-dedicated-p w)) + (not (cj/--ai-term-buffer-p (window-buffer w))))) + (window-list (selected-frame) 'never))) + +(defun cj/--ai-term-server-display (buffer) + "Display BUFFER for `server-window', keeping it off the agent terminal. + +When the selected window shows an AI-term agent buffer, put BUFFER in +a non-agent window (`cj/--ai-term-non-agent-window'), splitting a +left-side window off the agent when the agent is the only window, then +select that window. Otherwise hand off to `pop-to-buffer'. Returns +the window BUFFER ends up in -- the value `server-switch-buffer' +expects from a `server-window' function." + (if (cj/--ai-term-buffer-p (window-buffer (selected-window))) + (let* ((agent-win (selected-window)) + (target (or (cj/--ai-term-non-agent-window agent-win) + (split-window agent-win nil 'left)))) + (set-window-buffer target buffer) + (select-window target)) + (pop-to-buffer buffer) + (selected-window))) + +(defvar server-window) +(with-eval-after-load 'server + (setq server-window #'cj/--ai-term-server-display)) + +(provide 'ai-term-display) +;;; ai-term-display.el ends here diff --git a/modules/ai-term-sessions.el b/modules/ai-term-sessions.el new file mode 100644 index 00000000..99585a70 --- /dev/null +++ b/modules/ai-term-sessions.el @@ -0,0 +1,350 @@ +;;; ai-term-sessions.el --- AI-term project discovery and tmux sessions -*- lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; Layer: 3 (Domain Workflow). +;; Category: D. +;; Load shape: library. +;; Top-level side effects: none (defuns, the name-prefix defconst, and the +;; in-memory MRU state defvar). +;; Runtime requires: seq. +;; Direct test load: yes. +;; +;; Session/project layer of ai-term: agent buffer naming and detection, +;; project-candidate discovery and picker ordering, tmux session naming, +;; live-session discovery, launch-command building, and the active-agent +;; rotation inputs. No EAT, no window operations -- everything here is +;; exercisable without a terminal backend or a display. +;; +;; The user options this layer reads (`cj/ai-term-project-roots', +;; `cj/ai-term-container-roots', `cj/ai-term-tmux-session-prefix', +;; `cj/ai-term-tmux-window-name', `cj/ai-term-agent-command') are owned by +;; ai-term.el, the public face, which requires this module before defining +;; them; they are forward-declared here so this module compiles and reads +;; them without a cycle. + +;;; Code: + +(require 'seq) + +;; Owned by ai-term.el (the public face's defcustoms); forward-declared so +;; this module compiles and reads them without a cycle. +(defvar cj/ai-term-project-roots) +(defvar cj/ai-term-container-roots) +(defvar cj/ai-term-tmux-session-prefix) +(defvar cj/ai-term-tmux-window-name) +(defvar cj/ai-term-agent-command) + +(defconst cj/--ai-term-name-prefix "agent [" + "Buffer-name prefix shared by all AI-term buffers. + +Single source of truth for both buffer construction in +`cj/--ai-term-buffer-name' and detection in +`cj/--ai-term-buffer-p'. The display-buffer-alist rule keys on the +escaped form \"\\\\`agent \\\\[\" -- they must stay in sync.") + +(defun cj/--ai-term-buffer-name (dir) + "Return the AI-term buffer name for project directory DIR. + +The name pattern is \"agent [<basename>]\". The display-buffer-alist +rule keys on the literal prefix \"agent [\", so changing the format +breaks routing to the right-side window." + (format "%s%s]" + cj/--ai-term-name-prefix + (file-name-nondirectory (directory-file-name dir)))) + +(defun cj/--ai-term-buffer-p (buffer) + "Return non-nil when BUFFER is an AI-term buffer. + +A buffer qualifies when its name starts with the literal prefix in +`cj/--ai-term-name-prefix' (\"agent [\"). The check is anchored at +the start so names like \"foo agent [bar]\" do not match." + (and (bufferp buffer) + (buffer-live-p buffer) + (string-prefix-p cj/--ai-term-name-prefix (buffer-name buffer)))) + +(defun cj/--ai-term-agent-buffers () + "Return the live AI-term buffers in `buffer-list' order. + +Order matches `buffer-list' on the selected frame, which is most- +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-tmux-session-name (dir) + "Return the tmux session name for project directory DIR. + +`cj/ai-term-tmux-session-prefix' followed by DIR's basename, sanitized +to a form tmux won't re-mangle: runs of whitespace become a single +hyphen, and `.' / `:' become `_'. tmux disallows `.' and `:' in +session names and silently rewrites them to `_', so a project like +`.emacs.d' really runs in session `aiv-_emacs_d', not `aiv-.emacs.d' -- +sanitizing up front keeps the computed name matching the live one (and +keeps `cj/--ai-term-session-active-p' and the crash-recovery picker +from missing such projects). The prefix lets `tmux ls' output be +filtered to AI-term's own sessions (see +`cj/--ai-term-live-tmux-sessions')." + (concat cj/ai-term-tmux-session-prefix + (replace-regexp-in-string + "[.:]" "_" + (replace-regexp-in-string + "[[:space:]]+" "-" + (file-name-nondirectory (directory-file-name dir)))))) + +(defun cj/--ai-term-live-tmux-sessions () + "Return live tmux session names that carry the AI-term prefix. + +Runs `tmux list-sessions'. Returns the names beginning with +`cj/ai-term-tmux-session-prefix', or nil when tmux is not installed, +no server is running, or the command exits non-zero -- the picker +treats nil as \"no sessions to surface\" and falls back to a plain +alphabetical list." + (let* ((prefix cj/ai-term-tmux-session-prefix) + (exit nil) + (output (with-temp-buffer + (setq exit (condition-case nil + (process-file "tmux" nil '(t nil) nil + "list-sessions" "-F" + "#{session_name}") + (error nil))) + (buffer-string)))) + (when (and (integerp exit) (zerop exit)) + (seq-filter (lambda (name) (string-prefix-p prefix name)) + (split-string output "\n" t))))) + +(defun cj/--ai-term-session-active-p (dir sessions) + "Return non-nil when DIR's tmux session name is in SESSIONS. + +SESSIONS is the list from `cj/--ai-term-live-tmux-sessions' (or nil). +The match is forward: DIR's expected session name is computed and +looked up in SESSIONS, so the lossy whitespace->hyphen transform in +`cj/--ai-term-tmux-session-name' never needs reversing." + (and (member (cj/--ai-term-tmux-session-name dir) sessions) t)) + +(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 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 +window auto-names after its command and the two read distinctly. + +The shell command run on first creation is + <cj/ai-term-agent-command>; exec bash +so the tmux window survives the AI command exiting -- the session stays +alive with a bare bash prompt for recovery, and reattach works the same way." + (let ((session (cj/--ai-term-tmux-session-name dir)) + (start-dir (expand-file-name dir))) + ;; Pass the inner shell-command-string through `shell-quote-argument' + ;; so any single quotes embedded in a user-customized + ;; `cj/ai-term-agent-command' don't break the literal single-quote + ;; wrap below. The default value carries embedded double quotes + ;; (\"Read .ai/protocols.org and follow all instructions.\") which + ;; was safe in the prior shape but a single-quoted custom value + ;; silently broke the shell parse. + (format "tmux new-session -A -s %s -n %s -c %s %s" + (shell-quote-argument session) + (shell-quote-argument cj/ai-term-tmux-window-name) + (shell-quote-argument start-dir) + (shell-quote-argument + (concat cj/ai-term-agent-command "; exec bash"))))) + +(defun cj/--ai-term-kill-tmux-session (session) + "Kill the tmux SESSION via `tmux kill-session -t SESSION'. + +Returns the process exit status (0 on success), or nil when tmux is +unavailable or already gone -- a session that no longer exists is not +an error worth surfacing, since the goal is just to make sure it's +down." + (condition-case nil + (process-file "tmux" nil nil nil "kill-session" "-t" session) + (error nil))) + +(defun cj/--ai-term-has-marker-p (dir) + "Return non-nil when DIR contains .ai/protocols.org." + (file-exists-p (expand-file-name ".ai/protocols.org" dir))) + +(defun cj/--ai-term-candidates () + "Return the list of AI-agent project paths. + +Each entry of `cj/ai-term-project-roots' contributes itself when it +exists and contains .ai/protocols.org. Each entry of +`cj/ai-term-container-roots' contributes its immediate child +directories that contain .ai/protocols.org. + +Returns absolute paths. Nonexistent roots are skipped silently." + (let (result) + (dolist (root cj/ai-term-project-roots) + (let ((expanded (expand-file-name root))) + (when (and (file-directory-p expanded) + (cj/--ai-term-has-marker-p expanded)) + (push expanded result)))) + (dolist (root cj/ai-term-container-roots) + (let ((expanded (expand-file-name root))) + (when (file-directory-p expanded) + (dolist (child (directory-files + expanded t directory-files-no-dot-files-regexp t)) + (when (and (file-directory-p child) + (cj/--ai-term-has-marker-p child)) + (push child result)))))) + (nreverse result))) + +(defvar cj/--ai-term-mru nil + "Project dirs opened via the AI-term launcher this session, newest first. + +Maintained by `cj/--ai-term-record-mru' (called from +`cj/--ai-term-show-or-create') and consumed by +`cj/--ai-term-sort-candidates' so the project picker puts +recently-opened projects at the top of the active-sessions group. +In-memory only -- not persisted across Emacs restarts.") + +(defun cj/--ai-term-record-mru (dir) + "Move DIR to the front of `cj/--ai-term-mru'. + +DIR is normalized with `expand-file-name' + `directory-file-name' so a +trailing slash or `~' form doesn't create a duplicate entry; any prior +occurrence is removed first, keeping the list a true MRU order." + (let ((d (directory-file-name (expand-file-name dir)))) + (setq cj/--ai-term-mru (cons d (delete d cj/--ai-term-mru))))) + +(defun cj/--ai-term-mru-rank (dir) + "Return DIR's index in `cj/--ai-term-mru', or nil when it isn't there. + +DIR is normalized the same way `cj/--ai-term-record-mru' stores +entries, so a trailing slash doesn't defeat the lookup." + (seq-position cj/--ai-term-mru + (directory-file-name (expand-file-name dir)))) + +(defun cj/--ai-term-sort-candidates (dirs sessions) + "Order DIRS for the project picker. + +DIRS with a live tmux session in SESSIONS (per +`cj/--ai-term-session-active-p') come first, ordered most-recently- +opened first (per `cj/--ai-term-mru'); active dirs not opened yet this +session fall after them, alphabetical by abbreviated path. DIRS with no +session follow, always alphabetical. SESSIONS nil means nothing is +active, so the result is a plain alphabetical list; an empty MRU makes +the active group alphabetical too." + (let* ((alpha (lambda (a b) + (string< (abbreviate-file-name a) (abbreviate-file-name b)))) + (mru-then-alpha + (lambda (a b) + (let ((ra (cj/--ai-term-mru-rank a)) + (rb (cj/--ai-term-mru-rank b))) + (cond ((and ra rb) (< ra rb)) + (ra t) + (rb nil) + (t (funcall alpha a b)))))) + (active-p (lambda (d) (cj/--ai-term-session-active-p d sessions))) + (active (seq-filter active-p dirs)) + (inactive (seq-remove active-p dirs))) + (append (sort active mru-then-alpha) (sort inactive alpha)))) + +(defun cj/--ai-term-process-live-p (buffer) + "Return non-nil when BUFFER has a live process attached." + (let ((proc (get-buffer-process buffer))) + (and proc (process-live-p proc)))) + +(defun cj/--ai-term-format-candidate (path &optional sessions) + "Return the display name for PATH in the AI-term project picker. + +Appends \" [running]\" when the project's agent buffer exists with +a live process; otherwise \" [detached]\" when PATH's tmux session +name is in SESSIONS (a session that survived an Emacs crash, no +buffer yet); otherwise just the abbreviated path. Path is +abbreviated via `abbreviate-file-name' so it reads as ~/code/foo +rather than the full home-dir form." + (let* ((name (cj/--ai-term-buffer-name path)) + (buf (get-buffer name)) + (running (and buf (cj/--ai-term-process-live-p buf))) + (detached (and (not running) + (cj/--ai-term-session-active-p path sessions))) + (display-path (abbreviate-file-name path))) + (cond + (running (format "%s [running]" display-path)) + (detached (format "%s [detached]" display-path)) + (t display-path)))) + +(defun cj/--ai-term-completion-table (alist) + "Return a `completing-read' table over ALIST that pins candidate order. + +`completing-read' over a bare alist lets the front-end (Vertico) +re-sort candidates by recency / length / alpha, which would defeat +the picker's active-sessions-first grouping. Returning +`display-sort-function' and `cycle-sort-function' of `identity' in +the metadata keeps the order ALIST was built in." + (lambda (string predicate action) + (if (eq action 'metadata) + '(metadata (display-sort-function . identity) + (cycle-sort-function . identity)) + (complete-with-action action alist string predicate)))) + +(defun cj/--ai-term-pick-project () + "Prompt for an AI-agent project; return its absolute path. + +Candidates come from `cj/--ai-term-candidates', ordered by +`cj/--ai-term-sort-candidates' so projects with a live tmux session +appear first (then alphabetical by abbreviated path). Display uses +`cj/--ai-term-format-candidate', which abbreviates the path and +flags a live session via \" [running]\" (an Emacs terminal buffer is +alive) or \" [detached]\" (the tmux session survived, no buffer). +Signals `user-error' when no candidates exist." + (let ((candidates (cj/--ai-term-candidates))) + (unless candidates + (user-error "No AI-agent projects found under %s" + (mapconcat #'identity + (append cj/ai-term-project-roots + cj/ai-term-container-roots) + ", "))) + (let* ((sessions (cj/--ai-term-live-tmux-sessions)) + (sorted (cj/--ai-term-sort-candidates candidates sessions)) + (display-alist + (mapcar (lambda (p) + (cons (cj/--ai-term-format-candidate p sessions) p)) + sorted)) + (chosen (completing-read + "AI terminal project: " + (cj/--ai-term-completion-table display-alist) + nil t))) + (or (cdr (assoc chosen display-alist)) + (expand-file-name chosen))))) + +(provide 'ai-term-sessions) +;;; ai-term-sessions.el ends here diff --git a/modules/ai-term.el b/modules/ai-term.el index ecc2842b..43000fb9 100644 --- a/modules/ai-term.el +++ b/modules/ai-term.el @@ -8,8 +8,10 @@ ;; Category: D. ;; Load shape: eager. ;; 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. +;; Top-level side effects: global M-SPC binding, C-; a prefix map, and the +;; undead-buffers registration for agent buffers. +;; Runtime requires: seq, system-lib, keybindings, ai-term-sessions, +;; ai-term-display, ai-term-backend-eat. ;; Direct test load: yes. ;; ;; Opens project-scoped AI agents in EAT buffers backed by tmux sessions. Project @@ -19,20 +21,24 @@ ;; 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. +;; +;; This is the public face of the module: it owns the user options, the +;; public commands and their dispatch, the shutdown/wrap-up entry points, and +;; the C-; a keymap. Project/session discovery, window/display policy, and +;; the EAT terminal backend live in the ai-term-sessions / ai-term-display / +;; ai-term-backend-eat layers, which this module requires. Every public name +;; is unchanged so existing (require 'ai-term) callers and tests keep working. ;;; Code: -(require 'cl-lib) (require 'seq) -(require 'cj-window-geometry-lib) -(require 'cj-window-toggle-lib) -(require 'host-environment) +(require 'system-lib) ;; provides cj/completion-table (require 'keybindings) ;; provides cj/register-prefix-map (C-; a) +(require 'ai-term-sessions) +(require 'ai-term-display) +(require 'ai-term-backend-eat) -(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) (defgroup ai-term nil "In-Emacs AI-agent launcher with a vertical-split EAT terminal." @@ -88,265 +94,6 @@ running program." :type 'string :group 'ai-term) -(defconst cj/--ai-term-name-prefix "agent [" - "Buffer-name prefix shared by all AI-term buffers. - -Single source of truth for both buffer construction in -`cj/--ai-term-buffer-name' and detection in -`cj/--ai-term-buffer-p'. The display-buffer-alist rule keys on the -escaped form \"\\\\`agent \\\\[\" -- they must stay in sync.") - -(defun cj/--ai-term-buffer-name (dir) - "Return the AI-term buffer name for project directory DIR. - -The name pattern is \"agent [<basename>]\". The display-buffer-alist -rule keys on the literal prefix \"agent [\", so changing the format -breaks routing to the right-side window." - (format "%s%s]" - cj/--ai-term-name-prefix - (file-name-nondirectory (directory-file-name dir)))) - -(defun cj/--ai-term-buffer-p (buffer) - "Return non-nil when BUFFER is an AI-term buffer. - -A buffer qualifies when its name starts with the literal prefix in -`cj/--ai-term-name-prefix' (\"agent [\"). The check is anchored at -the start so names like \"foo agent [bar]\" do not match." - (and (bufferp buffer) - (buffer-live-p buffer) - (string-prefix-p cj/--ai-term-name-prefix (buffer-name buffer)))) - -(defun cj/--ai-term-agent-buffers () - "Return the live AI-term buffers in `buffer-list' order. - -Order matches `buffer-list' on the selected frame, which is most- -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. - -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 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) - (and (buffer-live-p b) - (not (cj/--ai-term-buffer-p b)) - (not (string-prefix-p " " (buffer-name b))))) - (buffer-list))) - -(defun cj/--ai-term-displayed-agent-window (&optional frame) - "Return a window in FRAME currently displaying an AI-term buffer, or nil. - -FRAME defaults to the selected frame. When more than one window in -the frame shows an agent buffer, the first one in `window-list' order -is returned. The minibuffer is excluded from the search." - (seq-find (lambda (w) - (cj/--ai-term-buffer-p (window-buffer w))) - (window-list (or frame (selected-frame)) 'never))) - -(defun cj/--ai-term-tmux-session-name (dir) - "Return the tmux session name for project directory DIR. - -`cj/ai-term-tmux-session-prefix' followed by DIR's basename, sanitized -to a form tmux won't re-mangle: runs of whitespace become a single -hyphen, and `.' / `:' become `_'. tmux disallows `.' and `:' in -session names and silently rewrites them to `_', so a project like -`.emacs.d' really runs in session `aiv-_emacs_d', not `aiv-.emacs.d' -- -sanitizing up front keeps the computed name matching the live one (and -keeps `cj/--ai-term-session-active-p' and the crash-recovery picker -from missing such projects). The prefix lets `tmux ls' output be -filtered to AI-term's own sessions (see -`cj/--ai-term-live-tmux-sessions')." - (concat cj/ai-term-tmux-session-prefix - (replace-regexp-in-string - "[.:]" "_" - (replace-regexp-in-string - "[[:space:]]+" "-" - (file-name-nondirectory (directory-file-name dir)))))) - -(defun cj/--ai-term-live-tmux-sessions () - "Return live tmux session names that carry the AI-term prefix. - -Runs `tmux list-sessions'. Returns the names beginning with -`cj/ai-term-tmux-session-prefix', or nil when tmux is not installed, -no server is running, or the command exits non-zero -- the picker -treats nil as \"no sessions to surface\" and falls back to a plain -alphabetical list." - (let* ((prefix cj/ai-term-tmux-session-prefix) - (exit nil) - (output (with-temp-buffer - (setq exit (condition-case nil - (process-file "tmux" nil '(t nil) nil - "list-sessions" "-F" - "#{session_name}") - (error nil))) - (buffer-string)))) - (when (and (integerp exit) (zerop exit)) - (seq-filter (lambda (name) (string-prefix-p prefix name)) - (split-string output "\n" t))))) - -(defun cj/--ai-term-session-active-p (dir sessions) - "Return non-nil when DIR's tmux session name is in SESSIONS. - -SESSIONS is the list from `cj/--ai-term-live-tmux-sessions' (or nil). -The match is forward: DIR's expected session name is computed and -looked up in SESSIONS, so the lossy whitespace->hyphen transform in -`cj/--ai-term-tmux-session-name' never needs reversing." - (and (member (cj/--ai-term-tmux-session-name dir) sessions) t)) - -(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 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 -window auto-names after its command and the two read distinctly. - -The shell command run on first creation is - <cj/ai-term-agent-command>; exec bash -so the tmux window survives the AI command exiting -- the session stays -alive with a bare bash prompt for recovery, and reattach works the same way." - (let ((session (cj/--ai-term-tmux-session-name dir)) - (start-dir (expand-file-name dir))) - ;; Pass the inner shell-command-string through `shell-quote-argument' - ;; so any single quotes embedded in a user-customized - ;; `cj/ai-term-agent-command' don't break the literal single-quote - ;; wrap below. The default value carries embedded double quotes - ;; (\"Read .ai/protocols.org and follow all instructions.\") which - ;; was safe in the prior shape but a single-quoted custom value - ;; silently broke the shell parse. - (format "tmux new-session -A -s %s -n %s -c %s %s" - (shell-quote-argument session) - (shell-quote-argument cj/ai-term-tmux-window-name) - (shell-quote-argument start-dir) - (shell-quote-argument - (concat cj/ai-term-agent-command "; exec bash"))))) - -(defun cj/--ai-term-has-marker-p (dir) - "Return non-nil when DIR contains .ai/protocols.org." - (file-exists-p (expand-file-name ".ai/protocols.org" dir))) - -(defun cj/--ai-term-candidates () - "Return the list of AI-agent project paths. - -Each entry of `cj/ai-term-project-roots' contributes itself when it -exists and contains .ai/protocols.org. Each entry of -`cj/ai-term-container-roots' contributes its immediate child -directories that contain .ai/protocols.org. - -Returns absolute paths. Nonexistent roots are skipped silently." - (let (result) - (dolist (root cj/ai-term-project-roots) - (let ((expanded (expand-file-name root))) - (when (and (file-directory-p expanded) - (cj/--ai-term-has-marker-p expanded)) - (push expanded result)))) - (dolist (root cj/ai-term-container-roots) - (let ((expanded (expand-file-name root))) - (when (file-directory-p expanded) - (dolist (child (directory-files - expanded t directory-files-no-dot-files-regexp t)) - (when (and (file-directory-p child) - (cj/--ai-term-has-marker-p child)) - (push child result)))))) - (nreverse result))) - -(defvar cj/--ai-term-mru nil - "Project dirs opened via the AI-term launcher this session, newest first. - -Maintained by `cj/--ai-term-record-mru' (called from -`cj/--ai-term-show-or-create') and consumed by -`cj/--ai-term-sort-candidates' so the project picker puts -recently-opened projects at the top of the active-sessions group. -In-memory only -- not persisted across Emacs restarts.") - -(defun cj/--ai-term-record-mru (dir) - "Move DIR to the front of `cj/--ai-term-mru'. - -DIR is normalized with `expand-file-name' + `directory-file-name' so a -trailing slash or `~' form doesn't create a duplicate entry; any prior -occurrence is removed first, keeping the list a true MRU order." - (let ((d (directory-file-name (expand-file-name dir)))) - (setq cj/--ai-term-mru (cons d (delete d cj/--ai-term-mru))))) - -(defun cj/--ai-term-mru-rank (dir) - "Return DIR's index in `cj/--ai-term-mru', or nil when it isn't there. - -DIR is normalized the same way `cj/--ai-term-record-mru' stores -entries, so a trailing slash doesn't defeat the lookup." - (seq-position cj/--ai-term-mru - (directory-file-name (expand-file-name dir)))) - -(defun cj/--ai-term-sort-candidates (dirs sessions) - "Order DIRS for the project picker. - -DIRS with a live tmux session in SESSIONS (per -`cj/--ai-term-session-active-p') come first, ordered most-recently- -opened first (per `cj/--ai-term-mru'); active dirs not opened yet this -session fall after them, alphabetical by abbreviated path. DIRS with no -session follow, always alphabetical. SESSIONS nil means nothing is -active, so the result is a plain alphabetical list; an empty MRU makes -the active group alphabetical too." - (let* ((alpha (lambda (a b) - (string< (abbreviate-file-name a) (abbreviate-file-name b)))) - (mru-then-alpha - (lambda (a b) - (let ((ra (cj/--ai-term-mru-rank a)) - (rb (cj/--ai-term-mru-rank b))) - (cond ((and ra rb) (< ra rb)) - (ra t) - (rb nil) - (t (funcall alpha a b)))))) - (active-p (lambda (d) (cj/--ai-term-session-active-p d sessions))) - (active (seq-filter active-p dirs)) - (inactive (seq-remove active-p dirs))) - (append (sort active mru-then-alpha) (sort inactive alpha)))) - -(defun cj/--ai-term-process-live-p (buffer) - "Return non-nil when BUFFER has a live process attached." - (let ((proc (get-buffer-process buffer))) - (and proc (process-live-p proc)))) - (defcustom cj/ai-term-desktop-width 0.5 "Default fraction of frame width for the AI-term window on a desktop. @@ -368,409 +115,12 @@ fallback when `cj/--ai-term-last-size' is nil." :type 'number :group 'ai-term) -(defun cj/--ai-term-default-direction (&optional frame) - "Return the default split direction for the agent window. - -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 default size fraction paired with the chosen direction. - -`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. - -Symbol: right, below, or left. `above' is never stored -- the agent -window must not be remembered at the top of the frame, so a top -placement falls back to the host default at capture time. nil means no -agent window has been toggled off yet this session, so the default -direction applies. Captured at toggle-off by -`cj/--ai-term-capture-state' and consumed by -`cj/--ai-term-display-saved'.") - -(defvar cj/--ai-term-last-was-bury nil - "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 -without deleting), nil when the window was deleted. Consumed by -`cj/--ai-term-display-saved' to decide between restoring the -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 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 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 -SAME agent that was on screen rather than whichever agent happens to be -most-recent in `buffer-list'. Without this, hiding one agent and -reopening could surface a different one when several agents are alive -- -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 size for the AI-term display. - -Positive integer: body-columns when `cj/--ai-term-last-direction' -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). 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' / -`window-height' as a fraction of the new window's parent in the -window tree. In a 3+ window layout the parent may be a sub-tree, -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 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 -frame and no direction can be inferred). Does nothing when WINDOW -is not live." - (cj/window-toggle-capture-state - window (cj/--ai-term-default-direction) - 'cj/--ai-term-last-direction - '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. - -Looks up `cj/--ai-term-displayed-agent-window' on the selected -frame. When an agent window exists, replaces its buffer with BUFFER -and returns the window. When none exists, returns nil so the next -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-; 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))) - (when win - (set-window-buffer win buffer) - win))) - -(defun cj/--ai-term-reuse-edge-window (buffer _alist) - "Display-buffer action: reuse the existing window forming the target half. - -When the frame already holds a window forming the half the agent would -occupy -- the right column on a desktop, the bottom row on a laptop, per -the saved or default direction -- swap BUFFER into it with -`set-window-buffer' and return that window, rather than splitting a third -window in. The target half is found by `cj/window-at-edge'. - -Returns nil when there is no such half to reuse (a single-window frame, -or a layout split on the other axis), so the chain falls through to -`cj/--ai-term-display-saved', which splits a fresh half. Also returns -nil when the edge window is dedicated -- those are not ours to replace. - -Records the displaced buffer through `display-buffer-record-window' -\(type `reuse') before swapping, so the native `quit-restore-window' -called at toggle-off puts that buffer back into the slot instead of -deleting the window -- toggling swaps the slot's buffer between the -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). - -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: restore fullscreen in a single-window frame, -otherwise split per saved direction and size. - -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 -toggle state vars, falling back to the host-aware defaults from -`cj/--ai-term-default-direction' and `cj/--ai-term-default-size'." - (cond - ;; 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) - win)) - (t - (setq cj/--ai-term-last-was-bury nil) - (cj/window-toggle-display-saved - buffer alist - 'cj/--ai-term-last-direction (cj/--ai-term-default-direction) - 'cj/--ai-term-last-size (cj/--ai-term-default-size))))) - -(defun cj/--ai-term-display-rule-list () - "Return the `display-buffer-alist' entry list installed by this module. - -The single rule routes any buffer whose name starts with \"agent [\" -through four actions in order: - -1. `display-buffer-reuse-window' -- if the same buffer is already - visible in any window, focus that one. -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-; 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), - reuse it instead of splitting a third window in. -4. `cj/--ai-term-display-saved' -- otherwise (single-window frame, - or a layout split on the other axis), split per the saved - direction + size from the last toggle-off (or defaults when no - capture has happened this session). - -`display-buffer-in-side-window' is avoided deliberately. Side -windows enforce dedication, which breaks `buffer-move' (C-M-arrows) -and `switch-to-buffer' replacement. The chain above keeps the -resulting window an ordinary window so all standard window commands -work. - -`display-buffer-use-some-window' is also avoided -- it would happily -steal any non-selected window (e.g. a code window above an agent -split) when the user is focused in agent and switches projects." - '(("\\`agent \\[" - (display-buffer-reuse-window - cj/--ai-term-reuse-existing-agent - cj/--ai-term-reuse-edge-window - cj/--ai-term-display-saved) - (inhibit-same-window . t)))) - -(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 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. - -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 -buffer." - (cj/--ai-term-record-mru dir) - (let ((existing (get-buffer name))) - (cond - ((and existing (cj/--ai-term-process-live-p existing)) - (display-buffer existing) - existing) - (t - (when existing - (kill-buffer existing)) - ;; `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. `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) - (eat-buffer-name name)) - (eat))) - (let ((buf (get-buffer name))) - (with-current-buffer buf - (cj/--ai-term-send-string - buf (concat (cj/--ai-term-launch-command dir) "\n"))) - (display-buffer buf) - buf))))) - -(defun cj/--ai-term-format-candidate (path &optional sessions) - "Return the display name for PATH in the AI-term project picker. - -Appends \" [running]\" when the project's agent buffer exists with -a live process; otherwise \" [detached]\" when PATH's tmux session -name is in SESSIONS (a session that survived an Emacs crash, no -buffer yet); otherwise just the abbreviated path. Path is -abbreviated via `abbreviate-file-name' so it reads as ~/code/foo -rather than the full home-dir form." - (let* ((name (cj/--ai-term-buffer-name path)) - (buf (get-buffer name)) - (running (and buf (cj/--ai-term-process-live-p buf))) - (detached (and (not running) - (cj/--ai-term-session-active-p path sessions))) - (display-path (abbreviate-file-name path))) - (cond - (running (format "%s [running]" display-path)) - (detached (format "%s [detached]" display-path)) - (t display-path)))) - -(defun cj/--ai-term-completion-table (alist) - "Return a `completing-read' table over ALIST that pins candidate order. - -`completing-read' over a bare alist lets the front-end (Vertico) -re-sort candidates by recency / length / alpha, which would defeat -the picker's active-sessions-first grouping. Returning -`display-sort-function' and `cycle-sort-function' of `identity' in -the metadata keeps the order ALIST was built in." - (lambda (string predicate action) - (if (eq action 'metadata) - '(metadata (display-sort-function . identity) - (cycle-sort-function . identity)) - (complete-with-action action alist string predicate)))) - -(defun cj/--ai-term-pick-project () - "Prompt for an AI-agent project; return its absolute path. - -Candidates come from `cj/--ai-term-candidates', ordered by -`cj/--ai-term-sort-candidates' so projects with a live tmux session -appear first (then alphabetical by abbreviated path). Display uses -`cj/--ai-term-format-candidate', which abbreviates the path and -flags a live session via \" [running]\" (an Emacs terminal buffer is -alive) or \" [detached]\" (the tmux session survived, no buffer). -Signals `user-error' when no candidates exist." - (let ((candidates (cj/--ai-term-candidates))) - (unless candidates - (user-error "No AI-agent projects found under %s" - (mapconcat #'identity - (append cj/ai-term-project-roots - cj/ai-term-container-roots) - ", "))) - (let* ((sessions (cj/--ai-term-live-tmux-sessions)) - (sorted (cj/--ai-term-sort-candidates candidates sessions)) - (display-alist - (mapcar (lambda (p) - (cons (cj/--ai-term-format-candidate p sessions) p)) - sorted)) - (chosen (completing-read - "AI terminal project: " - (cj/--ai-term-completion-table display-alist) - nil t))) - (or (cdr (assoc chosen display-alist)) - (expand-file-name chosen))))) - (defun cj/--ai-term-dispatch () "Compute the `cj/ai-term' (C-; a a) action without performing it. @@ -829,72 +179,6 @@ launches from either." (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 a 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. 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 - `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) "DWIM dispatch for the AI-term launcher. Bound to C-; a a. @@ -926,17 +210,6 @@ C-; a k closes an agent via `cj/ai-term-close'." ;; ----------------------------- Close an agent -------------------------------- -(defun cj/--ai-term-kill-tmux-session (session) - "Kill the tmux SESSION via `tmux kill-session -t SESSION'. - -Returns the process exit status (0 on success), or nil when tmux is -unavailable or already gone -- a session that no longer exists is not -an error worth surfacing, since the goal is just to make sure it's -down." - (condition-case nil - (process-file "tmux" nil nil nil "kill-session" "-t" session) - (error nil))) - (defun cj/--ai-term-close-buffer (buffer) "Gracefully tear down AI-term BUFFER: tmux session, then buffer. @@ -958,8 +231,6 @@ 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. @@ -1068,14 +339,6 @@ picker and C-; a k closes an 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 @@ -1165,50 +428,5 @@ Stop hook does not block; the daemon ticks the timer asynchronously." (setq remaining (1- remaining)))))) nil))) -;; ---------- emacsclient: keep opened files off the agent terminal ---------- -;; -;; `server-start' (in system-defaults.el) leaves `server-window' nil, so -;; `server-switch-buffer' opens an `emacsclient -n' file in the *selected* -;; window. When the user is typing in the agent terminal, that's the agent -;; window -- so "tell the agent to open X" would replace the agent buffer -;; with X. The function below, wired as `server-window', routes such files -;; into a non-agent window instead (splitting one off the agent when the -;; agent is the only window). emacsclient invocations from anywhere else -;; fall through to `pop-to-buffer' and behave as before. - -(defun cj/--ai-term-non-agent-window (&optional exclude) - "Return a window in the selected frame fit to show a non-agent buffer. - -Skips the minibuffer, the EXCLUDE window, dedicated windows, and any -window already showing an AI-term agent buffer. Returns nil when no -such window exists." - (seq-find (lambda (w) - (and (not (eq w exclude)) - (not (window-dedicated-p w)) - (not (cj/--ai-term-buffer-p (window-buffer w))))) - (window-list (selected-frame) 'never))) - -(defun cj/--ai-term-server-display (buffer) - "Display BUFFER for `server-window', keeping it off the agent terminal. - -When the selected window shows an AI-term agent buffer, put BUFFER in -a non-agent window (`cj/--ai-term-non-agent-window'), splitting a -left-side window off the agent when the agent is the only window, then -select that window. Otherwise hand off to `pop-to-buffer'. Returns -the window BUFFER ends up in -- the value `server-switch-buffer' -expects from a `server-window' function." - (if (cj/--ai-term-buffer-p (window-buffer (selected-window))) - (let* ((agent-win (selected-window)) - (target (or (cj/--ai-term-non-agent-window agent-win) - (split-window agent-win nil 'left)))) - (set-window-buffer target buffer) - (select-window target)) - (pop-to-buffer buffer) - (selected-window))) - -(defvar server-window) -(with-eval-after-load 'server - (setq server-window #'cj/--ai-term-server-display)) - (provide 'ai-term) ;;; ai-term.el ends here |
