aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-13 23:45:37 -0500
committerCraig Jennings <c@cjennings.net>2026-07-13 23:45:37 -0500
commit03a1f26b2879f678a48c036a9e6f4b9ace864c3b (patch)
tree3b0cf5c6337ea9b83cfc5f23235c19eb95f0f700 /modules
parent42cb6c172ebd8fa61de39244a049bb23e3d0a69a (diff)
downloaddotemacs-03a1f26b2879f678a48c036a9e6f4b9ace864c3b.tar.gz
dotemacs-03a1f26b2879f678a48c036a9e6f4b9ace864c3b.zip
feat(ai-term): pick the agent runtime at session launch
A fresh agent session now asks which runtime to run: claude, codex, or any local ollama model via codex --oss. RET keeps claude, so the common launch stays Enter-Enter. Reattaches and redisplays never ask. The picker fires only when neither a live agent buffer nor a surviving tmux session exists. I mirrored the runtime names and launch strings from the bin/ai launcher so the two stay one mental model, and the choice list itself comes from "ai --print-runtimes" when that launcher is installed, reusing its live ollama scan and dead-server timeout. A static claude-first list stands in without it. The explicit --local-provider=ollama flag is deliberate: configuring the provider through config.toml silently does nothing. The chosen command threads through show-or-create into the tmux launch line. On reattach, tmux new-session -A ignores it, so no reattach path changes behavior. cj/ai-term-agent-command keeps its meaning as the claude runtime's full line. Thirteen tests cover the runtime-to-command map, the print-runtimes parsing, the launcher fallback, and the launch-command override.
Diffstat (limited to 'modules')
-rw-r--r--modules/ai-term-backend-eat.el8
-rw-r--r--modules/ai-term-sessions.el12
-rw-r--r--modules/ai-term.el109
3 files changed, 119 insertions, 10 deletions
diff --git a/modules/ai-term-backend-eat.el b/modules/ai-term-backend-eat.el
index 9a166ff8..be84ef25 100644
--- a/modules/ai-term-backend-eat.el
+++ b/modules/ai-term-backend-eat.el
@@ -100,7 +100,7 @@ typed into a bare shell. Returns the poll timer."
(cancel-timer timer))))))
timer))
-(defun cj/--ai-term-show-or-create (dir name)
+(defun cj/--ai-term-show-or-create (dir name &optional agent-command)
"Show or create the AI-term buffer for project DIR with buffer NAME.
If a buffer named NAME exists with a live process, display it. If
@@ -108,6 +108,10 @@ the buffer exists but its process is dead, kill it and recreate. If
no such buffer exists, create a new EAT terminal in DIR and send
the project's tmux launch command (see `cj/--ai-term-launch-command') so
the same project basename reattaches across Emacs restarts.
+AGENT-COMMAND, when non-nil, is the full agent launch command for a
+fresh session (the multi-backend picker's choice); nil falls back to
+`cj/ai-term-agent-command'. A reattach ignores it (`tmux new-session
+-A' attaches without running the command).
EAT runs a plain shell with no auto-tmux hook, so the named
`tmux new-session -A' launch command is the only thing that starts the
@@ -148,7 +152,7 @@ buffer."
(with-current-buffer buf
(cj/--ai-term-apply-accent buf)
(cj/--ai-term-send-string
- buf (concat (cj/--ai-term-launch-command dir) "\n")))
+ buf (concat (cj/--ai-term-launch-command dir agent-command) "\n")))
(when fresh
(cj/--ai-term-schedule-color buf (cj/--ai-term-project-color dir)))
(display-buffer buf)
diff --git a/modules/ai-term-sessions.el b/modules/ai-term-sessions.el
index 99585a70..fab6b0a6 100644
--- a/modules/ai-term-sessions.el
+++ b/modules/ai-term-sessions.el
@@ -157,7 +157,7 @@ looked up in SESSIONS, so the lossy whitespace->hyphen transform in
`cj/--ai-term-tmux-session-name' never needs reversing."
(and (member (cj/--ai-term-tmux-session-name dir) sessions) t))
-(defun cj/--ai-term-launch-command (dir)
+(defun cj/--ai-term-launch-command (dir &optional agent-command)
"Return the shell command line that runs the AI tool in a project tmux session.
Uses `tmux new-session -A' so a second toggle on the same project reattaches
@@ -167,9 +167,12 @@ comes from `cj/--ai-term-tmux-session-name'; the first window is named
window auto-names after its command and the two read distinctly.
The shell command run on first creation is
- <cj/ai-term-agent-command>; exec bash
+ <agent command>; exec bash
so the tmux window survives the AI command exiting -- the session stays
-alive with a bare bash prompt for recovery, and reattach works the same way."
+alive with a bare bash prompt for recovery, and reattach works the same way.
+AGENT-COMMAND overrides `cj/ai-term-agent-command' for the fresh-session
+case (the multi-backend picker passes the chosen runtime's command); on a
+reattach `tmux new-session -A' ignores the command either way."
(let ((session (cj/--ai-term-tmux-session-name dir))
(start-dir (expand-file-name dir)))
;; Pass the inner shell-command-string through `shell-quote-argument'
@@ -184,7 +187,8 @@ alive with a bare bash prompt for recovery, and reattach works the same way."
(shell-quote-argument cj/ai-term-tmux-window-name)
(shell-quote-argument start-dir)
(shell-quote-argument
- (concat cj/ai-term-agent-command "; exec bash")))))
+ (concat (or agent-command cj/ai-term-agent-command)
+ "; exec bash")))))
(defun cj/--ai-term-kill-tmux-session (session)
"Kill the tmux SESSION via `tmux kill-session -t SESSION'.
diff --git a/modules/ai-term.el b/modules/ai-term.el
index e0706abb..9d7be47e 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -46,10 +46,24 @@
(defcustom cj/ai-term-agent-command
"claude \"Read .ai/protocols.org and follow all instructions.\""
- "Shell command sent to a fresh AI-term to start the agent.
+ "Shell command for the default (\"claude\") agent runtime.
-The default invokes the Claude Code CLI; set it to whatever terminal
-agent you run (aider, an open-source LLM TUI, etc.)."
+Sent to a fresh AI-term when no other runtime is picked; also the
+fallback when launch paths bypass the runtime picker (e.g. attaching a
+detached session, where the command is ignored anyway). Non-Claude
+runtimes compose their commands from `cj/ai-term-agent-prompt' instead
+-- see `cj/--ai-term-runtime-command'."
+ :type 'string
+ :group 'ai-term)
+
+(defcustom cj/ai-term-agent-prompt
+ "Read .ai/protocols.org and follow all instructions."
+ "Opening instructions passed to non-Claude agent runtimes.
+
+Claude, Codex, and codex --oss all take the opening instructions as a
+positional prompt, so this one string serves every runtime; only the
+command in front of it varies (the \"claude\" runtime carries its full
+line in `cj/ai-term-agent-command' for backward compatibility)."
:type 'string
:group 'ai-term)
@@ -240,6 +254,80 @@ without firing real `display-buffer' or `quit-window' calls."
(car buffers))))
(t '(pick-project))))))))
+;; ------------------------- Agent runtime selection ---------------------------
+;; A fresh session can run Claude, Codex (ChatGPT), or a local model through
+;; codex --oss (ollama). Runtime names and launch strings mirror the rulesets
+;; bin/ai launcher so the two launchers stay one mental model: "claude",
+;; "codex", "local:<model>". The choice list itself comes from
+;; `ai --print-runtimes' when that launcher is installed (single source of
+;; truth, including the live ollama model scan with its own timeout); without
+;; it a static claude/codex list stands in.
+
+(defun cj/--ai-term-runtime-command (runtime)
+ "Return the full agent shell command for RUNTIME.
+RUNTIME is \"claude\" (or nil, both meaning `cj/ai-term-agent-command'
+verbatim), \"codex\", or \"local:<model>\" for an ollama model via
+codex --oss. The non-Claude commands append `cj/ai-term-agent-prompt'
+as the positional opening prompt. An unknown RUNTIME signals a
+`user-error' rather than launching something half-formed. The explicit
+--local-provider flag is deliberate: setting the provider through
+config.toml silently does nothing (rulesets, 2026-07-13)."
+ (cond
+ ((or (null runtime) (equal runtime "claude"))
+ cj/ai-term-agent-command)
+ ((equal runtime "codex")
+ (concat "codex " (shell-quote-argument cj/ai-term-agent-prompt)))
+ ((string-prefix-p "local:" runtime)
+ (let ((model (substring runtime (length "local:"))))
+ (when (string-empty-p model)
+ (user-error "Agent runtime %s names no ollama model" runtime))
+ (concat "codex --oss --local-provider=ollama -m "
+ (shell-quote-argument model) " "
+ (shell-quote-argument cj/ai-term-agent-prompt))))
+ (t (user-error "Unknown agent runtime: %s" runtime))))
+
+(defun cj/--ai-term-parse-runtime-lines (output)
+ "Parse `ai --print-runtimes' OUTPUT into an alist of (NAME . LABEL).
+Each line is \"NAME — LABEL\"; blank lines and lines without the
+separator are dropped, so a stray warning in the output degrades to a
+shorter list instead of a parse error."
+ (delq nil
+ (mapcar (lambda (line)
+ (when (string-match "\\`\\(.+?\\) — \\(.+\\)\\'" line)
+ (cons (match-string 1 line) (match-string 2 line))))
+ (split-string output "\n" t))))
+
+(defun cj/--ai-term-runtime-choices ()
+ "Return the agent runtime choices as an alist of (NAME . LABEL).
+Shells out to `ai --print-runtimes' when the launcher is installed --
+that keeps the two launchers' lists identical and reuses its live
+ollama scan (which carries its own dead-server timeout). When the
+launcher is absent, errors, or prints nothing parseable, a static
+claude-first list stands in."
+ (or (when-let* ((ai (executable-find "ai")))
+ (with-temp-buffer
+ (when (eq 0 (ignore-errors
+ (process-file ai nil t nil "--print-runtimes")))
+ (cj/--ai-term-parse-runtime-lines (buffer-string)))))
+ '(("claude" . "Claude Code")
+ ("codex" . "ChatGPT (Codex CLI)"))))
+
+(defun cj/--ai-term-pick-runtime ()
+ "Prompt for the agent runtime of a fresh session; RET picks the first.
+The first choice is claude, so launching a project stays Enter-Enter
+for the common case. Labels annotate the candidates."
+ (let* ((choices (cj/--ai-term-runtime-choices))
+ (default (caar choices)))
+ (completing-read
+ (format "Agent runtime (default %s): " default)
+ (cj/completion-table-annotated
+ 'ai-term-runtime
+ (lambda (cand)
+ (when-let* ((label (cdr (assoc cand choices))))
+ (format " %s" label)))
+ choices)
+ nil t nil nil default)))
+
(defun cj/ai-term-pick-project (&optional arg)
"Pick an AI-agent project and open or reuse its EAT terminal.
@@ -254,12 +342,25 @@ With prefix ARG, display the buffer without selecting its window.
Bound to C-; a s -- always shows the project picker, even when an agent
buffer is currently displayed.
+A genuinely fresh launch (no live agent buffer AND no surviving tmux
+session) also asks which agent runtime to run -- claude, codex, or a
+local model; RET keeps claude, so the common case stays Enter-Enter.
+Reattaches and redisplays never ask: the session already runs whatever
+it runs.
+
EAT renders in terminal frames as well as GUI frames, so this
launches from either."
(interactive "P")
(let* ((dir (cj/--ai-term-pick-project))
(name (cj/--ai-term-buffer-name dir))
- (buf (cj/--ai-term-show-or-create dir name)))
+ (existing (get-buffer name))
+ (fresh (and (not (and existing
+ (cj/--ai-term-process-live-p existing)))
+ (not (cj/--ai-term-session-active-p
+ dir (cj/--ai-term-live-tmux-sessions)))))
+ (command (when fresh
+ (cj/--ai-term-runtime-command (cj/--ai-term-pick-runtime))))
+ (buf (cj/--ai-term-show-or-create dir name command)))
(unless arg
(let ((win (get-buffer-window buf)))
(when win (select-window win))))