aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-02 10:33:34 -0400
committerCraig Jennings <c@cjennings.net>2026-07-02 10:33:34 -0400
commit75cf36183811a1a9208baf6d75b56c274debebca (patch)
tree5b81eae8299a603cfc2c19fd416f456ca4e39121 /modules
parent82b195278b551839314edf1bfab37d5f54c432cb (diff)
downloaddotemacs-75cf36183811a1a9208baf6d75b56c274debebca.tar.gz
dotemacs-75cf36183811a1a9208baf6d75b56c274debebca.zip
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 <name> 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).
Diffstat (limited to 'modules')
-rw-r--r--modules/ai-term-backend-eat.el91
-rw-r--r--modules/ai-term.el27
2 files changed, 99 insertions, 19 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)