From ca7015486d230192e94c51c0e5d014fc83a7a35f Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 11 May 2026 05:17:44 -0500 Subject: feat(ai-vterm): surface surviving tmux sessions in the project picker Each project's tmux session is now named `` (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. --- modules/ai-vterm.el | 140 ++++++++++++++++++++++++----- tests/test-ai-vterm--launch-command.el | 25 +++--- tests/test-ai-vterm--live-tmux-sessions.el | 71 +++++++++++++++ tests/test-ai-vterm--pick-project.el | 74 ++++++++++++--- tests/test-ai-vterm--sort-candidates.el | 51 +++++++++++ tests/test-ai-vterm--tmux-session-name.el | 44 +++++---- 6 files changed, 344 insertions(+), 61 deletions(-) create mode 100644 tests/test-ai-vterm--live-tmux-sessions.el create mode 100644 tests/test-ai-vterm--sort-candidates.el 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 +;; "" (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 -- cgit v1.2.3