diff options
| -rw-r--r-- | docs/design/ai-vterm.org | 15 | ||||
| -rw-r--r-- | modules/ai-vterm.el | 51 | ||||
| -rw-r--r-- | modules/eshell-vterm-config.el | 10 | ||||
| -rw-r--r-- | tests/test-ai-vterm--launch-command.el | 61 | ||||
| -rw-r--r-- | tests/test-ai-vterm--show-or-create.el | 8 | ||||
| -rw-r--r-- | tests/test-ai-vterm--tmux-session-name.el | 43 |
6 files changed, 178 insertions, 10 deletions
diff --git a/docs/design/ai-vterm.org b/docs/design/ai-vterm.org index 62bafbc8..99526b63 100644 --- a/docs/design/ai-vterm.org +++ b/docs/design/ai-vterm.org @@ -106,9 +106,22 @@ After this, all navigation is handled by existing global bindings: Shift-arrows | Side-window already showing a different =claude [...]= | =display-buffer= swaps which buffer occupies the slot; hidden one keeps running | | =vterm= not installed | Module fails to load loudly (no graceful degradation) | +** Per-project tmux sessions + +The launch command sent to a fresh AI-vterm shell is + +#+begin_example +tmux new-session -A -s <basename> -c <dir> '<claude-cmd>; exec bash' +#+end_example + +- =-A= reattaches to an existing session of the same name instead of creating a new one. So a second F9 on the same project after an Emacs crash brings the running Claude back without spawning a duplicate. +- =-s <basename>= names the session after the project's directory basename. =tmux ls= shows the active sessions by project name. +- =-c <dir>= sets the start directory for new sessions (ignored on attach). +- =exec bash= tails the shell command so the tmux window survives Claude exiting -- the session stays alive with a bare prompt for recovery, and reattach is unaffected. + ** Tmux Auto-Launch Suppression -Existing =cj/vterm-launch-tmux= on =vterm-mode-hook= types =tmux\n= unconditionally. AI vterms set a buffer-local =cj/--ai-vterm-suppress-tmux= flag before =(vterm)=; the hook checks the flag and skips tmux when set. +The existing =cj/vterm-launch-tmux= on =vterm-mode-hook= types =tmux\n= unconditionally for any vterm buffer. AI-vterm =let='s a dynamic =cj/--ai-vterm-suppress-tmux= flag around =(vterm)= so the hook skips its bare =tmux\n= and the project-named launch command (above) runs instead. ** Testing diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el index afda4f44..4d83127a 100644 --- a/modules/ai-vterm.el +++ b/modules/ai-vterm.el @@ -32,6 +32,15 @@ :type 'string :group 'ai-vterm) +(defvar cj/--ai-vterm-suppress-tmux nil + "When non-nil, the generic vterm tmux-launch hook skips its auto-tmux step. + +ai-vterm dynamically binds this around `(vterm)' so the hook in +eshell-vterm-config.el doesn't send a bare \"tmux\\n\" before the named +session launch command runs. The hook reads the variable via +`bound-and-true-p' so loading order between the two modules doesn't +matter.") + (defcustom cj/ai-vterm-project-roots (list (expand-file-name "~/.emacs.d")) "Directories that are themselves Claude-template projects. @@ -58,6 +67,33 @@ breaks routing to the right-side window." (format "claude [%s]" (file-name-nondirectory (directory-file-name dir)))) +(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)))) + +(defun cj/--ai-vterm-launch-command (dir) + "Return the shell command line that runs Claude in a project tmux session. + +Uses `tmux new-session -A' so a second F9 on the same project reattaches +to the running session instead of spawning a new one. The session name +is the project's basename via `cj/--ai-vterm-tmux-session-name'. + +The shell command run on first creation is + <claude-command>; exec bash +so the tmux window survives Claude exiting -- the session stays alive +with a bare bash prompt for recovery, and reattach works the same way." + (let ((session (cj/--ai-vterm-tmux-session-name dir)) + (start-dir (expand-file-name dir))) + (format "tmux new-session -A -s %s -c %s '%s'" + (shell-quote-argument session) + (shell-quote-argument start-dir) + (concat cj/ai-vterm-claude-command "; exec bash")))) + (defun cj/--ai-vterm-has-marker-p (dir) "Return non-nil when DIR contains .ai/protocols.org." (file-exists-p (expand-file-name ".ai/protocols.org" dir))) @@ -131,8 +167,14 @@ window an ordinary window so all the standard window commands work." If a buffer named NAME exists with a live process, display it. If the buffer exists but its process is dead, kill it and recreate. If -no such buffer exists, create a new vterm in DIR and send -`cj/ai-vterm-claude-command' to it. +no such buffer exists, create a new vterm in DIR and send the +project's tmux launch command (see `cj/--ai-vterm-launch-command') so +the same project basename reattaches across Emacs restarts. + +The dynamic binding of `cj/--ai-vterm-suppress-tmux' around `(vterm)' +suppresses the generic tmux-launch hook in eshell-vterm-config.el so +it doesn't fire a bare \"tmux\\n\" before the project-named launch +command runs. Returns the buffer." (let ((existing (get-buffer name))) @@ -143,11 +185,12 @@ Returns the buffer." (t (when existing (kill-buffer existing)) - (let ((default-directory dir)) + (let ((default-directory dir) + (cj/--ai-vterm-suppress-tmux t)) (vterm name)) (let ((buf (get-buffer name))) (with-current-buffer buf - (vterm-send-string cj/ai-vterm-claude-command) + (vterm-send-string (cj/--ai-vterm-launch-command dir)) (vterm-send-return)) (display-buffer buf) buf))))) diff --git a/modules/eshell-vterm-config.el b/modules/eshell-vterm-config.el index 4c22944b..31c3a96e 100644 --- a/modules/eshell-vterm-config.el +++ b/modules/eshell-vterm-config.el @@ -202,10 +202,16 @@ (display-line-numbers-mode -1)) (defun cj/vterm-launch-tmux () - "Automatically launch tmux in vterm if not already in a tmux session." + "Automatically launch tmux in vterm if not already in a tmux session. + +Skipped when `cj/--ai-vterm-suppress-tmux' is non-nil so the AI-vterm +flow can run its own project-named tmux session instead of a bare, +auto-named one. `bound-and-true-p' keeps this safe whether or not +ai-vterm.el is loaded." (let ((proc (get-buffer-process (current-buffer)))) (when (and proc - (not (getenv "TMUX"))) ; Check if not already in tmux + (not (getenv "TMUX")) ; Check if not already in tmux + (not (bound-and-true-p cj/--ai-vterm-suppress-tmux))) (vterm-send-string "tmux\n")))) :hook ((vterm-mode . cj/turn-off-chrome-for-vterm) diff --git a/tests/test-ai-vterm--launch-command.el b/tests/test-ai-vterm--launch-command.el new file mode 100644 index 00000000..c6b7ac2b --- /dev/null +++ b/tests/test-ai-vterm--launch-command.el @@ -0,0 +1,61 @@ +;;; test-ai-vterm--launch-command.el --- Tests for cj/--ai-vterm-launch-command -*- lexical-binding: t; -*- + +;;; 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. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-vterm) + +(ert-deftest test-ai-vterm--launch-command-uses-new-session-attach () + "Normal: starts with `tmux new-session -A' so existing sessions reattach." + (let ((cj/ai-vterm-claude-command "claude")) + (should (string-prefix-p + "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")) + (should (string-match-p + " -s foo " + (cj/--ai-vterm-launch-command "/code/foo"))))) + +(ert-deftest test-ai-vterm--launch-command-includes-start-directory () + "Normal: `-c <dir>' so the new session's first window starts in DIR." + (let ((cj/ai-vterm-claude-command "claude")) + (should (string-match-p + " -c /code/foo " + (cj/--ai-vterm-launch-command "/code/foo"))))) + +(ert-deftest test-ai-vterm--launch-command-includes-claude-command () + "Normal: the configured claude command is in the launched shell command." + (let ((cj/ai-vterm-claude-command "claude --some-flag")) + (should (string-match-p + "claude --some-flag" + (cj/--ai-vterm-launch-command "/code/foo"))))) + +(ert-deftest test-ai-vterm--launch-command-tails-with-exec-bash () + "Boundary: `exec bash' tails so the tmux window survives Claude exiting." + (let ((cj/ai-vterm-claude-command "claude")) + (should (string-match-p + "exec bash" + (cj/--ai-vterm-launch-command "/code/foo"))))) + +(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")) + (should (string-match-p + " -s my-work " + (cj/--ai-vterm-launch-command "/code/my work"))))) + +(provide 'test-ai-vterm--launch-command) +;;; test-ai-vterm--launch-command.el ends here diff --git a/tests/test-ai-vterm--show-or-create.el b/tests/test-ai-vterm--show-or-create.el index 28e0faeb..3faf5f03 100644 --- a/tests/test-ai-vterm--show-or-create.el +++ b/tests/test-ai-vterm--show-or-create.el @@ -57,7 +57,7 @@ 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, claude cmd sent." + "Normal: no existing buffer -> vterm called once, launch cmd sent." (let ((name "claude [normal-create-test]")) (test-ai-vterm--cleanup name) (unwind-protect @@ -65,7 +65,8 @@ VARS is a plist of capture variable names: :calls, :strings, :returns, :returns returns :default-dir ddir) (cj/--ai-vterm-show-or-create "/tmp/some-project" name) (should (equal calls (list name))) - (should (equal strings (list cj/ai-vterm-claude-command))) + (should (equal strings + (list (cj/--ai-vterm-launch-command "/tmp/some-project")))) (should (= returns 1)) (should (equal ddir "/tmp/some-project"))) (test-ai-vterm--cleanup name)))) @@ -98,7 +99,8 @@ VARS is a plist of capture variable names: :calls, :strings, :returns, :returns returns :default-dir _ddir) (cj/--ai-vterm-show-or-create "/tmp/dead" name) (should (equal calls (list name))) - (should (equal strings (list cj/ai-vterm-claude-command))) + (should (equal strings + (list (cj/--ai-vterm-launch-command "/tmp/dead")))) (should (= returns 1)) (should-not (buffer-live-p stale))))) (test-ai-vterm--cleanup name)))) diff --git a/tests/test-ai-vterm--tmux-session-name.el b/tests/test-ai-vterm--tmux-session-name.el new file mode 100644 index 00000000..9d56040e --- /dev/null +++ b/tests/test-ai-vterm--tmux-session-name.el @@ -0,0 +1,43 @@ +;;; 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. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(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"))) + +(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"))) + +(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"))) + +(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"))) + +(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"))) + +(provide 'test-ai-vterm--tmux-session-name) +;;; test-ai-vterm--tmux-session-name.el ends here |
