aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/ai-term-backend-eat.el93
-rw-r--r--modules/ai-term-display.el460
-rw-r--r--modules/ai-term-sessions.el350
-rw-r--r--modules/ai-term.el812
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