From ebdf9e466b0e1f86e9b7d76650ac32408273e7a7 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 5 Jun 2026 05:28:58 -0500 Subject: 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. --- modules/ai-term.el | 974 +++++++++++++++++++++++++++++++++++++ modules/ai-vterm.el | 978 -------------------------------------- modules/auto-dim-config.el | 186 +------- modules/cj-window-geometry-lib.el | 4 +- modules/cj-window-toggle-lib.el | 4 +- modules/dashboard-config.el | 3 +- modules/term-config.el | 396 +++++++++++++++ modules/ui-config.el | 19 +- modules/vterm-config.el | 540 --------------------- 9 files changed, 1393 insertions(+), 1711 deletions(-) create mode 100644 modules/ai-term.el delete mode 100644 modules/ai-vterm.el create mode 100644 modules/term-config.el delete mode 100644 modules/vterm-config.el (limited to 'modules') 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 + +;;; 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 []", 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 +;; "" (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 []\". 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 + ; 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 []\" 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- (primary) and C-S-." + (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 "" #'cj/ai-term) +(keymap-global-set "C-" #'cj/ai-term-pick-project) +(keymap-global-set "M-" #'cj/ai-term-close) +(keymap-global-set "C-S-" #'cj/ai-term-close) + +;; ghostel's semi-char mode forwards keys not in `ghostel-keymap-exceptions' to +;; the terminal program, so a plain 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- / M- are bound here as well so the behaviour is uniform.) +(with-eval-after-load 'ghostel + (keymap-set ghostel-mode-map "" #'cj/ai-term) + (keymap-set ghostel-mode-map "C-" #'cj/ai-term-pick-project) + (keymap-set ghostel-mode-map "M-" #'cj/ai-term-close) + (keymap-set ghostel-mode-map "C-S-" #'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 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 - -;;; 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 []", 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 -;; "" (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 []\". 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 - ; 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 []\" 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- (primary) and C-S-." - (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 "" #'cj/ai-vterm) -(keymap-global-set "C-" #'cj/ai-vterm-pick-project) -(keymap-global-set "M-" #'cj/ai-vterm-close) -(keymap-global-set "C-S-" #'cj/ai-vterm-close) - -;; vterm binds .. to `vterm--self-insert', so a plain 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- / M- 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 "" #'cj/ai-vterm) - (keymap-set vterm-mode-map "C-" #'cj/ai-vterm-pick-project) - (keymap-set vterm-mode-map "M-" #'cj/ai-vterm-close) - (keymap-set vterm-mode-map "C-S-" #'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 diff --git a/modules/auto-dim-config.el b/modules/auto-dim-config.el index ebda92c2..c0e6e7a1 100644 --- a/modules/auto-dim-config.el +++ b/modules/auto-dim-config.el @@ -18,179 +18,16 @@ ;; debounce). The dimmed faces (auto-dim-other-buffers and ;; auto-dim-other-buffers-hide) live in the active theme ;; (themes/dupre-faces.el) so they track theme switches. +;; +;; Terminal buffers (ghostel) do not participate in window dimming: ghostel +;; bakes its color palette into the native module per-terminal, not per-window, +;; so there is no per-window color hook to dim through (the vterm engine had +;; one via `vterm--get-color', which this module used to advise). See the +;; terminal-migration follow-up task in todo.org for revisiting this. ;;; Code: -(require 'cl-lib) -(require 'color) - (declare-function auto-dim-other-buffers-mode "auto-dim-other-buffers") -(declare-function adob--update "auto-dim-other-buffers") -(declare-function vterm--get-color "vterm") -(declare-function vterm--invalidate "vterm") -(declare-function vterm--set-size "vterm") -(declare-function vterm--get-margin-width "vterm") -(defvar vterm-min-window-width) -(defvar vterm--term) - -(defvar cj/auto-dim--last-selected-window nil - "Most recent selected window seen by `cj/auto-dim--refresh-vterm-on-command'.") - -(defvar cj/auto-dim--vterm-refresh-timer nil - "Timer used to defer vterm redraws until after auto-dim updates.") - -(defcustom cj/auto-dim-vterm-foreground-blend 0.45 - "Blend amount for dimmed vterm foreground colors. - -0 keeps the original vterm color; 1 uses the -`auto-dim-other-buffers' foreground color." - :type 'number - :group 'auto-dim-other-buffers) - -(defcustom cj/auto-dim-vterm-background-blend 0.7 - "Blend amount for dimmed vterm background colors. - -0 keeps the original vterm color; 1 uses the -`auto-dim-other-buffers' background color." - :type 'number - :group 'auto-dim-other-buffers) - -(defun cj/auto-dim--vterm-buffer-dimmed-p () - "Return non-nil when the current vterm buffer should render dimmed. - -Vterm resolves terminal colors to concrete color strings while redrawing the -buffer, so this integration is buffer-level. If the same vterm buffer is shown -in multiple windows and any one of those windows is selected/undimmed, keep the -buffer bright." - (and (eq major-mode 'vterm-mode) - (let ((windows (get-buffer-window-list (current-buffer) nil 'visible))) - (and windows - (not (catch 'undimmed - (dolist (window windows) - (unless (window-parameter window 'adob--dim) - (throw 'undimmed t))))))))) - -(defun cj/auto-dim--face-color (face attribute fallback-face) - "Return FACE ATTRIBUTE, falling back to FALLBACK-FACE." - (let ((color (face-attribute face attribute nil 'default))) - (if (or (null color) (eq color 'unspecified)) - (face-attribute fallback-face attribute nil 'default) - color))) - -(defun cj/auto-dim--color-rgb (color) - "Return COLOR as a list of RGB floats, or nil if COLOR is unknown." - (cond - ((and (stringp color) - (string-match - "\\`#\\([[:xdigit:]]\\{2\\}\\)\\([[:xdigit:]]\\{2\\}\\)\\([[:xdigit:]]\\{2\\}\\)\\'" - color)) - (mapcar (lambda (index) - (/ (string-to-number (match-string index color) 16) 255.0)) - '(1 2 3))) - ((and (stringp color) - (string-match - "\\`#\\([[:xdigit:]]\\)\\([[:xdigit:]]\\)\\([[:xdigit:]]\\)\\'" - color)) - (mapcar (lambda (index) - (/ (* 17 (string-to-number (match-string index color) 16)) 255.0)) - '(1 2 3))) - (t - (ignore-errors - (mapcar (lambda (component) (/ component 65535.0)) - (color-values color)))))) - -(defun cj/auto-dim--blend-color (color target amount) - "Blend COLOR toward TARGET by AMOUNT and return a hex color string." - (if-let* ((rgb (cj/auto-dim--color-rgb color)) - (target-rgb (cj/auto-dim--color-rgb target))) - (apply #'color-rgb-to-hex - (append - (cl-mapcar - (lambda (source dest) - (+ (* source (- 1 amount)) (* dest amount))) - rgb target-rgb) - '(2))) - color)) - -(defun cj/auto-dim--vterm-dim-color (color foreground-p) - "Return dimmed vterm COLOR. - -When FOREGROUND-P is non-nil, blend toward the dimmed foreground face; otherwise -blend toward the dimmed background face." - (let* ((attribute (if foreground-p :foreground :background)) - (target (cj/auto-dim--face-color 'auto-dim-other-buffers attribute 'default)) - (amount (if foreground-p - cj/auto-dim-vterm-foreground-blend - cj/auto-dim-vterm-background-blend))) - (cj/auto-dim--blend-color color target amount))) - -(defun cj/auto-dim--vterm-get-color (orig-fun index &rest args) - "Advise vterm color lookup ORIG-FUN for dimmed windows. - -INDEX and ARGS are passed through to `vterm--get-color'." - (let ((color (apply orig-fun index args))) - (if (and color (cj/auto-dim--vterm-buffer-dimmed-p)) - (cj/auto-dim--vterm-dim-color color (memq :foreground args)) - color))) - -(defun cj/auto-dim--refresh-vterm-windows (&optional frame) - "Refresh visible vterm buffers in FRAME after dim state changes." - (when (or (fboundp 'vterm--set-size) (fboundp 'vterm--invalidate)) - (dolist (window (window-list frame 'no-minibuf)) - (with-current-buffer (window-buffer window) - (when (eq major-mode 'vterm-mode) - (let ((inhibit-read-only t)) - (if (and (bound-and-true-p vterm--term) - (window-live-p window) - (fboundp 'vterm--get-margin-width)) - (let* ((height (max 2 (window-body-height window))) - (min-width (if (boundp 'vterm-min-window-width) - vterm-min-window-width - 80)) - (width (max min-width - (- (window-body-width window) - (vterm--get-margin-width))))) - ;; `vterm--redraw' only repaints rows libvterm marked dirty. - ;; A resize marks the whole terminal grid dirty, so briefly - ;; nudge height and restore it to force a full repaint after - ;; dim-state changes. - (vterm--set-size vterm--term (1+ height) width) - (vterm--set-size vterm--term height width)) - (when (fboundp 'vterm--invalidate) - (vterm--invalidate))))))))) - -(defun cj/auto-dim--refresh-vterm-after-auto-dim (&optional frame) - "Update auto-dim state, then refresh visible vterm buffers in FRAME." - (setq cj/auto-dim--vterm-refresh-timer nil) - (when (fboundp 'adob--update) - (adob--update)) - (cj/auto-dim--refresh-vterm-windows frame)) - -(defun cj/auto-dim--schedule-vterm-refresh (&optional frame) - "Schedule a deferred vterm refresh for FRAME. - -The delay lets selection-changing commands finish before we recompute -auto-dim state and invalidate vterm." - (when cj/auto-dim--vterm-refresh-timer - (cancel-timer cj/auto-dim--vterm-refresh-timer)) - (setq cj/auto-dim--vterm-refresh-timer - (run-with-timer 0 nil #'cj/auto-dim--refresh-vterm-after-auto-dim frame))) - -(defun cj/auto-dim--refresh-vterm-on-command () - "Refresh visible vterm buffers when selected window changes. - -`window-selection-change-functions' does not catch every selection path used by -windmove/Shift-arrow focus changes in this config, so this post-command hook is -the fallback that makes vterm repaint after auto-dim changes window state." - (let ((window (selected-window))) - (unless (eq window cj/auto-dim--last-selected-window) - (setq cj/auto-dim--last-selected-window window) - (cj/auto-dim--schedule-vterm-refresh)))) - -(defun cj/auto-dim--after-select-window (&rest _) - "Schedule vterm refresh after `select-window'." - (setq cj/auto-dim--last-selected-window (selected-window)) - (cj/auto-dim--schedule-vterm-refresh)) (defun cj/auto-dim--never-dim-dashboard-p (buffer) "Return non-nil when BUFFER is the dashboard, so it stays lit. @@ -212,7 +49,7 @@ focus cue on a split-displayed dashboard, accepted as a fair trade." :custom ;; Dim only non-selected windows within Emacs, not the whole frame when ;; Emacs loses focus -- on Hyprland focus moves to other apps constantly, - ;; and the ai-vterm agents live in their own windows. + ;; and the ai-term agents live in their own windows. (auto-dim-other-buffers-dim-on-focus-out nil) (auto-dim-other-buffers-dim-on-switch-to-minibuffer t) :config @@ -259,14 +96,5 @@ focus cue on a split-displayed dashboard, accepted as a fair trade." #'cj/auto-dim--never-dim-dashboard-p) (auto-dim-other-buffers-mode 1)) -(with-eval-after-load 'vterm - (unless (advice-member-p #'cj/auto-dim--vterm-get-color #'vterm--get-color) - (advice-add #'vterm--get-color :around #'cj/auto-dim--vterm-get-color)) - (unless (advice-member-p #'cj/auto-dim--after-select-window #'select-window) - (advice-add #'select-window :after #'cj/auto-dim--after-select-window)) - (add-hook 'window-selection-change-functions - #'cj/auto-dim--schedule-vterm-refresh) - (add-hook 'post-command-hook #'cj/auto-dim--refresh-vterm-on-command)) - (provide 'auto-dim-config) ;;; auto-dim-config.el ends here diff --git a/modules/cj-window-geometry-lib.el b/modules/cj-window-geometry-lib.el index cc638f76..047fe7c4 100644 --- a/modules/cj-window-geometry-lib.el +++ b/modules/cj-window-geometry-lib.el @@ -5,8 +5,8 @@ ;;; Commentary: ;; Pure helpers for classifying a window's position in its frame and -;; computing body sizes. Shared between `ai-vterm.el' (F9 dispatch) -;; and `vterm-config.el' (F12 dispatch); the geometry- +;; computing body sizes. Shared between `ai-term.el' (F9 dispatch) +;; and `term-config.el' (F12 dispatch); the geometry- ;; preservation pattern in both modules captures direction + body ;; size at toggle-off and replays them on the next toggle-on. ;; diff --git a/modules/cj-window-toggle-lib.el b/modules/cj-window-toggle-lib.el index 9874a134..ba91f5a4 100644 --- a/modules/cj-window-toggle-lib.el +++ b/modules/cj-window-toggle-lib.el @@ -4,8 +4,8 @@ ;;; Commentary: -;; Parameterized helpers used by ai-vterm.el (F9) and -;; vterm-config.el (F12) to capture a window's geometry at +;; Parameterized helpers used by ai-term.el (F9) and +;; term-config.el (F12) to capture a window's geometry at ;; toggle-off and replay it on the next toggle-on. Each consumer ;; holds its own pair of state variables (last-direction symbol + ;; last-size integer/float) and passes the variable symbols to the diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el index 571eb58a..b4e4545d 100644 --- a/modules/dashboard-config.el +++ b/modules/dashboard-config.el @@ -20,6 +20,7 @@ (eval-when-compile (require 'undead-buffers)) (declare-function cj/make-buffer-undead "undead-buffers" (string)) (autoload 'cj/make-buffer-undead "undead-buffers" nil t) +(declare-function ghostel "ghostel" (&optional arg)) ;; ------------------------ Dashboard Bookmarks Override ----------------------- ;; overrides the bookmark insertion from the dashboard package to provide an @@ -76,7 +77,7 @@ Adjust this if the title doesn't appear centered under the banner image.") (list (list "c" #'nerd-icons-faicon "nf-fa-code" "Code" "Switch Project" (lambda () (projectile-switch-project))) (list "d" #'nerd-icons-faicon "nf-fa-folder_o" "Files" "Dirvish File Manager" (lambda () (dirvish user-home-dir))) - (list "t" #'nerd-icons-devicon "nf-dev-terminal" "Terminal" "Launch VTerm" (lambda () (vterm))) + (list "t" #'nerd-icons-devicon "nf-dev-terminal" "Terminal" "Launch Terminal" (lambda () (ghostel))) (list "a" #'nerd-icons-mdicon "nf-md-calendar" "Agenda" "Main Org Agenda" (lambda () (cj/main-agenda-display))) (list "r" #'nerd-icons-faicon "nf-fa-rss_square" "Feeds" "Elfeed Feed Reader" (lambda () (cj/elfeed-open))) (list "b" #'nerd-icons-faicon "nf-fae-book_open_o" "Books" "Calibre Ebook Reader" (lambda () (calibredb))) diff --git a/modules/term-config.el b/modules/term-config.el new file mode 100644 index 00000000..84ba7b3b --- /dev/null +++ b/modules/term-config.el @@ -0,0 +1,396 @@ +;;; term-config.el --- Settings for ghostel and the F12 toggle -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings + +;;; Commentary: +;; +;; Layer: 3 (Domain Workflow). +;; Category: D/P. +;; Load shape: eager. +;; Eager reason: registers terminal keymaps and the F12 toggle. +;; Top-level side effects: defines two keymaps (one under cj/custom-keymap), one +;; global key, two add-hook, package config. +;; Runtime requires: keybindings, seq, subr-x, cj-window-geometry-lib, +;; cj-window-toggle-lib. +;; Direct test load: yes (requires keybindings explicitly). +;; +;; GHOSTEL +;; ghostel is a native Emacs terminal emulator over libghostty-vt (the Ghostty +;; engine). Like a real terminal, in its default semi-char mode most keys are +;; sent to the running program; `ghostel-keymap-exceptions' lists the keys that +;; reach Emacs instead. We add C-; so the personal prefix keymap works inside +;; ghostel buffers. +;; +;; The module degrades gracefully when ghostel is unavailable (D6 of the +;; migration spec): the package installs via use-package, the native module +;; auto-downloads on first use, and ghostel emits its own warning if the module +;; cannot load. A machine without a prebuilt binary needs Zig to build it; the +;; terminal commands stay defined either way. +;; +;; Two ways to lift text out of a terminal, both with the same key story: +;; - C-; x c enters copy-mode via `cj/term-copy-mode-dwim'. When a tmux +;; client is attached (typical -- `cj/term-launch-tmux' auto-starts tmux), +;; sends tmux's prefix C-b [ so the user lands in tmux's own copy-mode with +;; the full pane history available. Without tmux, falls back to +;; `ghostel-copy-mode' (read-only standard-Emacs navigation over the +;; scrollback; M-w copies and stays, q / C-g exit). +;; - C-; x h captures the current tmux pane's full history into a temporary +;; Emacs buffer. +;; In both copy surfaces, M-w copies the active region and stays open so several +;; pieces can be grabbed in a row; C-g / q leave without copying. + +;;; Code: + +(require 'keybindings) +(require 'seq) +(require 'subr-x) +(require 'cj-window-geometry-lib) +(require 'cj-window-toggle-lib) + +(declare-function ghostel "ghostel" (&optional directory)) +(declare-function ghostel-send-string "ghostel" (string)) +(declare-function ghostel-copy-mode "ghostel" ()) +(declare-function ghostel-clear-scrollback "ghostel" ()) +(declare-function ghostel-next-prompt "ghostel" (&optional n)) +(declare-function ghostel-previous-prompt "ghostel" (&optional n)) +(declare-function ghostel-send-next-key "ghostel" ()) +(defvar ghostel-mode-map) +(defvar ghostel-keymap-exceptions) +(defvar ghostel-buffer-name) + +(defvar-keymap cj/term-map + :doc "Personal terminal command map.") +;; Lowercase x picked over T for fewer Shift presses; t is the toggle leaf. +(cj/register-prefix-map "x" cj/term-map) + +;; ----------------------------- tmux history ---------------------------------- + +(defvar-local cj/term-tmux-history--origin-buffer nil + "Buffer active before opening the tmux history buffer.") + +(defvar-local cj/term-tmux-history--origin-window nil + "Window active before opening the tmux history buffer.") + +(defvar-local cj/term-tmux-history--origin-point nil + "Point in the origin buffer before opening the tmux history buffer.") + +(defun cj/term--tmux-output (&rest args) + "Run tmux with ARGS and return its stdout. +Signal `user-error' when tmux exits with a non-zero status." + (with-temp-buffer + (let ((exit-code (apply #'process-file "tmux" nil t nil args))) + (unless (zerop exit-code) + (user-error "tmux failed: %s" (string-trim (buffer-string)))) + (buffer-string)))) + +(defun cj/term--tmux-pane-id-for-tty (tty) + "Return the tmux pane id for client TTY." + (let* ((output (cj/term--tmux-output + "list-clients" "-F" "#{client_tty}\t#{pane_id}")) + (lines (split-string output "\n" t)) + (match (seq-find + (lambda (line) + (let ((fields (split-string line "\t"))) + (equal (car fields) tty))) + lines))) + (unless match + (user-error "No tmux client found for terminal tty %s" tty)) + (cadr (split-string match "\t")))) + +(defun cj/term--tmux-capture-pane (pane-id) + "Return full joined tmux history for PANE-ID." + (cj/term--tmux-output + "capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" pane-id)) + +(defun cj/term--current-tmux-pane-id () + "Return the tmux pane id for the current ghostel buffer." + (unless (eq major-mode 'ghostel-mode) + (user-error "Current buffer is not a ghostel buffer")) + (let* ((proc (get-buffer-process (current-buffer))) + (tty (and proc (process-tty-name proc)))) + (unless (and tty (not (string-empty-p tty))) + (user-error "Could not determine terminal tty")) + (cj/term--tmux-pane-id-for-tty tty))) + +(defvar-keymap cj/term-tmux-history-mode-map + :doc "Keymap for `cj/term-tmux-history-mode'. +M-w copies the active region without leaving the buffer; C-g, , or q +returns to the terminal without copying. RET is left unbound." + "M-w" #'kill-ring-save + "C-g" #'cj/term-tmux-history-quit + "" #'cj/term-tmux-history-quit + "q" #'cj/term-tmux-history-quit) + +(define-derived-mode cj/term-tmux-history-mode special-mode "Tmux History" + "Mode for copying captured tmux pane history with normal Emacs keys." + (setq-local truncate-lines t) + (goto-address-mode 1)) + +(defun cj/term-tmux-history-quit () + "Quit tmux history and return to its origin buffer." + (interactive) + (let ((history-buffer (current-buffer)) + (origin-buffer cj/term-tmux-history--origin-buffer) + (origin-window cj/term-tmux-history--origin-window) + (origin-point cj/term-tmux-history--origin-point)) + (when (buffer-live-p origin-buffer) + (if (window-live-p origin-window) + (progn + (set-window-buffer origin-window origin-buffer) + (select-window origin-window)) + (pop-to-buffer origin-buffer)) + (with-current-buffer origin-buffer + (when (integer-or-marker-p origin-point) + (goto-char origin-point)))) + (when (buffer-live-p history-buffer) + (kill-buffer history-buffer)))) + +(defun cj/term-tmux-history () + "Open full tmux pane history in a temporary Emacs buffer. + +The history buffer uses normal Emacs navigation and selection. `M-w' +copies the active region and stays open, so several pieces can be +copied in a row; `q', `', or `C-g' returns point to the +terminal buffer that launched it. + +The history view replaces the origin terminal buffer in the same window +\(via `switch-to-buffer'), not a split or a popped-up window." + (interactive) + (let* ((origin-buffer (current-buffer)) + (origin-window (selected-window)) + (origin-point (point)) + (pane-id (cj/term--current-tmux-pane-id)) + (history (cj/term--tmux-capture-pane pane-id)) + (buffer (get-buffer-create + (format "*terminal tmux history: %s*" (buffer-name origin-buffer))))) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (erase-buffer) + (insert history)) + (cj/term-tmux-history-mode) + (setq-local cj/term-tmux-history--origin-buffer origin-buffer) + (setq-local cj/term-tmux-history--origin-window origin-window) + (setq-local cj/term-tmux-history--origin-point origin-point) + (goto-char (point-max))) + (switch-to-buffer buffer))) + +;; ----------------------------- copy mode ------------------------------------- + +(defun cj/term--in-tmux-p () + "Return non-nil when the current ghostel buffer has a tmux client attached. +Errors from the pane-id lookup (not in ghostel-mode, no tty, no matching +client, tmux not installed) are treated as nil so callers can use this as a +cheap boolean predicate." + (and (eq major-mode 'ghostel-mode) + (condition-case _ + (and (cj/term--current-tmux-pane-id) t) + (error nil)))) + +(defun cj/term-copy-mode-dwim () + "Enter copy-mode using the engine appropriate to this terminal. + +When tmux is attached, write tmux's default prefix sequence (C-b [) into the +pty so the user lands in tmux's copy-mode with the full pane history. Without +tmux, falls through to `ghostel-copy-mode', a read-only standard-Emacs view of +the scrollback (M-w copies and stays, q / C-g exit)." + (interactive) + (if (cj/term--in-tmux-p) + (ghostel-send-string "\C-b[") + (ghostel-copy-mode))) + +;; ----------------------------- ghostel package ------------------------------- + +(defun cj/turn-off-chrome-for-term () + "Turn off line numbers and hl-line in a terminal buffer." + (hl-line-mode -1) + (display-line-numbers-mode -1)) + +(defun cj/term-launch-tmux () + "Auto-launch tmux in a ghostel buffer unless already inside tmux. + +Skipped when `cj/--ai-term-suppress-tmux' is non-nil so the AI-agent flow can +run its own project-named tmux session instead of a bare, auto-named one. +`bound-and-true-p' keeps this safe whether or not ai-term.el is loaded." + (let ((proc (get-buffer-process (current-buffer)))) + (when (and proc + (not (getenv "TMUX")) + (not (bound-and-true-p cj/--ai-term-suppress-tmux))) + (ghostel-send-string "tmux\n")))) + +(use-package ghostel + :ensure t + :commands (ghostel) + :init + ;; C-; must reach Emacs so the personal prefix keymap works in terminals. + (with-eval-after-load 'ghostel + (add-to-list 'ghostel-keymap-exceptions "C-;")) + :hook + ((ghostel-mode . cj/turn-off-chrome-for-term) + (ghostel-mode . cj/term-launch-tmux)) + :custom + (ghostel-kill-buffer-on-exit t) + ;; Byte analog of the prior 100000-line vterm setting (~100 bytes/line) -- D7. + (ghostel-max-scrollback (* 10 1024 1024))) + +;; ----------------------- F12 toggle (custom) ----------------------- +;; +;; Mirrors the geometry-preservation pattern shared with ai-term.el: capture +;; direction + body size at toggle-off, replay them via a custom display action +;; using frame-edge directions and body-relative sizes so the result is +;; divider-independent and layout-stable. Excludes agent-prefixed buffers, +;; which ai-term.el owns via F9. + +(defcustom cj/term-toggle-window-height 0.7 + "Default fraction of frame height for the F12 terminal window." + :type 'number + :group 'term) + +(defvar cj/--term-toggle-last-direction nil + "Last user-chosen direction for the F12 terminal display. +Symbol: right, left, or below. `above' is never stored. nil means use the +default `below' for F12's traditional bottom split.") + +(defvar cj/--term-toggle-last-size nil + "Last user-chosen body size for the F12 terminal display. +Positive integer: body-cols (right/left) or body-lines (below/above). +nil means fall back to `cj/term-toggle-window-height' as a fraction.") + +(defun cj/--term-toggle-buffer-p (buffer) + "Return non-nil when BUFFER is a terminal buffer F12 should manage. + +Qualifies when BUFFER is alive and has `ghostel-mode' (or its name starts with +the ghostel buffer-name prefix), AND its name does NOT start with the agent +prefix used by ai-term.el." + (and (bufferp buffer) + (buffer-live-p buffer) + (with-current-buffer buffer + (and (or (eq major-mode 'ghostel-mode) + (string-prefix-p (or (bound-and-true-p ghostel-buffer-name) + "*ghostel*") + (buffer-name buffer))) + (not (string-prefix-p "agent [" (buffer-name buffer))))))) + +(defun cj/--term-toggle-buffers () + "Return live F12-managed terminal buffers in `buffer-list' (MRU) order." + (seq-filter #'cj/--term-toggle-buffer-p (buffer-list))) + +(defun cj/--term-toggle-displayed-window (&optional frame) + "Return a window in FRAME currently displaying an F12 terminal buffer, or nil. +FRAME defaults to the selected frame. Minibuffer is excluded." + (seq-find (lambda (w) + (cj/--term-toggle-buffer-p (window-buffer w))) + (window-list (or frame (selected-frame)) 'never))) + +(defun cj/--term-toggle-capture-state (window) + "Capture WINDOW's direction + body size into module-level state. +Default direction is `below' to match F12's traditional bottom split." + (cj/window-toggle-capture-state + window 'below + 'cj/--term-toggle-last-direction + 'cj/--term-toggle-last-size + '(right below left))) + +(defun cj/--term-toggle-display-saved (buffer alist) + "Display-buffer action: split per saved direction and body size. +Delegates to `cj/window-toggle-display-saved' against the F12 state vars, +falling back to `below' and `cj/term-toggle-window-height'." + (cj/window-toggle-display-saved + buffer alist + 'cj/--term-toggle-last-direction 'below + 'cj/--term-toggle-last-size cj/term-toggle-window-height)) + +(defun cj/--term-toggle-display-rule-list () + "Return the `display-buffer-alist' entry list installed by F12. +Routes any terminal buffer satisfying `cj/--term-toggle-buffer-p' through +reuse-window then the saved-geometry action. Excludes agent buffers." + '(((lambda (buffer-or-name _) + (cj/--term-toggle-buffer-p (get-buffer buffer-or-name))) + (display-buffer-reuse-window + cj/--term-toggle-display-saved) + (inhibit-same-window . t)))) + +(dolist (entry (cj/--term-toggle-display-rule-list)) + (add-to-list 'display-buffer-alist entry)) + +(defun cj/--term-toggle-dispatch () + "Compute the F12 (`cj/term-toggle') action without performing it. + +Returns one of: +- (toggle-off . WINDOW) -- terminal displayed in WINDOW; hide it. +- (show-recent . BUFFER) -- terminal alive but not shown; redisplay. +- (create-new) -- no terminal buffer alive; create one." + (let ((win (cj/--term-toggle-displayed-window))) + (cond + (win (cons 'toggle-off win)) + (t + (let ((buffers (cj/--term-toggle-buffers))) + (cond + (buffers (cons 'show-recent (car buffers))) + (t '(create-new)))))))) + +(defun cj/term-toggle () + "Toggle a normal (non-agent) ghostel terminal buffer. + +- If an F12-managed terminal is displayed in this frame, capture its geometry + and delete its window (toggle off). Falls back to burying when it is the + only window in the frame. +- Otherwise, if any F12-managed terminal buffer is alive, display the most + recent one via the saved-geometry action. +- Otherwise, create a new terminal via `(ghostel)' which routes through the + same display action. + +Excludes agent-prefixed buffers; those have their own F9 dispatch via +`cj/ai-term'." + (interactive) + (pcase (cj/--term-toggle-dispatch) + (`(toggle-off . ,win) + (cj/--term-toggle-capture-state win) + (if (one-window-p) + (bury-buffer (window-buffer win)) + (delete-window win)) + nil) + (`(show-recent . ,buf) + (display-buffer buf) + (let ((w (get-buffer-window buf))) + (when w (select-window w))) + buf) + (`(create-new) + (ghostel)))) + +(keymap-global-set "" #'cj/term-toggle) + +;; ----------------------------- prefix menu ----------------------------------- + +(keymap-set cj/term-map "c" #'cj/term-copy-mode-dwim) +(keymap-set cj/term-map "h" #'cj/term-tmux-history) +(keymap-set cj/term-map "l" #'ghostel-clear-scrollback) +(keymap-set cj/term-map "N" #'ghostel) +(keymap-set cj/term-map "n" #'ghostel-next-prompt) +(keymap-set cj/term-map "p" #'ghostel-previous-prompt) +(keymap-set cj/term-map "q" #'ghostel-send-next-key) +(keymap-set cj/term-map "t" #'cj/term-toggle) + +(defun cj/term-install-keys () + "Make `C-;' resolve as the personal keymap inside ghostel buffers, and bind +the F-key toggles so they reach Emacs from inside a terminal buffer." + (when (boundp 'ghostel-mode-map) + (keymap-set ghostel-mode-map "C-;" cj/custom-keymap) + (keymap-set ghostel-mode-map "" #'cj/term-toggle))) + +(cj/term-install-keys) +(with-eval-after-load 'ghostel + (cj/term-install-keys)) + +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-; x" "terminal menu" + "C-; x c" "copy mode (tmux/ghostel)" + "C-; x h" "tmux scrollback history" + "C-; x l" "clear scrollback" + "C-; x N" "new terminal" + "C-; x n" "next prompt" + "C-; x p" "previous prompt" + "C-; x q" "send next key to terminal" + "C-; x t" "toggle terminal")) + +(provide 'term-config) +;;; term-config.el ends here. diff --git a/modules/ui-config.el b/modules/ui-config.el index a4c18421..7afe528b 100644 --- a/modules/ui-config.el +++ b/modules/ui-config.el @@ -111,16 +111,17 @@ When `cj/enable-transparency' is nil, reset alpha to fully opaque." One of `read-only', `overwrite', `modified', or `unmodified' — keys of `cj/buffer-status-colors'. -A live vterm buffer (in `vterm-mode' but NOT `vterm-copy-mode') -reports `unmodified' even though `vterm-mode' sets `buffer-read-only': -keystrokes there go to the terminal process, so from the user's side -the buffer is writeable, and the read-only (orange) cursor would be -misleading. `vterm-copy-mode' is the exception — there the buffer -really is a read-only Emacs buffer the user navigates, so it falls -through to `read-only' and keeps the orange cursor." +A live ghostel terminal (in `ghostel-mode' and an input mode that +forwards keys — semi-char / char / line) reports `unmodified' even +though the buffer is read-only: keystrokes go to the terminal process, +so from the user's side the buffer is writeable and the read-only +(orange) cursor would be misleading. ghostel's `copy' and `emacs' +input modes are the exception — there the buffer really is a read-only +Emacs buffer the user navigates, so it falls through to `read-only' +and keeps the orange cursor." (cond - ((and (eq major-mode 'vterm-mode) - (not (bound-and-true-p vterm-copy-mode))) + ((and (eq major-mode 'ghostel-mode) + (not (memq (bound-and-true-p ghostel--input-mode) '(copy emacs)))) 'unmodified) (buffer-read-only 'read-only) (overwrite-mode 'overwrite) diff --git a/modules/vterm-config.el b/modules/vterm-config.el deleted file mode 100644 index c8a57d30..00000000 --- a/modules/vterm-config.el +++ /dev/null @@ -1,540 +0,0 @@ -;;; vterm-config.el --- Settings for vterm and the F12 toggle -*- lexical-binding: t; coding: utf-8; -*- -;; author Craig Jennings - -;;; Commentary: -;; -;; Layer: 3 (Domain Workflow). -;; Category: D/P. -;; Load shape: eager. -;; Eager reason: registers terminal keymaps and the F12 toggle; a command/hook -;; deferral candidate. -;; Top-level side effects: defines two keymaps (one under cj/custom-keymap), one -;; global key, two add-hook, package config. -;; Runtime requires: keybindings, seq, subr-x, cj-window-geometry-lib, -;; cj-window-toggle-lib. -;; Direct test load: yes (requires keybindings explicitly). -;; -;; VTERM -;; At the moment, vterm behaves like a real terminal. For most keys, vterm will -;; just send them to the process that is currently running. So, C-a may be -;; beginning-of-the-line in a shell, or the prefix key in a screen session. - -;; Two ways to lift text out of a vterm, both with the same key story: -;; - C-; x c enters copy-mode via `cj/vterm-copy-mode-dwim'. When a tmux -;; client is attached to the vterm (typical -- `cj/vterm-launch-tmux' -;; auto-starts tmux), sends tmux's prefix C-b [ so the user lands in -;; tmux's own copy-mode with the full pane history available -;; (history-limit, default 100000 in this config's tmux.conf). Without -;; tmux, falls back to `vterm-copy-mode' against vterm's scrollback. -;; - C-; x h captures the current tmux pane's full history into a temporary -;; Emacs buffer. -;; In all three surfaces (vterm-copy-mode, tmux copy-mode, history buffer), -;; M-w copies the active region and stays open so several pieces can be -;; grabbed in a row; C-g, , or q leaves without copying; RET is -;; unbound -- no special "copy and exit" shortcut. The tmux-side bindings -;; live in ~/code/archsetup/dotfiles/common/.tmux.conf. - -;; ANSI-TERM & TERM -;; I haven't yet found a need for term or ansi-term in my workflows, so I leave -;; them with their default configurations. - -;;; Code: - -(require 'keybindings) -(require 'seq) -(require 'subr-x) -(require 'cj-window-geometry-lib) -(require 'cj-window-toggle-lib) - -;; Declare so `let'-bindings in this file are dynamic (special) rather than -;; lexical. Without this, `(let ((vterm-timer-delay 0)) (vterm-send-string -;; ...))' creates a lexical binding that `vterm-send-string' (in vterm.el) -;; cannot see, so its `accept-process-output' still blocks on the global nil. -(defvar vterm-timer-delay) - -(defvar-keymap cj/vterm-map - :doc "Personal vterm command map.") -;; Lowercase x picked over V for fewer Shift presses; v is the VC menu. -(cj/register-prefix-map "x" cj/vterm-map) - -(defvar-local cj/vterm-tmux-history--origin-buffer nil - "Buffer active before opening the tmux history buffer.") - -(defvar-local cj/vterm-tmux-history--origin-window nil - "Window active before opening the tmux history buffer.") - -(defvar-local cj/vterm-tmux-history--origin-point nil - "Point in the origin buffer before opening the tmux history buffer.") - -(defun cj/vterm--tmux-output (&rest args) - "Run tmux with ARGS and return its stdout. -Signal `user-error' when tmux exits with a non-zero status." - (with-temp-buffer - (let ((exit-code (apply #'process-file "tmux" nil t nil args))) - (unless (zerop exit-code) - (user-error "tmux failed: %s" (string-trim (buffer-string)))) - (buffer-string)))) - -(defun cj/vterm--tmux-pane-id-for-tty (tty) - "Return the tmux pane id for client TTY." - (let* ((output (cj/vterm--tmux-output - "list-clients" "-F" "#{client_tty}\t#{pane_id}")) - (lines (split-string output "\n" t)) - (match (seq-find - (lambda (line) - (let ((fields (split-string line "\t"))) - (equal (car fields) tty))) - lines))) - (unless match - (user-error "No tmux client found for vterm tty %s" tty)) - (cadr (split-string match "\t")))) - -(defun cj/vterm--tmux-capture-pane (pane-id) - "Return full joined tmux history for PANE-ID." - (cj/vterm--tmux-output - "capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" pane-id)) - -(defun cj/vterm--current-tmux-pane-id () - "Return the tmux pane id for the current vterm buffer." - (unless (eq major-mode 'vterm-mode) - (user-error "Current buffer is not a vterm buffer")) - (let* ((proc (get-buffer-process (current-buffer))) - (tty (and proc (process-tty-name proc)))) - (unless (and tty (not (string-empty-p tty))) - (user-error "Could not determine vterm tty")) - (cj/vterm--tmux-pane-id-for-tty tty))) - -(defvar-keymap cj/vterm-tmux-history-mode-map - :doc "Keymap for `cj/vterm-tmux-history-mode'. -M-w copies the active region without leaving the buffer; C-g, , or q -returns to the vterm without copying. RET is left unbound." - "M-w" #'kill-ring-save - "C-g" #'cj/vterm-tmux-history-quit - "" #'cj/vterm-tmux-history-quit - "q" #'cj/vterm-tmux-history-quit) - -(define-derived-mode cj/vterm-tmux-history-mode special-mode "Tmux History" - "Mode for copying captured tmux pane history with normal Emacs keys." - (setq-local truncate-lines t) - (goto-address-mode 1)) - -(defun cj/vterm-tmux-history-quit () - "Quit tmux history and return to its origin buffer." - (interactive) - (let ((history-buffer (current-buffer)) - (origin-buffer cj/vterm-tmux-history--origin-buffer) - (origin-window cj/vterm-tmux-history--origin-window) - (origin-point cj/vterm-tmux-history--origin-point)) - (when (buffer-live-p origin-buffer) - (if (window-live-p origin-window) - (progn - (set-window-buffer origin-window origin-buffer) - (select-window origin-window)) - (pop-to-buffer origin-buffer)) - (with-current-buffer origin-buffer - (when (integer-or-marker-p origin-point) - (goto-char origin-point)))) - (when (buffer-live-p history-buffer) - (kill-buffer history-buffer)))) - -(defun cj/vterm-tmux-history () - "Open full tmux pane history in a temporary Emacs buffer. - -The history buffer uses normal Emacs navigation and selection. `M-w' -copies the active region and stays open, so several pieces can be -copied in a row; `q', `', or `C-g' returns point to the vterm -buffer that launched it. - -The history view replaces the origin vterm buffer in the same window -(via `switch-to-buffer'), not a split or a popped-up window -- reading -past output should keep the agent's frame slot intact, and quit puts -the live terminal back where it was." - (interactive) - (let* ((origin-buffer (current-buffer)) - (origin-window (selected-window)) - (origin-point (point)) - (pane-id (cj/vterm--current-tmux-pane-id)) - (history (cj/vterm--tmux-capture-pane pane-id)) - (buffer (get-buffer-create - (format "*vterm tmux history: %s*" (buffer-name origin-buffer))))) - (with-current-buffer buffer - (let ((inhibit-read-only t)) - (erase-buffer) - (insert history)) - (cj/vterm-tmux-history-mode) - (setq-local cj/vterm-tmux-history--origin-buffer origin-buffer) - (setq-local cj/vterm-tmux-history--origin-window origin-window) - (setq-local cj/vterm-tmux-history--origin-point origin-point) - (goto-char (point-max))) - (switch-to-buffer buffer))) - -(defun cj/vterm-copy-mode-cancel () - "Exit `vterm-copy-mode' without copying." - (interactive) - (unless (bound-and-true-p vterm-copy-mode) - (user-error "This command is effective only in vterm-copy-mode")) - (vterm-copy-mode -1)) - -(defun cj/vterm--in-tmux-p () - "Return non-nil when the current vterm has a tmux client attached. -Errors from the pane-id lookup (not in vterm-mode, no tty, no -matching client, tmux not installed) are treated as nil so callers -can use this as a cheap boolean predicate." - (and (eq major-mode 'vterm-mode) - (condition-case _ - (and (cj/vterm--current-tmux-pane-id) t) - (error nil)))) - -(declare-function vterm-send-string "vterm" (string &optional paste-p)) - -(defun cj/vterm-copy-mode-dwim () - "Enter copy-mode using the engine appropriate to this vterm. - -When tmux is attached to the current vterm, write tmux's default -prefix sequence (C-b [) into the pty so the user lands in tmux's -copy-mode with the full pane history (`history-limit', default -100000) available. The matching tmux keys in -`~/code/archsetup/dotfiles/common/.tmux.conf' mirror this module's -Emacs story: M-w copies and stays, C-g / q / exit, Enter -is unbound. - -Without tmux, falls through to `vterm-copy-mode' which walks only -vterm's own scrollback (effectively just the visible screen, -because tmux redraws via cursor positioning rather than scrolling -new lines through vterm's buffer)." - (interactive) - (if (cj/vterm--in-tmux-p) - (vterm-send-string "\C-b[") - (vterm-copy-mode))) - -(defun cj/vterm--send-mouse-wheel (button) - "Forward a wheel event to the program running in the current vterm. - -BUTTON is the SGR mouse button code: 64 for wheel up, 65 for wheel -down. X / Y coordinates are placeholders (1,1); tmux dispatches -`WheelUpPane' / `WheelDownPane' on the button code and ignores the -position when there is only one pane. - -vterm's keymap binds only `mouse-1' and `mouse-yank-primary' -- -wheel events fall through to Emacs's default scroll behavior, which -moves the window over vterm's scrollback instead of reaching the -pty. Without this forwarding, tmux's `set -g mouse on' never fires -because tmux never sees the events. - -`vterm-timer-delay' is locally pinned to 0 so -`vterm-send-string''s `accept-process-output' returns immediately. -With the buffer-local nil (`vterm-config' sets it for refresh -batching), `accept-process-output' blocks forever when the program -in the pty consumes the event without producing visible output -- -common for TUIs like Claude Code. Result before the pin: spinning -cursor until C-g, no actual scroll." - (let ((vterm-timer-delay 0)) - (vterm-send-string (format "\e[<%d;1;1M" button)))) - -(defun cj/vterm-mouse-wheel-up () - "Forward a wheel-up event to the program running in this vterm." - (interactive) - (cj/vterm--send-mouse-wheel 64)) - -(defun cj/vterm-mouse-wheel-down () - "Forward a wheel-down event to the program running in this vterm." - (interactive) - (cj/vterm--send-mouse-wheel 65)) - -(defun cj/vterm-send-escape () - "Send the ESC byte to the program running in this vterm. - -`' is bound globally to `keyboard-escape-quit' (see -`modules/keybindings.el'), so without this override Emacs swallows -the key before it can reach the pty. Forwarding it here lets tmux -copy-mode cancel, vi-mode exits, and any other in-terminal program -that relies on Escape see the key. - -`vterm-timer-delay' is locally pinned to 0; see -`cj/vterm--send-mouse-wheel' for the hang scenario this avoids." - (interactive) - (let ((vterm-timer-delay 0)) - (vterm-send-string "\e"))) - -(use-package vterm - :defer .5 - :commands (vterm vterm-other-window) - :init - (defvar vterm-keymap-exceptions - '("C-c" "C-x" "C-u" "C-g" "C-h" "C-l" "M-x" "M-o" "C-y" "M-y") - "Exceptions for `vterm-keymap'.") - (add-to-list 'vterm-keymap-exceptions "C-;") - (setq vterm-always-compile-module t) - - (defun cj/turn-off-chrome-for-vterm () - (hl-line-mode -1) - (display-line-numbers-mode -1)) - - (defun cj/vterm-launch-tmux () - "Automatically launch tmux in vterm if not already in a tmux session. - -Skipped when `cj/--ai-vterm-suppress-tmux' is non-nil so the AI-vterm -flow can run its own project-named tmux session instead of a bare, -auto-named one. `bound-and-true-p' keeps this safe whether or not -ai-vterm.el is loaded." - (let ((proc (get-buffer-process (current-buffer)))) - (when (and proc - (not (getenv "TMUX")) ; Check if not already in tmux - (not (bound-and-true-p cj/--ai-vterm-suppress-tmux))) - (vterm-send-string "tmux\n")))) - :hook - ((vterm-mode . cj/turn-off-chrome-for-vterm) - (vterm-mode . cj/vterm-launch-tmux)) - :bind - (:map vterm-mode-map - ("" . nil) - ("" . nil) - ("" . nil) - ("" . nil) - ("C-c C-t" . nil) - ("C-y" . vterm-yank) - ("" . cj/vterm-mouse-wheel-up) - ("" . cj/vterm-mouse-wheel-down) - ("" . cj/vterm-mouse-wheel-up) - ("" . cj/vterm-mouse-wheel-down) - ("" . cj/vterm-send-escape)) - :custom - (vterm-kill-buffer-on-exit t) - (vterm-max-scrollback 100000) - :config - (setq vterm-timer-delay nil)) - -;; vterm-toggle is kept installed so `M-x vterm-toggle' still works, -;; but F12 below is bound to a custom toggle (`cj/vterm-toggle') that -;; excludes agent-prefixed buffers from its candidate set. -(use-package vterm-toggle - :defer .5 - :config - (setq vterm-toggle-fullscreen-p nil)) - -;; ----------------------- F12 toggle (custom) ----------------------- -;; -;; Replacement for `vterm-toggle' on F12. Two reasons to roll our own: -;; -;; 1. agent exclusion. vterm-toggle picks the most-recently-selected -;; vterm buffer as the toggle target. When the user just used F9 -;; on an agent vterm, the most-recent vterm IS agent, so F12 ends -;; up toggling agent -- which has its own F9 / C-F9 / M-F9 surface -;; in `ai-vterm.el' and shouldn't be affected by F12. The agent -;; exclusion lives in the candidate filter (`cj/--vterm-toggle-buffer-p'). -;; -;; 2. user-modified geometry. vterm-toggle's display rule had a -;; hard-coded `(window-height . 0.7)' that overrode any mouse-resize -;; or M-S-t orientation flip on the next toggle. This module mirrors -;; the geometry-preservation pattern shipped in ai-vterm.el: capture -;; direction + body size at toggle-off, replay them via a custom -;; display action (`cj/--vterm-toggle-display-saved') that uses -;; frame-edge directions and `(body-columns . N)' / `(body-lines . N)' -;; so the result is divider-independent and layout-stable. - -(defcustom cj/vterm-toggle-window-height 0.7 - "Default fraction of frame height for the F12 vterm window. -Used as the size fallback when `cj/--vterm-toggle-last-size' is nil -(i.e. the user hasn't toggled off a vterm yet this session)." - :type 'number - :group 'vterm) - -(defvar cj/--vterm-toggle-last-direction nil - "Last user-chosen direction for the F12 vterm display. -Symbol: right, left, or below. `above' is never stored -- a top -placement falls back to `below' at capture time, so F12 never reopens -from the top. nil means use the default `below' for F12's traditional -bottom split.") - -(defvar cj/--vterm-toggle-last-size nil - "Last user-chosen body size for the F12 vterm display. -Positive integer: body-cols (right/left) or body-lines (below/above). -nil means fall back to `cj/vterm-toggle-window-height' as a fraction.") - -(defun cj/--vterm-toggle-buffer-p (buffer) - "Return non-nil when BUFFER is a vterm buffer F12 should manage. - -Qualifies when BUFFER is alive, has `vterm-mode' (or its name starts -with the vterm-toggle prefix), AND its name does NOT start with the -agent prefix used by ai-vterm.el. The agent exclusion keeps F12 -from grabbing buffers that ai-vterm.el's F9 dispatch owns." - (and (bufferp buffer) - (buffer-live-p buffer) - (with-current-buffer buffer - (and (or (eq major-mode 'vterm-mode) - (string-prefix-p (or (bound-and-true-p vterm-buffer-name) - "*vterm*") - (buffer-name buffer))) - (not (string-prefix-p "agent [" (buffer-name buffer))))))) - -(defun cj/--vterm-toggle-buffers () - "Return live F12-managed vterm buffers in `buffer-list' (MRU) order." - (seq-filter #'cj/--vterm-toggle-buffer-p (buffer-list))) - -(defun cj/--vterm-toggle-displayed-window (&optional frame) - "Return a window in FRAME currently displaying an F12 vterm buffer, or nil. -FRAME defaults to the selected frame. Minibuffer is excluded." - (seq-find (lambda (w) - (cj/--vterm-toggle-buffer-p (window-buffer w))) - (window-list (or frame (selected-frame)) 'never))) - -(defun cj/--vterm-toggle-capture-state (window) - "Capture WINDOW's direction + body size into module-level state. - -Default direction is `below' to match F12's traditional bottom -split when WINDOW fills the frame's root area." - (cj/window-toggle-capture-state - window 'below - 'cj/--vterm-toggle-last-direction - 'cj/--vterm-toggle-last-size - '(right below left))) - -(defun cj/--vterm-toggle-display-saved (buffer alist) - "Display-buffer action: split per saved direction and body size. - -Delegates to `cj/window-toggle-display-saved' against the F12 state -vars, falling back to `below' and `cj/vterm-toggle-window-height'." - (cj/window-toggle-display-saved - buffer alist - 'cj/--vterm-toggle-last-direction 'below - 'cj/--vterm-toggle-last-size cj/vterm-toggle-window-height)) - -(defun cj/--vterm-toggle-display-rule-list () - "Return the `display-buffer-alist' entry list installed by F12. - -Routes any vterm buffer that satisfies `cj/--vterm-toggle-buffer-p' -through two actions: reuse-window (for visible vterm windows) then -the saved-geometry display action. Excludes agent buffers via the -predicate -- those are handled by ai-vterm.el's display rule." - '(((lambda (buffer-or-name _) - (cj/--vterm-toggle-buffer-p (get-buffer buffer-or-name))) - (display-buffer-reuse-window - cj/--vterm-toggle-display-saved) - (inhibit-same-window . t)))) - -(dolist (entry (cj/--vterm-toggle-display-rule-list)) - (add-to-list 'display-buffer-alist entry)) - -(defun cj/--vterm-toggle-dispatch () - "Compute the F12 (`cj/vterm-toggle') action without performing it. - -Returns one of: -- (toggle-off . WINDOW) -- vterm displayed in WINDOW; hide it. -- (show-recent . BUFFER) -- vterm alive but not shown; redisplay. -- (create-new) -- no vterm buffer alive; create one." - (let ((win (cj/--vterm-toggle-displayed-window))) - (cond - (win (cons 'toggle-off win)) - (t - (let ((buffers (cj/--vterm-toggle-buffers))) - (cond - (buffers (cons 'show-recent (car buffers))) - (t '(create-new)))))))) - -(declare-function vterm "vterm" (&optional buffer-name)) - -(defun cj/vterm-toggle () - "Toggle a normal (non-agent) vterm buffer. - -- If an F12-managed vterm is currently displayed in this frame, - capture its geometry and delete its window (toggle off). Falls - back to burying the buffer when the vterm is the only window in - the frame. -- Otherwise, if any F12-managed vterm buffer is alive, display the - most-recent one via the saved-geometry action. -- Otherwise, create a new vterm via `(vterm)' which routes through - the same display action. - -Excludes agent-prefixed vterm buffers; those have their own F9 / -C-F9 / M-F9 dispatch via `cj/ai-vterm'." - (interactive) - (pcase (cj/--vterm-toggle-dispatch) - (`(toggle-off . ,win) - (cj/--vterm-toggle-capture-state win) - (if (one-window-p) - (bury-buffer (window-buffer win)) - (delete-window win)) - nil) - (`(show-recent . ,buf) - (display-buffer buf) - (let ((w (get-buffer-window buf))) - (when w (select-window w))) - buf) - (`(create-new) - (vterm)))) - -(keymap-global-set "" #'cj/vterm-toggle) - -(keymap-set cj/vterm-map "c" #'cj/vterm-copy-mode-dwim) -(keymap-set cj/vterm-map "h" #'cj/vterm-tmux-history) -(keymap-set cj/vterm-map "l" #'vterm-clear-scrollback) -(keymap-set cj/vterm-map "N" #'vterm) -(keymap-set cj/vterm-map "n" #'vterm-next-prompt) -(keymap-set cj/vterm-map "o" #'vterm-other-window) -(keymap-set cj/vterm-map "p" #'vterm-previous-prompt) -(keymap-set cj/vterm-map "q" #'vterm-send-next-key) -(keymap-set cj/vterm-map "r" #'vterm-reset-cursor-point) -(keymap-set cj/vterm-map "t" #'cj/vterm-toggle) - -(defun cj/vterm-install-prefix-key () - "Make `C-;' resolve as the personal keymap inside vterm buffers." - (when (boundp 'vterm-mode-map) - (keymap-set vterm-mode-map "C-;" cj/custom-keymap))) - -(defun cj/vterm-install-copy-mode-cancel-keys () - "Install copy and exit keys in `vterm-copy-mode-map'. - -`M-w' copies the active region without leaving copy-mode, so several -pieces can be copied in a row. `C-g', `', and `q' all leave -copy-mode without copying. vterm's default `RET' / `' -> -`vterm-copy-mode-done' bindings are removed so RET isn't a special -\"copy and exit\" -- matching the tmux history buffer." - (when (boundp 'vterm-copy-mode-map) - (keymap-set vterm-copy-mode-map "M-w" #'kill-ring-save) - (keymap-set vterm-copy-mode-map "C-g" #'cj/vterm-copy-mode-cancel) - (keymap-set vterm-copy-mode-map "" #'cj/vterm-copy-mode-cancel) - (keymap-set vterm-copy-mode-map "q" #'cj/vterm-copy-mode-cancel) - (keymap-unset vterm-copy-mode-map "RET" t) - (keymap-unset vterm-copy-mode-map "" t))) - -(cj/vterm-install-prefix-key) -(cj/vterm-install-copy-mode-cancel-keys) -(with-eval-after-load 'vterm - (cj/vterm-install-prefix-key) - (cj/vterm-install-copy-mode-cancel-keys)) - -(defun cj/--vterm-copy-mode-restore-cursor () - "Force a visible cursor on entry to `vterm-copy-mode'. - -The vterm C module sets `cursor-type' to nil whenever the underlying -TUI sends DECTCEM (`\\e[?25l') to hide the terminal cursor — typical -for full-screen TUIs like Claude Code. In `vterm-copy-mode' the user -is navigating the buffer, not watching the TUI, so the cursor must -be visible. Switches to a `box' so the cursor color and blinking -behavior follow Emacs's normal cursor-face / `blink-cursor-mode' -defaults. On exit, kills the buffer-local override so vterm's normal -cursor-visibility tracking resumes." - (if vterm-copy-mode - (setq-local cursor-type 'box) - (kill-local-variable 'cursor-type))) - -(add-hook 'vterm-copy-mode-hook #'cj/--vterm-copy-mode-restore-cursor) - -(add-hook 'vterm-mode-hook #'goto-address-mode) - -(with-eval-after-load 'which-key - (which-key-add-key-based-replacements - "C-; x" "vterm menu" - "C-; x c" "copy mode (tmux/vterm)" - "C-; x h" "tmux scrollback history" - "C-; x l" "clear vterm scrollback" - "C-; x N" "new vterm" - "C-; x n" "next prompt" - "C-; x o" "vterm other window" - "C-; x p" "previous prompt" - "C-; x q" "send next key to vterm" - "C-; x r" "reset vterm cursor point" - "C-; x t" "toggle vterm")) - -(provide 'vterm-config) -;;; vterm-config.el ends here. -- cgit v1.2.3