From 75cf36183811a1a9208baf6d75b56c274debebca Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 2 Jul 2026 10:33:34 -0400 Subject: feat(ai-term): auto-set each project's session color on fresh launch Every project now maps to a stable Claude Code session color: an override alist wins, else a character-sum hash of the project basename picks one of the eight names. When a fresh tmux session is created (never on reattach), a poller waits for the TUI to boot and types /color itself, with the Enter deferred a beat so the slash-command menu can't swallow it. Two refusals keep the injection safe: the bypass banner must be on screen (a bare shell never gets typed into) and the prompt line must still be empty (typed-ahead input is never corrupted). --- modules/ai-term-backend-eat.el | 91 +++++++++++---- modules/ai-term.el | 27 +++++ tests/test-ai-term--accent.el | 4 + tests/test-ai-term--project-color.el | 203 ++++++++++++++++++++++++++++++++++ tests/test-ai-term--show-or-create.el | 8 +- 5 files changed, 313 insertions(+), 20 deletions(-) create mode 100644 tests/test-ai-term--project-color.el 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) -- cgit v1.2.3