aboutsummaryrefslogtreecommitdiff
path: root/modules/ai-term.el
diff options
context:
space:
mode:
Diffstat (limited to 'modules/ai-term.el')
-rw-r--r--modules/ai-term.el224
1 files changed, 181 insertions, 43 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el
index e0706abb..52494c18 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,27 @@ 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))
+ ;; One tmux fetch per launch: the same list feeds the picker's sorting,
+ ;; the fresh check here, and show-or-create's own fresh check.
+ (let* ((sessions (cj/--ai-term-live-tmux-sessions))
+ (dir (cj/--ai-term-pick-project sessions))
(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 sessions))))
+ (command (when fresh
+ (cj/--ai-term-runtime-command (cj/--ai-term-pick-runtime))))
+ (buf (cj/--ai-term-show-or-create dir name command sessions)))
(unless arg
(let ((win (get-buffer-window buf)))
(when win (select-window win))))
@@ -299,18 +402,22 @@ C-; a k closes an agent via `cj/ai-term-close'."
(defun cj/--ai-term-close-buffer (buffer)
"Gracefully tear down AI-term BUFFER: tmux session, then buffer.
-Derives the tmux session name from BUFFER's `default-directory' (the
-project dir the terminal was created in) and kills it so the agent
-process stops. When BUFFER is shown, swaps its window to a non-agent
-buffer (the working file) rather than deleting the window -- closing an
-agent must not collapse the user's window layout; the hide toggle is
-what collapses the split. Then kills BUFFER (suppressing the
+Derives the tmux session name from BUFFER's immutable name (\"agent
+[<basename>]\") and kills it so the agent process stops. The name, not
+`default-directory', is the reliable key: ghostel retargets the
+directory via OSC 7 as the shell cds, so a directory-derived name after
+a cd misses the real session (orphaning the agent) or collides with a
+different aiv- session. When BUFFER is shown, swaps its window to a
+non-agent buffer (the working file) rather than deleting the window --
+closing an agent must not collapse the user's window layout; the hide
+toggle is what collapses the split. Then kills BUFFER (suppressing the
process-still-running prompt -- the session is already down). No-op
when BUFFER isn't an AI-term buffer."
(when (cj/--ai-term-buffer-p buffer)
(cj/--ai-term-kill-tmux-session
(cj/--ai-term-tmux-session-name
- (buffer-local-value 'default-directory buffer)))
+ (or (cj/--ai-term-buffer-basename buffer)
+ (buffer-local-value 'default-directory buffer))))
(let ((win (get-buffer-window buffer)))
(when (window-live-p win)
(cj/--ai-term-swap-to-working-buffer win)))
@@ -353,26 +460,18 @@ interrupt work in progress. Bound to C-; a k."
;; ------------------------- Step to the next agent ----------------------------
-(defun cj/ai-term-next ()
- "Step to the next open AI-term agent in the queue.
-
-The queue is every active agent ordered by buffer name -- a stable
-rotation, unaffected by which agent was most recently selected. Active
-means a live agent buffer (attached) OR a live tmux session with no Emacs
-buffer (detached); stepping onto a detached agent attaches it (recreates
-its terminal, which reattaches the session). When an agent window is on
-screen, swap it to the next agent (wrapping after the last) and select it.
-When no agent is displayed but agents exist, show the first. When none
-are open, open the project picker to launch the first agent rather than
-erroring. When the sole agent is already focused, echo that there are
-no other ai-terms to switch to instead of swapping to itself.
-
-Bound to M-SPC. Unlike C-; a a (toggle the most-recent agent on/off), this
-is the \"switch among existing agents\" surface; C-; a s opens the project
-picker and C-; a k closes an agent."
- (interactive)
- (let* ((dirs (cj/--ai-term-active-agent-dirs))
- (win (cj/--ai-term-displayed-agent-window))
+(defun cj/--ai-term-step-among (dirs)
+ "Step to the next AI-term agent among DIRS, an ordered active-dir list.
+
+Shared body for `cj/ai-term-next' (all active agents) and
+`cj/ai-term-next-attached' (attached agents only). When an agent window
+is on screen, swap it to the next agent in DIRS (wrapping after the last)
+and select it: a live attached agent swaps buffer-only, a detached one is
+materialized by `cj/--ai-term-show-or-create'. When DIRS is empty, open
+the project picker rather than erroring, so the swap key doubles as a
+start-an-agent key. When the sole eligible agent is already focused, echo
+that there is nowhere else to go instead of swapping to itself."
+ (let* ((win (cj/--ai-term-displayed-agent-window))
(current-name (and win (buffer-name (window-buffer win))))
(current-dir (and current-name
(seq-find (lambda (d)
@@ -381,8 +480,8 @@ picker and C-; a k closes an agent."
(next-dir (cj/--ai-term-next-agent-dir current-dir dirs)))
(cond
((not next-dir)
- ;; No agents open: launch the first via the project picker instead of
- ;; erroring, so the swap key doubles as a "start an agent" key.
+ ;; No eligible agents: launch the first via the project picker instead
+ ;; of erroring, so the swap key doubles as a "start an agent" key.
(cj/ai-term-pick-project))
;; Sole agent, already focused: the rotation wraps back to the same
;; agent, so a swap would be a silent no-op. Say there's nowhere to
@@ -407,16 +506,49 @@ picker and C-; a k closes an agent."
(let ((w (get-buffer-window name)))
(when w (select-window w)))))))))
+(defun cj/ai-term-next ()
+ "Step to the next open AI-term agent -- attached or detached.
+
+The queue is every active agent ordered by buffer name -- a stable
+rotation, unaffected by which agent was most recently selected. Active
+means a live agent buffer (attached) OR a live tmux session with no Emacs
+buffer (detached); stepping onto a detached agent attaches it (recreates
+its terminal, which reattaches the session).
+
+Bound to M-S-SPC (and C-; a n). For a chord that stays among the agents
+already on screen, use `cj/ai-term-next-attached' (M-SPC). Unlike C-; a a
+\(toggle the most-recent agent on/off), this is the \"switch among existing
+agents\" surface; C-; a s opens the project picker and C-; a k closes an
+agent."
+ (interactive)
+ (cj/--ai-term-step-among (cj/--ai-term-active-agent-dirs)))
+
+(defun cj/ai-term-next-attached ()
+ "Step to the next ATTACHED AI-term agent -- live Emacs buffers only.
+
+Cycles only agents currently on screen (a live agent buffer), skipping
+detached tmux sessions. Use `cj/ai-term-next' (M-S-SPC) to include
+detached sessions and attach them. When no agent is attached, opens the
+project picker.
+
+Bound to M-SPC -- the fast \"swap to the next visible agent\" chord."
+ (interactive)
+ (cj/--ai-term-step-among (cj/--ai-term-attached-agent-dirs)))
+
;; ai-term lives under the C-; a prefix (vacated when gptel was archived).
-;; The frequent "swap to the next agent" also gets M-SPC for a fast chord.
+;; The frequent "swap to the next agent" gets M-SPC (attached only) for a fast
+;; chord, with M-S-SPC to include detached sessions.
(defvar-keymap cj/ai-term-keymap
:doc "Keymap for ai-term agent commands (C-; a)."
"a" #'cj/ai-term ;; toggle the most-recent agent on/off
"s" #'cj/ai-term-pick-project ;; select / launch via the project picker
- "n" #'cj/ai-term-next ;; swap to the next open agent
+ "n" #'cj/ai-term-next ;; swap to the next open agent (all)
"k" #'cj/ai-term-close) ;; kill the current agent
(cj/register-prefix-map "a" cj/ai-term-keymap "ai-term")
-(keymap-global-set "M-SPC" #'cj/ai-term-next)
+;; M-SPC cycles only attached agents (on-screen); M-S-SPC cycles all, attaching
+;; a detached tmux session when it lands on one.
+(keymap-global-set "M-SPC" #'cj/ai-term-next-attached)
+(keymap-global-set "M-S-SPC" #'cj/ai-term-next)
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
@@ -425,7 +557,8 @@ picker and C-; a k closes an agent."
"C-; a s" "select / launch"
"C-; a n" "next agent"
"C-; a k" "kill agent"
- "M-SPC" "ai-term: next agent"))
+ "M-SPC" "ai-term: next attached"
+ "M-S-SPC" "ai-term: next (all)"))
;; ------------------- Wrap-it-up teardown + shutdown -------------------------
;;
@@ -446,11 +579,16 @@ A defcustom so development and tests can stub it instead of powering off
(defun cj/ai-term-quit (&optional project)
"Tear down PROJECT's AI-term: kill its tmux session, buffer, and restore layout.
PROJECT is a project basename (as the rulesets Stop hook passes) or a directory;
-nil means the current project (`default-directory'). Kills the `aiv-<name>'
-tmux session (taking the agent process with it), then, when the agent buffer is
-live, swaps its window back to the working buffer and kills it. Idempotent and
-safe headless: a session or buffer already gone is a no-op, not an error."
- (let* ((key (or project default-directory))
+nil means the current project -- the current agent buffer's embedded basename
+when called from inside one (immune to the OSC 7 `default-directory' drift a
+cd in the agent shell causes), else `default-directory'. Kills the
+`aiv-<name>' tmux session (taking the agent process with it), then, when the
+agent buffer is live, swaps its window back to the working buffer and kills
+it. Idempotent and safe headless: a session or buffer already gone is a
+no-op, not an error."
+ (let* ((key (or project
+ (cj/--ai-term-buffer-basename (current-buffer))
+ default-directory))
(session (cj/--ai-term-tmux-session-name key))
(buffer (get-buffer (cj/--ai-term-buffer-name key))))
(cj/--ai-term-kill-tmux-session session)