aboutsummaryrefslogtreecommitdiff
path: root/modules/ai-vterm.el
diff options
context:
space:
mode:
Diffstat (limited to 'modules/ai-vterm.el')
-rw-r--r--modules/ai-vterm.el978
1 files changed, 0 insertions, 978 deletions
diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el
deleted file mode 100644
index 4f086636..00000000
--- a/modules/ai-vterm.el
+++ /dev/null
@@ -1,978 +0,0 @@
-;;; ai-vterm.el --- In-Emacs AI-agent launcher with vertical-split vterm -*- 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 vterm 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 vterm
-;; 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-vterm-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-vterm-tmux-session-prefix><basename>" (default prefix "aiv-").
-;; The prefix lets `tmux ls' be filtered to AI-vterm'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 vterm buffer exists), the rest follow in
-;; alphabetical order.
-;;
-;; Four F-key entry points:
-;;
-;; - F9 `cj/ai-vterm' -- 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-vterm-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-vterm-close' -- gracefully close an agent: kill its
-;; tmux session (stopping the agent process), then its vterm
-;; buffer and window. Confirms first. Targets the current
-;; agent, the sole live agent, or prompts among several.
-;; - C-S-F9 `cj/ai-vterm-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 vterm "vterm" (&optional buffer-name))
-(declare-function vterm-send-string "vterm" (string &optional paste-p))
-(declare-function vterm-send-return "vterm" ())
-(defvar vterm-mode-map)
-
-(defgroup ai-vterm nil
- "In-Emacs AI-agent launcher with vertical-split vterm."
- :group 'tools)
-
-(defcustom cj/ai-vterm-agent-command
- "claude \"Read .ai/protocols.org and follow all instructions.\""
- "Shell command sent to a fresh AI-vterm 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-vterm)
-
-(defvar cj/--ai-vterm-suppress-tmux nil
- "When non-nil, the generic vterm tmux-launch hook skips its auto-tmux step.
-
-ai-vterm dynamically binds this around `(vterm)' so the hook in
-vterm-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-vterm-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-vterm)
-
-(defcustom cj/ai-vterm-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-vterm)
-
-(defcustom cj/ai-vterm-tmux-session-prefix "aiv-"
- "Prefix prepended to tmux session names AI-vterm 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-vterm'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-vterm\"."
- :type 'string
- :group 'ai-vterm)
-
-(defcustom cj/ai-vterm-tmux-window-name "ai"
- "Name given to the first tmux window in an AI-vterm 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-vterm)
-
-(defconst cj/--ai-vterm-name-prefix "agent ["
- "Buffer-name prefix shared by all AI-vterm buffers.
-
-Single source of truth for both buffer construction in
-`cj/--ai-vterm-buffer-name' and detection in
-`cj/--ai-vterm-buffer-p'. The display-buffer-alist rule keys on the
-escaped form \"\\\\`agent \\\\[\" -- they must stay in sync.")
-
-(defun cj/--ai-vterm-buffer-name (dir)
- "Return the AI-vterm 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-vterm-name-prefix
- (file-name-nondirectory (directory-file-name dir))))
-
-(defun cj/--ai-vterm-buffer-p (buffer)
- "Return non-nil when BUFFER is an AI-vterm buffer.
-
-A buffer qualifies when its name starts with the literal prefix in
-`cj/--ai-vterm-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-vterm-name-prefix (buffer-name buffer))))
-
-(defun cj/--ai-vterm-agent-buffers ()
- "Return the live AI-vterm buffers in `buffer-list' order.
-
-Order matches `buffer-list' on the selected frame, which is most-
-recently-selected first. Non-AI-vterm buffers are filtered out via
-`cj/--ai-vterm-buffer-p'."
- (seq-filter #'cj/--ai-vterm-buffer-p (buffer-list)))
-
-(defun cj/--ai-vterm-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-vterm agent buffer (per
-`cj/--ai-vterm-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-vterm-buffer-p b))
- (not (string-prefix-p " " (buffer-name b)))))
- (buffer-list)))
-
-(defun cj/--ai-vterm-displayed-agent-window (&optional frame)
- "Return a window in FRAME currently displaying an AI-vterm 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-vterm-buffer-p (window-buffer w)))
- (window-list (or frame (selected-frame)) 'never)))
-
-(defun cj/--ai-vterm-tmux-session-name (dir)
- "Return the tmux session name for project directory DIR.
-
-`cj/ai-vterm-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-vterm-session-active-p' and the crash-recovery picker
-from missing such projects). The prefix lets `tmux ls' output be
-filtered to AI-vterm's own sessions (see
-`cj/--ai-vterm-live-tmux-sessions')."
- (concat cj/ai-vterm-tmux-session-prefix
- (replace-regexp-in-string
- "[.:]" "_"
- (replace-regexp-in-string
- "[[:space:]]+" "-"
- (file-name-nondirectory (directory-file-name dir))))))
-
-(defun cj/--ai-vterm-live-tmux-sessions ()
- "Return live tmux session names that carry the AI-vterm prefix.
-
-Runs `tmux list-sessions'. Returns the names beginning with
-`cj/ai-vterm-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-vterm-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-vterm-session-active-p (dir sessions)
- "Return non-nil when DIR's tmux session name is in SESSIONS.
-
-SESSIONS is the list from `cj/--ai-vterm-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-vterm-tmux-session-name' never needs reversing."
- (and (member (cj/--ai-vterm-tmux-session-name dir) sessions) t))
-
-(defun cj/--ai-vterm-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-vterm-tmux-session-name'; the first window is named
-`cj/ai-vterm-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-vterm-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-vterm-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-vterm-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-vterm-tmux-window-name)
- (shell-quote-argument start-dir)
- (shell-quote-argument
- (concat cj/ai-vterm-agent-command "; exec bash")))))
-
-(defun cj/--ai-vterm-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-vterm-candidates ()
- "Return the list of AI-agent project paths.
-
-Each entry of `cj/ai-vterm-project-roots' contributes itself when it
-exists and contains .ai/protocols.org. Each entry of
-`cj/ai-vterm-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-vterm-project-roots)
- (let ((expanded (expand-file-name root)))
- (when (and (file-directory-p expanded)
- (cj/--ai-vterm-has-marker-p expanded))
- (push expanded result))))
- (dolist (root cj/ai-vterm-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-vterm-has-marker-p child))
- (push child result))))))
- (nreverse result)))
-
-(defvar cj/--ai-vterm-mru nil
- "Project dirs opened via the AI-vterm launcher this session, newest first.
-
-Maintained by `cj/--ai-vterm-record-mru' (called from
-`cj/--ai-vterm-show-or-create') and consumed by
-`cj/--ai-vterm-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-vterm-record-mru (dir)
- "Move DIR to the front of `cj/--ai-vterm-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-vterm-mru (cons d (delete d cj/--ai-vterm-mru)))))
-
-(defun cj/--ai-vterm-mru-rank (dir)
- "Return DIR's index in `cj/--ai-vterm-mru', or nil when it isn't there.
-
-DIR is normalized the same way `cj/--ai-vterm-record-mru' stores
-entries, so a trailing slash doesn't defeat the lookup."
- (seq-position cj/--ai-vterm-mru
- (directory-file-name (expand-file-name dir))))
-
-(defun cj/--ai-vterm-sort-candidates (dirs sessions)
- "Order DIRS for the project picker.
-
-DIRS with a live tmux session in SESSIONS (per
-`cj/--ai-vterm-session-active-p') come first, ordered most-recently-
-opened first (per `cj/--ai-vterm-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-vterm-mru-rank a))
- (rb (cj/--ai-vterm-mru-rank b)))
- (cond ((and ra rb) (< ra rb))
- (ra t)
- (rb nil)
- (t (funcall alpha a b))))))
- (active-p (lambda (d) (cj/--ai-vterm-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-vterm-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-vterm-desktop-width 0.5
- "Default fraction of frame width for the AI-vterm window on a desktop.
-
-On a desktop the agent opens as a right-side vertical split (see
-`cj/--ai-vterm-default-direction'), so this fraction is interpreted
-as a window width. Used by `cj/--ai-vterm-default-size' as the size
-fallback when `cj/--ai-vterm-last-size' is nil (i.e. the user hasn't
-yet toggled off an agent window in this session)."
- :type 'number
- :group 'ai-vterm)
-
-(defcustom cj/ai-vterm-laptop-height 0.75
- "Default fraction of frame height for the AI-vterm window on a laptop.
-
-On a laptop the agent opens as a bottom horizontal split (see
-`cj/--ai-vterm-default-direction'), so this fraction is interpreted
-as a window height. Used by `cj/--ai-vterm-default-size' as the size
-fallback when `cj/--ai-vterm-last-size' is nil."
- :type 'number
- :group 'ai-vterm)
-
-(defun cj/--ai-vterm-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-vterm-default-size ()
- "Return the host-appropriate default size fraction for the agent window.
-
-`cj/ai-vterm-laptop-height' on a laptop, `cj/ai-vterm-desktop-width'
-on a desktop -- pairing with the axis chosen by
-`cj/--ai-vterm-default-direction'."
- (if (env-laptop-p)
- cj/ai-vterm-laptop-height
- cj/ai-vterm-desktop-width))
-
-(defvar cj/--ai-vterm-last-direction nil
- "Last user-chosen direction for the AI-vterm 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-vterm-capture-state' and consumed by
-`cj/--ai-vterm-display-saved'.")
-
-(defvar cj/--ai-vterm-last-was-bury nil
- "Non-nil when the last F9 toggle-off used `bury-buffer'.
-
-Set by `cj/ai-vterm' 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-vterm-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-vterm-last-hidden-buffer nil
- "The agent buffer hidden by the most recent F9 toggle-off.
-
-Captured in `cj/ai-vterm' just before an agent window is torn down, and
-consumed by `cj/--ai-vterm-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-vterm-last-size nil
- "Last user-chosen body size for the AI-vterm display.
-
-Positive integer: body-columns when `cj/--ai-vterm-last-direction'
-is right or left, body-lines when below or above. nil means use
-the host-aware default from `cj/--ai-vterm-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-vterm-capture-state (window)
- "Capture WINDOW's direction and size into module-level state.
-
-Sets `cj/--ai-vterm-last-direction' and `cj/--ai-vterm-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-vterm-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-vterm-default-direction)
- 'cj/--ai-vterm-last-direction
- 'cj/--ai-vterm-last-size
- '(right below left)))
-
-(defun cj/--ai-vterm-reuse-existing-agent (buffer _alist)
- "Display-buffer action: reuse any window in this frame already showing
-an agent buffer.
-
-Looks up `cj/--ai-vterm-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-vterm-displayed-agent-window)))
- (when win
- (set-window-buffer win buffer)
- win)))
-
-(defun cj/--ai-vterm-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-vterm-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-vterm-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-vterm-last-direction
- (cj/--ai-vterm-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-vterm-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-vterm-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-vterm-default-direction' and `cj/--ai-vterm-default-size'."
- (cond
- ((and cj/--ai-vterm-last-was-bury (one-window-p))
- (setq cj/--ai-vterm-last-was-bury nil)
- (let ((win (selected-window)))
- (set-window-buffer win buffer)
- win))
- (t
- (setq cj/--ai-vterm-last-was-bury nil)
- (cj/window-toggle-display-saved
- buffer alist
- 'cj/--ai-vterm-last-direction (cj/--ai-vterm-default-direction)
- 'cj/--ai-vterm-last-size (cj/--ai-vterm-default-size)))))
-
-(defun cj/--ai-vterm-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-vterm-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-vterm-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-vterm-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-vterm-reuse-existing-agent
- cj/--ai-vterm-reuse-edge-window
- cj/--ai-vterm-display-saved)
- (inhibit-same-window . t))))
-
-(dolist (entry (cj/--ai-vterm-display-rule-list))
- (add-to-list 'display-buffer-alist entry))
-
-(defun cj/--ai-vterm-show-or-create (dir name)
- "Show or create the AI-vterm 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 vterm in DIR and send the
-project's tmux launch command (see `cj/--ai-vterm-launch-command') so
-the same project basename reattaches across Emacs restarts.
-
-The dynamic binding of `cj/--ai-vterm-suppress-tmux' around `(vterm)'
-suppresses the generic tmux-launch hook in vterm-config.el so
-it doesn't fire a bare \"tmux\\n\" before the project-named launch
-command runs.
-
-Records DIR in `cj/--ai-vterm-mru' (whichever branch runs) so the
-project picker can list recently-opened projects first. Returns the
-buffer."
- (cj/--ai-vterm-record-mru dir)
- (let ((existing (get-buffer name)))
- (cond
- ((and existing (cj/--ai-vterm-process-live-p existing))
- (display-buffer existing)
- existing)
- (t
- (when existing
- (kill-buffer existing))
- ;; `vterm' calls pop-to-buffer-same-window internally, which
- ;; replaces the selected window's buffer (e.g. the dashboard at
- ;; fresh startup) before our display-buffer-alist rule has a
- ;; chance to route it. `save-window-excursion' reverts that
- ;; side-effect; the explicit display-buffer call below then
- ;; routes the buffer through the alist into a right-side split.
- (save-window-excursion
- (let ((default-directory dir)
- (cj/--ai-vterm-suppress-tmux t))
- (vterm name)))
- (let ((buf (get-buffer name)))
- (with-current-buffer buf
- (vterm-send-string (cj/--ai-vterm-launch-command dir))
- (vterm-send-return))
- (display-buffer buf)
- buf)))))
-
-(defun cj/--ai-vterm-format-candidate (path &optional sessions)
- "Return the display name for PATH in the AI-vterm 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-vterm-buffer-name path))
- (buf (get-buffer name))
- (running (and buf (cj/--ai-vterm-process-live-p buf)))
- (detached (and (not running)
- (cj/--ai-vterm-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-vterm-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-vterm-pick-project ()
- "Prompt for an AI-agent project; return its absolute path.
-
-Candidates come from `cj/--ai-vterm-candidates', ordered by
-`cj/--ai-vterm-sort-candidates' so projects with a live tmux session
-appear first (then alphabetical by abbreviated path). Display uses
-`cj/--ai-vterm-format-candidate', which abbreviates the path and
-flags a live session via \" [running]\" (an Emacs vterm buffer is
-alive) or \" [detached]\" (the tmux session survived, no buffer).
-Signals `user-error' when no candidates exist."
- (let ((candidates (cj/--ai-vterm-candidates)))
- (unless candidates
- (user-error "No AI-agent projects found under %s"
- (mapconcat #'identity
- (append cj/ai-vterm-project-roots
- cj/ai-vterm-container-roots)
- ", ")))
- (let* ((sessions (cj/--ai-vterm-live-tmux-sessions))
- (sorted (cj/--ai-vterm-sort-candidates candidates sessions))
- (display-alist
- (mapcar (lambda (p)
- (cons (cj/--ai-vterm-format-candidate p sessions) p))
- sorted))
- (chosen (completing-read
- "AI vterm project: "
- (cj/--ai-vterm-completion-table display-alist)
- nil t)))
- (or (cdr (assoc chosen display-alist))
- (expand-file-name chosen)))))
-
-(defun cj/--ai-vterm-dispatch ()
- "Compute the F9 (`cj/ai-vterm') 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-vterm-displayed-agent-window)))
- (cond
- (win (cons 'toggle-off win))
- (t
- (let ((buffers (cj/--ai-vterm-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-vterm-last-hidden-buffer)
- (memq cj/--ai-vterm-last-hidden-buffer buffers))
- cj/--ai-vterm-last-hidden-buffer
- (car buffers))))
- (t '(pick-project))))))))
-
-(defun cj/--ai-vterm-refuse-in-terminal ()
- "Signal a `user-error' when the current frame is a terminal frame.
-
-AI-vterm launches a graphical vterm side window, so it is GUI-only.
-Each interactive entry point calls this first, so F9 and friends
-decline -- with a message in the echo area -- in a terminal frame
-instead of launching a vterm. The check is per-frame at command time
-rather than at load, so a daemon serving both GUI and terminal frames
-keeps the launcher working in its GUI frames and declines only in the
-terminal ones."
- (when (env-terminal-p)
- (user-error "AI-vterm is GUI-only; not available in a terminal frame")))
-
-(defun cj/ai-vterm-pick-project (&optional arg)
- "Pick an AI-agent project and open or reuse its vterm.
-
-The project is picked from a filtered completing-read list of dirs
-that contain .ai/protocols.org. The vterm 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 vterm.
-
-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."
- (interactive "P")
- (cj/--ai-vterm-refuse-in-terminal)
- (let* ((dir (cj/--ai-vterm-pick-project))
- (name (cj/--ai-vterm-buffer-name dir))
- (buf (cj/--ai-vterm-show-or-create dir name)))
- (unless arg
- (let ((win (get-buffer-window buf)))
- (when win (select-window win))))
- buf))
-
-(defun cj/ai-vterm (&optional arg)
- "Smart F9 dispatch for the AI-vterm launcher.
-
-Behavior depends on the current state:
-
-- If an AI-vterm buffer is currently displayed in this frame, F9
- quits its window (toggle off, buffer stays alive).
-- Else, if exactly one alive AI-vterm 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-vterm-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-vterm-pick-project' (C-F9) to force the project picker.
-M-F9 (and C-S-F9) close an agent via `cj/ai-vterm-close'."
- (interactive "P")
- (cj/--ai-vterm-refuse-in-terminal)
- (pcase (cj/--ai-vterm-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-vterm-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-vterm-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-vterm-capture-state win)
- (setq cj/--ai-vterm-last-was-bury t)
- (bury-buffer (window-buffer win))
- (when (and (window-live-p win)
- (cj/--ai-vterm-buffer-p (window-buffer win)))
- (with-selected-window win
- (switch-to-buffer
- (or (cj/--ai-vterm-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-vterm-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-vterm-capture-state win)
- (setq cj/--ai-vterm-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-vterm-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-vterm-pick-project arg))))
-
-;; ----------------------------- Close an agent --------------------------------
-
-(defun cj/--ai-vterm-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-vterm-close-buffer (buffer)
- "Gracefully tear down AI-vterm BUFFER: tmux session, window, buffer.
-
-Derives the tmux session name from BUFFER's `default-directory' (the
-project dir the vterm 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-vterm buffer."
- (when (cj/--ai-vterm-buffer-p buffer)
- (cj/--ai-vterm-kill-tmux-session
- (cj/--ai-vterm-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-vterm-close-target ()
- "Return the AI-vterm buffer `cj/ai-vterm-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-vterm-buffer-p (current-buffer)) (current-buffer))
- (t (let ((buffers (cj/--ai-vterm-agent-buffers)))
- (cond
- ((null buffers) nil)
- ((null (cdr buffers)) (car buffers))
- (t (get-buffer
- (completing-read "Close AI vterm: "
- (mapcar #'buffer-name buffers) nil t))))))))
-
-(defun cj/ai-vterm-close ()
- "Gracefully close an AI-vterm 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-vterm-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)
- (cj/--ai-vterm-refuse-in-terminal)
- (let ((buffer (cj/--ai-vterm-close-target)))
- (unless buffer
- (user-error "No AI-vterm 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-vterm-close-buffer buffer)
- (message "Closed agent %s." name)))))
-
-(keymap-global-set "<f9>" #'cj/ai-vterm)
-(keymap-global-set "C-<f9>" #'cj/ai-vterm-pick-project)
-(keymap-global-set "M-<f9>" #'cj/ai-vterm-close)
-(keymap-global-set "C-S-<f9>" #'cj/ai-vterm-close)
-
-;; vterm binds <f1>..<f12> to `vterm--self-insert', so a plain <f9> typed
-;; while point is inside an agent buffer gets sent to the terminal 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 `vterm-mode-map' so
-;; the toggle reaches Emacs from there too. (C-<f9> / M-<f9> aren't in vterm's
-;; intercept set, but bind them here as well so the behaviour is uniform.)
-(with-eval-after-load 'vterm
- (keymap-set vterm-mode-map "<f9>" #'cj/ai-vterm)
- (keymap-set vterm-mode-map "C-<f9>" #'cj/ai-vterm-pick-project)
- (keymap-set vterm-mode-map "M-<f9>" #'cj/ai-vterm-close)
- (keymap-set vterm-mode-map "C-S-<f9>" #'cj/ai-vterm-close))
-
-;; ---------- emacsclient: keep opened files off the agent vterm ----------
-;;
-;; `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 vterm, 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-vterm-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-vterm 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-vterm-buffer-p (window-buffer w)))))
- (window-list (selected-frame) 'never)))
-
-(defun cj/--ai-vterm-server-display (buffer)
- "Display BUFFER for `server-window', keeping it off the agent vterm.
-
-When the selected window shows an AI-vterm agent buffer, put BUFFER in
-a non-agent window (`cj/--ai-vterm-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-vterm-buffer-p (window-buffer (selected-window)))
- (let* ((agent-win (selected-window))
- (target (or (cj/--ai-vterm-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-vterm-server-display))
-
-(provide 'ai-vterm)
-;;; ai-vterm.el ends here