aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/ai-term-backend-eat.el91
-rw-r--r--modules/ai-term.el27
-rw-r--r--tests/test-ai-term--accent.el4
-rw-r--r--tests/test-ai-term--project-color.el203
-rw-r--r--tests/test-ai-term--show-or-create.el8
5 files changed, 313 insertions, 20 deletions
diff --git a/modules/ai-term-backend-eat.el b/modules/ai-term-backend-eat.el
index 21385e37..9a166ff8 100644
--- a/modules/ai-term-backend-eat.el
+++ b/modules/ai-term-backend-eat.el
@@ -27,6 +27,7 @@
(declare-function eat "eat" (&optional program arg))
(declare-function eat-term-set-parameter "eat" (terminal parameter value))
(declare-function cj/ai-term-next "ai-term" ())
+(declare-function cj/--ai-term-project-color "ai-term" (dir))
(defvar eat-buffer-name)
(defvar eat-semi-char-mode-map)
(defvar eat-terminal)
@@ -55,6 +56,50 @@ the program repaints it (Claude Code's TUI repaints continuously)."
(intern (format "color-%d-face" (car entry)))
(cdr entry))))))
+(defun cj/--ai-term-color-ready-p (buffer)
+ "Return non-nil when BUFFER's Claude TUI is ready for a /color injection.
+Ready means the bypass-permissions banner is on screen (the TUI booted;
+a plain shell never shows it, so this fails safe) AND the input prompt
+line is still empty (the user hasn't started typing -- injecting into a
+half-typed prompt would corrupt their input)."
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (save-excursion
+ (goto-char (point-min))
+ (and (search-forward "⏵⏵" nil t)
+ (progn (goto-char (point-min))
+ (re-search-forward "^❯ *$" nil t))
+ t)))))
+
+(defun cj/--ai-term-send-color (buffer color)
+ "Type \"/color COLOR\" into BUFFER's TUI, then Enter a beat later.
+The Enter is deferred because the slash-command menu pops up while the
+text arrives; a CR in the same write can select a menu entry instead of
+running the typed command (the same race the color probe dodged)."
+ (cj/--ai-term-send-string buffer (concat "/color " color))
+ (run-at-time 1 nil #'cj/--ai-term-send-string buffer "\r"))
+
+(defun cj/--ai-term-schedule-color (buffer color)
+ "Poll BUFFER until its Claude TUI is ready, then send /color COLOR.
+Polls every 2 seconds and gives up after 45 tries (90s) or when the
+buffer dies -- covering a Claude that never launches, so nothing is ever
+typed into a bare shell. Returns the poll timer."
+ (let ((tries 0) (timer nil))
+ (setq timer
+ (run-at-time
+ 2 2
+ (lambda ()
+ (setq tries (1+ tries))
+ (cond
+ ((not (buffer-live-p buffer))
+ (cancel-timer timer))
+ ((cj/--ai-term-color-ready-p buffer)
+ (cancel-timer timer)
+ (cj/--ai-term-send-color buffer color))
+ ((>= tries 45)
+ (cancel-timer timer))))))
+ timer))
+
(defun cj/--ai-term-show-or-create (dir name)
"Show or create the AI-term buffer for project DIR with buffer NAME.
@@ -81,25 +126,33 @@ buffer."
(t
(when existing
(kill-buffer existing))
- ;; `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. `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)
- (eat-buffer-name name))
- (eat)))
- (let ((buf (get-buffer name)))
- (with-current-buffer buf
- (cj/--ai-term-apply-accent buf)
- (cj/--ai-term-send-string
- buf (concat (cj/--ai-term-launch-command dir) "\n")))
- (display-buffer buf)
- buf)))))
+ ;; Fresh vs reattach is decided BEFORE the launch command runs (the
+ ;; `tmux new-session -A' it sends creates the session). Only a fresh
+ ;; session gets the project /color injected below; a reattach carries
+ ;; whatever color the running Claude already has.
+ (let ((fresh (not (cj/--ai-term-session-active-p
+ dir (cj/--ai-term-live-tmux-sessions)))))
+ ;; `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. `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)
+ (eat-buffer-name name))
+ (eat)))
+ (let ((buf (get-buffer name)))
+ (with-current-buffer buf
+ (cj/--ai-term-apply-accent buf)
+ (cj/--ai-term-send-string
+ buf (concat (cj/--ai-term-launch-command dir) "\n")))
+ (when fresh
+ (cj/--ai-term-schedule-color buf (cj/--ai-term-project-color dir)))
+ (display-buffer buf)
+ buf))))))
;; 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
diff --git a/modules/ai-term.el b/modules/ai-term.el
index bd955292..b67245fd 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -154,6 +154,33 @@ The /color session accents get their own faces below."
"Agent terminal rendering of Claude Code's cyan session color (dupre steel)."
:group 'ai-term)
+(defvar cj/--ai-term-color-names
+ '("red" "blue" "green" "yellow" "purple" "orange" "pink" "cyan")
+ "Claude Code's /color session-color names (v2.1.198's eb array).
+The hash fallback in `cj/--ai-term-project-color' indexes into this
+list, so its order changes which project lands on which color --
+append rather than reorder if Claude Code grows new names.")
+
+(defcustom cj/ai-term-project-colors nil
+ "Alist of (PROJECT-BASENAME . COLOR-NAME) overriding the hashed color.
+BASENAME is the project directory's basename as shown in the agent
+picker (e.g. \".emacs.d\"); COLOR-NAME is one of
+`cj/--ai-term-color-names'. Projects not listed get a deterministic
+color hashed from their basename, so every project always comes up in
+the same color either way."
+ :type '(alist :key-type string :value-type string)
+ :group 'ai-term)
+
+(defun cj/--ai-term-project-color (dir)
+ "Return the /color name for project DIR: alist override, else hash.
+The fallback sums the basename's characters mod the color count, so the
+same project maps to the same color on every machine and session."
+ (let ((basename (file-name-nondirectory (directory-file-name dir))))
+ (or (cdr (assoc-string basename cj/ai-term-project-colors))
+ (nth (mod (apply #'+ (string-to-list basename))
+ (length cj/--ai-term-color-names))
+ cj/--ai-term-color-names))))
+
(defvar cj/ai-term-palette-faces
'((211 . cj/ai-term-accent) ; bypass banner (fixed, not a /color)
(167 . cj/ai-term-color-red)
diff --git a/tests/test-ai-term--accent.el b/tests/test-ai-term--accent.el
index 2f381bab..c5b0ecc5 100644
--- a/tests/test-ai-term--accent.el
+++ b/tests/test-ai-term--accent.el
@@ -120,6 +120,10 @@
(lambda (_buf _s) nil))
((symbol-function 'display-buffer)
(lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda () nil))
+ ((symbol-function 'cj/--ai-term-schedule-color)
+ (lambda (_buffer _color) nil))
((symbol-function 'cj/--ai-term-apply-accent)
(lambda (buffer) (push (buffer-name buffer) applied))))
(cj/--ai-term-show-or-create "/tmp/accent-wire-test" name)
diff --git a/tests/test-ai-term--project-color.el b/tests/test-ai-term--project-color.el
new file mode 100644
index 00000000..244d91ee
--- /dev/null
+++ b/tests/test-ai-term--project-color.el
@@ -0,0 +1,203 @@
+;;; test-ai-term--project-color.el --- Tests for per-project session colors -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests the per-project /color auto-assignment: the project -> color-name
+;; mapping (override alist + deterministic hash fallback), the TUI-ready
+;; detector, the two-step /color send, the polling scheduler, and the
+;; show-or-create wiring (fresh sessions get a color; tmux reattaches are
+;; never injected into). eat, tmux discovery, and timers are stubbed.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(declare-function cj/--ai-term-project-color "ai-term" (dir))
+(declare-function cj/--ai-term-color-ready-p "ai-term-backend-eat" (buffer))
+(declare-function cj/--ai-term-send-color "ai-term-backend-eat" (buffer color))
+(declare-function cj/--ai-term-schedule-color "ai-term-backend-eat" (buffer color))
+(declare-function cj/--ai-term-show-or-create "ai-term-backend-eat" (dir name))
+
+(defvar cj/ai-term-project-colors)
+(defvar cj/--ai-term-color-names)
+(defvar cj/--ai-term-mru)
+(defvar eat-buffer-name)
+
+;; eat isn't loaded in batch -- provide a stub so cl-letf has an override.
+(unless (fboundp 'eat)
+ (defun eat (&optional _program _arg) nil))
+
+;;; ------------------------ cj/--ai-term-project-color ------------------------
+
+(ert-deftest test-ai-term-project-color-is-deterministic ()
+ "Normal: the same project dir always maps to the same color name."
+ (let ((cj/ai-term-project-colors nil))
+ (should (equal (cj/--ai-term-project-color "/home/u/code/someproj")
+ (cj/--ai-term-project-color "/home/u/code/someproj")))))
+
+(ert-deftest test-ai-term-project-color-returns-a-known-name ()
+ "Normal: the hash fallback lands on one of Claude Code's color names."
+ (let ((cj/ai-term-project-colors nil))
+ (should (seq-contains-p cj/--ai-term-color-names
+ (cj/--ai-term-project-color "/home/u/code/xyz")
+ #'equal))))
+
+(ert-deftest test-ai-term-project-color-alist-override-wins ()
+ "Normal: an explicit project entry beats the hash."
+ (let ((cj/ai-term-project-colors '(("myproj" . "purple"))))
+ (should (equal (cj/--ai-term-project-color "/home/u/code/myproj") "purple"))))
+
+(ert-deftest test-ai-term-project-color-trailing-slash-insensitive ()
+ "Boundary: a trailing slash on the dir does not change the color."
+ (let ((cj/ai-term-project-colors nil))
+ (should (equal (cj/--ai-term-project-color "/home/u/code/proj")
+ (cj/--ai-term-project-color "/home/u/code/proj/")))))
+
+;;; ----------------------- cj/--ai-term-color-ready-p -------------------------
+
+(defun test-ai-term-pc--buffer-with (content)
+ "Make a temp buffer holding CONTENT and return `cj/--ai-term-color-ready-p' on it."
+ (with-temp-buffer
+ (insert content)
+ (cj/--ai-term-color-ready-p (current-buffer))))
+
+(ert-deftest test-ai-term-color-ready-detects-idle-tui ()
+ "Normal: banner present + untouched prompt line reads as ready."
+ (should (test-ai-term-pc--buffer-with
+ "───────\n❯ \n───────\n ⏵⏵ bypass permissions on (shift+tab to cycle)\n")))
+
+(ert-deftest test-ai-term-color-ready-rejects-typed-prompt ()
+ "Boundary: text already on the prompt line means never inject."
+ (should-not (test-ai-term-pc--buffer-with
+ "───────\n❯ fix the bug\n───────\n ⏵⏵ bypass permissions on\n")))
+
+(ert-deftest test-ai-term-color-ready-rejects-plain-shell ()
+ "Error: a shell without the Claude banner is not ready (fail-safe: no injection)."
+ (should-not (test-ai-term-pc--buffer-with "cjennings@velox ~ $ \n")))
+
+(ert-deftest test-ai-term-color-ready-rejects-dead-buffer ()
+ "Error: a killed buffer is never ready."
+ (let ((buf (generate-new-buffer "pc-dead")))
+ (kill-buffer buf)
+ (should-not (cj/--ai-term-color-ready-p buf))))
+
+;;; ------------------------ cj/--ai-term-send-color ---------------------------
+
+(ert-deftest test-ai-term-send-color-two-step ()
+ "Normal: the command text goes first; the CR follows via a timer (menu race guard)."
+ (let ((sent nil) (timer-fns nil))
+ (cl-letf (((symbol-function 'cj/--ai-term-send-string)
+ (lambda (_buf s) (push s sent)))
+ ((symbol-function 'run-at-time)
+ (lambda (_time _repeat fn &rest args)
+ (push (cons fn args) timer-fns)
+ 'fake-timer)))
+ (with-temp-buffer
+ (cj/--ai-term-send-color (current-buffer) "purple"))
+ (should (equal sent '("/color purple")))
+ (should (= (length timer-fns) 1))
+ ;; Fire the deferred CR.
+ (apply (caar timer-fns) (cdar timer-fns))
+ (should (equal sent '("\r" "/color purple"))))))
+
+;;; ---------------------- cj/--ai-term-schedule-color -------------------------
+
+(ert-deftest test-ai-term-schedule-color-sends-when-ready ()
+ "Normal: the poll sends once the TUI is ready, then cancels its timer."
+ (let ((poll-fn nil) (cancelled nil) (sent nil))
+ (cl-letf (((symbol-function 'run-at-time)
+ (lambda (_time _repeat fn &rest args)
+ (setq poll-fn (lambda () (apply fn args)))
+ 'fake-timer))
+ ((symbol-function 'cancel-timer)
+ (lambda (&rest _) (setq cancelled t)))
+ ((symbol-function 'cj/--ai-term-send-color)
+ (lambda (_buf color) (push color sent))))
+ (with-temp-buffer
+ (rename-buffer "pc-sched-ready" t)
+ (cj/--ai-term-schedule-color (current-buffer) "green")
+ (should poll-fn)
+ ;; Not ready yet: nothing sent, timer stays.
+ (funcall poll-fn)
+ (should-not sent)
+ (should-not cancelled)
+ ;; Becomes ready: sends and cancels.
+ (insert "❯ \n⏵⏵ bypass permissions on\n")
+ (funcall poll-fn)
+ (should (equal sent '("green")))
+ (should cancelled)))))
+
+(ert-deftest test-ai-term-schedule-color-gives-up-on-dead-buffer ()
+ "Error: a killed buffer cancels the poll without sending."
+ (let ((poll-fn nil) (cancelled nil) (sent nil)
+ (buf (generate-new-buffer "pc-sched-dead")))
+ (cl-letf (((symbol-function 'run-at-time)
+ (lambda (_time _repeat fn &rest args)
+ (setq poll-fn (lambda () (apply fn args)))
+ 'fake-timer))
+ ((symbol-function 'cancel-timer)
+ (lambda (&rest _) (setq cancelled t)))
+ ((symbol-function 'cj/--ai-term-send-color)
+ (lambda (_buf color) (push color sent))))
+ (cj/--ai-term-schedule-color buf "red")
+ (kill-buffer buf)
+ (funcall poll-fn)
+ (should-not sent)
+ (should cancelled))))
+
+;;; ------------------------- show-or-create wiring ----------------------------
+
+(defmacro test-ai-term-pc--with-create-mocks (sessions scheduled &rest body)
+ "Run BODY with the create path mocked; live tmux SESSIONS list injected.
+SCHEDULED captures (BUFFER-NAME . COLOR) pairs from the schedule call."
+ (declare (indent 2) (debug t))
+ `(cl-letf (((symbol-function 'eat)
+ (lambda (&optional _program _arg)
+ (get-buffer-create eat-buffer-name)))
+ ((symbol-function 'cj/--ai-term-send-string)
+ (lambda (_buf _s) nil))
+ ((symbol-function 'display-buffer)
+ (lambda (&rest _) nil))
+ ((symbol-function 'cj/--ai-term-apply-accent)
+ (lambda (_buffer) nil))
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda () ,sessions))
+ ((symbol-function 'cj/--ai-term-schedule-color)
+ (lambda (buffer color)
+ (push (cons (buffer-name buffer) color) ,scheduled))))
+ ,@body))
+
+(ert-deftest test-ai-term-show-or-create-schedules-color-on-fresh-session ()
+ "Normal: a project with no live tmux session gets a scheduled /color."
+ (let ((name "agent [pc-fresh-test]")
+ (cj/--ai-term-mru nil)
+ (scheduled nil))
+ (when (get-buffer name) (kill-buffer name))
+ (unwind-protect
+ (test-ai-term-pc--with-create-mocks nil scheduled
+ (cj/--ai-term-show-or-create "/tmp/pc-fresh-test" name)
+ (should (= (length scheduled) 1))
+ (should (equal (caar scheduled) name))
+ (should (equal (cdar scheduled)
+ (cj/--ai-term-project-color "/tmp/pc-fresh-test"))))
+ (when (get-buffer name) (kill-buffer name)))))
+
+(ert-deftest test-ai-term-show-or-create-skips-color-on-reattach ()
+ "Boundary: a live tmux session (reattach) is never injected with /color."
+ (let* ((dir "/tmp/pc-reattach-test")
+ (name "agent [pc-reattach-test]")
+ (cj/--ai-term-mru nil)
+ (scheduled nil)
+ (live (list (cj/--ai-term-tmux-session-name dir))))
+ (when (get-buffer name) (kill-buffer name))
+ (unwind-protect
+ (test-ai-term-pc--with-create-mocks live scheduled
+ (cj/--ai-term-show-or-create dir name)
+ (should-not scheduled))
+ (when (get-buffer name) (kill-buffer name)))))
+
+(provide 'test-ai-term--project-color)
+;;; test-ai-term--project-color.el ends here
diff --git a/tests/test-ai-term--show-or-create.el b/tests/test-ai-term--show-or-create.el
index 4f5f1f67..9574b8a6 100644
--- a/tests/test-ai-term--show-or-create.el
+++ b/tests/test-ai-term--show-or-create.el
@@ -44,7 +44,13 @@ mirroring the real entry point."
(push (buffer-name b) ,calls)
b)))
((symbol-function 'cj/--ai-term-send-string)
- (lambda (_buf s) (push s ,strings))))
+ (lambda (_buf s) (push s ,strings)))
+ ;; Keep the create path hermetic: no tmux subprocess for the
+ ;; fresh-session check, no real /color poll timer.
+ ((symbol-function 'cj/--ai-term-live-tmux-sessions)
+ (lambda () nil))
+ ((symbol-function 'cj/--ai-term-schedule-color)
+ (lambda (_buffer _color) nil)))
,@body))))
(defun test-ai-term--cleanup (name)