aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-25 23:16:17 -0400
committerCraig Jennings <c@cjennings.net>2026-06-25 23:16:17 -0400
commit6c8f2a9cb02514791dc54af7d50d5797882b34b2 (patch)
tree406e84b88f4d6d67395385bf8226ebd6416b471b
parent3f0a979501067d72bcd5e6b86fa33cc3ba3ea9dd (diff)
downloaddotemacs-6c8f2a9cb02514791dc54af7d50d5797882b34b2.tar.gz
dotemacs-6c8f2a9cb02514791dc54af7d50d5797882b34b2.zip
feat(ai-term): run agents through EAT instead of ghostel
Port ai-term from ghostel to EAT. Agents spawn in an EAT terminal running the same tmux session (tmux new-session -A -s aiv-<project>), so the persistence and detach/reattach model is unchanged. A spike confirmed EAT + tmux detach and reattach exactly like ghostel + tmux. The swaps: (ghostel) becomes (eat) with eat-buffer-name carrying the agent name, ghostel-send-string becomes a process-send-string helper, and the M-SPC swap chord is bound directly in eat-semi-char-mode-map (no exception-list plus rebuild dance). Buffer detection was already name-based, so the dispatch, next, and cycle logic is unchanged. Dropped the now-unused suppress-tmux variable. Tests updated to mock eat.
-rw-r--r--init.el2
-rw-r--r--modules/ai-term.el87
-rw-r--r--tests/test-ai-term--keybindings.el28
-rw-r--r--tests/test-ai-term--show-or-create.el98
4 files changed, 96 insertions, 119 deletions
diff --git a/init.el b/init.el
index f50c1fb8f..dc09b085e 100644
--- a/init.el
+++ b/init.el
@@ -80,7 +80,7 @@
(require 'eshell-config) ;; emacs shell configuration
(require 'eat-config) ;; EAT terminal + the F12 dock-and-remember toggle
(require 'term-config) ;; ghostel (ai-term backend) + tmux history copy
-(require 'ai-term) ;; in-Emacs Claude launcher (vertical-split ghostel)
+(require 'ai-term) ;; in-Emacs Claude launcher (vertical-split EAT terminal)
(require 'help-utils) ;; search: arch-wiki, devdoc, tldr, wikipedia
(require 'help-config) ;; info, man, help config
(require 'face-diagnostic) ;; describe face/font at point (cj/describe-face-at-point)
diff --git a/modules/ai-term.el b/modules/ai-term.el
index b463da90b..3beabe6b5 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -81,16 +81,12 @@
(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))
+(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 +98,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.
@@ -669,19 +656,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 +689,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)))))
@@ -818,7 +806,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
@@ -831,8 +819,8 @@ 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)."
+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))
@@ -1067,16 +1055,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 -------------------------
;;
diff --git a/tests/test-ai-term--keybindings.el b/tests/test-ai-term--keybindings.el
index a8b92ffa8..6f7f53a5e 100644
--- a/tests/test-ai-term--keybindings.el
+++ b/tests/test-ai-term--keybindings.el
@@ -4,12 +4,11 @@
;; ai-term lives under the C-; a prefix (vacated when gptel was archived), with
;; the frequent "swap to the next agent" also on M-SPC for a fast chord. M-SPC
;; must reach Emacs from inside an agent buffer, so it is bound in
-;; `ghostel-mode-map' and added to `ghostel-keymap-exceptions' (the semi-char
-;; map otherwise forwards it to the pty). C-; is already an exception via
-;; term-config, so the C-; a family resolves through the global prefix. These
-;; tests require ghostel (so ai-term's `with-eval-after-load' fires) before
-;; ai-term, then confirm the bindings landed and the old F9 family is gone.
-;; `(require 'ghostel)' does not load the native module, so this stays light.
+;; `eat-semi-char-mode-map' (EAT forwards unbound keys to the pty otherwise).
+;; C-; is already bound there via eat-config, so the C-; a family resolves
+;; through the global prefix. These tests require eat (so ai-term's
+;; `with-eval-after-load' fires) before ai-term, then confirm the bindings
+;; landed and the old F9 family is gone.
;;; Code:
@@ -19,7 +18,7 @@
(setq package-user-dir (expand-file-name "elpa" user-emacs-directory))
(package-initialize)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'ghostel)
+(require 'eat)
(require 'ai-term)
(ert-deftest test-ai-term-keymap-leaf-bindings ()
@@ -37,16 +36,11 @@
"Normal: M-SPC runs `cj/ai-term-next' (the fast swap chord)."
(should (eq (lookup-key (current-global-map) (kbd "M-SPC")) #'cj/ai-term-next)))
-(ert-deftest test-ai-term-meta-space-bound-in-ghostel-mode-map ()
- "Normal: M-SPC is bound in `ghostel-mode-map' so swap works inside an agent."
- (should (eq (keymap-lookup ghostel-mode-map "M-SPC") #'cj/ai-term-next)))
-
-(ert-deftest test-ai-term-meta-space-in-keymap-exceptions ()
- "Regression: M-SPC is in `ghostel-keymap-exceptions' so semi-char mode lets it
-reach Emacs instead of forwarding it to the pty."
- (should (member "M-SPC" ghostel-keymap-exceptions))
- (should-not (eq (keymap-lookup ghostel-semi-char-mode-map "M-SPC")
- 'ghostel--send-event)))
+(ert-deftest test-ai-term-meta-space-bound-in-eat-semi-char-mode-map ()
+ "Normal: M-SPC is bound in `eat-semi-char-mode-map' so swap works inside an
+agent. EAT forwards unbound keys to the pty, so the bind is what lets it reach
+Emacs -- no ghostel-style exception list or rebuild is needed."
+ (should (eq (keymap-lookup eat-semi-char-mode-map "M-SPC") #'cj/ai-term-next)))
(ert-deftest test-ai-term-f9-family-removed-globally ()
"Regression: the old F9 family no longer binds the ai-term commands globally."
diff --git a/tests/test-ai-term--show-or-create.el b/tests/test-ai-term--show-or-create.el
index c6653dcdd..4f5f1f67f 100644
--- a/tests/test-ai-term--show-or-create.el
+++ b/tests/test-ai-term--show-or-create.el
@@ -3,13 +3,13 @@
;;; Commentary:
;; Tests the show-or-create branching:
;;
-;; - buffer absent -> ghostel called, agent command + newline sent
-;; - buffer present, live -> ghostel not called, buffer displayed
-;; - buffer present, dead -> old buffer killed, ghostel recreates
+;; - buffer absent -> eat called, agent command + newline sent
+;; - buffer present, live -> eat not called, buffer displayed
+;; - buffer present, dead -> old buffer killed, eat recreates
;;
-;; ghostel functions are stubbed so the test does no process spawning and
-;; never loads the native module. Production calls (ghostel) with no name and
-;; relies on the dynamically bound `ghostel-buffer-name'; the mock honors that.
+;; eat + the send helper are stubbed so the test does no process spawning.
+;; Production calls (eat) and relies on the dynamically bound `eat-buffer-name';
+;; the mock honors that.
;;; Code:
@@ -19,19 +19,17 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'ai-term)
-;; ghostel isn't loaded in batch -- provide stubs so cl-letf has overrides.
-(unless (fboundp 'ghostel)
- (defun ghostel (&optional _arg) nil))
-(unless (fboundp 'ghostel-send-string)
- (defun ghostel-send-string (_s) nil))
+;; eat isn't loaded in batch -- provide a stub so cl-letf has an override.
+(unless (fboundp 'eat)
+ (defun eat (&optional _program _arg) nil))
-(defmacro test-ai-term--with-mock-ghostel (vars &rest body)
- "Run BODY with ghostel + ghostel-send-string mocked.
+(defmacro test-ai-term--with-mock-eat (vars &rest body)
+ "Run BODY with eat + `cj/--ai-term-send-string' mocked.
-VARS is a plist of capture variable names: :calls (buffer names ghostel
-was asked to create), :strings (sent strings), :default-dir. The mocked
-`ghostel' creates and returns a buffer named after the dynamically bound
-`ghostel-buffer-name', mirroring the real entry point."
+VARS is a plist of capture variable names: :calls (buffer names eat was asked
+to create), :strings (sent strings), :default-dir. The mocked `eat' creates
+and returns a buffer named after the dynamically bound `eat-buffer-name',
+mirroring the real entry point."
(declare (indent 1) (debug t))
(let ((calls (plist-get vars :calls))
(strings (plist-get vars :strings))
@@ -39,14 +37,14 @@ was asked to create), :strings (sent strings), :default-dir. The mocked
`(let ((,calls '())
(,strings '())
(,ddir nil))
- (cl-letf (((symbol-function 'ghostel)
- (lambda (&optional _arg)
+ (cl-letf (((symbol-function 'eat)
+ (lambda (&optional _program _arg)
(setq ,ddir default-directory)
- (let ((b (get-buffer-create ghostel-buffer-name)))
+ (let ((b (get-buffer-create eat-buffer-name)))
(push (buffer-name b) ,calls)
b)))
- ((symbol-function 'ghostel-send-string)
- (lambda (s) (push s ,strings))))
+ ((symbol-function 'cj/--ai-term-send-string)
+ (lambda (_buf s) (push s ,strings))))
,@body))))
(defun test-ai-term--cleanup (name)
@@ -55,33 +53,33 @@ was asked to create), :strings (sent strings), :default-dir. The mocked
(kill-buffer name)))
(ert-deftest test-ai-term--show-or-create-creates-when-buffer-missing ()
- "Normal: no existing buffer -> ghostel called once, launch cmd + newline
-sent, the project recorded at the front of the MRU list."
+ "Normal: no existing buffer -> eat called once, launch cmd + newline sent,
+the project recorded at the front of the MRU list."
(let ((name "agent [normal-create-test]")
(cj/--ai-term-mru nil))
(test-ai-term--cleanup name)
(unwind-protect
- (test-ai-term--with-mock-ghostel (:calls calls :strings strings
- :default-dir ddir)
+ (test-ai-term--with-mock-eat (:calls calls :strings strings
+ :default-dir ddir)
(cj/--ai-term-show-or-create "/tmp/some-project" name)
(should (equal calls (list name)))
- (should (equal (reverse strings)
- (list (cj/--ai-term-launch-command "/tmp/some-project")
- "\n")))
+ (should (equal strings
+ (list (concat (cj/--ai-term-launch-command "/tmp/some-project")
+ "\n"))))
(should (equal ddir "/tmp/some-project"))
(should (equal (car cj/--ai-term-mru) "/tmp/some-project")))
(test-ai-term--cleanup name))))
(ert-deftest test-ai-term--show-or-create-displays-existing-when-process-live ()
- "Normal: buffer exists with live process -> ghostel not called."
+ "Normal: buffer exists with live process -> eat not called."
(let ((name "agent [reuse-test]"))
(test-ai-term--cleanup name)
(unwind-protect
(let ((buf (get-buffer-create name)))
(cl-letf (((symbol-function 'cj/--ai-term-process-live-p)
(lambda (b) (and (eq b buf) t))))
- (test-ai-term--with-mock-ghostel (:calls calls :strings strings
- :default-dir _ddir)
+ (test-ai-term--with-mock-eat (:calls calls :strings strings
+ :default-dir _ddir)
(cj/--ai-term-show-or-create "/tmp/reuse" name)
(should (null calls))
(should (null strings)))))
@@ -95,27 +93,27 @@ sent, the project recorded at the front of the MRU list."
(let ((stale (get-buffer-create name)))
(cl-letf (((symbol-function 'cj/--ai-term-process-live-p)
(lambda (_b) nil)))
- (test-ai-term--with-mock-ghostel (:calls calls :strings strings
- :default-dir _ddir)
+ (test-ai-term--with-mock-eat (:calls calls :strings strings
+ :default-dir _ddir)
(cj/--ai-term-show-or-create "/tmp/dead" name)
(should (equal calls (list name)))
- (should (equal (reverse strings)
- (list (cj/--ai-term-launch-command "/tmp/dead")
- "\n")))
+ (should (equal strings
+ (list (concat (cj/--ai-term-launch-command "/tmp/dead")
+ "\n"))))
(should-not (buffer-live-p stale)))))
(test-ai-term--cleanup name))))
(ert-deftest test-ai-term--show-or-create-preserves-selected-window ()
- "Regression: ghostel's same-window switch must not bury the dashboard.
+ "Regression: eat's same-window switch must not bury the dashboard.
-Real `ghostel' switches the selected window to its buffer as a side-effect of
+Real `eat' switches the selected window to its buffer as a side-effect of
construction. On a fresh-boot frame (one window showing the dashboard), that
side-effect would otherwise leave the original window pointing at the new
-agent buffer. The wrapper runs `(ghostel)' inside `save-window-excursion' so
-the original window state is restored before `display-buffer' fires, leaving
-the dashboard put and letting the alist place agent into a fresh split.
+agent buffer. The wrapper runs `(eat)' inside `save-window-excursion' so the
+original window state is restored before `display-buffer' fires, leaving the
+dashboard put and letting the alist place agent into a fresh split.
-This test stubs `ghostel' to mimic the same-window side-effect and asserts the
+This test stubs `eat' to mimic the same-window side-effect and asserts the
originally-selected window still shows its original buffer afterward."
(let ((agent-name "agent [preserve-window-test]")
(orig-name "*test-original-buffer*"))
@@ -128,24 +126,24 @@ originally-selected window still shows its original buffer afterward."
(orig-win (selected-window)))
(set-window-buffer orig-win orig-buf)
(cl-letf
- (((symbol-function 'ghostel)
- (lambda (&optional _arg)
- (let ((buf (get-buffer-create ghostel-buffer-name)))
+ (((symbol-function 'eat)
+ (lambda (&optional _program _arg)
+ (let ((buf (get-buffer-create eat-buffer-name)))
(set-window-buffer (selected-window) buf)
buf)))
- ((symbol-function 'ghostel-send-string)
- (lambda (_s) nil)))
+ ((symbol-function 'cj/--ai-term-send-string)
+ (lambda (_buf _s) nil)))
(cj/--ai-term-show-or-create "/tmp/preserve" agent-name)
(should (eq (window-buffer orig-win) orig-buf)))))
(test-ai-term--cleanup agent-name)
(when (get-buffer orig-name) (kill-buffer orig-name)))))
(ert-deftest test-ai-term--show-or-create-returns-buffer ()
- "Normal: return value is the ghostel buffer named after the project."
+ "Normal: return value is the eat buffer named after the project."
(let ((name "agent [return-test]"))
(test-ai-term--cleanup name)
(unwind-protect
- (test-ai-term--with-mock-ghostel (:calls _c :strings _s :default-dir _d)
+ (test-ai-term--with-mock-eat (:calls _c :strings _s :default-dir _d)
(let ((result (cj/--ai-term-show-or-create "/tmp/return" name)))
(should (bufferp result))
(should (equal (buffer-name result) name))))