diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-05 05:28:58 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-05 05:28:58 -0500 |
| commit | ebdf9e466b0e1f86e9b7d76650ac32408273e7a7 (patch) | |
| tree | dab9b453f3a93c324b5388b3843502a088c7ed46 /modules/ai-term.el | |
| parent | c094b2e4e64530379a9cb273303308a9affcabf6 (diff) | |
| download | dotemacs-ebdf9e466b0e1f86e9b7d76650ac32408273e7a7.tar.gz dotemacs-ebdf9e466b0e1f86e9b7d76650ac32408273e7a7.zip | |
feat(term): replace vterm with ghostel as the terminal engine
I swapped the terminal engine from vterm to ghostel (libghostty-vt) everywhere. term-config replaces vterm-config (the F12 terminal, the C-; x menu, tmux history capture), and ai-term replaces ai-vterm (the F9 Claude-agent launcher). ghostel renders the agent TUI without vterm's flicker under heavy streaming, and one engine now covers every terminal workflow.
Two behavior changes fall out of the swap. F9 launches in a terminal frame now: ghostel renders in TTY frames, so the old GUI-only guard is gone. Terminal windows no longer dim when unfocused: ghostel resolves its palette into the native module per-terminal, so there's no per-window color hook to dim through the way vterm had.
auto-dim drops its vterm color-advice path, the dashboard Terminal button launches ghostel, and the vterm and vterm-toggle packages are removed. The tmux pane-history and copy-mode machinery carried over unchanged. It keys on the pty tty, which ghostel exposes.
Diffstat (limited to 'modules/ai-term.el')
| -rw-r--r-- | modules/ai-term.el | 974 |
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 |
