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 +++++++++++++ 2 files changed, 99 insertions(+), 19 deletions(-) (limited to 'modules') 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) -- cgit v1.2.3