summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/ai-vterm.el68
-rw-r--r--tests/test-ai-vterm--record-mru.el48
-rw-r--r--tests/test-ai-vterm--show-or-create.el9
-rw-r--r--tests/test-ai-vterm--sort-candidates.el27
-rw-r--r--tests/test-ai-vterm--tmux-session-name.el20
5 files changed, 151 insertions, 21 deletions
diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el
index 22c9b5e9..b0872b6b 100644
--- a/modules/ai-vterm.el
+++ b/modules/ai-vterm.el
@@ -164,15 +164,22 @@ is returned. The minibuffer is excluded from the search."
(defun cj/--ai-vterm-tmux-session-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
+`cj/ai-vterm-tmux-session-prefix' followed by DIR's basename, sanitized
+to a form tmux won't re-mangle: runs of whitespace become a single
+hyphen, and `.' / `:' become `_'. tmux disallows `.' and `:' in
+session names and silently rewrites them to `_', so a project like
+`.emacs.d' really runs in session `aiv-_emacs_d', not `aiv-.emacs.d' --
+sanitizing up front keeps the computed name matching the live one (and
+keeps `cj/--ai-vterm-session-active-p' and the crash-recovery picker
+from missing such projects). 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)))))
+ "[.:]" "_"
+ (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.
@@ -254,20 +261,56 @@ Returns absolute paths. Nonexistent roots are skipped silently."
(push child result))))))
(nreverse result)))
+(defvar cj/--ai-vterm-mru nil
+ "Project dirs opened via the AI-vterm launcher this session, newest first.
+
+Maintained by `cj/--ai-vterm-record-mru' (called from
+`cj/--ai-vterm-show-or-create') and consumed by
+`cj/--ai-vterm-sort-candidates' so the project picker puts
+recently-opened projects at the top of the active-sessions group.
+In-memory only -- not persisted across Emacs restarts.")
+
+(defun cj/--ai-vterm-record-mru (dir)
+ "Move DIR to the front of `cj/--ai-vterm-mru'.
+
+DIR is normalized with `expand-file-name' + `directory-file-name' so a
+trailing slash or `~' form doesn't create a duplicate entry; any prior
+occurrence is removed first, keeping the list a true MRU order."
+ (let ((d (directory-file-name (expand-file-name dir))))
+ (setq cj/--ai-vterm-mru (cons d (delete d cj/--ai-vterm-mru)))))
+
+(defun cj/--ai-vterm-mru-rank (dir)
+ "Return DIR's index in `cj/--ai-vterm-mru', or nil when it isn't there.
+
+DIR is normalized the same way `cj/--ai-vterm-record-mru' stores
+entries, so a trailing slash doesn't defeat the lookup."
+ (seq-position cj/--ai-vterm-mru
+ (directory-file-name (expand-file-name dir))))
+
(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."
+`cj/--ai-vterm-session-active-p') come first, ordered most-recently-
+opened first (per `cj/--ai-vterm-mru'); active dirs not opened yet this
+session fall after them, alphabetical by abbreviated path. DIRS with no
+session follow, always alphabetical. SESSIONS nil means nothing is
+active, so the result is a plain alphabetical list; an empty MRU makes
+the active group alphabetical too."
(let* ((alpha (lambda (a b)
(string< (abbreviate-file-name a) (abbreviate-file-name b))))
+ (mru-then-alpha
+ (lambda (a b)
+ (let ((ra (cj/--ai-vterm-mru-rank a))
+ (rb (cj/--ai-vterm-mru-rank b)))
+ (cond ((and ra rb) (< ra rb))
+ (ra t)
+ (rb nil)
+ (t (funcall alpha a 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))))
+ (append (sort active mru-then-alpha) (sort inactive alpha))))
(defun cj/--ai-vterm-process-live-p (buffer)
"Return non-nil when BUFFER has a live process attached."
@@ -409,7 +452,10 @@ suppresses the generic tmux-launch hook in vterm-config.el so
it doesn't fire a bare \"tmux\\n\" before the project-named launch
command runs.
-Returns the buffer."
+Records DIR in `cj/--ai-vterm-mru' (whichever branch runs) so the
+project picker can list recently-opened projects first. Returns the
+buffer."
+ (cj/--ai-vterm-record-mru dir)
(let ((existing (get-buffer name)))
(cond
((and existing (cj/--ai-vterm-process-live-p existing))
diff --git a/tests/test-ai-vterm--record-mru.el b/tests/test-ai-vterm--record-mru.el
new file mode 100644
index 00000000..16db4eea
--- /dev/null
+++ b/tests/test-ai-vterm--record-mru.el
@@ -0,0 +1,48 @@
+;;; test-ai-vterm--record-mru.el --- Tests for the AI-vterm project MRU list -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `cj/--ai-vterm-record-mru' tracks which project dirs have been opened via
+;; the launcher this session, most-recently-opened first, so the picker can
+;; surface recently-used projects at the top of the active-sessions group.
+;; `cj/--ai-vterm-mru-rank' reports a dir's position in that list (or nil).
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-vterm)
+
+(ert-deftest test-ai-vterm--record-mru-pushes-to-front ()
+ "Normal: a freshly recorded dir leads the list, newest first."
+ (let ((cj/--ai-vterm-mru nil))
+ (cj/--ai-vterm-record-mru "/c/alpha")
+ (cj/--ai-vterm-record-mru "/c/beta")
+ (should (equal cj/--ai-vterm-mru '("/c/beta" "/c/alpha")))))
+
+(ert-deftest test-ai-vterm--record-mru-dedups-and-moves-to-front ()
+ "Normal: re-recording a dir moves it to the front with no duplicate."
+ (let ((cj/--ai-vterm-mru nil))
+ (cj/--ai-vterm-record-mru "/c/alpha")
+ (cj/--ai-vterm-record-mru "/c/beta")
+ (cj/--ai-vterm-record-mru "/c/alpha")
+ (should (equal cj/--ai-vterm-mru '("/c/alpha" "/c/beta")))))
+
+(ert-deftest test-ai-vterm--record-mru-normalizes-trailing-slash ()
+ "Boundary: `/c/foo' and `/c/foo/' are the same MRU entry."
+ (let ((cj/--ai-vterm-mru nil))
+ (cj/--ai-vterm-record-mru "/c/foo/")
+ (cj/--ai-vterm-record-mru "/c/foo")
+ (should (equal cj/--ai-vterm-mru '("/c/foo")))))
+
+(ert-deftest test-ai-vterm--mru-rank-returns-index-or-nil ()
+ "Normal/Boundary: rank is the list position; nil when the dir isn't there;
+the lookup normalizes a trailing slash the same way `record-mru' does."
+ (let ((cj/--ai-vterm-mru '("/c/beta" "/c/alpha")))
+ (should (= 0 (cj/--ai-vterm-mru-rank "/c/beta")))
+ (should (= 1 (cj/--ai-vterm-mru-rank "/c/alpha")))
+ (should (= 0 (cj/--ai-vterm-mru-rank "/c/beta/")))
+ (should-not (cj/--ai-vterm-mru-rank "/c/gamma"))))
+
+(provide 'test-ai-vterm--record-mru)
+;;; test-ai-vterm--record-mru.el ends here
diff --git a/tests/test-ai-vterm--show-or-create.el b/tests/test-ai-vterm--show-or-create.el
index 0a3dbde5..01083f84 100644
--- a/tests/test-ai-vterm--show-or-create.el
+++ b/tests/test-ai-vterm--show-or-create.el
@@ -57,8 +57,10 @@ VARS is a plist of capture variable names: :calls, :strings, :returns,
(kill-buffer name)))
(ert-deftest test-ai-vterm--show-or-create-creates-when-buffer-missing ()
- "Normal: no existing buffer -> vterm called once, launch cmd sent."
- (let ((name "agent [normal-create-test]"))
+ "Normal: no existing buffer -> vterm called once, launch cmd sent, the
+project recorded at the front of the MRU list."
+ (let ((name "agent [normal-create-test]")
+ (cj/--ai-vterm-mru nil))
(test-ai-vterm--cleanup name)
(unwind-protect
(test-ai-vterm--with-mock-vterm (:calls calls :strings strings
@@ -68,7 +70,8 @@ VARS is a plist of capture variable names: :calls, :strings, :returns,
(should (equal strings
(list (cj/--ai-vterm-launch-command "/tmp/some-project"))))
(should (= returns 1))
- (should (equal ddir "/tmp/some-project")))
+ (should (equal ddir "/tmp/some-project"))
+ (should (equal (car cj/--ai-vterm-mru) "/tmp/some-project")))
(test-ai-vterm--cleanup name))))
(ert-deftest test-ai-vterm--show-or-create-displays-existing-when-process-live ()
diff --git a/tests/test-ai-vterm--sort-candidates.el b/tests/test-ai-vterm--sort-candidates.el
index 5e3d760a..26953604 100644
--- a/tests/test-ai-vterm--sort-candidates.el
+++ b/tests/test-ai-vterm--sort-candidates.el
@@ -3,8 +3,10 @@
;;; Commentary:
;; The project picker lists candidates with a live tmux session first
;; (so an agent that survived an Emacs crash is easy to get back to),
-;; then everything else. Within each group the order is alphabetical
-;; by abbreviated path.
+;; then everything else. Within the active group, projects opened this
+;; session (`cj/--ai-vterm-mru') lead, most-recent first; the rest of the
+;; active group, and the whole no-session group, sort alphabetically by
+;; abbreviated path. With an empty MRU it's just active-first-then-alpha.
;;; Code:
@@ -40,6 +42,27 @@
"Boundary: no candidates -> nil."
(should (null (cj/--ai-vterm-sort-candidates nil '("aiv-foo")))))
+(ert-deftest test-ai-vterm--sort-candidates-active-group-mru-first ()
+ "Normal: within the active group, recently-opened projects lead in MRU
+order; active dirs not opened this session fall after them alpha; the
+no-session group trails, alpha."
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-")
+ (cj/--ai-vterm-mru '("/c/baz" "/c/foo")))
+ (should (equal (cj/--ai-vterm-sort-candidates
+ '("/c/foo" "/c/bar" "/c/baz" "/c/qux")
+ '("aiv-foo" "aiv-bar" "aiv-baz"))
+ '("/c/baz" "/c/foo" "/c/bar" "/c/qux")))))
+
+(ert-deftest test-ai-vterm--sort-candidates-mru-does-not-bump-inactive ()
+ "Boundary: an MRU dir whose tmux session has died sorts alpha in the
+no-session group, not at the top."
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-")
+ (cj/--ai-vterm-mru '("/c/zed")))
+ (should (equal (cj/--ai-vterm-sort-candidates
+ '("/c/foo" "/c/zed" "/c/bar")
+ '("aiv-foo"))
+ '("/c/foo" "/c/bar" "/c/zed")))))
+
(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-"))
diff --git a/tests/test-ai-vterm--tmux-session-name.el b/tests/test-ai-vterm--tmux-session-name.el
index 8d9220eb..073dc312 100644
--- a/tests/test-ai-vterm--tmux-session-name.el
+++ b/tests/test-ai-vterm--tmux-session-name.el
@@ -5,8 +5,10 @@
;; the project's basename, so reopening the agent 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.
+;; down to AI-vterm's own sessions. The basename is sanitized to a form
+;; tmux won't re-mangle: runs of whitespace become hyphens, and `.' / `:'
+;; (which tmux disallows in session names and silently rewrites to `_')
+;; become `_' up front so the computed name matches the real session.
;;; Code:
@@ -27,11 +29,19 @@
(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)."
+(ert-deftest test-ai-vterm--tmux-session-name-dots-become-underscores ()
+ "Boundary: tmux disallows `.' in session names and rewrites it to `_',
+so the basename's dots are sanitized to `_' up front -- `.emacs.d' must
+yield `aiv-_emacs_d', matching the session tmux actually creates."
(let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
(should (equal (cj/--ai-vterm-tmux-session-name "/home/cjennings/.emacs.d")
- "aiv-.emacs.d"))))
+ "aiv-_emacs_d"))))
+
+(ert-deftest test-ai-vterm--tmux-session-name-colon-becomes-underscore ()
+ "Boundary: `:' is also disallowed by tmux in session names -> `_'."
+ (let ((cj/ai-vterm-tmux-session-prefix "aiv-"))
+ (should (equal (cj/--ai-vterm-tmux-session-name "/tmp/a:b")
+ "aiv-a_b"))))
(ert-deftest test-ai-vterm--tmux-session-name-space-becomes-hyphen ()
"Boundary: a space in the basename is replaced with a hyphen."