diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-11 05:17:44 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-11 05:17:44 -0500 |
| commit | ca7015486d230192e94c51c0e5d014fc83a7a35f (patch) | |
| tree | 2e06df454a3915ea6074e697fc64f6981fe0177e /modules | |
| parent | 364d69dc6f9be5d310c0ac1f0c69c31b08d82821 (diff) | |
| download | dotemacs-ca7015486d230192e94c51c0e5d014fc83a7a35f.tar.gz dotemacs-ca7015486d230192e94c51c0e5d014fc83a7a35f.zip | |
feat(ai-vterm): surface surviving tmux sessions in the project picker
Each project's tmux session is now named `<cj/ai-vterm-tmux-session-prefix><basename>` (default `aiv-`), so `tmux ls` can be filtered to AI-vterm's own sessions. After an Emacs crash the C-F9 project picker reads `tmux list-sessions`, matches surviving sessions back to their directories, and sorts those to the top: `[detached]` when only the tmux session is alive, `[running]` when a vterm buffer exists. The rest follow alphabetically. With tmux missing or no server running, it falls back to a plain alphabetical list. The picker's collection is a completion table that pins display order so Vertico doesn't re-sort and undo the active-first grouping.
The prefix is a new `defcustom` rather than `claude-`, which collides with hand-rolled tmux sessions. Sessions named before this change use the bare basename and won't be matched afterward. One `tmux kill-server` clears any orphans.
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/ai-vterm.el | 140 |
1 files changed, 119 insertions, 21 deletions
diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el index b657b249..8f688301 100644 --- a/modules/ai-vterm.el +++ b/modules/ai-vterm.el @@ -12,6 +12,15 @@ ;; buffers that share the same right-side slot; switching among them is a ;; buffer-switch, not a kill-and-recreate. ;; +;; Each project's Claude runs inside a tmux session named +;; "<cj/ai-vterm-tmux-session-prefix><basename>" (default prefix "aiv-"). +;; The prefix lets `tmux ls' be filtered to AI-vterm's own sessions, so +;; after an Emacs crash the project picker can match surviving sessions +;; back to their directories: matched projects sort to the top of the +;; picker (flagged "[detached]" -- session alive, no Emacs buffer -- or +;; "[running]" when a live vterm buffer exists), the rest follow in +;; alphabetical order. +;; ;; Three F-key entry points: ;; ;; - F9 `cj/ai-vterm' -- DWIM dispatch. If a claude buffer is @@ -79,6 +88,19 @@ contain .ai/protocols.org. Use this for container dirs like ~/code." :type '(repeat directory) :group 'ai-vterm) +(defcustom cj/ai-vterm-tmux-session-prefix "aiv-" + "Prefix prepended to tmux session names AI-vterm creates. + +The session name for a project is this prefix followed by the +project's basename (whitespace collapsed to hyphens). The prefix +lets `tmux ls' output be filtered down to AI-vterm's own sessions -- +so after an Emacs crash the project picker can match surviving +sessions back to their directories and surface them first. Pick +something unlikely to collide with hand-rolled tmux sessions; the +default \"aiv-\" is short for \"ai-vterm\"." + :type 'string + :group 'ai-vterm) + (defconst cj/--ai-vterm-name-prefix "claude [" "Buffer-name prefix shared by all AI-vterm buffers. @@ -126,13 +148,47 @@ is returned. The minibuffer is excluded from the search." (window-list (or frame (selected-frame)) 'never))) (defun cj/--ai-vterm-tmux-session-name (dir) - "Return the tmux name derived from project directory DIR. - -The basename of DIR, with any run of whitespace collapsed to a single -hyphen so the result is safe to pass on a tmux command line." - (replace-regexp-in-string - "[[:space:]]+" "-" - (file-name-nondirectory (directory-file-name dir)))) + "Return the tmux session name for project directory DIR. + +`cj/ai-vterm-tmux-session-prefix' followed by DIR's basename, with any +run of whitespace collapsed to a single hyphen so the result is safe +to pass on a tmux command line. The prefix lets `tmux ls' output be +filtered to AI-vterm's own sessions (see +`cj/--ai-vterm-live-tmux-sessions')." + (concat cj/ai-vterm-tmux-session-prefix + (replace-regexp-in-string + "[[:space:]]+" "-" + (file-name-nondirectory (directory-file-name dir))))) + +(defun cj/--ai-vterm-live-tmux-sessions () + "Return live tmux session names that carry the AI-vterm prefix. + +Runs `tmux list-sessions'. Returns the names beginning with +`cj/ai-vterm-tmux-session-prefix', or nil when tmux is not installed, +no server is running, or the command exits non-zero -- the picker +treats nil as \"no sessions to surface\" and falls back to a plain +alphabetical list." + (let* ((prefix cj/ai-vterm-tmux-session-prefix) + (exit nil) + (output (with-temp-buffer + (setq exit (condition-case nil + (process-file "tmux" nil '(t nil) nil + "list-sessions" "-F" + "#{session_name}") + (error nil))) + (buffer-string)))) + (when (and (integerp exit) (zerop exit)) + (seq-filter (lambda (name) (string-prefix-p prefix name)) + (split-string output "\n" t))))) + +(defun cj/--ai-vterm-session-active-p (dir sessions) + "Return non-nil when DIR's tmux session name is in SESSIONS. + +SESSIONS is the list from `cj/--ai-vterm-live-tmux-sessions' (or nil). +The match is forward: DIR's expected session name is computed and +looked up in SESSIONS, so the lossy whitespace->hyphen transform in +`cj/--ai-vterm-tmux-session-name' never needs reversing." + (and (member (cj/--ai-vterm-tmux-session-name dir) sessions) t)) (defun cj/--ai-vterm-launch-command (dir) "Return the shell command line that runs Claude in a project tmux session. @@ -181,6 +237,21 @@ Returns absolute paths. Nonexistent roots are skipped silently." (push child result)))))) (nreverse result))) +(defun cj/--ai-vterm-sort-candidates (dirs sessions) + "Order DIRS for the project picker. + +DIRS with a live tmux session in SESSIONS (per +`cj/--ai-vterm-session-active-p') come first, the rest follow; within +each group the order is alphabetical by abbreviated path. SESSIONS +nil means nothing is active, so the result is a plain alphabetical +list." + (let* ((alpha (lambda (a b) + (string< (abbreviate-file-name a) (abbreviate-file-name b)))) + (active-p (lambda (d) (cj/--ai-vterm-session-active-p d sessions))) + (active (seq-filter active-p dirs)) + (inactive (seq-remove active-p dirs))) + (append (sort active alpha) (sort inactive alpha)))) + (defun cj/--ai-vterm-process-live-p (buffer) "Return non-nil when BUFFER has a live process attached." (let ((proc (get-buffer-process buffer))) @@ -347,27 +418,49 @@ Returns the buffer." (display-buffer buf) buf))))) -(defun cj/--ai-vterm-format-candidate (path) +(defun cj/--ai-vterm-format-candidate (path &optional sessions) "Return the display name for PATH in the AI-vterm project picker. Appends \" [running]\" when the project's claude buffer exists with -a live process, so the user sees at a glance which projects already -have a session. Path is abbreviated via `abbreviate-file-name' so -it reads as ~/code/foo rather than the full home-dir form." +a live process; otherwise \" [detached]\" when PATH's tmux session +name is in SESSIONS (a session that survived an Emacs crash, no +buffer yet); otherwise just the abbreviated path. Path is +abbreviated via `abbreviate-file-name' so it reads as ~/code/foo +rather than the full home-dir form." (let* ((name (cj/--ai-vterm-buffer-name path)) (buf (get-buffer name)) (running (and buf (cj/--ai-vterm-process-live-p buf))) + (detached (and (not running) + (cj/--ai-vterm-session-active-p path sessions))) (display-path (abbreviate-file-name path))) - (if running - (format "%s [running]" display-path) - display-path))) + (cond + (running (format "%s [running]" display-path)) + (detached (format "%s [detached]" display-path)) + (t display-path)))) + +(defun cj/--ai-vterm-completion-table (alist) + "Return a `completing-read' table over ALIST that pins candidate order. + +`completing-read' over a bare alist lets the front-end (Vertico) +re-sort candidates by recency / length / alpha, which would defeat +the picker's active-sessions-first grouping. Returning +`display-sort-function' and `cycle-sort-function' of `identity' in +the metadata keeps the order ALIST was built in." + (lambda (string predicate action) + (if (eq action 'metadata) + '(metadata (display-sort-function . identity) + (cycle-sort-function . identity)) + (complete-with-action action alist string predicate)))) (defun cj/--ai-vterm-pick-project () "Prompt for a Claude-template project; return its absolute path. -Candidates come from `cj/--ai-vterm-candidates'. Display uses +Candidates come from `cj/--ai-vterm-candidates', ordered by +`cj/--ai-vterm-sort-candidates' so projects with a live tmux session +appear first (then alphabetical by abbreviated path). Display uses `cj/--ai-vterm-format-candidate', which abbreviates the path and -flags projects with a live session via a \" [running]\" suffix. +flags a live session via \" [running]\" (an Emacs vterm buffer is +alive) or \" [detached]\" (the tmux session survived, no buffer). Signals `user-error' when no candidates exist." (let ((candidates (cj/--ai-vterm-candidates))) (unless candidates @@ -376,11 +469,16 @@ Signals `user-error' when no candidates exist." (append cj/ai-vterm-project-roots cj/ai-vterm-container-roots) ", "))) - (let* ((display-alist - (mapcar (lambda (p) (cons (cj/--ai-vterm-format-candidate p) p)) - candidates)) - (chosen (completing-read "AI vterm project: " - display-alist nil t))) + (let* ((sessions (cj/--ai-vterm-live-tmux-sessions)) + (sorted (cj/--ai-vterm-sort-candidates candidates sessions)) + (display-alist + (mapcar (lambda (p) + (cons (cj/--ai-vterm-format-candidate p sessions) p)) + sorted)) + (chosen (completing-read + "AI vterm project: " + (cj/--ai-vterm-completion-table display-alist) + nil t))) (or (cdr (assoc chosen display-alist)) (expand-file-name chosen))))) |
