diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-11 13:27:26 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-11 13:27:26 -0500 |
| commit | c14d6c8d0f669d694dd765d6f592ce6eb72c50f5 (patch) | |
| tree | 26c45c9f9f77129a8440019a6825a24a1eea028d | |
| parent | de8659c61e47787c635ad9a6184fcef655ae89d2 (diff) | |
| download | dotemacs-c14d6c8d0f669d694dd765d6f592ce6eb72c50f5.tar.gz dotemacs-c14d6c8d0f669d694dd765d6f592ce6eb72c50f5.zip | |
feat(ai-vterm): order the project picker by most-recently-used
The picker's active group (projects with a live tmux session) used to sort alphabetically. It now leads with projects opened this session, most-recent first, then the rest of the active group alpha, then the no-session group alpha. An in-session list (`cj/--ai-vterm-mru'), pushed to the front by `cj/--ai-vterm-show-or-create' on every open, drives the order. An empty list reproduces the old alphabetical behavior.
I also pulled in a fix: `cj/--ai-vterm-tmux-session-name' now sanitizes `.' and `:' in the basename to `_'. tmux disallows those chars in session names and silently rewrites them, so `.emacs.d' really runs in session `aiv-_emacs_d', not `aiv-.emacs.d'. The computed name never matched, so `.emacs.d' was wrongly treated as having no session and landed in the no-session picker group. (A crash-recovery would also spawn a duplicate instead of reattaching.) Sanitizing the same way tmux does keeps the names in sync.
| -rw-r--r-- | modules/ai-vterm.el | 68 | ||||
| -rw-r--r-- | tests/test-ai-vterm--record-mru.el | 48 | ||||
| -rw-r--r-- | tests/test-ai-vterm--show-or-create.el | 9 | ||||
| -rw-r--r-- | tests/test-ai-vterm--sort-candidates.el | 27 | ||||
| -rw-r--r-- | tests/test-ai-vterm--tmux-session-name.el | 20 |
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." |
