aboutsummaryrefslogtreecommitdiff
path: root/modules/ai-term.el
diff options
context:
space:
mode:
Diffstat (limited to 'modules/ai-term.el')
-rw-r--r--modules/ai-term.el279
1 files changed, 133 insertions, 146 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el
index b463da90b..6dfb669a9 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -1,4 +1,4 @@
-;;; ai-term.el --- In-Emacs AI-agent launcher with vertical-split terminal -*- lexical-binding: t; -*-
+;;; ai-term.el --- AI-agent terminals backed by EAT and tmux -*- lexical-binding: t; -*-
;; Author: Craig Jennings <c@cjennings.net>
@@ -7,70 +7,18 @@
;; 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.
+;; Eager reason: binds M-SPC and the C-; a AI-agent prefix.
+;; Top-level side effects: global M-SPC binding and C-; a prefix map.
+;; Runtime requires: cl-lib, seq, window-toggle/geometry helpers, host-environment.
;; Direct test load: yes.
;;
-;; Picks an AI-agent project (a dir under ~/.emacs.d, ~/code/*, or
-;; ~/projects/* containing .ai/protocols.org), opens or reuses a terminal
-;; buffer named "agent [<basename>]", sends the agent's startup
-;; instruction to it, and routes the buffer to a side window via
-;; display-buffer-alist. When the frame already has a window forming the
-;; half the agent would occupy (a right column on a desktop, a bottom row
-;; on a laptop), the agent reuses that slot rather than splitting a third
-;; window in; toggling off restores the displaced buffer to the slot.
-;; Otherwise placement is a host-aware split: a right-side split at 50%
-;; width on a desktop, a bottom split at 75% height on a laptop (see
-;; `cj/--ai-term-default-direction'). Multiple
-;; projects produce multiple coexisting buffers that share the same
-;; slot; switching among them is a buffer-switch, not a
-;; kill-and-recreate.
+;; Opens project-scoped AI agents in EAT buffers backed by tmux sessions. Project
+;; candidates come from configured roots that contain .ai/protocols.org.
;;
-;; Each project's agent runs inside a tmux session named
-;; "<cj/ai-term-tmux-session-prefix><basename>" (default prefix "aiv-").
-;; The prefix lets `tmux ls' be filtered to AI-term's own sessions, so
-;; after an Emacs crash the project picker can match surviving sessions
-;; back to their directories: matched projects sort to the top of the
-;; picker (flagged "[detached]" -- session alive, no Emacs buffer -- or
-;; "[running]" when a live terminal buffer exists), the rest follow in
-;; alphabetical order.
-;;
-;; Four F-key entry points:
-;;
-;; - F9 `cj/ai-term' -- DWIM dispatch. If an agent buffer is
-;; currently displayed in this frame, F9 toggles it off: when it
-;; took over an existing window (a reused slot) the buffer it
-;; displaced returns to that slot, when it was split into its own
-;; window that window is removed, and when it fills the frame it
-;; is buried. Otherwise, if exactly one agent buffer is alive,
-;; F9 re-displays it; if zero or two-plus are alive, F9 falls
-;; through to the project picker.
-;; - C-F9 `cj/ai-term-pick-project' -- always show the project
-;; picker, even when an agent buffer is currently displayed.
-;; Used when the user wants to start a new project session
-;; instead of toggling the current one.
-;; - s-F9 `cj/ai-term-next' -- step to the next active agent in the
-;; queue. The queue is every active agent in buffer-name order
-;; (a stable rotation): attached agents (a live buffer) and
-;; detached ones (a live tmux session with no Emacs buffer).
-;; Stepping onto a detached agent attaches it. When an agent
-;; window is on screen, swap it to the next agent and focus it,
-;; wrapping after the last; when none is shown but agents exist,
-;; show the first. This is the "switch among existing agents"
-;; surface F9 deliberately doesn't provide.
-;; - M-F9 `cj/ai-term-close' -- gracefully close an agent: kill its
-;; tmux session (stopping the agent process), then its terminal
-;; buffer. Its window stays in the layout (swapped to the
-;; working buffer), so closing never collapses a split. Confirms
-;; first. Targets the current agent, the sole live agent, or
-;; prompts among several.
-;;
-;; Existing windmove (Shift-arrows) handles code <-> agent focus
-;; toggling. Buffer-move (C-M-arrows) handles side-swap. Neither
-;; needs anything new from this module.
+;; Agent display reuses the host-appropriate side slot when possible, otherwise
+;; splits right on desktop frames and below on laptop frames. Attached buffers
+;; and detached tmux sessions share the same rotation; selecting a detached
+;; agent recreates its EAT buffer and attaches to the live session.
;;; Code:
@@ -81,16 +29,13 @@
(require 'host-environment)
(require 'keybindings) ;; provides cj/register-prefix-map (C-; a)
-(declare-function ghostel "ghostel" (&optional arg))
-(declare-function ghostel-send-string "ghostel" (string))
-(declare-function ghostel--rebuild-semi-char-keymap "ghostel" ())
-(defvar ghostel-keymap-exceptions)
-(defvar ghostel-mode-map)
-(defvar ghostel-buffer-name)
-(defvar ghostel-buffer-name-function)
+(declare-function eat "eat" (&optional program arg))
+(declare-function cj/make-buffer-pattern-undead "undead-buffers")
+(defvar eat-buffer-name)
+(defvar eat-semi-char-mode-map)
(defgroup ai-term nil
- "In-Emacs AI-agent launcher with a vertical-split ghostel terminal."
+ "In-Emacs AI-agent launcher with a vertical-split EAT terminal."
:group 'tools)
(defcustom cj/ai-term-agent-command
@@ -102,15 +47,6 @@ 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.
@@ -228,7 +164,7 @@ which the step materializes by attaching."
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
+with a space). Used by the single-window toggle-off so dismissing a
full-frame agent returns to the file the user was working in (e.g.
todo.org) rather than swapping in another agent."
(seq-find (lambda (b)
@@ -300,7 +236,7 @@ looked up in SESSIONS, so the lossy whitespace->hyphen transform in
(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
+Uses `tmux new-session -A' so a second toggle on the same project reattaches
to the running session instead of spawning a new one. The session name
comes from `cj/--ai-term-tmux-session-name'; the first window is named
`cj/ai-term-tmux-window-name' (default \"ai\") so a later hand-opened
@@ -465,7 +401,7 @@ direction applies. Captured at toggle-off 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'.
+ "Non-nil when the last toggle-off used `bury-buffer'.
Set by `cj/ai-term' in its `toggle-off' branch: t when the agent
window was the only window in the frame (so toggle-off buried
@@ -475,7 +411,7 @@ buried agent in the current window (the only one) or splitting per
the saved direction.")
(defvar cj/--ai-term-last-toggle-deleted-split nil
- "Non-nil when the last F9 toggle-off deleted the agent's own split window.
+ "Non-nil when the last toggle-off deleted the agent's own split window.
Set t by `cj/--ai-term-toggle-off' only when it actually `delete-window's
the agent (a multi-window layout where the agent had its own window);
@@ -487,7 +423,7 @@ working window at the edge, displacing its buffer and collapsing the layout
-- the toggle must be reversible (off then on returns the same windows).")
(defvar cj/--ai-term-last-hidden-buffer nil
- "The agent buffer hidden by the most recent F9 toggle-off.
+ "The agent buffer hidden by the most recent toggle-off.
Captured in `cj/ai-term' just before an agent window is torn down, and
consumed by `cj/--ai-term-dispatch' so the next toggle-on reopens the
@@ -529,11 +465,24 @@ and a fraction-of-frame produces the wrong size on replay
(squeezes the other windows). An integer is unambiguous, at the
cost of not auto-scaling if the frame itself resizes.")
+(defvar cj/--ai-term-last-fullscreen nil
+ "Non-nil when the agent window was last seen filling its frame.
+
+Maintained by `cj/--ai-term-track-geometry' on
+`window-configuration-change-hook': set t whenever a live agent window is
+the sole window in its frame, cleared when the agent is shown as a split
+\(its dock direction and size are captured then instead). Consulted by
+`cj/--ai-term-display-saved' so a summon into a single-window frame
+restores the agent fullscreen rather than docking it -- the sole-window
+state isn't a representable dock size, so this flag is how it round-trips.
+Unlike `cj/--ai-term-last-was-bury' it does not depend on a toggle-off, so
+it also covers leaving the agent by switching buffers or `C-x 1'.")
+
(defun cj/--ai-term-capture-state (window)
"Capture WINDOW's direction and size into module-level state.
Sets `cj/--ai-term-last-direction' and `cj/--ai-term-last-size'
-so a subsequent F9 display can restore the user's chosen orientation
+so a subsequent display can restore the user's chosen orientation
and size. Called at toggle-off (just before the window is torn
down). The default direction is host-aware via
`cj/--ai-term-default-direction' (used only when WINDOW fills its
@@ -545,6 +494,35 @@ is not live."
'cj/--ai-term-last-size
'(right below left)))
+(defun cj/--ai-term-window-sole-p (window)
+ "Return non-nil when WINDOW is the only live window in its frame.
+A frame's sole window is its root window; once split, the root is an
+internal window and no live window equals it."
+ (and (window-live-p window)
+ (eq window (frame-root-window (window-frame window)))))
+
+(defun cj/--ai-term-track-geometry (&rest _)
+ "Track whether the displayed agent window is fullscreen.
+
+Run from `window-configuration-change-hook'. Sets
+`cj/--ai-term-last-fullscreen' to whether a live agent window is the sole
+window in its frame, and leaves it untouched when no agent window is
+displayed -- that retained value is the just-left state a later summon
+replays. Dock direction and size stay owned by the toggle-off capture
+\(`cj/--ai-term-capture-state'); this hook must not re-capture them, or the
+repeated capture/replay drifts the dock height a couple rows per cycle."
+ (let ((win (cj/--ai-term-displayed-agent-window)))
+ (when (window-live-p win)
+ (setq cj/--ai-term-last-fullscreen (cj/--ai-term-window-sole-p win)))))
+
+(add-hook 'window-configuration-change-hook #'cj/--ai-term-track-geometry)
+
+;; Agent buffers ("agent [<project>]") are buried, not killed, by the
+;; kill-all sweep (F1 / `cj/dashboard-only'). Register the family pattern so
+;; every agent -- however and whenever created -- survives with its session.
+(with-eval-after-load 'undead-buffers
+ (cj/make-buffer-pattern-undead "\\`agent \\["))
+
(defun cj/--ai-term-reuse-existing-agent (buffer _alist)
"Display-buffer action: reuse any window in this frame already showing
an agent buffer.
@@ -557,7 +535,7 @@ 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
+swaps projects via C-; a s. The selective lookup here keeps non-agent
windows undisturbed and preserves the user's split geometry across
project changes."
(let ((win (cj/--ai-term-displayed-agent-window)))
@@ -605,19 +583,27 @@ keeping the toggle reversible."
win))))
(defun cj/--ai-term-display-saved (buffer alist)
- "Display-buffer action: split per saved direction and size.
+ "Display-buffer action: restore fullscreen in a single-window frame,
+otherwise 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.
+When the frame is a single window and the agent was last fullscreen
+\(`cj/--ai-term-last-fullscreen', tracked by `cj/--ai-term-track-geometry')
+or the prior toggle-off was a single-window bury
+\(`cj/--ai-term-last-was-bury'), restore the agent into the selected window
+in place rather than splitting. This round-trips a fullscreen agent --
+left by toggle-off, `C-x 1', or switching buffers -- since the sole-window
+state isn't a representable dock size.
Otherwise delegates to `cj/window-toggle-display-saved' against the
-F9 state vars, falling back to the host-aware defaults from
+toggle 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))
+ ;; NOMINI t: don't count an active minibuffer as a second window. A summon
+ ;; can run with a picker prompt up, and a bare `one-window-p' then returns
+ ;; nil on a structurally single-window frame, misfiring the fullscreen
+ ;; restore into a dock -- which clears the fullscreen flag and cascades.
+ ((and (or cj/--ai-term-last-fullscreen cj/--ai-term-last-was-bury)
+ (one-window-p t))
(setq cj/--ai-term-last-was-bury nil)
(let ((win (selected-window)))
(set-window-buffer win buffer)
@@ -640,7 +626,7 @@ through four actions in order:
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).
+ project changes via C-; a s).
3. `cj/--ai-term-reuse-edge-window' -- otherwise, if the frame
already has a window forming the half the agent would occupy
(the right column on a desktop, the bottom row on a laptop),
@@ -669,19 +655,26 @@ split) when the user is focused in agent and switches projects."
(dolist (entry (cj/--ai-term-display-rule-list))
(add-to-list 'display-buffer-alist entry))
+(defun cj/--ai-term-send-string (buffer string)
+ "Send STRING to BUFFER's terminal process (the agent's shell).
+Sends to the pty directly so the launch command reaches the shell EAT runs."
+ (let ((proc (get-buffer-process buffer)))
+ (when (process-live-p proc)
+ (process-send-string proc string))))
+
(defun cj/--ai-term-show-or-create (dir name)
"Show or create the AI-term buffer for project DIR with buffer NAME.
If a buffer named NAME exists with a live process, display it. If
the buffer exists but its process is dead, kill it and recreate. If
-no such buffer exists, create a new ghostel terminal in DIR and send
+no such buffer exists, create a new EAT terminal in DIR and send
the project's tmux launch command (see `cj/--ai-term-launch-command') so
the same project basename reattaches across Emacs restarts.
-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.
+EAT runs a plain shell with no auto-tmux hook, so the named
+`tmux new-session -A' launch command is the only thing that starts the
+session -- the spike confirmed EAT + tmux detach and reattach exactly
+like ghostel + tmux did.
Records DIR in `cj/--ai-term-mru' (whichever branch runs) so the
project picker can list recently-opened projects first. Returns the
@@ -695,28 +688,22 @@ buffer."
(t
(when existing
(kill-buffer existing))
- ;; `ghostel' switches to its buffer in the selected window before our
+ ;; `eat' switches to its buffer in the selected window before our
;; display-buffer-alist rule can route it; `save-window-excursion'
;; reverts that, and the explicit display-buffer below routes the buffer
- ;; through the alist into the agent slot. `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.
+ ;; through the alist into the agent slot. `eat-buffer-name' is bound to
+ ;; NAME so the terminal is created under the agent name; EAT (unlike
+ ;; ghostel) does not rename the buffer from the terminal's OSC title, so
+ ;; the "agent [" prefix that buffer detection and the display rule key on
+ ;; stays put.
(save-window-excursion
(let ((default-directory dir)
- (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))))))
+ (eat-buffer-name name))
+ (eat)))
(let ((buf (get-buffer name)))
(with-current-buffer buf
- (ghostel-send-string (cj/--ai-term-launch-command dir))
- (ghostel-send-string "\n"))
+ (cj/--ai-term-send-string
+ buf (concat (cj/--ai-term-launch-command dir) "\n")))
(display-buffer buf)
buf)))))
@@ -785,17 +772,17 @@ Signals `user-error' when no candidates exist."
(expand-file-name chosen)))))
(defun cj/--ai-term-dispatch ()
- "Compute the F9 (`cj/ai-term') action without performing it.
+ "Compute the `cj/ai-term' (C-; a a) 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
+When 2+ agent buffers are alive, C-; a a redisplays the most-recently-
+selected one rather than opening the project picker. C-; a s is the
+explicit \"start a different project\" surface; C-; a n is the explicit
+\"switch among existing agents\" surface. C-; a a keeps a single, simple
job: toggle whichever agent was last in use.
A pure-decision helper so the dispatch logic is exercisable in tests
@@ -818,7 +805,7 @@ without firing real `display-buffer' or `quit-window' calls."
(t '(pick-project))))))))
(defun cj/ai-term-pick-project (&optional arg)
- "Pick an AI-agent project and open or reuse its ghostel terminal.
+ "Pick an AI-agent project and open or reuse its EAT terminal.
The project is picked from a filtered completing-read list of dirs
that contain .ai/protocols.org. The terminal buffer is named
@@ -828,11 +815,11 @@ 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
+Bound to C-; a s -- 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)."
+EAT renders in terminal frames as well as GUI frames, so this
+launches from either."
(interactive "P")
(let* ((dir (cj/--ai-term-pick-project))
(name (cj/--ai-term-buffer-name dir))
@@ -854,7 +841,7 @@ the agent itself."
(other-buffer (window-buffer win) t)))))
(defun cj/--ai-term-toggle-off (win)
- "Hide the agent shown in WIN for an F9 toggle-off. Always returns nil.
+ "Hide the agent shown in WIN for a toggle-off. Always returns nil.
Two cases, by window count:
@@ -867,7 +854,7 @@ Two cases, by window count:
force a swap to a non-agent buffer to keep the toggle observable.
- Multi-window: collapse the agent split outright by deleting its window, so
- the working buffer (e.g. todo.org) reclaims the space. F9 is a pure
+ the working buffer (e.g. todo.org) reclaims the space. The toggle is a pure
show/hide toggle of THE agent split -- it must never surface a different
agent. `quit-restore-window' can't guarantee that here: switching among
several agents reuses the one slot via `set-window-buffer' (see
@@ -909,21 +896,21 @@ Two cases, by window count:
nil)
(defun cj/ai-term (&optional arg)
- "Smart F9 dispatch for the AI-term launcher.
+ "DWIM dispatch for the AI-term launcher. Bound to C-; a a.
Behavior depends on the current state:
-- If an AI-term buffer is currently displayed in this frame, F9
+- If an AI-term buffer is currently displayed in this frame, it
quits its window (toggle off, buffer stays alive).
-- Else, if exactly one alive AI-term buffer exists, F9 re-displays
+- Else, if exactly one alive AI-term buffer exists, it 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'.
+- Else (zero or 2+), it 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 closes an agent via `cj/ai-term-close'."
+See `cj/ai-term-pick-project' (C-; a s) to force the project picker.
+C-; a k closes an agent via `cj/ai-term-close'."
(interactive "P")
(pcase (cj/--ai-term-dispatch)
(`(toggle-off . ,win)
@@ -957,7 +944,7 @@ 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. When BUFFER is shown, swaps its window to a non-agent
buffer (the working file) rather than deleting the window -- closing an
-agent must not collapse the user's window layout; the F9 hide toggle is
+agent must not collapse the user's window layout; the hide toggle is
what collapses the split. Then kills BUFFER (suppressing the
process-still-running prompt -- the session is already down). No-op
when BUFFER isn't an AI-term buffer."
@@ -971,6 +958,8 @@ when BUFFER isn't an AI-term buffer."
(let ((kill-buffer-query-functions nil))
(kill-buffer buffer))))
+(require 'system-lib)
+
(defun cj/--ai-term-close-target ()
"Return the AI-term buffer `cj/ai-term-close' should act on, or nil.
@@ -985,7 +974,8 @@ buffers; nil when none are alive."
((null (cdr buffers)) (car buffers))
(t (get-buffer
(completing-read "Close AI terminal: "
- (mapcar #'buffer-name buffers) nil t))))))))
+ (cj/completion-table 'buffer (mapcar #'buffer-name buffers))
+ nil t))))))))
(defun cj/ai-term-close ()
"Gracefully close an AI-term agent: kill its tmux session and buffer.
@@ -993,7 +983,7 @@ buffers; nil when none are alive."
Targets the current agent buffer, the sole live agent, or prompts when
several are alive (see `cj/--ai-term-close-target'). Asks for
confirmation first -- this kills the running agent process, which can
-interrupt work in progress. Bound to M-<f9>."
+interrupt work in progress. Bound to C-; a k."
(interactive)
(let ((buffer (cj/--ai-term-close-target)))
(unless buffer
@@ -1067,16 +1057,13 @@ picker and C-; a k closes an agent."
"C-; a k" "kill agent"
"M-SPC" "ai-term: next agent"))
-;; In ghostel's semi-char mode, keys not in `ghostel-keymap-exceptions' are
-;; forwarded to the pty, and `ghostel-semi-char-mode-map' outranks the major
-;; mode map. M-SPC (swap to the next agent) must reach Emacs from inside an
-;; agent buffer, so add it to the exceptions, rebuild the semi-char map, and
-;; bind it in `ghostel-mode-map'. C-; is already an exception (term-config),
-;; so the C-; a family resolves through the global prefix without extra wiring.
-(with-eval-after-load 'ghostel
- (keymap-set ghostel-mode-map "M-SPC" #'cj/ai-term-next)
- (add-to-list 'ghostel-keymap-exceptions "M-SPC")
- (ghostel--rebuild-semi-char-keymap))
+;; In EAT's semi-char mode, keys not bound in `eat-semi-char-mode-map' are
+;; forwarded to the pty. M-SPC (swap to the next agent) must reach Emacs from
+;; inside an agent buffer, so bind it in that map -- no exception-list or rebuild
+;; dance like ghostel needed. C-; is already bound there (eat-config), so the
+;; C-; a family resolves through the global prefix without extra wiring.
+(with-eval-after-load 'eat
+ (keymap-set eat-semi-char-mode-map "M-SPC" #'cj/ai-term-next))
;; ------------------- Wrap-it-up teardown + shutdown -------------------------
;;