summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-11 05:17:44 -0500
committerCraig Jennings <c@cjennings.net>2026-05-11 05:17:44 -0500
commitca7015486d230192e94c51c0e5d014fc83a7a35f (patch)
tree2e06df454a3915ea6074e697fc64f6981fe0177e
parent364d69dc6f9be5d310c0ac1f0c69c31b08d82821 (diff)
downloaddotemacs-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.
-rw-r--r--modules/ai-vterm.el140
-rw-r--r--tests/test-ai-vterm--launch-command.el25
-rw-r--r--tests/test-ai-vterm--live-tmux-sessions.el71
-rw-r--r--tests/test-ai-vterm--pick-project.el74
-rw-r--r--tests/test-ai-vterm--sort-candidates.el51
-rw-r--r--tests/test-ai-vterm--tmux-session-name.el44
6 files changed, 344 insertions, 61 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)))))
diff --git a/tests/test-ai-vterm--launch-command.el b/tests/test-ai-vterm--launch-command.el
index c6b7ac2b..464c88b6 100644
--- a/tests/test-ai-vterm--launch-command.el
+++ b/tests/test-ai-vterm--launch-command.el
@@ -2,11 +2,12 @@
;;; Commentary:
;; The launch command is what gets typed into a fresh vterm shell to bring
-;; up Claude inside a per-project tmux session. The session is named after
-;; the project basename so a second F9 on the same project reattaches to
-;; the running Claude rather than spawning a new one. The trailing
-;; `exec bash' keeps the tmux window alive if Claude exits, leaving the
-;; session intact for recovery.
+;; up Claude inside a per-project tmux session. The session is named
+;; `cj/ai-vterm-tmux-session-prefix' + the project basename, so a second
+;; F9 on the same project reattaches to the running Claude rather than
+;; spawning a new one, and `tmux ls' output can be filtered to AI-vterm's
+;; own sessions. The trailing `exec bash' keeps the tmux window alive if
+;; Claude exits, leaving the session intact for recovery.
;;; Code:
@@ -22,11 +23,12 @@
"tmux new-session -A "
(cj/--ai-vterm-launch-command "/code/foo")))))
-(ert-deftest test-ai-vterm--launch-command-includes-session-name ()
- "Normal: the session name comes from the basename helper."
- (let ((cj/ai-vterm-claude-command "claude"))
+(ert-deftest test-ai-vterm--launch-command-includes-prefixed-session-name ()
+ "Normal: the session name is the prefixed form from the name helper."
+ (let ((cj/ai-vterm-claude-command "claude")
+ (cj/ai-vterm-tmux-session-prefix "aiv-"))
(should (string-match-p
- " -s foo "
+ " -s aiv-foo "
(cj/--ai-vterm-launch-command "/code/foo")))))
(ert-deftest test-ai-vterm--launch-command-includes-start-directory ()
@@ -52,9 +54,10 @@
(ert-deftest test-ai-vterm--launch-command-handles-spaces-in-basename ()
"Boundary: a basename with whitespace becomes hyphenated before quoting."
- (let ((cj/ai-vterm-claude-command "claude"))
+ (let ((cj/ai-vterm-claude-command "claude")
+ (cj/ai-vterm-tmux-session-prefix "aiv-"))
(should (string-match-p
- " -s my-work "
+ " -s aiv-my-work "
(cj/--ai-vterm-launch-command "/code/my work")))))
(provide 'test-ai-vterm--launch-command)
diff --git a/tests/test-ai-vterm--live-tmux-sessions.el b/tests/test-ai-vterm--live-tmux-sessions.el
new file mode 100644
index 00000000..38a0488d
--- /dev/null
+++ b/tests/test-ai-vterm--live-tmux-sessions.el
@@ -0,0 +1,71 @@
+;;; test-ai-vterm--live-tmux-sessions.el --- Tests for cj/--ai-vterm-live-tmux-sessions -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Lists the live tmux sessions that carry the AI-vterm prefix so the
+;; project picker can surface projects whose Claude session survived an
+;; Emacs crash. tmux being absent or no server running is a normal
+;; "nothing to match" outcome, not an error -- the lister returns nil.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+(defmacro test-ai-vterm--with-tmux-list (exit-code output &rest body)
+ "Run BODY with `process-file' mocked to a tmux list-sessions response.
+
+EXIT-CODE is what `process-file' returns (or the symbol `error' to
+make it signal). OUTPUT is written to the stdout destination buffer."
+ (declare (indent 2))
+ `(cl-letf (((symbol-function 'process-file)
+ (lambda (_program _infile destination _display &rest _args)
+ (when (eq ,exit-code 'error)
+ (error "tmux: command not found"))
+ (let ((buffer (cond
+ ((eq destination t) (current-buffer))
+ ((bufferp destination) destination)
+ ((consp destination)
+ (and (eq (car destination) t)
+ (current-buffer))))))
+ (when (bufferp buffer)
+ (with-current-buffer buffer (insert ,output))))
+ ,exit-code)))
+ ,@body))
+
+(ert-deftest test-ai-vterm--live-tmux-sessions-filters-to-prefix ()
+ "Normal: only sessions starting with the AI-vterm prefix come back."
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (test-ai-vterm--with-tmux-list 0 "aiv-foo\nrandom-session\naiv-bar\n"
+ (should (equal (cj/--ai-vterm-live-tmux-sessions)
+ '("aiv-foo" "aiv-bar"))))))
+
+(ert-deftest test-ai-vterm--live-tmux-sessions-honors-custom-prefix ()
+ "Normal: a non-default prefix is what gets matched."
+ (let ((cj/ai-vterm-tmux-session-prefix "em-"))
+ (test-ai-vterm--with-tmux-list 0 "em-foo\naiv-bar\nem-baz\n"
+ (should (equal (cj/--ai-vterm-live-tmux-sessions)
+ '("em-foo" "em-baz"))))))
+
+(ert-deftest test-ai-vterm--live-tmux-sessions-empty-output-yields-nil ()
+ "Boundary: a running server with no matching sessions yields nil."
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (test-ai-vterm--with-tmux-list 0 "other-a\nother-b\n"
+ (should (null (cj/--ai-vterm-live-tmux-sessions))))))
+
+(ert-deftest test-ai-vterm--live-tmux-sessions-no-server-yields-nil ()
+ "Error: tmux exits non-zero (no server running) -> nil, not a signal."
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (test-ai-vterm--with-tmux-list 1 "no server running on /tmp/tmux-1000/default\n"
+ (should (null (cj/--ai-vterm-live-tmux-sessions))))))
+
+(ert-deftest test-ai-vterm--live-tmux-sessions-tmux-missing-yields-nil ()
+ "Error: tmux not installed -> `process-file' signals; lister returns nil."
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (test-ai-vterm--with-tmux-list 'error ""
+ (should (null (cj/--ai-vterm-live-tmux-sessions))))))
+
+(provide 'test-ai-vterm--live-tmux-sessions)
+;;; test-ai-vterm--live-tmux-sessions.el ends here
diff --git a/tests/test-ai-vterm--pick-project.el b/tests/test-ai-vterm--pick-project.el
index fd5295bf..a90fe822 100644
--- a/tests/test-ai-vterm--pick-project.el
+++ b/tests/test-ai-vterm--pick-project.el
@@ -1,10 +1,12 @@
;;; test-ai-vterm--pick-project.el --- Tests for cj/--ai-vterm-pick-project -*- lexical-binding: t; -*-
;;; Commentary:
-;; The picker presents abbreviated paths to `completing-read', then
-;; returns the absolute path corresponding to the user's choice. Empty
-;; candidate set raises a `user-error' rather than offering an empty
-;; prompt.
+;; The picker presents abbreviated paths to `completing-read' (projects
+;; with a live tmux session first, then alphabetical), then returns the
+;; absolute path corresponding to the user's choice. An empty candidate
+;; set raises a `user-error' rather than offering an empty prompt. The
+;; collection is a completion table that pins display order (so Vertico
+;; doesn't re-sort and defeat the active-first grouping).
;;; Code:
@@ -14,17 +16,21 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'ai-vterm)
+(defun test-ai-vterm--collection-strings (collection)
+ "Return the candidate display strings from a completing-read COLLECTION.
+Works whether COLLECTION is an alist or a completion-table function."
+ (all-completions "" collection))
+
(ert-deftest test-ai-vterm--pick-project-returns-absolute-path-of-choice ()
"Normal: user picks a candidate, picker returns its absolute path."
(cl-letf (((symbol-function 'cj/--ai-vterm-candidates)
(lambda () '("/home/u/code/foo" "/home/u/code/bar")))
+ ((symbol-function 'cj/--ai-vterm-live-tmux-sessions)
+ (lambda () nil))
((symbol-function 'completing-read)
(lambda (_p collection &rest _)
- ;; Pick the one whose display form matches ~/code/bar
- ;; (collection is alist of display . abs)
- (car (cl-find-if
- (lambda (cell) (string-match-p "bar" (car cell)))
- collection)))))
+ (seq-find (lambda (s) (string-match-p "bar" s))
+ (test-ai-vterm--collection-strings collection)))))
(should (equal (cj/--ai-vterm-pick-project) "/home/u/code/bar"))))
(ert-deftest test-ai-vterm--pick-project-empty-candidates-raises-user-error ()
@@ -34,15 +40,33 @@
(ert-deftest test-ai-vterm--pick-project-presents-abbreviated-paths ()
"Normal: the completing-read collection holds abbreviated display forms."
- (let (received-collection)
+ (let (received-strings)
(cl-letf (((symbol-function 'cj/--ai-vterm-candidates)
(lambda () (list (expand-file-name "~/code/foo"))))
+ ((symbol-function 'cj/--ai-vterm-live-tmux-sessions)
+ (lambda () nil))
((symbol-function 'completing-read)
(lambda (_p collection &rest _)
- (setq received-collection collection)
- (caar collection))))
+ (setq received-strings (test-ai-vterm--collection-strings collection))
+ (car received-strings))))
(cj/--ai-vterm-pick-project)
- (should (equal (caar received-collection) "~/code/foo")))))
+ (should (equal (car received-strings) "~/code/foo")))))
+
+(ert-deftest test-ai-vterm--pick-project-active-sessions-sort-first ()
+ "Normal: a project with a live tmux session leads; it carries [detached]."
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-")
+ received-strings)
+ (cl-letf (((symbol-function 'cj/--ai-vterm-candidates)
+ (lambda () '("/c/foo" "/c/bar" "/c/baz")))
+ ((symbol-function 'cj/--ai-vterm-live-tmux-sessions)
+ (lambda () '("aiv-baz")))
+ ((symbol-function 'completing-read)
+ (lambda (_p collection &rest _)
+ (setq received-strings (test-ai-vterm--collection-strings collection))
+ (car received-strings))))
+ (cj/--ai-vterm-pick-project)
+ (should (equal received-strings
+ '("/c/baz [detached]" "/c/bar" "/c/foo"))))))
(ert-deftest test-ai-vterm--format-candidate-flags-running-project ()
"Normal: a path whose claude buffer has a live process gets a [running] suffix."
@@ -56,6 +80,30 @@
(format "%s [running]" (abbreviate-file-name path)))))
(kill-buffer buf))))
+(ert-deftest test-ai-vterm--format-candidate-flags-detached-session ()
+ "Normal: no buffer but a matching tmux session -> [detached] suffix."
+ (let* ((cj/ai-vterm-tmux-session-prefix "aiv-")
+ (path (expand-file-name "~/code/has-session"))
+ (bn (cj/--ai-vterm-buffer-name path)))
+ (when (get-buffer bn) (kill-buffer bn))
+ (should (equal (cj/--ai-vterm-format-candidate
+ path (list (cj/--ai-vterm-tmux-session-name path)))
+ (format "%s [detached]" (abbreviate-file-name path))))))
+
+(ert-deftest test-ai-vterm--format-candidate-running-beats-detached ()
+ "Boundary: a live buffer wins over a matching session -> [running], not [detached]."
+ (let* ((cj/ai-vterm-tmux-session-prefix "aiv-")
+ (path (expand-file-name "~/code/both"))
+ (bn (cj/--ai-vterm-buffer-name path))
+ (buf (get-buffer-create bn)))
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/--ai-vterm-process-live-p)
+ (lambda (b) (eq b buf))))
+ (should (equal (cj/--ai-vterm-format-candidate
+ path (list (cj/--ai-vterm-tmux-session-name path)))
+ (format "%s [running]" (abbreviate-file-name path)))))
+ (kill-buffer buf))))
+
(ert-deftest test-ai-vterm--format-candidate-omits-flag-when-not-running ()
"Boundary: a path with no buffer or no live process -> plain abbreviated path."
(let ((path (expand-file-name "~/code/not-running")))
diff --git a/tests/test-ai-vterm--sort-candidates.el b/tests/test-ai-vterm--sort-candidates.el
new file mode 100644
index 00000000..0b602083
--- /dev/null
+++ b/tests/test-ai-vterm--sort-candidates.el
@@ -0,0 +1,51 @@
+;;; test-ai-vterm--sort-candidates.el --- Tests for cj/--ai-vterm-sort-candidates -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The project picker lists candidates with a live tmux session first
+;; (so a Claude that survived an Emacs crash is easy to get back to),
+;; then everything else. Within each group the order is alphabetical
+;; by abbreviated path.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+(ert-deftest test-ai-vterm--sort-candidates-active-first-then-alpha ()
+ "Normal: the one project with a live session leads; the rest go alpha."
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (should (equal (cj/--ai-vterm-sort-candidates
+ '("/c/foo" "/c/bar" "/c/baz")
+ '("aiv-bar"))
+ '("/c/bar" "/c/baz" "/c/foo")))))
+
+(ert-deftest test-ai-vterm--sort-candidates-multiple-active-each-group-alpha ()
+ "Normal: both groups sort alphabetically internally."
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (should (equal (cj/--ai-vterm-sort-candidates
+ '("/c/foo" "/c/bar" "/c/baz")
+ '("aiv-foo" "aiv-bar"))
+ '("/c/bar" "/c/foo" "/c/baz")))))
+
+(ert-deftest test-ai-vterm--sort-candidates-no-sessions-is-plain-alpha ()
+ "Boundary: nil session set -> a plain alphabetical list."
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (should (equal (cj/--ai-vterm-sort-candidates
+ '("/c/foo" "/c/bar") nil)
+ '("/c/bar" "/c/foo")))))
+
+(ert-deftest test-ai-vterm--sort-candidates-empty-dirs-yields-nil ()
+ "Boundary: no candidates -> nil."
+ (should (null (cj/--ai-vterm-sort-candidates nil '("aiv-foo")))))
+
+(ert-deftest test-ai-vterm--session-active-p-matches-by-derived-name ()
+ "Normal: a dir is active when its derived session name is in the set."
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (should (cj/--ai-vterm-session-active-p "/c/foo" '("aiv-bar" "aiv-foo")))
+ (should-not (cj/--ai-vterm-session-active-p "/c/qux" '("aiv-bar" "aiv-foo")))
+ (should-not (cj/--ai-vterm-session-active-p "/c/foo" nil))))
+
+(provide 'test-ai-vterm--sort-candidates)
+;;; test-ai-vterm--sort-candidates.el ends here
diff --git a/tests/test-ai-vterm--tmux-session-name.el b/tests/test-ai-vterm--tmux-session-name.el
index 9d56040e..44c20a8b 100644
--- a/tests/test-ai-vterm--tmux-session-name.el
+++ b/tests/test-ai-vterm--tmux-session-name.el
@@ -1,11 +1,12 @@
;;; test-ai-vterm--tmux-session-name.el --- Tests for cj/--ai-vterm-tmux-session-name -*- lexical-binding: t; -*-
;;; Commentary:
-;; The tmux session name is derived from the project's basename so that
-;; reopening Claude on the same project (e.g. after an Emacs crash)
-;; reattaches to the same tmux session rather than spawning a new one.
-;; Whitespace in the basename gets converted to hyphens so the name is
-;; safe to pass on a tmux command line.
+;; The tmux session name is `cj/ai-vterm-tmux-session-prefix' followed by
+;; the project's basename, so reopening Claude on the same project (e.g.
+;; after an Emacs crash) reattaches to the same tmux session rather than
+;; spawning a new one -- and the prefix lets `tmux ls' output be filtered
+;; down to AI-vterm's own sessions. Whitespace in the basename becomes
+;; hyphens so the name is safe to pass on a tmux command line.
;;; Code:
@@ -15,29 +16,40 @@
(require 'ai-vterm)
(ert-deftest test-ai-vterm--tmux-session-name-normal-project ()
- "Normal: a typical project path yields its basename."
- (should (equal (cj/--ai-vterm-tmux-session-name "/home/cjennings/projects/foo")
- "foo")))
+ "Normal: basename gets the configured prefix."
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (should (equal (cj/--ai-vterm-tmux-session-name "/home/cjennings/projects/foo")
+ "aiv-foo"))))
(ert-deftest test-ai-vterm--tmux-session-name-trailing-slash ()
"Boundary: trailing slash collapses before basename extraction."
- (should (equal (cj/--ai-vterm-tmux-session-name "/home/cjennings/projects/foo/")
- "foo")))
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (should (equal (cj/--ai-vterm-tmux-session-name "/home/cjennings/projects/foo/")
+ "aiv-foo"))))
(ert-deftest test-ai-vterm--tmux-session-name-dot-prefix-dir ()
"Boundary: dot-prefix dirs preserve the dot (tmux accepts dots)."
- (should (equal (cj/--ai-vterm-tmux-session-name "/home/cjennings/.emacs.d")
- ".emacs.d")))
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (should (equal (cj/--ai-vterm-tmux-session-name "/home/cjennings/.emacs.d")
+ "aiv-.emacs.d"))))
(ert-deftest test-ai-vterm--tmux-session-name-space-becomes-hyphen ()
"Boundary: a space in the basename is replaced with a hyphen."
- (should (equal (cj/--ai-vterm-tmux-session-name "/tmp/my work")
- "my-work")))
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (should (equal (cj/--ai-vterm-tmux-session-name "/tmp/my work")
+ "aiv-my-work"))))
(ert-deftest test-ai-vterm--tmux-session-name-multiple-spaces-collapse ()
"Boundary: a run of whitespace collapses to a single hyphen."
- (should (equal (cj/--ai-vterm-tmux-session-name "/tmp/a b\tc")
- "a-b-c")))
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (should (equal (cj/--ai-vterm-tmux-session-name "/tmp/a b\tc")
+ "aiv-a-b-c"))))
+
+(ert-deftest test-ai-vterm--tmux-session-name-honors-custom-prefix ()
+ "Normal: a non-default prefix is what gets prepended."
+ (let ((cj/ai-vterm-tmux-session-prefix "em-"))
+ (should (equal (cj/--ai-vterm-tmux-session-name "/home/cjennings/projects/foo")
+ "em-foo"))))
(provide 'test-ai-vterm--tmux-session-name)
;;; test-ai-vterm--tmux-session-name.el ends here