From 6c8f2a9cb02514791dc54af7d50d5797882b34b2 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 25 Jun 2026 23:16:17 -0400 Subject: 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-), 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. --- init.el | 2 +- modules/ai-term.el | 87 +++++++++++++------------------ tests/test-ai-term--keybindings.el | 28 ++++------ tests/test-ai-term--show-or-create.el | 98 +++++++++++++++++------------------ 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)))) -- cgit v1.2.3