aboutsummaryrefslogtreecommitdiff
path: root/modules/ai-term.el
diff options
context:
space:
mode:
Diffstat (limited to 'modules/ai-term.el')
-rw-r--r--modules/ai-term.el974
1 files changed, 974 insertions, 0 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el
new file mode 100644
index 00000000..85b84a12
--- /dev/null
+++ b/modules/ai-term.el
@@ -0,0 +1,974 @@
+;;; ai-term.el --- In-Emacs AI-agent launcher with vertical-split terminal -*- lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; Layer: 3 (Domain Workflow).
+;; Category: D.
+;; Load shape: eager.
+;; Eager reason: registers four global keys for the AI-agent terminal launcher; a
+;; command-loaded deferral candidate.
+;; Top-level side effects: four global key bindings.
+;; Runtime requires: cl-lib, seq, cj-window-geometry-lib, cj-window-toggle-lib,
+;; host-environment.
+;; Direct test load: yes.
+;;
+;; Picks an AI-agent project (a dir under ~/.emacs.d, ~/code/*, or
+;; ~/projects/* containing .ai/protocols.org), opens or reuses a terminal
+;; buffer named "agent [<basename>]", sends the agent's startup
+;; instruction to it, and routes the buffer to a side window via
+;; display-buffer-alist. When the frame already has a window forming the
+;; half the agent would occupy (a right column on a desktop, a bottom row
+;; on a laptop), the agent reuses that slot rather than splitting a third
+;; window in; toggling off restores the displaced buffer to the slot.
+;; Otherwise placement is a host-aware split: a right-side split at 50%
+;; width on a desktop, a bottom split at 75% height on a laptop (see
+;; `cj/--ai-term-default-direction'). Multiple
+;; projects produce multiple coexisting buffers that share the same
+;; slot; switching among them is a buffer-switch, not a
+;; kill-and-recreate.
+;;
+;; Each project's agent runs inside a tmux session named
+;; "<cj/ai-term-tmux-session-prefix><basename>" (default prefix "aiv-").
+;; The prefix lets `tmux ls' be filtered to AI-term's own sessions, so
+;; after an Emacs crash the project picker can match surviving sessions
+;; back to their directories: matched projects sort to the top of the
+;; picker (flagged "[detached]" -- session alive, no Emacs buffer -- or
+;; "[running]" when a live terminal buffer exists), the rest follow in
+;; alphabetical order.
+;;
+;; Four F-key entry points:
+;;
+;; - F9 `cj/ai-term' -- DWIM dispatch. If an agent buffer is
+;; currently displayed in this frame, F9 toggles it off: when it
+;; took over an existing window (a reused slot) the buffer it
+;; displaced returns to that slot, when it was split into its own
+;; window that window is removed, and when it fills the frame it
+;; is buried. Otherwise, if exactly one agent buffer is alive,
+;; F9 re-displays it; if zero or two-plus are alive, F9 falls
+;; through to the project picker.
+;; - C-F9 `cj/ai-term-pick-project' -- always show the project
+;; picker, even when an agent buffer is currently displayed.
+;; Used when the user wants to start a new project session
+;; instead of toggling the current one.
+;; - M-F9 `cj/ai-term-close' -- gracefully close an agent: kill its
+;; tmux session (stopping the agent process), then its terminal
+;; buffer and window. Confirms first. Targets the current
+;; agent, the sole live agent, or prompts among several.
+;; - C-S-F9 `cj/ai-term-close' -- same close command, second binding.
+;; (M-F9 is the primary; C-S-F9 may be swallowed by the
+;; Wayland/PGTK layer on some machines.)
+;;
+;; Existing windmove (Shift-arrows) handles code <-> agent focus
+;; toggling. Buffer-move (C-M-arrows) handles side-swap. Neither
+;; needs anything new from this module.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'seq)
+(require 'cj-window-geometry-lib)
+(require 'cj-window-toggle-lib)
+(require 'host-environment)
+
+(declare-function ghostel "ghostel" (&optional arg))
+(declare-function ghostel-send-string "ghostel" (string))
+(defvar ghostel-mode-map)
+(defvar ghostel-buffer-name)
+(defvar ghostel-buffer-name-function)
+
+(defgroup ai-term nil
+ "In-Emacs AI-agent launcher with a vertical-split ghostel terminal."
+ :group 'tools)
+
+(defcustom cj/ai-term-agent-command
+ "claude \"Read .ai/protocols.org and follow all instructions.\""
+ "Shell command sent to a fresh AI-term to start the agent.
+
+The default invokes the Claude Code CLI; set it to whatever terminal
+agent you run (aider, an open-source LLM TUI, etc.)."
+ :type 'string
+ :group 'ai-term)
+
+(defvar cj/--ai-term-suppress-tmux nil
+ "When non-nil, the generic ghostel tmux-launch hook skips its auto-tmux step.
+
+ai-term dynamically binds this around `(ghostel)' so the hook in
+term-config.el doesn't send a bare \"tmux\\n\" before the named
+session launch command runs. The hook reads the variable via
+`bound-and-true-p' so loading order between the two modules doesn't
+matter.")
+
+(defcustom cj/ai-term-project-roots
+ (list (expand-file-name "~/.emacs.d"))
+ "Directories that are themselves AI-agent projects.
+Each entry is included as a candidate when it exists and contains
+.ai/protocols.org. Use this for single-project roots like ~/.emacs.d."
+ :type '(repeat directory)
+ :group 'ai-term)
+
+(defcustom cj/ai-term-container-roots
+ (list (expand-file-name "~/code")
+ (expand-file-name "~/projects"))
+ "Directories whose immediate children are scanned for agent projects.
+Each entry's child directories are included as candidates when they
+contain .ai/protocols.org. Use this for container dirs like ~/code."
+ :type '(repeat directory)
+ :group 'ai-term)
+
+(defcustom cj/ai-term-tmux-session-prefix "aiv-"
+ "Prefix prepended to tmux session names AI-term creates.
+
+The session name for a project is this prefix followed by the
+project's basename (whitespace collapsed to hyphens). The prefix
+lets `tmux ls' output be filtered down to AI-term's own sessions --
+so after an Emacs crash the project picker can match surviving
+sessions back to their directories and surface them first. Pick
+something unlikely to collide with hand-rolled tmux sessions; the
+default \"aiv-\" is short for \"ai-term\"."
+ :type 'string
+ :group 'ai-term)
+
+(defcustom cj/ai-term-tmux-window-name "ai"
+ "Name given to the first tmux window in an AI-term session.
+
+Passed as `tmux new-session -n', so the window running the AI tool
+shows up as this name in `tmux ls' / the status line. A later
+window opened by hand (e.g. a shell) auto-names after its command,
+so the two read distinctly instead of both showing up as the
+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-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 F9 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 F9 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.
+
+On a desktop the agent opens as a right-side vertical split (see
+`cj/--ai-term-default-direction'), so this fraction is interpreted
+as a window width. Used by `cj/--ai-term-default-size' as the size
+fallback when `cj/--ai-term-last-size' is nil (i.e. the user hasn't
+yet toggled off an agent window in this session)."
+ :type 'number
+ :group 'ai-term)
+
+(defcustom cj/ai-term-laptop-height 0.75
+ "Default fraction of frame height for the AI-term window on a laptop.
+
+On a laptop the agent opens as a bottom horizontal split (see
+`cj/--ai-term-default-direction'), so this fraction is interpreted
+as a window height. Used by `cj/--ai-term-default-size' as the size
+fallback when `cj/--ai-term-last-size' is nil."
+ :type 'number
+ :group 'ai-term)
+
+(defun cj/--ai-term-default-direction ()
+ "Return the host-appropriate default split direction for the agent window.
+
+`below' on a laptop (bottom horizontal split), `right' on a desktop
+(right-side vertical split). Detected via `env-laptop-p'."
+ (if (env-laptop-p) 'below 'right))
+
+(defun cj/--ai-term-default-size ()
+ "Return the host-appropriate default size fraction for the agent window.
+
+`cj/ai-term-laptop-height' on a laptop, `cj/ai-term-desktop-width'
+on a desktop -- pairing with the axis chosen by
+`cj/--ai-term-default-direction'."
+ (if (env-laptop-p)
+ cj/ai-term-laptop-height
+ cj/ai-term-desktop-width))
+
+(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 F9 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-hidden-buffer nil
+ "The agent buffer hidden by the most recent F9 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 body size for the AI-term display.
+
+Positive integer: body-columns when `cj/--ai-term-last-direction'
+is right or left, body-lines when below or above. nil means use
+the host-aware default from `cj/--ai-term-default-size' (a float
+fraction).
+
+Body size, not total size, because total-width includes the
+right-edge divider when the window has a right sibling but excludes
+it when the window is at the frame edge. Capturing total-width
+from a rightmost agent (no divider) and replaying into a middle
+position (with divider) leaves the body 1 column short -- visible
+as 1 col of the sibling buffer peeking through where agent should
+have ended. Body-width is divider-independent and matches what the
+user actually sees.
+
+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.")
+
+(defun cj/--ai-term-capture-state (window)
+ "Capture WINDOW's direction and size into module-level state.
+
+Sets `cj/--ai-term-last-direction' and `cj/--ai-term-last-size'
+so a subsequent F9 display can restore the user's chosen orientation
+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-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-F9. 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)."
+ (let* ((direction (or cj/--ai-term-last-direction
+ (cj/--ai-term-default-direction)))
+ (win (cj/window-at-edge direction)))
+ (when (and win (not (window-dedicated-p win)))
+ (display-buffer-record-window 'reuse win buffer)
+ (set-window-buffer win buffer)
+ win)))
+
+(defun cj/--ai-term-display-saved (buffer alist)
+ "Display-buffer action: split per saved direction and size.
+
+When the prior toggle-off was a bury (single-window state, flagged
+via `cj/--ai-term-last-was-bury') and the frame is still single-
+window, restore the agent into the selected window in place rather
+than splitting -- preserves the user's lone-window layout across
+F9 toggles.
+
+Otherwise delegates to `cj/window-toggle-display-saved' against the
+F9 state vars, falling back to the host-aware defaults from
+`cj/--ai-term-default-direction' and `cj/--ai-term-default-size'."
+ (cond
+ ((and cj/--ai-term-last-was-bury (one-window-p))
+ (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-F9).
+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-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 ghostel 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.
+
+The dynamic binding of `cj/--ai-term-suppress-tmux' around `(ghostel)'
+suppresses the generic tmux-launch hook in term-config.el so
+it doesn't fire a bare \"tmux\\n\" before the project-named launch
+command runs.
+
+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))
+ ;; `ghostel' 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. `ghostel-buffer-name' is bound
+ ;; to NAME so the terminal is created under the agent name, and
+ ;; `ghostel-buffer-name-function' is pinned nil (dynamically during
+ ;; creation, then buffer-locally) so OSC title escapes from the agent
+ ;; don't rename it out from under the "agent [" prefix that buffer
+ ;; detection and the display rule key on.
+ (save-window-excursion
+ (let ((default-directory dir)
+ (ghostel-buffer-name name)
+ (ghostel-buffer-name-function nil)
+ (cj/--ai-term-suppress-tmux t))
+ (let ((buf (ghostel)))
+ (when (buffer-live-p buf)
+ (with-current-buffer buf
+ (setq-local ghostel-buffer-name-function nil))))))
+ (let ((buf (get-buffer name)))
+ (with-current-buffer buf
+ (ghostel-send-string (cj/--ai-term-launch-command dir))
+ (ghostel-send-string "\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 F9 (`cj/ai-term') action without performing it.
+
+Returns one of:
+- (toggle-off . WINDOW) -- agent is displayed in WINDOW; quit it.
+- (redisplay-recent . BUFFER) -- 1+ alive agent buffers; show MRU.
+- (pick-project) -- zero alive agent buffers; prompt.
+
+When 2+ agent buffers are alive, F9 redisplays the most-recently-
+selected one rather than opening the project picker. C-F9 is the
+explicit \"start a different project\" surface; M-F9 is the explicit
+\"switch among existing agents\" surface. F9 keeps a single, simple
+job: toggle whichever agent was last in use.
+
+A pure-decision helper so the dispatch logic is exercisable in tests
+without firing real `display-buffer' or `quit-window' calls."
+ (let ((win (cj/--ai-term-displayed-agent-window)))
+ (cond
+ (win (cons 'toggle-off win))
+ (t
+ (let ((buffers (cj/--ai-term-agent-buffers)))
+ (cond
+ (buffers
+ ;; Reopen the agent the last toggle-off hid (faithful toggle), so
+ ;; long as it's still alive and among the live agents. Otherwise
+ ;; fall back to the most-recently-selected agent.
+ (cons 'redisplay-recent
+ (if (and (buffer-live-p cj/--ai-term-last-hidden-buffer)
+ (memq cj/--ai-term-last-hidden-buffer buffers))
+ cj/--ai-term-last-hidden-buffer
+ (car buffers))))
+ (t '(pick-project))))))))
+
+(defun cj/ai-term-pick-project (&optional arg)
+ "Pick an AI-agent project and open or reuse its ghostel terminal.
+
+The project is picked from a filtered completing-read list of dirs
+that contain .ai/protocols.org. The terminal buffer is named
+\"agent [<basename>]\" and is routed to a right-side window via
+`display-buffer-alist'. Multiple projects coexist as separate
+buffers; reinvoking on the same project reuses its existing terminal.
+
+With prefix ARG, display the buffer without selecting its window.
+
+Bound to C-F9 -- always shows the project picker, even when an agent
+buffer is currently displayed.
+
+ghostel renders in terminal frames as well as GUI frames, so this
+launches from either (only kitty inline-graphics degrade in a TTY)."
+ (interactive "P")
+ (let* ((dir (cj/--ai-term-pick-project))
+ (name (cj/--ai-term-buffer-name dir))
+ (buf (cj/--ai-term-show-or-create dir name)))
+ (unless arg
+ (let ((win (get-buffer-window buf)))
+ (when win (select-window win))))
+ buf))
+
+(defun cj/ai-term (&optional arg)
+ "Smart F9 dispatch for the AI-term launcher.
+
+Behavior depends on the current state:
+
+- If an AI-term buffer is currently displayed in this frame, F9
+ quits its window (toggle off, buffer stays alive).
+- Else, if exactly one alive AI-term buffer exists, F9 re-displays
+ it (DWIM -- the obvious next step is to look at it).
+- Else (zero or 2+), F9 falls through to `cj/ai-term-pick-project'.
+
+With prefix ARG, display the buffer without selecting its window
+when a buffer is being shown (no effect on the toggle-off branch).
+
+See `cj/ai-term-pick-project' (C-F9) to force the project picker.
+M-F9 (and C-S-F9) close an agent via `cj/ai-term-close'."
+ (interactive "P")
+ (pcase (cj/--ai-term-dispatch)
+ (`(toggle-off . ,win)
+ ;; Remember which agent we're hiding so the next toggle-on reopens this
+ ;; same one, not whichever agent is most-recent in `buffer-list'.
+ (setq cj/--ai-term-last-hidden-buffer (window-buffer win))
+ (cond
+ ;; Lone fullscreen agent (e.g. after `C-x 1' inside it): there is no
+ ;; prior layout for the native undo to restore and deleting would
+ ;; leave the frame empty. Bury and flag, so the next toggle-on
+ ;; (`cj/--ai-term-display-saved') restores the agent in place at
+ ;; full frame rather than splitting. Capture geometry for that
+ ;; restore. `bury-buffer' can no-op when the window's prev-buffer
+ ;; history holds only the agent (common right after `C-x 1'), so
+ ;; force a swap to a non-agent buffer to keep the toggle observable.
+ ((one-window-p)
+ (cj/--ai-term-capture-state win)
+ (setq cj/--ai-term-last-was-bury t)
+ (bury-buffer (window-buffer win))
+ (when (and (window-live-p win)
+ (cj/--ai-term-buffer-p (window-buffer win)))
+ (with-selected-window win
+ (switch-to-buffer
+ (or (cj/--ai-term-most-recent-non-agent-buffer)
+ (other-buffer (window-buffer win) t))))))
+ ;; Multi-window: collapse the agent split outright by deleting its
+ ;; window, so the working buffer (e.g. todo.org) reclaims the space.
+ ;; F9 is a pure show/hide toggle of THE agent split -- it must never
+ ;; surface a different agent. `quit-restore-window' can't guarantee
+ ;; that here: switching among several agents reuses the one slot via
+ ;; `set-window-buffer' (see `cj/--ai-term-reuse-existing-agent'),
+ ;; which leaves the window's `quit-restore' parameter pointing at the
+ ;; FIRST agent shown. Once it's stale, `quit-restore-window' falls
+ ;; back to `switch-to-prev-buffer' and surfaces another agent instead
+ ;; of removing the window -- exactly the "F9 shows another agent"
+ ;; bug. `delete-window' is unconditional and slot-history-independent.
+ ;; Capture geometry first so the next toggle-on splits at the same
+ ;; size (the user's chosen split width is preserved across the toggle).
+ (t
+ (cj/--ai-term-capture-state win)
+ (setq cj/--ai-term-last-was-bury nil)
+ (if (and (window-live-p win)
+ (> (length (window-list (window-frame win) 'never)) 1))
+ (delete-window win)
+ ;; Degenerate fallback (window became sole between dispatch and
+ ;; here): swap to a non-agent buffer rather than leave the agent up.
+ (when (window-live-p win)
+ (with-selected-window win
+ (switch-to-buffer
+ (or (cj/--ai-term-most-recent-non-agent-buffer)
+ (other-buffer (window-buffer win) t))))))))
+ nil)
+ (`(redisplay-recent . ,buf)
+ (display-buffer buf)
+ (unless arg
+ (let ((w (get-buffer-window buf)))
+ (when w (select-window w))))
+ buf)
+ (`(pick-project)
+ (cj/ai-term-pick-project arg))))
+
+;; ----------------------------- 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, window, buffer.
+
+Derives the tmux session name from BUFFER's `default-directory' (the
+project dir the terminal was created in) and kills it so the agent
+process stops. Deletes BUFFER's window when it's shown and isn't the
+only window in its frame, then kills BUFFER (suppressing the
+process-still-running prompt -- the session is already down). No-op
+when BUFFER isn't an AI-term buffer."
+ (when (cj/--ai-term-buffer-p buffer)
+ (cj/--ai-term-kill-tmux-session
+ (cj/--ai-term-tmux-session-name
+ (buffer-local-value 'default-directory buffer)))
+ (let ((win (get-buffer-window buffer)))
+ (when (and win (> (length (window-list (window-frame win) 'never)) 1))
+ (delete-window win)))
+ (let ((kill-buffer-query-functions nil))
+ (kill-buffer buffer))))
+
+(defun cj/--ai-term-close-target ()
+ "Return the AI-term buffer `cj/ai-term-close' should act on, or nil.
+
+The current buffer when it is an agent buffer; else the sole live
+agent buffer; else a `completing-read' choice among the live agent
+buffers; nil when none are alive."
+ (cond
+ ((cj/--ai-term-buffer-p (current-buffer)) (current-buffer))
+ (t (let ((buffers (cj/--ai-term-agent-buffers)))
+ (cond
+ ((null buffers) nil)
+ ((null (cdr buffers)) (car buffers))
+ (t (get-buffer
+ (completing-read "Close AI terminal: "
+ (mapcar #'buffer-name buffers) nil t))))))))
+
+(defun cj/ai-term-close ()
+ "Gracefully close an AI-term agent: kill its tmux session and buffer.
+
+Targets the current agent buffer, the sole live agent, or prompts when
+several are alive (see `cj/--ai-term-close-target'). Asks for
+confirmation first -- this kills the running agent process, which can
+interrupt work in progress. Bound to M-<f9> (primary) and C-S-<f9>."
+ (interactive)
+ (let ((buffer (cj/--ai-term-close-target)))
+ (unless buffer
+ (user-error "No AI-term agent buffers to close"))
+ (let ((name (buffer-name buffer)))
+ (when (y-or-n-p (format "Close agent %s? This kills its tmux session. "
+ name))
+ (cj/--ai-term-close-buffer buffer)
+ (message "Closed agent %s." name)))))
+
+(keymap-global-set "<f9>" #'cj/ai-term)
+(keymap-global-set "C-<f9>" #'cj/ai-term-pick-project)
+(keymap-global-set "M-<f9>" #'cj/ai-term-close)
+(keymap-global-set "C-S-<f9>" #'cj/ai-term-close)
+
+;; ghostel's semi-char mode forwards keys not in `ghostel-keymap-exceptions' to
+;; the terminal program, so a plain <f9> typed while point is inside an agent
+;; buffer would be sent to the program instead of toggling the agent -- which
+;; bites hard when the agent buffer is the only window in the frame. Re-bind
+;; the F9 family in `ghostel-mode-map' so the toggle reaches Emacs from there
+;; too. (C-<f9> / M-<f9> are bound here as well so the behaviour is uniform.)
+(with-eval-after-load 'ghostel
+ (keymap-set ghostel-mode-map "<f9>" #'cj/ai-term)
+ (keymap-set ghostel-mode-map "C-<f9>" #'cj/ai-term-pick-project)
+ (keymap-set ghostel-mode-map "M-<f9>" #'cj/ai-term-close)
+ (keymap-set ghostel-mode-map "C-S-<f9>" #'cj/ai-term-close))
+
+;; ---------- 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