From ebdf9e466b0e1f86e9b7d76650ac32408273e7a7 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 5 Jun 2026 05:28:58 -0500 Subject: feat(term): replace vterm with ghostel as the terminal engine I swapped the terminal engine from vterm to ghostel (libghostty-vt) everywhere. term-config replaces vterm-config (the F12 terminal, the C-; x menu, tmux history capture), and ai-term replaces ai-vterm (the F9 Claude-agent launcher). ghostel renders the agent TUI without vterm's flicker under heavy streaming, and one engine now covers every terminal workflow. Two behavior changes fall out of the swap. F9 launches in a terminal frame now: ghostel renders in TTY frames, so the old GUI-only guard is gone. Terminal windows no longer dim when unfocused: ghostel resolves its palette into the native module per-terminal, so there's no per-window color hook to dim through the way vterm had. auto-dim drops its vterm color-advice path, the dashboard Terminal button launches ghostel, and the vterm and vterm-toggle packages are removed. The tmux pane-history and copy-mode machinery carried over unchanged. It keys on the pty tty, which ghostel exposes. --- docs/design/init-load-graph.org | 2 +- docs/design/module-inventory.org | 4 +- docs/design/vterm-to-ghostel-migration-spec.org | 415 ++++++++++ init.el | 4 +- modules/ai-term.el | 974 +++++++++++++++++++++++ modules/ai-vterm.el | 978 ------------------------ modules/auto-dim-config.el | 186 +---- modules/cj-window-geometry-lib.el | 4 +- modules/cj-window-toggle-lib.el | 4 +- modules/dashboard-config.el | 3 +- modules/term-config.el | 396 ++++++++++ modules/ui-config.el | 19 +- modules/vterm-config.el | 540 ------------- tests/test-ai-term--agent-buffers.el | 59 ++ tests/test-ai-term--buffer-name.el | 42 + tests/test-ai-term--candidates.el | 139 ++++ tests/test-ai-term--capture-state.el | 63 ++ tests/test-ai-term--close.el | 86 +++ tests/test-ai-term--collapse-split.el | 171 +++++ tests/test-ai-term--default-geometry.el | 56 ++ tests/test-ai-term--dispatch.el | 70 ++ tests/test-ai-term--display-rule.el | 78 ++ tests/test-ai-term--display-saved.el | 173 +++++ tests/test-ai-term--displayed-agent-window.el | 60 ++ tests/test-ai-term--f9-in-term.el | 45 ++ tests/test-ai-term--launch-command.el | 94 +++ tests/test-ai-term--live-tmux-sessions.el | 71 ++ tests/test-ai-term--pick-project.el | 117 +++ tests/test-ai-term--record-mru.el | 48 ++ tests/test-ai-term--reuse-edge-window.el | 273 +++++++ tests/test-ai-term--reuse-existing-agent.el | 99 +++ tests/test-ai-term--server-display.el | 127 +++ tests/test-ai-term--show-or-create.el | 155 ++++ tests/test-ai-term--single-window-toggle.el | 186 +++++ tests/test-ai-term--sort-candidates.el | 74 ++ tests/test-ai-term--tmux-session-name.el | 65 ++ tests/test-ai-vterm--agent-buffers.el | 59 -- tests/test-ai-vterm--buffer-name.el | 42 - tests/test-ai-vterm--candidates.el | 139 ---- tests/test-ai-vterm--capture-state.el | 63 -- tests/test-ai-vterm--close.el | 86 --- tests/test-ai-vterm--collapse-split.el | 171 ----- tests/test-ai-vterm--default-geometry.el | 56 -- tests/test-ai-vterm--dispatch.el | 70 -- tests/test-ai-vterm--display-rule.el | 78 -- tests/test-ai-vterm--display-saved.el | 173 ----- tests/test-ai-vterm--displayed-agent-window.el | 60 -- tests/test-ai-vterm--f9-in-vterm.el | 47 -- tests/test-ai-vterm--launch-command.el | 94 --- tests/test-ai-vterm--live-tmux-sessions.el | 71 -- tests/test-ai-vterm--pick-project.el | 117 --- tests/test-ai-vterm--record-mru.el | 48 -- tests/test-ai-vterm--reuse-edge-window.el | 273 ------- tests/test-ai-vterm--reuse-existing-agent.el | 99 --- tests/test-ai-vterm--server-display.el | 127 --- tests/test-ai-vterm--show-or-create.el | 163 ---- tests/test-ai-vterm--single-window-toggle.el | 186 ----- tests/test-ai-vterm--sort-candidates.el | 74 -- tests/test-ai-vterm--terminal-guard.el | 78 -- tests/test-ai-vterm--tmux-session-name.el | 65 -- tests/test-auto-dim-config.el | 106 +-- tests/test-dashboard-config-launchers.el | 2 +- tests/test-init-module-headers.el | 5 +- tests/test-term-tmux-history.el | 312 ++++++++ tests/test-term-toggle--buffer-filter.el | 94 +++ tests/test-term-toggle--dispatch.el | 53 ++ tests/test-term-toggle--display.el | 87 +++ tests/test-ui-config--buffer-cursor-state.el | 50 +- tests/test-vterm-copy-mode-cursor.el | 145 ---- tests/test-vterm-tmux-history.el | 383 ---------- tests/test-vterm-toggle--buffer-filter.el | 94 --- tests/test-vterm-toggle--dispatch.el | 53 -- tests/test-vterm-toggle--display.el | 87 --- tests/testutil-ghostel-buffers.el | 49 ++ tests/testutil-vterm-buffers.el | 51 -- todo.org | 101 ++- 76 files changed, 4880 insertions(+), 5111 deletions(-) create mode 100644 docs/design/vterm-to-ghostel-migration-spec.org create mode 100644 modules/ai-term.el delete mode 100644 modules/ai-vterm.el create mode 100644 modules/term-config.el delete mode 100644 modules/vterm-config.el create mode 100644 tests/test-ai-term--agent-buffers.el create mode 100644 tests/test-ai-term--buffer-name.el create mode 100644 tests/test-ai-term--candidates.el create mode 100644 tests/test-ai-term--capture-state.el create mode 100644 tests/test-ai-term--close.el create mode 100644 tests/test-ai-term--collapse-split.el create mode 100644 tests/test-ai-term--default-geometry.el create mode 100644 tests/test-ai-term--dispatch.el create mode 100644 tests/test-ai-term--display-rule.el create mode 100644 tests/test-ai-term--display-saved.el create mode 100644 tests/test-ai-term--displayed-agent-window.el create mode 100644 tests/test-ai-term--f9-in-term.el create mode 100644 tests/test-ai-term--launch-command.el create mode 100644 tests/test-ai-term--live-tmux-sessions.el create mode 100644 tests/test-ai-term--pick-project.el create mode 100644 tests/test-ai-term--record-mru.el create mode 100644 tests/test-ai-term--reuse-edge-window.el create mode 100644 tests/test-ai-term--reuse-existing-agent.el create mode 100644 tests/test-ai-term--server-display.el create mode 100644 tests/test-ai-term--show-or-create.el create mode 100644 tests/test-ai-term--single-window-toggle.el create mode 100644 tests/test-ai-term--sort-candidates.el create mode 100644 tests/test-ai-term--tmux-session-name.el delete mode 100644 tests/test-ai-vterm--agent-buffers.el delete mode 100644 tests/test-ai-vterm--buffer-name.el delete mode 100644 tests/test-ai-vterm--candidates.el delete mode 100644 tests/test-ai-vterm--capture-state.el delete mode 100644 tests/test-ai-vterm--close.el delete mode 100644 tests/test-ai-vterm--collapse-split.el delete mode 100644 tests/test-ai-vterm--default-geometry.el delete mode 100644 tests/test-ai-vterm--dispatch.el delete mode 100644 tests/test-ai-vterm--display-rule.el delete mode 100644 tests/test-ai-vterm--display-saved.el delete mode 100644 tests/test-ai-vterm--displayed-agent-window.el delete mode 100644 tests/test-ai-vterm--f9-in-vterm.el delete mode 100644 tests/test-ai-vterm--launch-command.el delete mode 100644 tests/test-ai-vterm--live-tmux-sessions.el delete mode 100644 tests/test-ai-vterm--pick-project.el delete mode 100644 tests/test-ai-vterm--record-mru.el delete mode 100644 tests/test-ai-vterm--reuse-edge-window.el delete mode 100644 tests/test-ai-vterm--reuse-existing-agent.el delete mode 100644 tests/test-ai-vterm--server-display.el delete mode 100644 tests/test-ai-vterm--show-or-create.el delete mode 100644 tests/test-ai-vterm--single-window-toggle.el delete mode 100644 tests/test-ai-vterm--sort-candidates.el delete mode 100644 tests/test-ai-vterm--terminal-guard.el delete mode 100644 tests/test-ai-vterm--tmux-session-name.el create mode 100644 tests/test-term-tmux-history.el create mode 100644 tests/test-term-toggle--buffer-filter.el create mode 100644 tests/test-term-toggle--dispatch.el create mode 100644 tests/test-term-toggle--display.el delete mode 100644 tests/test-vterm-copy-mode-cursor.el delete mode 100644 tests/test-vterm-tmux-history.el delete mode 100644 tests/test-vterm-toggle--buffer-filter.el delete mode 100644 tests/test-vterm-toggle--dispatch.el delete mode 100644 tests/test-vterm-toggle--display.el create mode 100644 tests/testutil-ghostel-buffers.el delete mode 100644 tests/testutil-vterm-buffers.el diff --git a/docs/design/init-load-graph.org b/docs/design/init-load-graph.org index d4a68f47..3db2fe85 100644 --- a/docs/design/init-load-graph.org +++ b/docs/design/init-load-graph.org @@ -284,7 +284,7 @@ Category key: | =diff-config= | C/P | eager or package-loaded | Diff/merge UX. | | =erc-config= | O/D/P | command-loaded | IRC should not be startup load by default. | | =slack-config= | O/D/P | command-loaded | Slack package/auth and which-key registration should be after-load. | -| =eshell-vterm-config= | D/P | command/hook-loaded | Shell/terminal packages. | +| =eshell + term-config= | D/P | command/hook-loaded | Shell/terminal packages. | | =help-utils= | L/D | autoload commands | Search/help commands. | | =help-config= | C/P | eager or after help | Info/man/help config. | | =tramp-config= | D/P | package-loaded | Remote shell configuration. | diff --git a/docs/design/module-inventory.org b/docs/design/module-inventory.org index ffef323a..385bdbd5 100644 --- a/docs/design/module-inventory.org +++ b/docs/design/module-inventory.org @@ -193,7 +193,7 @@ flyspell-and-abbrev is the one Core-UX member (text-mode hooks). | Module | Layer | Cat | Current | Target | Runtime requires | Top-level side effects | Direct load | |--------+-------+-----+---------+--------+------------------+------------------------+-------------| | =ai-config= | 3 | D/P | eager | command | keybindings, system-lib | cj/ai-keymap under cj/custom-keymap | yes | -| =ai-vterm= | 3 | D | eager | command | cl-lib, seq, cj-window-geometry-lib, cj-window-toggle-lib, host-environment | 4 global keys | yes | +| =ai-term= | 3 | D | eager | command | cl-lib, seq, cj-window-geometry-lib, cj-window-toggle-lib, host-environment | 4 global keys | yes | | =browser-config= | 3 | D/P | eager | command | cl-lib | 1 global key | yes | | =calendar-sync= | 3 | D/S | eager (.local.el) | eager (.local.el) | cl-lib, subr-x, system-lib, cj-org-text-lib, keybindings | calendar keymap (C-; g), guarded timer/network | yes | | =calibredb-epub-config= | 4 | O/D/P | eager | command | user-constants, subr-x | add-hook, advice-add, package config | yes | @@ -236,7 +236,7 @@ owns the intentional end-of-startup buffer-bury timer. | =tramp-config= | 3 | D/P | eager | package | none | package config | yes | | =transcription-config= | 4 | O/D/P | eager | command | dired, notifications, system-lib, user-constants | 1 add-to-list | yes | | =video-audio-recording= | 4 | O/D/S | eager | command | system-lib, keybindings | cj/record-map under C-; r | yes | -| =vterm-config= | 3 | D/P | eager | command | keybindings, seq, subr-x, cj-window-geometry-lib, cj-window-toggle-lib | 2 keymaps, 1 global key, 2 add-hook | yes | +| =term-config= | 3 | D/P | eager | command | keybindings, seq, subr-x, cj-window-geometry-lib, cj-window-toggle-lib | 2 keymaps, 1 global key, 2 add-hook | yes | | =weather-config= | 4 | O/D/P | eager | command | none | package config | yes | | =wrap-up= | 2 | S | eager | eager | system-lib | one-shot startup buffer-bury timer | yes | diff --git a/docs/design/vterm-to-ghostel-migration-spec.org b/docs/design/vterm-to-ghostel-migration-spec.org new file mode 100644 index 00000000..b7d61e23 --- /dev/null +++ b/docs/design/vterm-to-ghostel-migration-spec.org @@ -0,0 +1,415 @@ +#+TITLE: Migration: vterm → ghostel (single terminal engine) +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-04 + +* Status + +READY. Review incorporated (external review, 2026-06-04). Supersedes the +EAT-consolidation direction in =todo.org= (task "Migrate all terminals from +vterm to ghostel"). Research background: +[[file:../2026-05-25-emacs-terminal-comparison.org][docs/2026-05-25-emacs-terminal-comparison.org]]. + +* Goal + +Replace vterm with [[https://github.com/dakra/ghostel][ghostel]] (a native Emacs module over libghostty-vt, the +Ghostty terminal engine) as the single terminal engine across every +workflow, and rename the AI-agent launcher =ai-vterm= → =ai-term=. When the +migration lands, vterm and vterm-toggle are removed from the config. + +Why ghostel over the prior EAT plan: ghostel is the most faithful Claude +Code TUI renderer and the fastest engine (≈81 vs vterm 34 vs eat 4.9 MB/s), +and an audit confirmed it exposes an analog for every vterm primitive this +config uses. EAT's washed colors, its scroll-pop / stuck-input bug under +Claude Code, and its slowest throughput made it the weaker single-engine +pick. One engine beats running two. + +* What the spike established (2026-06-04, read-only) + +Sandbox: =/tmp/ghostel-spike= via =emacs --init-directory=, nothing touched +in the real config. Emacs 30.2 GTK, x86_64, modules supported. + +- *Install / multi-machine*: ghostel installs from MELPA; the native module + auto-downloaded (=v0.33.0 ghostel-module-x86_64-linux.so=) and loaded with + no toolchain. Confirms the per-machine prebuilt-binary story works + (x86_64-linux covers velox). Only a non-prebuilt arch needs the Zig build. +- *tmux pty (linchpin)*: a spawned ghostel reports =process-tty-name + "/dev/pts/1"=. The tmux pane-id lookup in the current vterm-config keys on + exactly that, so the tmux copy-mode / history machinery ports unchanged. +- *Colorization model*: =ghostel-color-palette= is a vector of 16 named + faces, but =ghostel--apply-palette= RESOLVES those faces (+ =ghostel-default= + fg/bg) to hex and pushes them to the native module + (=ghostel--set-palette= / =ghostel--set-default-colors=); the module bakes + the colors into the grid. Theme changes are handled by =ghostel-sync-theme= + (hooked to =enable-theme-functions= / =load-theme= advice), which + re-resolves and re-pushes. *Consequence:* buffer-local =face-remap= does + NOT dim a ghostel buffer, and there is no per-window color hook. This + drives Decision D1. +- *TTY frames*: no =display-graphic-p= / =window-system= guards in + =ghostel.el= — it renders text + faces and only kitty inline-graphics + degrade in a TTY. ghostel works in terminal frames (Decision D4). +- *copy-mode*: a read-only input-mode toggle (=ghostel-copy-mode=) with + standard Emacs nav / mark; =q= / =C-g= exit, =M-w= copies and stays. The + vterm copy-mode contract maps near-free. +- *F8 / key forwarding (diagnostic)*: ghostel's default semi-char mode + forwards unlisted keys to the terminal program; only + =ghostel-keymap-exceptions= (default =C-c C-x C-u C-h M-x M-: C-\=) reach + Emacs. This is why F-key bindings (F9 family, F12) must be installed in + =ghostel-mode-map= for terminal buffers, exactly as the current config does + for =vterm-mode-map=. +- *GUI / TTY visual*: Craig confirmed the Claude Code TUI and a TTY frame + both render great. dupre chrome applies; the 16 ANSI terminal faces are + ghostel defaults (dupre does not theme them) — Decision D2. + +* Agreed decisions + +All confirmed by Craig 2026-06-04 (incorporating the external review). + +- *D1 — auto-dim*: terminal buffers do NOT participate in unfocused-window + dimming in v1. =auto-dim-config.el= drops its entire vterm integration + (~140 lines of =vterm--get-color= advice + redraw scheduling). Rationale: + ghostel bakes the palette per-terminal, not per-window, so vterm's + per-window dim is not achievable; a buffer-wide palette re-push on + focus-loss is more code, forces repaints, and only works when the buffer is + in one window — not worth it. +- *D2 — dupre ANSI palette*: a follow-up, not v1. The 16 =ghostel-color-*= + faces (+ =ghostel-default=) get themed in dupre later, unless the engine + swap exposes visibly poor colors during verification. +- *D3 — eshell*: out of scope. =ghostel-eshell= adoption is a separate + follow-up task; eshell stays the shell. +- *D4 — TTY refuse-guard*: dropped. =cj/--ai-vterm-refuse-in-terminal= and + its echo-area refusal message are removed; F9 launches in a terminal frame. + Its manual-verify test is removed too (it asserted the refusal). +- *D5 — module names*: =vterm-config.el= → =term-config.el=; =ai-vterm.el= → + =ai-term.el=; =cj/vterm-*= → =cj/term-*=; =cj/ai-vterm-*= → =cj/ai-term-*=. + The "agent [" buffer prefix is unchanged. +- *D6 — module-failure behavior*: ghostel degrades with a warning rather than + failing startup. Load it guarded (=(require 'ghostel nil t)=) and, on + failure, emit a =display-warning= and leave the terminal commands defined + but inert. Rationale: the daemon serves many frames across machines, and + the project idiom is graceful degradation (the =(when (require 'foo nil t) + ...)= rule and =cj/executable-find-or-warn=); hard-failing startup on a + machine missing the prebuilt module is worse than a warned degrade. Tests + stub ghostel and never require the native module. (Modifies the reviewer's + recommendation of "fail loudly" — see Review dispositions.) +- *D7 — scrollback value*: =ghostel-max-scrollback= set to =10 MB= (=(* 10 + 1024 1024)=) as a defcustom, the byte analog of the prior =100000=-line + intent (~100 bytes/line). Verified under heavy output during manual + testing. + +* Primitive mapping (vterm → ghostel) + +| vterm | ghostel | note | +|--------------------------------+-------------------------------------------+------| +| =(vterm NAME)= | =(ghostel)= + rename to NAME | via =ghostel-buffer-name-function= or post-create rename | +| =vterm-send-string= | =ghostel-send-string= | public; confirmed | +| =vterm-send-return= | =(ghostel-send-string "\n")= | | +| =vterm-mode= | =ghostel-mode= | all major-mode checks | +| =vterm-mode-map= | =ghostel-mode-map= | F9 + F12 rebind, C-; install | +| =vterm-keymap-exceptions= | =ghostel-keymap-exceptions= | add =C-;= | +| =vterm-copy-mode= | =ghostel-copy-mode= | read-only input mode | +| =vterm-copy-mode-map= bindings | input-mode (q/C-g exit, M-w copies-stays) | near-free parity | +| =vterm-clear-scrollback= | =ghostel-clear-scrollback= | C-; x l | +| =vterm-next-prompt= | =ghostel-next-prompt= | C-; x n | +| =vterm-previous-prompt= | =ghostel-previous-prompt= | C-; x p | +| =vterm-send-next-key= | =ghostel-send-next-key= | C-; x q | +| =vterm-yank= | =ghostel-yank= | C-y | +| =vterm-reset-cursor-point= | drop (renderer owns point) | decided: no analog needed | +| =vterm-other-window= | =(ghostel)= + other-window display | thin wrapper | +| =vterm-max-scrollback= (lines) | =ghostel-max-scrollback= = 10 MB (D7) | unit change lines→bytes | +| =vterm-kill-buffer-on-exit= | =ghostel-kill-buffer-on-exit= | | +| =vterm-timer-delay= (nil hack) | =ghostel-timer-delay= / adaptive-fps | hacks DROP | +| =cj/vterm--send-mouse-wheel= | drop (ghostel forwards SGR natively) | net deletion; verify under tmux/Claude/lazygit | +| =cj/vterm-send-escape= | =(ghostel-send-string "\e")= if needed | re-check == global conflict | +| =vterm--get-color= advice | none (D1) | auto-dim integration deleted | +| =vterm-always-compile-module= | =ghostel-module-auto-install= | + D6 guarded load | +| tmux pane-id via =process-tty-name= | unchanged | confirmed /dev/pts | + +* Surface to change + +Audited file set. + +** Main modules +- =modules/vterm-config.el= (~540L) → =modules/term-config.el=. Ports with + renamed primitives; deletes the mouse-wheel forwarding and the + =vterm-timer-delay= hacks; renames =cj/vterm-*= → =cj/term-*= (no + compatibility shim). Keeps the tmux history / copy-mode-dwim logic pure + around =process-tty-name= and =tmux= process calls (engine-agnostic — the + part most worth preserving). =cj/vterm-map= (C-; x) → =cj/term-map=; + which-key label "vterm menu" → "terminal menu". +- =modules/ai-vterm.el= (~978L) → =modules/ai-term.el=. Only ~6 call sites + are vterm-specific (=vterm= / =vterm-send-string= / =vterm-send-return=, + the suppress-tmux coupling, the =vterm-mode-map= F9 rebind, the + declare-functions). The ~970L of picker / MRU / crash-recovery / display + chain / dispatch / geometry is engine-agnostic and renames cleanly + (=cj/ai-vterm-*= → =cj/ai-term-*=). Buffer prefix "agent [" stays. The + refuse-in-terminal guard is deleted (D4). + + *tmux-suppression invariant (contract).* =cj/--ai-term-show-or-create= must + preserve exactly one tmux launch path for agent buffers: the dynamic + binding of the suppress flag around =(ghostel)= keeps the generic + auto-tmux hook from sending a bare =tmux\n= before the project-named + =tmux new-session -A= command runs. Porting must not introduce a second + launch path. + +** Satellites +- =modules/auto-dim-config.el= — per D1, delete the vterm color advice + + redraw scheduling entirely (no ghostel replacement in v1). +- =modules/ui-config.el= — =vterm-mode= / =vterm-copy-mode= cursor/modeline + check → ghostel equivalents (live ghostel = writeable cursor state; + =ghostel-copy-mode= = read-only). +- =modules/dashboard-config.el= — launcher lambda → =(ghostel)=; label + "Launch VTerm" → "Launch Terminal". +- =modules/cj-window-geometry-lib.el=, =modules/cj-window-toggle-lib.el= — + vterm only in comments; update doc references. +- =init.el= — =(require 'ai-vterm)= → =(require 'ai-term)=; add term-config + require (guarded per D6). + +** Docs (active references only — historical notes stay) +- =todo.org= current task link (already updated to this -spec path). +- =docs/design/module-inventory.org=, =docs/design/init-load-graph.org= — + update active =vterm-config= / =ai-vterm= references to the new names. + +** Tests (~35 files) +- 24 =test-ai-vterm--*.el= are mostly engine-agnostic logic (buffer-name, + candidates, sort, dispatch, geometry, MRU) → rename to =test-ai-term--*.el= + mechanically, only after a green baseline; assertions stand. +- Coupled, need rework: =testutil-vterm-buffers.el= (→ stub ghostel), + =test-ai-vterm--f9-in-vterm.el=, =test-ai-vterm--show-or-create.el=, + =test-vterm-copy-mode-cursor.el=, =test-vterm-tmux-history.el=, + =test-vterm-toggle--*.el= (×3). +- Cross-cutting touch: =test-auto-dim-config.el= (delete vterm-integration + tests per D1), =test-ui-config--buffer-cursor-state.el=, + =test-dashboard-config-launchers.el=, =test-init-module-headers.el=, + =test-cj-window-toggle-lib.el=. + +* Dependency / module failure behavior (D6) + +- ghostel is a required MELPA package. It loads guarded: + =(unless (require 'ghostel nil t) (display-warning 'term "..."))=. +- On a prebuilt arch the native module auto-downloads + (=ghostel-module-auto-install=). On a non-prebuilt arch the user installs + Zig 0.15.2 and builds per ghostel's instructions; until then the warning + fires and terminal commands are inert (defined but no-op / user-error), + never breaking startup or other frames. +- Tests stub ghostel in the test-util layer and never require the native + module, so the suite runs on any machine and in CI/batch. + +* Key & menu ownership (per phase) + +To avoid order-dependent duplicate bindings, ownership transfers cleanly: + +- *Before*: =vterm-config= owns F12, =C-; x=, the vterm display rule, and the + which-key labels. +- *Phase 1*: =term-config= is added and immediately becomes the owner of F12 + and =C-; x= (and the terminal display rule). =vterm-config= is no longer + required, so its bindings do not co-install. The vterm package remains + installed only as a fallback engine until Phase 4. +- *Phase 2*: =ai-term= owns the F9 family (global + in =ghostel-mode-map=); + =ai-vterm= is no longer required. +- *Phase 4*: vterm / vterm-toggle packages removed; no vterm ownership + remains anywhere. + +* Implementation phases (TDD, green at each step) + +Each phase is a shippable deliverable; the suite + byte-compile stay green at +every step. + +- *Phase 0 — characterization baseline.* Before any port, add/confirm + characterization tests for the behaviors that must survive: F12 + dispatch/display, tmux pane-id + history-buffer replacement, AI + show-or-create tmux launch command, F9 from inside terminal mode, + cursor-state classification, dashboard launcher action. Green baseline. + Deliverable: characterization tests committed; no behavior change. +- *Phase 1 — ghostel + term-config.* Add ghostel (use-package, MELPA, + guarded per D6). New =term-config.el= owning F12, =cj/term-map= (C-; x), + copy-mode parity, tmux history/copy-mode-dwim (pure =process-tty-name= + path), which-key "terminal menu", =ghostel-max-scrollback= 10 MB, + =ghostel-keymap-exceptions= incl. =C-;=. =vterm-config= dropped from the + require list (ownership transfers). Tests for the new module + ghostel + stubs. Deliverable: F12 general terminal runs on ghostel. +- *Phase 2 — ai-term.* Rename =ai-vterm.el= → =ai-term.el=; swap the ~6 vterm + call sites to ghostel; F9/C-F9/M-F9 on global + =ghostel-mode-map=; drop + the refuse-in-terminal guard (D4); preserve the tmux-suppression invariant. + Rename engine-agnostic tests to =test-ai-term--*= (after green); rework the + coupled ones; add D4 regression tests (no refusal path; F9 installed in + =ghostel-mode-map=) and a negative test that agent buffers are excluded + from F12 toggling under the new names. Deliverable: agents run on ghostel. +- *Phase 3 — satellites.* auto-dim vterm integration deleted (D1); + ui-config cursor/modeline check ported; dashboard launcher + label; + geometry/toggle-lib doc refs; init.el requires; active doc references. + Deliverable: no module references vterm except the package itself. +- *Phase 4 — remove vterm.* Delete vterm + vterm-toggle packages, dead + config, the mouse-wheel / timer hacks. Full test sweep + byte-compile + + manual smoke after a daemon restart (the restart is an acceptance gate — + see below). Deliverable: vterm gone; ghostel is the only terminal engine. + +** Follow-up / vNext (not this series) +- D2 — theme the 16 =ghostel-color-*= + =ghostel-default= faces in dupre. +- D3 — evaluate =ghostel-eshell= as eshell's visual backend. +- Evaluate =ghostel-compile= against the F4 dev-fkeys compile flow. +- =ghostel-comint= for =M-x shell= / REPL output fidelity (optional). + +* Acceptance criteria + +The migration is complete when all hold: + +1. =init.el= requires =term-config= and =ai-term=; nothing in the config + requires =vterm-config= or =ai-vterm=. +2. vterm / vterm-toggle packages and their keybindings are removed, after + ghostel parity is green. +3. F12 normal-terminal toggle excludes agent buffers and preserves saved + geometry. +4. F9 / C-F9 / M-F9 work from normal buffers AND inside =ghostel-mode= + buffers. +5. AI project launch reuses/reattaches the named =aiv-= tmux session and does + NOT receive the generic auto-tmux launch. +6. =C-; x c= and =C-; x h= preserve the tmux copy / history behavior. +7. Live ghostel buffers report a writeable cursor state; ghostel copy-mode + reports read-only. +8. Terminal-frame F9 launches (the refusal path and its test are gone). +9. ghostel-unavailable degrades with a warning, not a startup failure (D6). +10. Full test suite, byte-compile, and manual smoke all pass after a daemon + restart. + +* Test strategy + +- *Characterization first* (Phase 0): capture current behavior before porting + so parity is measurable. +- *Stub ghostel* in the test-util layer; tests never require the native + module (runs in batch/CI on any machine). +- *Rename mechanically after green*: only rename engine-agnostic + =test-ai-vterm--*= → =test-ai-term--*= once the baseline is green. +- *Regression tests for D4*: no terminal-frame refusal path remains; F9 + bindings are installed in =ghostel-mode-map=. +- *Negative test*: agent buffers are excluded from F12 normal-terminal + toggling under the new buffer/mode names. + +* Manual-verify test matrix + +Per =verification.md=, filed under "Emacs Manual Testing and Validation" at +Phase 4, run again after a daemon restart. Each: steps + expected. + +- Claude Code TUI in ghostel (GUI): colors true, flicker-free under heavy + stream, box-drawing + cursor correct. +- Claude Code TUI in a TTY frame (velox-style =emacs -nw=): renders as + text+color, layout intact; inline images absent (expected). +- F9 / C-F9 / M-F9 dispatch: toggle, pick-project, close — same behavior as + the vterm era, on ghostel, including from a terminal frame (now launches). +- tmux: agent launches in its named session; second F9 reattaches; close + kills the session; =C-; x h= captures tmux history; =C-; x c= enters tmux + copy-mode. +- copy-mode parity: =M-w= copies and stays, =q= / =C-g= exit. +- mouse wheel inside tmux / Claude Code / lazygit scrolls correctly (this was + a prior explicit vterm fix being removed — confirm ghostel's native SGR + forwarding covers it). +- lazygit, htop/btop, a heavy-output build, ssh to a remote: render + behave. +- Crash recovery: kill Emacs with a live =aiv-= tmux session, restart, the + picker flags it =[detached]= and reattaches. + +* Risks / notes + +- *Daemon module reload*: a loaded native module needs a daemon restart to + upgrade; the Phase 4 restart is an acceptance gate before deleting vterm + (plus the gold-standard full-launch smoke per CLAUDE.md after =:config= + edits). +- *Buffer naming*: forcing "agent [basename]" goes through + =ghostel-buffer-name-function= or a post-create rename — confirm the exact + hook in Phase 2. +- * global rebind*: vterm needed a custom escape forwarder because + == is globally =keyboard-escape-quit=; re-check whether ghostel in + semi-char mode forwards it or needs the same treatment. +- *ssh terminfo*: ghostel advertises =TERM=xterm-ghostty=; outbound ssh to + hosts lacking that terminfo may need =ghostel-ssh-install-terminfo= or a + fallback =ghostel-term=. Covered by the ssh manual-verify row. +- *ANSI palette*: until D2 lands, terminal ANSI colors are ghostel defaults. + +* Implementation tasks (drop-in for todo.org) + +#+begin_src org +*** TODO [#B] Phase 0: terminal characterization baseline :terminal:ghostel:tests: +Characterization tests for F12 dispatch/display, tmux pane-id + history replacement, AI show-or-create launch command, F9-in-terminal, cursor-state classification, dashboard launcher. Green baseline, no behavior change. +*** TODO [#B] Phase 1: add ghostel + term-config.el :terminal:ghostel: +ghostel use-package (MELPA, guarded per D6); term-config.el owns F12 + C-; x + copy-mode + tmux history; which-key "terminal menu"; ghostel-max-scrollback 10MB; C-; in ghostel-keymap-exceptions. Drop vterm-config from requires. Tests + ghostel stubs. +*** TODO [#B] Phase 2: rename ai-vterm→ai-term on ghostel :terminal:ghostel: +Swap the 6 vterm call sites; F9 family on global + ghostel-mode-map; drop refuse-in-terminal guard (D4); preserve tmux-suppression invariant. Rename engine-agnostic tests after green; rework coupled tests; add D4 + F12-excludes-agent regression tests. +*** TODO [#B] Phase 3: port satellites to ghostel :terminal:ghostel: +Delete auto-dim vterm integration (D1); port ui-config cursor check; dashboard launcher + "Launch Terminal" label; geometry/toggle-lib doc refs; init.el requires; module-inventory + init-load-graph doc refs. +*** TODO [#B] Phase 4: remove vterm and vterm-toggle :terminal:ghostel: +Delete packages + dead config + mouse-wheel/timer hacks. Full suite + byte-compile + manual smoke after daemon restart (acceptance gate). Run the manual-verify matrix. +*** TODO [#C] Follow-up: theme ghostel ANSI faces in dupre :terminal:ghostel:dupre: +D2 — set the 16 ghostel-color-* + ghostel-default faces in dupre-faces/palette. +*** TODO [#C] Follow-up: evaluate ghostel-eshell + ghostel-compile :terminal:ghostel:eval: +D3 — ghostel-eshell as eshell visual backend; ghostel-compile against F4 dev-fkeys. +#+end_src + +* Review dispositions + +Only the *modified* recommendations are listed; everything else in the external +review was accepted as written. + +- *Module-failure behavior (modified).* The reviewer recommended ghostel be required + and "startup may fail loudly" if the package/module can't load. Modified to + degrade-with-warning (D6): guarded require + =display-warning=, terminal + commands inert, startup unaffected. Reason: the daemon serves many frames + across machines and the project idiom is graceful degradation; hard-failing + startup on a machine missing the prebuilt module is worse than a warned + degrade. The rest of the recommendation (ghostel required; non-prebuilt + needs Zig; tests stub the module) is accepted. +- *Scrollback value (modified → concretized).* The reviewer asked for a concrete + byte value or a defcustom. Chose =10 MB= as a defcustom (D7), the byte + analog of 100000 lines, verified under heavy output. (Not a disagreement — + filling the gap the review flagged.) + +Everything else accepted as written: D1-D5 baked as Agreed decisions; +Implementation phases + Acceptance criteria + Dependency-failure + Test +strategy sections added; key/menu ownership made explicit per phase; +tmux-suppression stated as a contract; UX changes (TTY-refusal removal, +"Launch Terminal", "terminal menu"); architecture (rename =cj/vterm-*= → +=cj/term-*=, keep tmux fns pure, no vterm-private-redraw port); doc cleanup +for active references; mouse-wheel manual verify; daemon-restart acceptance +gate. + +* Review and iteration history + +** 2026-06-04 Thursday @ 23:17:54 -0500 — reviewer + +- *What changed or was recommended:* Ran the spec-review workflow after + renaming this file to the required =-spec.org= suffix. Wrote a companion + review with a =Not ready= rubric: D1-D5 still need acceptance, the handoff + needs an =Implementation phases= section, acceptance criteria are missing, + and ghostel package/native-module failure behavior needs an explicit v1 + contract. +- *Why:* The migration direction is sound, but the current draft still leaves + implementation-affecting decisions and completion criteria for the builder + to infer. +- *Artifacts:* review file (deleted on incorporation). + +** 2026-06-04 Thursday @ 23:24:28 -0500 — responder + +- *What changed:* Incorporated the external review via the spec-response + workflow. Craig accepted D1-D5; baked them (plus D6 module-failure and D7 + scrollback) into a new "Agreed decisions" section and out of "Open + decisions." Added Implementation phases, Acceptance criteria, Dependency / + module failure behavior, Test strategy, explicit per-phase key/menu + ownership, the tmux-suppression contract, and an Implementation-tasks + drop-in block. Applied the UX, architecture, doc-cleanup, and + manual-verify additions. Status raised DRAFT → READY. +- *Why:* Close the "Not ready" findings — resolve the open decisions, + give the builder phases + acceptance criteria, and define + ghostel-unavailable behavior — so a reader can implement from this file. +- *Modified vs the review:* module-failure = degrade-with-warning, not + fail-loud (D6 rationale); scrollback concretized to 10 MB (D7). See Review + dispositions. Everything else accepted as written. +- *Artifacts:* This spec; review file deleted; =todo.org= task link updated. + +** 2026-06-04 Thursday @ 23:30:18 -0500 — reviewer + +- *What changed or was recommended:* Re-reviewed the incorporated spec and + assigned a =Ready= rubric. No further blocking review notes. The prior + blockers are closed: D1-D7 are accepted decisions, implementation phases and + acceptance criteria are present, ghostel-unavailable behavior is explicit, + key/menu ownership is phased, and implementation tasks are enumerated. +- *Why:* Confirm the spec-response pass left an implementable handoff rather + than just adding prose. +- *Artifacts:* This history entry; no new review file because the spec is + implementation-ready. diff --git a/init.el b/init.el index fe1acd35..390de45e 100644 --- a/init.el +++ b/init.el @@ -77,8 +77,8 @@ (require 'telega-config) ;; telegram client via telega.el (TDLib in docker) (require 'signal-config) ;; signal client via forked signel + signal-cli (require 'eshell-config) ;; emacs shell configuration -(require 'vterm-config) ;; vterm + F12 toggle + tmux history copy -(require 'ai-vterm) ;; in-Emacs Claude launcher (vertical-split vterm) +(require 'term-config) ;; ghostel + F12 toggle + tmux history copy +(require 'ai-term) ;; in-Emacs Claude launcher (vertical-split ghostel) (require 'help-utils) ;; search: arch-wiki, devdoc, tldr, wikipedia (require 'help-config) ;; info, man, help config (require 'tramp-config) ;; remote shell connections diff --git a/modules/ai-term.el b/modules/ai-term.el new file mode 100644 index 00000000..85b84a12 --- /dev/null +++ b/modules/ai-term.el @@ -0,0 +1,974 @@ +;;; ai-term.el --- In-Emacs AI-agent launcher with vertical-split terminal -*- lexical-binding: t; -*- + +;; Author: Craig Jennings + +;;; Commentary: +;; +;; Layer: 3 (Domain Workflow). +;; Category: D. +;; Load shape: eager. +;; Eager reason: registers four global keys for the AI-agent terminal launcher; a +;; command-loaded deferral candidate. +;; Top-level side effects: four global key bindings. +;; Runtime requires: cl-lib, seq, cj-window-geometry-lib, cj-window-toggle-lib, +;; host-environment. +;; Direct test load: yes. +;; +;; Picks an AI-agent project (a dir under ~/.emacs.d, ~/code/*, or +;; ~/projects/* containing .ai/protocols.org), opens or reuses a terminal +;; buffer named "agent []", sends the agent's startup +;; instruction to it, and routes the buffer to a side window via +;; display-buffer-alist. When the frame already has a window forming the +;; half the agent would occupy (a right column on a desktop, a bottom row +;; on a laptop), the agent reuses that slot rather than splitting a third +;; window in; toggling off restores the displaced buffer to the slot. +;; Otherwise placement is a host-aware split: a right-side split at 50% +;; width on a desktop, a bottom split at 75% height on a laptop (see +;; `cj/--ai-term-default-direction'). Multiple +;; projects produce multiple coexisting buffers that share the same +;; slot; switching among them is a buffer-switch, not a +;; kill-and-recreate. +;; +;; Each project's agent runs inside a tmux session named +;; "" (default prefix "aiv-"). +;; The prefix lets `tmux ls' be filtered to AI-term's own sessions, so +;; after an Emacs crash the project picker can match surviving sessions +;; back to their directories: matched projects sort to the top of the +;; picker (flagged "[detached]" -- session alive, no Emacs buffer -- or +;; "[running]" when a live terminal buffer exists), the rest follow in +;; alphabetical order. +;; +;; Four F-key entry points: +;; +;; - F9 `cj/ai-term' -- DWIM dispatch. If an agent buffer is +;; currently displayed in this frame, F9 toggles it off: when it +;; took over an existing window (a reused slot) the buffer it +;; displaced returns to that slot, when it was split into its own +;; window that window is removed, and when it fills the frame it +;; is buried. Otherwise, if exactly one agent buffer is alive, +;; F9 re-displays it; if zero or two-plus are alive, F9 falls +;; through to the project picker. +;; - C-F9 `cj/ai-term-pick-project' -- always show the project +;; picker, even when an agent buffer is currently displayed. +;; Used when the user wants to start a new project session +;; instead of toggling the current one. +;; - M-F9 `cj/ai-term-close' -- gracefully close an agent: kill its +;; tmux session (stopping the agent process), then its terminal +;; buffer and window. Confirms first. Targets the current +;; agent, the sole live agent, or prompts among several. +;; - C-S-F9 `cj/ai-term-close' -- same close command, second binding. +;; (M-F9 is the primary; C-S-F9 may be swallowed by the +;; Wayland/PGTK layer on some machines.) +;; +;; Existing windmove (Shift-arrows) handles code <-> agent focus +;; toggling. Buffer-move (C-M-arrows) handles side-swap. Neither +;; needs anything new from this module. + +;;; Code: + +(require 'cl-lib) +(require 'seq) +(require 'cj-window-geometry-lib) +(require 'cj-window-toggle-lib) +(require 'host-environment) + +(declare-function ghostel "ghostel" (&optional arg)) +(declare-function ghostel-send-string "ghostel" (string)) +(defvar ghostel-mode-map) +(defvar ghostel-buffer-name) +(defvar ghostel-buffer-name-function) + +(defgroup ai-term nil + "In-Emacs AI-agent launcher with a vertical-split ghostel terminal." + :group 'tools) + +(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. + +The default invokes the Claude Code CLI; set it to whatever terminal +agent you run (aider, an open-source LLM TUI, etc.)." + :type 'string + :group 'ai-term) + +(defvar cj/--ai-term-suppress-tmux nil + "When non-nil, the generic ghostel tmux-launch hook skips its auto-tmux step. + +ai-term dynamically binds this around `(ghostel)' so the hook in +term-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-term-project-roots + (list (expand-file-name "~/.emacs.d")) + "Directories that are themselves AI-agent projects. +Each entry is included as a candidate when it exists and contains +.ai/protocols.org. Use this for single-project roots like ~/.emacs.d." + :type '(repeat directory) + :group 'ai-term) + +(defcustom cj/ai-term-container-roots + (list (expand-file-name "~/code") + (expand-file-name "~/projects")) + "Directories whose immediate children are scanned for agent projects. +Each entry's child directories are included as candidates when they +contain .ai/protocols.org. Use this for container dirs like ~/code." + :type '(repeat directory) + :group 'ai-term) + +(defcustom cj/ai-term-tmux-session-prefix "aiv-" + "Prefix prepended to tmux session names AI-term creates. + +The session name for a project is this prefix followed by the +project's basename (whitespace collapsed to hyphens). The prefix +lets `tmux ls' output be filtered down to AI-term's own sessions -- +so after an Emacs crash the project picker can match surviving +sessions back to their directories and surface them first. Pick +something unlikely to collide with hand-rolled tmux sessions; the +default \"aiv-\" is short for \"ai-term\"." + :type 'string + :group 'ai-term) + +(defcustom cj/ai-term-tmux-window-name "ai" + "Name given to the first tmux window in an AI-term session. + +Passed as `tmux new-session -n', so the window running the AI tool +shows up as this name in `tmux ls' / the status line. A later +window opened by hand (e.g. a shell) auto-names after its command, +so the two read distinctly instead of both showing up as the +running program." + :type 'string + :group 'ai-term) + +(defconst cj/--ai-term-name-prefix "agent [" + "Buffer-name prefix shared by all AI-term buffers. + +Single source of truth for both buffer construction in +`cj/--ai-term-buffer-name' and detection in +`cj/--ai-term-buffer-p'. The display-buffer-alist rule keys on the +escaped form \"\\\\`agent \\\\[\" -- they must stay in sync.") + +(defun cj/--ai-term-buffer-name (dir) + "Return the AI-term buffer name for project directory DIR. + +The name pattern is \"agent []\". The display-buffer-alist +rule keys on the literal prefix \"agent [\", so changing the format +breaks routing to the right-side window." + (format "%s%s]" + cj/--ai-term-name-prefix + (file-name-nondirectory (directory-file-name dir)))) + +(defun cj/--ai-term-buffer-p (buffer) + "Return non-nil when BUFFER is an AI-term buffer. + +A buffer qualifies when its name starts with the literal prefix in +`cj/--ai-term-name-prefix' (\"agent [\"). The check is anchored at +the start so names like \"foo agent [bar]\" do not match." + (and (bufferp buffer) + (buffer-live-p buffer) + (string-prefix-p cj/--ai-term-name-prefix (buffer-name buffer)))) + +(defun cj/--ai-term-agent-buffers () + "Return the live AI-term buffers in `buffer-list' order. + +Order matches `buffer-list' on the selected frame, which is most- +recently-selected first. Non-AI-term buffers are filtered out via +`cj/--ai-term-buffer-p'." + (seq-filter #'cj/--ai-term-buffer-p (buffer-list))) + +(defun cj/--ai-term-most-recent-non-agent-buffer () + "Return the most-recently-selected live non-agent buffer, or nil. + +Walks `buffer-list' (most-recently-selected first) and returns the +first buffer that is not an AI-term agent buffer (per +`cj/--ai-term-buffer-p') and is not an internal buffer (name starting +with a space). Used by the single-window F9 toggle-off so dismissing a +full-frame agent returns to the file the user was working in (e.g. +todo.org) rather than swapping in another agent." + (seq-find (lambda (b) + (and (buffer-live-p b) + (not (cj/--ai-term-buffer-p b)) + (not (string-prefix-p " " (buffer-name b))))) + (buffer-list))) + +(defun cj/--ai-term-displayed-agent-window (&optional frame) + "Return a window in FRAME currently displaying an AI-term buffer, or nil. + +FRAME defaults to the selected frame. When more than one window in +the frame shows an agent buffer, the first one in `window-list' order +is returned. The minibuffer is excluded from the search." + (seq-find (lambda (w) + (cj/--ai-term-buffer-p (window-buffer w))) + (window-list (or frame (selected-frame)) 'never))) + +(defun cj/--ai-term-tmux-session-name (dir) + "Return the tmux session name for project directory DIR. + +`cj/ai-term-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-term-session-active-p' and the crash-recovery picker +from missing such projects). The prefix lets `tmux ls' output be +filtered to AI-term's own sessions (see +`cj/--ai-term-live-tmux-sessions')." + (concat cj/ai-term-tmux-session-prefix + (replace-regexp-in-string + "[.:]" "_" + (replace-regexp-in-string + "[[:space:]]+" "-" + (file-name-nondirectory (directory-file-name dir)))))) + +(defun cj/--ai-term-live-tmux-sessions () + "Return live tmux session names that carry the AI-term prefix. + +Runs `tmux list-sessions'. Returns the names beginning with +`cj/ai-term-tmux-session-prefix', or nil when tmux is not installed, +no server is running, or the command exits non-zero -- the picker +treats nil as \"no sessions to surface\" and falls back to a plain +alphabetical list." + (let* ((prefix cj/ai-term-tmux-session-prefix) + (exit nil) + (output (with-temp-buffer + (setq exit (condition-case nil + (process-file "tmux" nil '(t nil) nil + "list-sessions" "-F" + "#{session_name}") + (error nil))) + (buffer-string)))) + (when (and (integerp exit) (zerop exit)) + (seq-filter (lambda (name) (string-prefix-p prefix name)) + (split-string output "\n" t))))) + +(defun cj/--ai-term-session-active-p (dir sessions) + "Return non-nil when DIR's tmux session name is in SESSIONS. + +SESSIONS is the list from `cj/--ai-term-live-tmux-sessions' (or nil). +The match is forward: DIR's expected session name is computed and +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) + "Return the shell command line that runs the AI tool 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 +comes from `cj/--ai-term-tmux-session-name'; the first window is named +`cj/ai-term-tmux-window-name' (default \"ai\") so a later hand-opened +window auto-names after its command and the two read distinctly. + +The shell command run on first creation is + ; 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." + (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' + ;; so any single quotes embedded in a user-customized + ;; `cj/ai-term-agent-command' don't break the literal single-quote + ;; wrap below. The default value carries embedded double quotes + ;; (\"Read .ai/protocols.org and follow all instructions.\") which + ;; was safe in the prior shape but a single-quoted custom value + ;; silently broke the shell parse. + (format "tmux new-session -A -s %s -n %s -c %s %s" + (shell-quote-argument session) + (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"))))) + +(defun cj/--ai-term-has-marker-p (dir) + "Return non-nil when DIR contains .ai/protocols.org." + (file-exists-p (expand-file-name ".ai/protocols.org" dir))) + +(defun cj/--ai-term-candidates () + "Return the list of AI-agent project paths. + +Each entry of `cj/ai-term-project-roots' contributes itself when it +exists and contains .ai/protocols.org. Each entry of +`cj/ai-term-container-roots' contributes its immediate child +directories that contain .ai/protocols.org. + +Returns absolute paths. Nonexistent roots are skipped silently." + (let (result) + (dolist (root cj/ai-term-project-roots) + (let ((expanded (expand-file-name root))) + (when (and (file-directory-p expanded) + (cj/--ai-term-has-marker-p expanded)) + (push expanded result)))) + (dolist (root cj/ai-term-container-roots) + (let ((expanded (expand-file-name root))) + (when (file-directory-p expanded) + (dolist (child (directory-files + expanded t directory-files-no-dot-files-regexp t)) + (when (and (file-directory-p child) + (cj/--ai-term-has-marker-p child)) + (push child result)))))) + (nreverse result))) + +(defvar cj/--ai-term-mru nil + "Project dirs opened via the AI-term launcher this session, newest first. + +Maintained by `cj/--ai-term-record-mru' (called from +`cj/--ai-term-show-or-create') and consumed by +`cj/--ai-term-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-term-record-mru (dir) + "Move DIR to the front of `cj/--ai-term-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-term-mru (cons d (delete d cj/--ai-term-mru))))) + +(defun cj/--ai-term-mru-rank (dir) + "Return DIR's index in `cj/--ai-term-mru', or nil when it isn't there. + +DIR is normalized the same way `cj/--ai-term-record-mru' stores +entries, so a trailing slash doesn't defeat the lookup." + (seq-position cj/--ai-term-mru + (directory-file-name (expand-file-name dir)))) + +(defun cj/--ai-term-sort-candidates (dirs sessions) + "Order DIRS for the project picker. + +DIRS with a live tmux session in SESSIONS (per +`cj/--ai-term-session-active-p') come first, ordered most-recently- +opened first (per `cj/--ai-term-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-term-mru-rank a)) + (rb (cj/--ai-term-mru-rank b))) + (cond ((and ra rb) (< ra rb)) + (ra t) + (rb nil) + (t (funcall alpha a b)))))) + (active-p (lambda (d) (cj/--ai-term-session-active-p d sessions))) + (active (seq-filter active-p dirs)) + (inactive (seq-remove active-p dirs))) + (append (sort active mru-then-alpha) (sort inactive alpha)))) + +(defun cj/--ai-term-process-live-p (buffer) + "Return non-nil when BUFFER has a live process attached." + (let ((proc (get-buffer-process buffer))) + (and proc (process-live-p proc)))) + +(defcustom cj/ai-term-desktop-width 0.5 + "Default fraction of frame width for the AI-term window on a desktop. + +On a desktop the agent opens as a right-side vertical split (see +`cj/--ai-term-default-direction'), so this fraction is interpreted +as a window width. Used by `cj/--ai-term-default-size' as the size +fallback when `cj/--ai-term-last-size' is nil (i.e. the user hasn't +yet toggled off an agent window in this session)." + :type 'number + :group 'ai-term) + +(defcustom cj/ai-term-laptop-height 0.75 + "Default fraction of frame height for the AI-term window on a laptop. + +On a laptop the agent opens as a bottom horizontal split (see +`cj/--ai-term-default-direction'), so this fraction is interpreted +as a window height. Used by `cj/--ai-term-default-size' as the size +fallback when `cj/--ai-term-last-size' is nil." + :type 'number + :group 'ai-term) + +(defun cj/--ai-term-default-direction () + "Return the host-appropriate default split direction for the agent window. + +`below' on a laptop (bottom horizontal split), `right' on a desktop +(right-side vertical split). Detected via `env-laptop-p'." + (if (env-laptop-p) 'below 'right)) + +(defun cj/--ai-term-default-size () + "Return the host-appropriate default size fraction for the agent window. + +`cj/ai-term-laptop-height' on a laptop, `cj/ai-term-desktop-width' +on a desktop -- pairing with the axis chosen by +`cj/--ai-term-default-direction'." + (if (env-laptop-p) + cj/ai-term-laptop-height + cj/ai-term-desktop-width)) + +(defvar cj/--ai-term-last-direction nil + "Last user-chosen direction for the AI-term display. + +Symbol: right, below, or left. `above' is never stored -- the agent +window must not be remembered at the top of the frame, so a top +placement falls back to the host default at capture time. nil means no +agent window has been toggled off yet this session, so the default +direction applies. Captured at toggle-off by +`cj/--ai-term-capture-state' and consumed by +`cj/--ai-term-display-saved'.") + +(defvar cj/--ai-term-last-was-bury nil + "Non-nil when the last F9 toggle-off used `bury-buffer'. + +Set by `cj/ai-term' in its `toggle-off' branch: t when the agent +window was the only window in the frame (so toggle-off buried +without deleting), nil when the window was deleted. Consumed by +`cj/--ai-term-display-saved' to decide between restoring the +buried agent in the current window (the only one) or splitting per +the saved direction.") + +(defvar cj/--ai-term-last-hidden-buffer nil + "The agent buffer hidden by the most recent F9 toggle-off. + +Captured in `cj/ai-term' just before an agent window is torn down, and +consumed by `cj/--ai-term-dispatch' so the next toggle-on reopens the +SAME agent that was on screen rather than whichever agent happens to be +most-recent in `buffer-list'. Without this, hiding one agent and +reopening could surface a different one when several agents are alive -- +the \"the displayed buffer changes\" bug. Falls back to the buffer-list +MRU when nil or when the remembered buffer has been killed.") + +(defvar cj/--ai-term-last-size nil + "Last user-chosen body size for the AI-term display. + +Positive integer: body-columns when `cj/--ai-term-last-direction' +is right or left, body-lines when below or above. nil means use +the host-aware default from `cj/--ai-term-default-size' (a float +fraction). + +Body size, not total size, because total-width includes the +right-edge divider when the window has a right sibling but excludes +it when the window is at the frame edge. Capturing total-width +from a rightmost agent (no divider) and replaying into a middle +position (with divider) leaves the body 1 column short -- visible +as 1 col of the sibling buffer peeking through where agent should +have ended. Body-width is divider-independent and matches what the +user actually sees. + +Absolute values rather than fractions because +`display-buffer-in-direction' interprets a float `window-width' / +`window-height' as a fraction of the new window's parent in the +window tree. In a 3+ window layout the parent may be a sub-tree, +and a fraction-of-frame produces the wrong size on replay +(squeezes the other windows). An integer is unambiguous, at the +cost of not auto-scaling if the frame itself resizes.") + +(defun cj/--ai-term-capture-state (window) + "Capture WINDOW's direction and size into module-level state. + +Sets `cj/--ai-term-last-direction' and `cj/--ai-term-last-size' +so a subsequent F9 display can restore the user's chosen orientation +and size. Called at toggle-off (just before the window is torn +down). The default direction is host-aware via +`cj/--ai-term-default-direction' (used only when WINDOW fills its +frame and no direction can be inferred). Does nothing when WINDOW +is not live." + (cj/window-toggle-capture-state + window (cj/--ai-term-default-direction) + 'cj/--ai-term-last-direction + 'cj/--ai-term-last-size + '(right below left))) + +(defun cj/--ai-term-reuse-existing-agent (buffer _alist) + "Display-buffer action: reuse any window in this frame already showing +an agent buffer. + +Looks up `cj/--ai-term-displayed-agent-window' on the selected +frame. When an agent window exists, replaces its buffer with BUFFER +and returns the window. When none exists, returns nil so the next +action in the chain runs. + +This is more specific than `display-buffer-use-some-window', which +would happily steal any non-selected window (e.g. a code window +above the agent split) when the user is focused in agent and +swaps projects via C-F9. The selective lookup here keeps non-agent +windows undisturbed and preserves the user's split geometry across +project changes." + (let ((win (cj/--ai-term-displayed-agent-window))) + (when win + (set-window-buffer win buffer) + win))) + +(defun cj/--ai-term-reuse-edge-window (buffer _alist) + "Display-buffer action: reuse the existing window forming the target half. + +When the frame already holds a window forming the half the agent would +occupy -- the right column on a desktop, the bottom row on a laptop, per +the saved or default direction -- swap BUFFER into it with +`set-window-buffer' and return that window, rather than splitting a third +window in. The target half is found by `cj/window-at-edge'. + +Returns nil when there is no such half to reuse (a single-window frame, +or a layout split on the other axis), so the chain falls through to +`cj/--ai-term-display-saved', which splits a fresh half. Also returns +nil when the edge window is dedicated -- those are not ours to replace. + +Records the displaced buffer through `display-buffer-record-window' +\(type `reuse') before swapping, so the native `quit-restore-window' +called at toggle-off puts that buffer back into the slot instead of +deleting the window -- toggling swaps the slot's buffer between the +displaced buffer and the agent, never changing the window count. + +Runs after `cj/--ai-term-reuse-existing-agent', so an agent already on +screen has been handled already; the window reused here always holds a +non-agent buffer, which is replaced (it stays alive, just unshown)." + (let* ((direction (or cj/--ai-term-last-direction + (cj/--ai-term-default-direction))) + (win (cj/window-at-edge direction))) + (when (and win (not (window-dedicated-p win))) + (display-buffer-record-window 'reuse win buffer) + (set-window-buffer win buffer) + win))) + +(defun cj/--ai-term-display-saved (buffer alist) + "Display-buffer action: split per saved direction and size. + +When the prior toggle-off was a bury (single-window state, flagged +via `cj/--ai-term-last-was-bury') and the frame is still single- +window, restore the agent into the selected window in place rather +than splitting -- preserves the user's lone-window layout across +F9 toggles. + +Otherwise delegates to `cj/window-toggle-display-saved' against the +F9 state vars, falling back to the host-aware defaults from +`cj/--ai-term-default-direction' and `cj/--ai-term-default-size'." + (cond + ((and cj/--ai-term-last-was-bury (one-window-p)) + (setq cj/--ai-term-last-was-bury nil) + (let ((win (selected-window))) + (set-window-buffer win buffer) + win)) + (t + (setq cj/--ai-term-last-was-bury nil) + (cj/window-toggle-display-saved + buffer alist + 'cj/--ai-term-last-direction (cj/--ai-term-default-direction) + 'cj/--ai-term-last-size (cj/--ai-term-default-size))))) + +(defun cj/--ai-term-display-rule-list () + "Return the `display-buffer-alist' entry list installed by this module. + +The single rule routes any buffer whose name starts with \"agent [\" +through four actions in order: + +1. `display-buffer-reuse-window' -- if the same buffer is already + visible in any window, focus that one. +2. `cj/--ai-term-reuse-existing-agent' -- otherwise, if any + window in this frame already shows an agent-prefixed buffer, + swap its buffer for the new one (preserves geometry across + project changes via C-F9). +3. `cj/--ai-term-reuse-edge-window' -- otherwise, if the frame + already has a window forming the half the agent would occupy + (the right column on a desktop, the bottom row on a laptop), + reuse it instead of splitting a third window in. +4. `cj/--ai-term-display-saved' -- otherwise (single-window frame, + or a layout split on the other axis), split per the saved + direction + size from the last toggle-off (or defaults when no + capture has happened this session). + +`display-buffer-in-side-window' is avoided deliberately. Side +windows enforce dedication, which breaks `buffer-move' (C-M-arrows) +and `switch-to-buffer' replacement. The chain above keeps the +resulting window an ordinary window so all standard window commands +work. + +`display-buffer-use-some-window' is also avoided -- it would happily +steal any non-selected window (e.g. a code window above an agent +split) when the user is focused in agent and switches projects." + '(("\\`agent \\[" + (display-buffer-reuse-window + cj/--ai-term-reuse-existing-agent + cj/--ai-term-reuse-edge-window + cj/--ai-term-display-saved) + (inhibit-same-window . t)))) + +(dolist (entry (cj/--ai-term-display-rule-list)) + (add-to-list 'display-buffer-alist entry)) + +(defun cj/--ai-term-show-or-create (dir name) + "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 +the buffer exists but its process is dead, kill it and recreate. If +no such buffer exists, create a new ghostel 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. + +The dynamic binding of `cj/--ai-term-suppress-tmux' around `(ghostel)' +suppresses the generic tmux-launch hook in term-config.el so +it doesn't fire a bare \"tmux\\n\" before the project-named launch +command runs. + +Records DIR in `cj/--ai-term-mru' (whichever branch runs) so the +project picker can list recently-opened projects first. Returns the +buffer." + (cj/--ai-term-record-mru dir) + (let ((existing (get-buffer name))) + (cond + ((and existing (cj/--ai-term-process-live-p existing)) + (display-buffer existing) + existing) + (t + (when existing + (kill-buffer existing)) + ;; `ghostel' switches to its buffer in the selected window before our + ;; display-buffer-alist rule can route it; `save-window-excursion' + ;; reverts that, and the explicit display-buffer below routes the buffer + ;; through the alist into the agent slot. `ghostel-buffer-name' is bound + ;; to NAME so the terminal is created under the agent name, and + ;; `ghostel-buffer-name-function' is pinned nil (dynamically during + ;; creation, then buffer-locally) so OSC title escapes from the agent + ;; don't rename it out from under the "agent [" prefix that buffer + ;; detection and the display rule key on. + (save-window-excursion + (let ((default-directory dir) + (ghostel-buffer-name name) + (ghostel-buffer-name-function nil) + (cj/--ai-term-suppress-tmux t)) + (let ((buf (ghostel))) + (when (buffer-live-p buf) + (with-current-buffer buf + (setq-local ghostel-buffer-name-function nil)))))) + (let ((buf (get-buffer name))) + (with-current-buffer buf + (ghostel-send-string (cj/--ai-term-launch-command dir)) + (ghostel-send-string "\n")) + (display-buffer buf) + buf))))) + +(defun cj/--ai-term-format-candidate (path &optional sessions) + "Return the display name for PATH in the AI-term project picker. + +Appends \" [running]\" when the project's agent buffer exists with +a live process; otherwise \" [detached]\" when PATH's tmux session +name is in SESSIONS (a session that survived an Emacs crash, no +buffer yet); otherwise just the abbreviated path. Path is +abbreviated via `abbreviate-file-name' so it reads as ~/code/foo +rather than the full home-dir form." + (let* ((name (cj/--ai-term-buffer-name path)) + (buf (get-buffer name)) + (running (and buf (cj/--ai-term-process-live-p buf))) + (detached (and (not running) + (cj/--ai-term-session-active-p path sessions))) + (display-path (abbreviate-file-name path))) + (cond + (running (format "%s [running]" display-path)) + (detached (format "%s [detached]" display-path)) + (t display-path)))) + +(defun cj/--ai-term-completion-table (alist) + "Return a `completing-read' table over ALIST that pins candidate order. + +`completing-read' over a bare alist lets the front-end (Vertico) +re-sort candidates by recency / length / alpha, which would defeat +the picker's active-sessions-first grouping. Returning +`display-sort-function' and `cycle-sort-function' of `identity' in +the metadata keeps the order ALIST was built in." + (lambda (string predicate action) + (if (eq action 'metadata) + '(metadata (display-sort-function . identity) + (cycle-sort-function . identity)) + (complete-with-action action alist string predicate)))) + +(defun cj/--ai-term-pick-project () + "Prompt for an AI-agent project; return its absolute path. + +Candidates come from `cj/--ai-term-candidates', ordered by +`cj/--ai-term-sort-candidates' so projects with a live tmux session +appear first (then alphabetical by abbreviated path). Display uses +`cj/--ai-term-format-candidate', which abbreviates the path and +flags a live session via \" [running]\" (an Emacs terminal buffer is +alive) or \" [detached]\" (the tmux session survived, no buffer). +Signals `user-error' when no candidates exist." + (let ((candidates (cj/--ai-term-candidates))) + (unless candidates + (user-error "No AI-agent projects found under %s" + (mapconcat #'identity + (append cj/ai-term-project-roots + cj/ai-term-container-roots) + ", "))) + (let* ((sessions (cj/--ai-term-live-tmux-sessions)) + (sorted (cj/--ai-term-sort-candidates candidates sessions)) + (display-alist + (mapcar (lambda (p) + (cons (cj/--ai-term-format-candidate p sessions) p)) + sorted)) + (chosen (completing-read + "AI terminal project: " + (cj/--ai-term-completion-table display-alist) + nil t))) + (or (cdr (assoc chosen display-alist)) + (expand-file-name chosen))))) + +(defun cj/--ai-term-dispatch () + "Compute the F9 (`cj/ai-term') action without performing it. + +Returns one of: +- (toggle-off . WINDOW) -- agent is displayed in WINDOW; quit it. +- (redisplay-recent . BUFFER) -- 1+ alive agent buffers; show MRU. +- (pick-project) -- zero alive agent buffers; prompt. + +When 2+ agent buffers are alive, F9 redisplays the most-recently- +selected one rather than opening the project picker. C-F9 is the +explicit \"start a different project\" surface; M-F9 is the explicit +\"switch among existing agents\" surface. F9 keeps a single, simple +job: toggle whichever agent was last in use. + +A pure-decision helper so the dispatch logic is exercisable in tests +without firing real `display-buffer' or `quit-window' calls." + (let ((win (cj/--ai-term-displayed-agent-window))) + (cond + (win (cons 'toggle-off win)) + (t + (let ((buffers (cj/--ai-term-agent-buffers))) + (cond + (buffers + ;; Reopen the agent the last toggle-off hid (faithful toggle), so + ;; long as it's still alive and among the live agents. Otherwise + ;; fall back to the most-recently-selected agent. + (cons 'redisplay-recent + (if (and (buffer-live-p cj/--ai-term-last-hidden-buffer) + (memq cj/--ai-term-last-hidden-buffer buffers)) + cj/--ai-term-last-hidden-buffer + (car buffers)))) + (t '(pick-project)))))))) + +(defun cj/ai-term-pick-project (&optional arg) + "Pick an AI-agent project and open or reuse its ghostel terminal. + +The project is picked from a filtered completing-read list of dirs +that contain .ai/protocols.org. The terminal buffer is named +\"agent []\" and is routed to a right-side window via +`display-buffer-alist'. Multiple projects coexist as separate +buffers; reinvoking on the same project reuses its existing terminal. + +With prefix ARG, display the buffer without selecting its window. + +Bound to C-F9 -- always shows the project picker, even when an agent +buffer is currently displayed. + +ghostel renders in terminal frames as well as GUI frames, so this +launches from either (only kitty inline-graphics degrade in a TTY)." + (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))) + (unless arg + (let ((win (get-buffer-window buf))) + (when win (select-window win)))) + buf)) + +(defun cj/ai-term (&optional arg) + "Smart F9 dispatch for the AI-term launcher. + +Behavior depends on the current state: + +- If an AI-term buffer is currently displayed in this frame, F9 + quits its window (toggle off, buffer stays alive). +- Else, if exactly one alive AI-term buffer exists, F9 re-displays + it (DWIM -- the obvious next step is to look at it). +- Else (zero or 2+), F9 falls through to `cj/ai-term-pick-project'. + +With prefix ARG, display the buffer without selecting its window +when a buffer is being shown (no effect on the toggle-off branch). + +See `cj/ai-term-pick-project' (C-F9) to force the project picker. +M-F9 (and C-S-F9) close an agent via `cj/ai-term-close'." + (interactive "P") + (pcase (cj/--ai-term-dispatch) + (`(toggle-off . ,win) + ;; Remember which agent we're hiding so the next toggle-on reopens this + ;; same one, not whichever agent is most-recent in `buffer-list'. + (setq cj/--ai-term-last-hidden-buffer (window-buffer win)) + (cond + ;; Lone fullscreen agent (e.g. after `C-x 1' inside it): there is no + ;; prior layout for the native undo to restore and deleting would + ;; leave the frame empty. Bury and flag, so the next toggle-on + ;; (`cj/--ai-term-display-saved') restores the agent in place at + ;; full frame rather than splitting. Capture geometry for that + ;; restore. `bury-buffer' can no-op when the window's prev-buffer + ;; history holds only the agent (common right after `C-x 1'), so + ;; force a swap to a non-agent buffer to keep the toggle observable. + ((one-window-p) + (cj/--ai-term-capture-state win) + (setq cj/--ai-term-last-was-bury t) + (bury-buffer (window-buffer win)) + (when (and (window-live-p win) + (cj/--ai-term-buffer-p (window-buffer win))) + (with-selected-window win + (switch-to-buffer + (or (cj/--ai-term-most-recent-non-agent-buffer) + (other-buffer (window-buffer win) t)))))) + ;; Multi-window: collapse the agent split outright by deleting its + ;; window, so the working buffer (e.g. todo.org) reclaims the space. + ;; F9 is a pure show/hide toggle of THE agent split -- it must never + ;; surface a different agent. `quit-restore-window' can't guarantee + ;; that here: switching among several agents reuses the one slot via + ;; `set-window-buffer' (see `cj/--ai-term-reuse-existing-agent'), + ;; which leaves the window's `quit-restore' parameter pointing at the + ;; FIRST agent shown. Once it's stale, `quit-restore-window' falls + ;; back to `switch-to-prev-buffer' and surfaces another agent instead + ;; of removing the window -- exactly the "F9 shows another agent" + ;; bug. `delete-window' is unconditional and slot-history-independent. + ;; Capture geometry first so the next toggle-on splits at the same + ;; size (the user's chosen split width is preserved across the toggle). + (t + (cj/--ai-term-capture-state win) + (setq cj/--ai-term-last-was-bury nil) + (if (and (window-live-p win) + (> (length (window-list (window-frame win) 'never)) 1)) + (delete-window win) + ;; Degenerate fallback (window became sole between dispatch and + ;; here): swap to a non-agent buffer rather than leave the agent up. + (when (window-live-p win) + (with-selected-window win + (switch-to-buffer + (or (cj/--ai-term-most-recent-non-agent-buffer) + (other-buffer (window-buffer win) t)))))))) + nil) + (`(redisplay-recent . ,buf) + (display-buffer buf) + (unless arg + (let ((w (get-buffer-window buf))) + (when w (select-window w)))) + buf) + (`(pick-project) + (cj/ai-term-pick-project arg)))) + +;; ----------------------------- Close an agent -------------------------------- + +(defun cj/--ai-term-kill-tmux-session (session) + "Kill the tmux SESSION via `tmux kill-session -t SESSION'. + +Returns the process exit status (0 on success), or nil when tmux is +unavailable or already gone -- a session that no longer exists is not +an error worth surfacing, since the goal is just to make sure it's +down." + (condition-case nil + (process-file "tmux" nil nil nil "kill-session" "-t" session) + (error nil))) + +(defun cj/--ai-term-close-buffer (buffer) + "Gracefully tear down AI-term BUFFER: tmux session, window, 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. Deletes BUFFER's window when it's shown and isn't the +only window in its frame, 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))) + (let ((win (get-buffer-window buffer))) + (when (and win (> (length (window-list (window-frame win) 'never)) 1)) + (delete-window win))) + (let ((kill-buffer-query-functions nil)) + (kill-buffer buffer)))) + +(defun cj/--ai-term-close-target () + "Return the AI-term buffer `cj/ai-term-close' should act on, or nil. + +The current buffer when it is an agent buffer; else the sole live +agent buffer; else a `completing-read' choice among the live agent +buffers; nil when none are alive." + (cond + ((cj/--ai-term-buffer-p (current-buffer)) (current-buffer)) + (t (let ((buffers (cj/--ai-term-agent-buffers))) + (cond + ((null buffers) nil) + ((null (cdr buffers)) (car buffers)) + (t (get-buffer + (completing-read "Close AI terminal: " + (mapcar #'buffer-name buffers) nil t)))))))) + +(defun cj/ai-term-close () + "Gracefully close an AI-term agent: kill its tmux session and buffer. + +Targets the current agent buffer, the sole live agent, or prompts when +several are alive (see `cj/--ai-term-close-target'). Asks for +confirmation first -- this kills the running agent process, which can +interrupt work in progress. Bound to M- (primary) and C-S-." + (interactive) + (let ((buffer (cj/--ai-term-close-target))) + (unless buffer + (user-error "No AI-term agent buffers to close")) + (let ((name (buffer-name buffer))) + (when (y-or-n-p (format "Close agent %s? This kills its tmux session. " + name)) + (cj/--ai-term-close-buffer buffer) + (message "Closed agent %s." name))))) + +(keymap-global-set "" #'cj/ai-term) +(keymap-global-set "C-" #'cj/ai-term-pick-project) +(keymap-global-set "M-" #'cj/ai-term-close) +(keymap-global-set "C-S-" #'cj/ai-term-close) + +;; ghostel's semi-char mode forwards keys not in `ghostel-keymap-exceptions' to +;; the terminal program, so a plain typed while point is inside an agent +;; buffer would be sent to the program instead of toggling the agent -- which +;; bites hard when the agent buffer is the only window in the frame. Re-bind +;; the F9 family in `ghostel-mode-map' so the toggle reaches Emacs from there +;; too. (C- / M- are bound here as well so the behaviour is uniform.) +(with-eval-after-load 'ghostel + (keymap-set ghostel-mode-map "" #'cj/ai-term) + (keymap-set ghostel-mode-map "C-" #'cj/ai-term-pick-project) + (keymap-set ghostel-mode-map "M-" #'cj/ai-term-close) + (keymap-set ghostel-mode-map "C-S-" #'cj/ai-term-close)) + +;; ---------- emacsclient: keep opened files off the agent terminal ---------- +;; +;; `server-start' (in system-defaults.el) leaves `server-window' nil, so +;; `server-switch-buffer' opens an `emacsclient -n' file in the *selected* +;; window. When the user is typing in the agent terminal, that's the agent +;; window -- so "tell the agent to open X" would replace the agent buffer +;; with X. The function below, wired as `server-window', routes such files +;; into a non-agent window instead (splitting one off the agent when the +;; agent is the only window). emacsclient invocations from anywhere else +;; fall through to `pop-to-buffer' and behave as before. + +(defun cj/--ai-term-non-agent-window (&optional exclude) + "Return a window in the selected frame fit to show a non-agent buffer. + +Skips the minibuffer, the EXCLUDE window, dedicated windows, and any +window already showing an AI-term agent buffer. Returns nil when no +such window exists." + (seq-find (lambda (w) + (and (not (eq w exclude)) + (not (window-dedicated-p w)) + (not (cj/--ai-term-buffer-p (window-buffer w))))) + (window-list (selected-frame) 'never))) + +(defun cj/--ai-term-server-display (buffer) + "Display BUFFER for `server-window', keeping it off the agent terminal. + +When the selected window shows an AI-term agent buffer, put BUFFER in +a non-agent window (`cj/--ai-term-non-agent-window'), splitting a +left-side window off the agent when the agent is the only window, then +select that window. Otherwise hand off to `pop-to-buffer'. Returns +the window BUFFER ends up in -- the value `server-switch-buffer' +expects from a `server-window' function." + (if (cj/--ai-term-buffer-p (window-buffer (selected-window))) + (let* ((agent-win (selected-window)) + (target (or (cj/--ai-term-non-agent-window agent-win) + (split-window agent-win nil 'left)))) + (set-window-buffer target buffer) + (select-window target)) + (pop-to-buffer buffer) + (selected-window))) + +(defvar server-window) +(with-eval-after-load 'server + (setq server-window #'cj/--ai-term-server-display)) + +(provide 'ai-term) +;;; ai-term.el ends here diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el deleted file mode 100644 index 4f086636..00000000 --- a/modules/ai-vterm.el +++ /dev/null @@ -1,978 +0,0 @@ -;;; ai-vterm.el --- In-Emacs AI-agent launcher with vertical-split vterm -*- lexical-binding: t; -*- - -;; Author: Craig Jennings - -;;; Commentary: -;; -;; Layer: 3 (Domain Workflow). -;; Category: D. -;; Load shape: eager. -;; Eager reason: registers four global keys for the AI-agent vterm launcher; a -;; command-loaded deferral candidate. -;; Top-level side effects: four global key bindings. -;; Runtime requires: cl-lib, seq, cj-window-geometry-lib, cj-window-toggle-lib, -;; host-environment. -;; Direct test load: yes. -;; -;; Picks an AI-agent project (a dir under ~/.emacs.d, ~/code/*, or -;; ~/projects/* containing .ai/protocols.org), opens or reuses a vterm -;; buffer named "agent []", sends the agent's startup -;; instruction to it, and routes the buffer to a side window via -;; display-buffer-alist. When the frame already has a window forming the -;; half the agent would occupy (a right column on a desktop, a bottom row -;; on a laptop), the agent reuses that slot rather than splitting a third -;; window in; toggling off restores the displaced buffer to the slot. -;; Otherwise placement is a host-aware split: a right-side split at 50% -;; width on a desktop, a bottom split at 75% height on a laptop (see -;; `cj/--ai-vterm-default-direction'). Multiple -;; projects produce multiple coexisting buffers that share the same -;; slot; switching among them is a buffer-switch, not a -;; kill-and-recreate. -;; -;; Each project's agent runs inside a tmux session named -;; "" (default prefix "aiv-"). -;; The prefix lets `tmux ls' be filtered to AI-vterm's own sessions, so -;; after an Emacs crash the project picker can match surviving sessions -;; back to their directories: matched projects sort to the top of the -;; picker (flagged "[detached]" -- session alive, no Emacs buffer -- or -;; "[running]" when a live vterm buffer exists), the rest follow in -;; alphabetical order. -;; -;; Four F-key entry points: -;; -;; - F9 `cj/ai-vterm' -- DWIM dispatch. If an agent buffer is -;; currently displayed in this frame, F9 toggles it off: when it -;; took over an existing window (a reused slot) the buffer it -;; displaced returns to that slot, when it was split into its own -;; window that window is removed, and when it fills the frame it -;; is buried. Otherwise, if exactly one agent buffer is alive, -;; F9 re-displays it; if zero or two-plus are alive, F9 falls -;; through to the project picker. -;; - C-F9 `cj/ai-vterm-pick-project' -- always show the project -;; picker, even when an agent buffer is currently displayed. -;; Used when the user wants to start a new project session -;; instead of toggling the current one. -;; - M-F9 `cj/ai-vterm-close' -- gracefully close an agent: kill its -;; tmux session (stopping the agent process), then its vterm -;; buffer and window. Confirms first. Targets the current -;; agent, the sole live agent, or prompts among several. -;; - C-S-F9 `cj/ai-vterm-close' -- same close command, second binding. -;; (M-F9 is the primary; C-S-F9 may be swallowed by the -;; Wayland/PGTK layer on some machines.) -;; -;; Existing windmove (Shift-arrows) handles code <-> agent focus -;; toggling. Buffer-move (C-M-arrows) handles side-swap. Neither -;; needs anything new from this module. - -;;; Code: - -(require 'cl-lib) -(require 'seq) -(require 'cj-window-geometry-lib) -(require 'cj-window-toggle-lib) -(require 'host-environment) - -(declare-function vterm "vterm" (&optional buffer-name)) -(declare-function vterm-send-string "vterm" (string &optional paste-p)) -(declare-function vterm-send-return "vterm" ()) -(defvar vterm-mode-map) - -(defgroup ai-vterm nil - "In-Emacs AI-agent launcher with vertical-split vterm." - :group 'tools) - -(defcustom cj/ai-vterm-agent-command - "claude \"Read .ai/protocols.org and follow all instructions.\"" - "Shell command sent to a fresh AI-vterm to start the agent. - -The default invokes the Claude Code CLI; set it to whatever terminal -agent you run (aider, an open-source LLM TUI, etc.)." - :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 -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 AI-agent projects. -Each entry is included as a candidate when it exists and contains -.ai/protocols.org. Use this for single-project roots like ~/.emacs.d." - :type '(repeat directory) - :group 'ai-vterm) - -(defcustom cj/ai-vterm-container-roots - (list (expand-file-name "~/code") - (expand-file-name "~/projects")) - "Directories whose immediate children are scanned for agent projects. -Each entry's child directories are included as candidates when they -contain .ai/protocols.org. Use this for container dirs like ~/code." - :type '(repeat directory) - :group 'ai-vterm) - -(defcustom cj/ai-vterm-tmux-session-prefix "aiv-" - "Prefix prepended to tmux session names AI-vterm creates. - -The session name for a project is this prefix followed by the -project's basename (whitespace collapsed to hyphens). The prefix -lets `tmux ls' output be filtered down to AI-vterm's own sessions -- -so after an Emacs crash the project picker can match surviving -sessions back to their directories and surface them first. Pick -something unlikely to collide with hand-rolled tmux sessions; the -default \"aiv-\" is short for \"ai-vterm\"." - :type 'string - :group 'ai-vterm) - -(defcustom cj/ai-vterm-tmux-window-name "ai" - "Name given to the first tmux window in an AI-vterm session. - -Passed as `tmux new-session -n', so the window running the AI tool -shows up as this name in `tmux ls' / the status line. A later -window opened by hand (e.g. a shell) auto-names after its command, -so the two read distinctly instead of both showing up as the -running program." - :type 'string - :group 'ai-vterm) - -(defconst cj/--ai-vterm-name-prefix "agent [" - "Buffer-name prefix shared by all AI-vterm buffers. - -Single source of truth for both buffer construction in -`cj/--ai-vterm-buffer-name' and detection in -`cj/--ai-vterm-buffer-p'. The display-buffer-alist rule keys on the -escaped form \"\\\\`agent \\\\[\" -- they must stay in sync.") - -(defun cj/--ai-vterm-buffer-name (dir) - "Return the AI-vterm buffer name for project directory DIR. - -The name pattern is \"agent []\". The display-buffer-alist -rule keys on the literal prefix \"agent [\", so changing the format -breaks routing to the right-side window." - (format "%s%s]" - cj/--ai-vterm-name-prefix - (file-name-nondirectory (directory-file-name dir)))) - -(defun cj/--ai-vterm-buffer-p (buffer) - "Return non-nil when BUFFER is an AI-vterm buffer. - -A buffer qualifies when its name starts with the literal prefix in -`cj/--ai-vterm-name-prefix' (\"agent [\"). The check is anchored at -the start so names like \"foo agent [bar]\" do not match." - (and (bufferp buffer) - (buffer-live-p buffer) - (string-prefix-p cj/--ai-vterm-name-prefix (buffer-name buffer)))) - -(defun cj/--ai-vterm-agent-buffers () - "Return the live AI-vterm buffers in `buffer-list' order. - -Order matches `buffer-list' on the selected frame, which is most- -recently-selected first. Non-AI-vterm buffers are filtered out via -`cj/--ai-vterm-buffer-p'." - (seq-filter #'cj/--ai-vterm-buffer-p (buffer-list))) - -(defun cj/--ai-vterm-most-recent-non-agent-buffer () - "Return the most-recently-selected live non-agent buffer, or nil. - -Walks `buffer-list' (most-recently-selected first) and returns the -first buffer that is not an AI-vterm agent buffer (per -`cj/--ai-vterm-buffer-p') and is not an internal buffer (name starting -with a space). Used by the single-window F9 toggle-off so dismissing a -full-frame agent returns to the file the user was working in (e.g. -todo.org) rather than swapping in another agent." - (seq-find (lambda (b) - (and (buffer-live-p b) - (not (cj/--ai-vterm-buffer-p b)) - (not (string-prefix-p " " (buffer-name b))))) - (buffer-list))) - -(defun cj/--ai-vterm-displayed-agent-window (&optional frame) - "Return a window in FRAME currently displaying an AI-vterm buffer, or nil. - -FRAME defaults to the selected frame. When more than one window in -the frame shows an agent buffer, the first one in `window-list' order -is returned. The minibuffer is excluded from the search." - (seq-find (lambda (w) - (cj/--ai-vterm-buffer-p (window-buffer w))) - (window-list (or frame (selected-frame)) 'never))) - -(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, 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 - "[.:]" "_" - (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. - -Runs `tmux list-sessions'. Returns the names beginning with -`cj/ai-vterm-tmux-session-prefix', or nil when tmux is not installed, -no server is running, or the command exits non-zero -- the picker -treats nil as \"no sessions to surface\" and falls back to a plain -alphabetical list." - (let* ((prefix cj/ai-vterm-tmux-session-prefix) - (exit nil) - (output (with-temp-buffer - (setq exit (condition-case nil - (process-file "tmux" nil '(t nil) nil - "list-sessions" "-F" - "#{session_name}") - (error nil))) - (buffer-string)))) - (when (and (integerp exit) (zerop exit)) - (seq-filter (lambda (name) (string-prefix-p prefix name)) - (split-string output "\n" t))))) - -(defun cj/--ai-vterm-session-active-p (dir sessions) - "Return non-nil when DIR's tmux session name is in SESSIONS. - -SESSIONS is the list from `cj/--ai-vterm-live-tmux-sessions' (or nil). -The match is forward: DIR's expected session name is computed and -looked up in SESSIONS, so the lossy whitespace->hyphen transform in -`cj/--ai-vterm-tmux-session-name' never needs reversing." - (and (member (cj/--ai-vterm-tmux-session-name dir) sessions) t)) - -(defun cj/--ai-vterm-launch-command (dir) - "Return the shell command line that runs the AI tool 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 -comes from `cj/--ai-vterm-tmux-session-name'; the first window is named -`cj/ai-vterm-tmux-window-name' (default \"ai\") so a later hand-opened -window auto-names after its command and the two read distinctly. - -The shell command run on first creation is - ; 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." - (let ((session (cj/--ai-vterm-tmux-session-name dir)) - (start-dir (expand-file-name dir))) - ;; Pass the inner shell-command-string through `shell-quote-argument' - ;; so any single quotes embedded in a user-customized - ;; `cj/ai-vterm-agent-command' don't break the literal single-quote - ;; wrap below. The default value carries embedded double quotes - ;; (\"Read .ai/protocols.org and follow all instructions.\") which - ;; was safe in the prior shape but a single-quoted custom value - ;; silently broke the shell parse. - (format "tmux new-session -A -s %s -n %s -c %s %s" - (shell-quote-argument session) - (shell-quote-argument cj/ai-vterm-tmux-window-name) - (shell-quote-argument start-dir) - (shell-quote-argument - (concat cj/ai-vterm-agent-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))) - -(defun cj/--ai-vterm-candidates () - "Return the list of AI-agent project paths. - -Each entry of `cj/ai-vterm-project-roots' contributes itself when it -exists and contains .ai/protocols.org. Each entry of -`cj/ai-vterm-container-roots' contributes its immediate child -directories that contain .ai/protocols.org. - -Returns absolute paths. Nonexistent roots are skipped silently." - (let (result) - (dolist (root cj/ai-vterm-project-roots) - (let ((expanded (expand-file-name root))) - (when (and (file-directory-p expanded) - (cj/--ai-vterm-has-marker-p expanded)) - (push expanded result)))) - (dolist (root cj/ai-vterm-container-roots) - (let ((expanded (expand-file-name root))) - (when (file-directory-p expanded) - (dolist (child (directory-files - expanded t directory-files-no-dot-files-regexp t)) - (when (and (file-directory-p child) - (cj/--ai-vterm-has-marker-p child)) - (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, 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 mru-then-alpha) (sort inactive alpha)))) - -(defun cj/--ai-vterm-process-live-p (buffer) - "Return non-nil when BUFFER has a live process attached." - (let ((proc (get-buffer-process buffer))) - (and proc (process-live-p proc)))) - -(defcustom cj/ai-vterm-desktop-width 0.5 - "Default fraction of frame width for the AI-vterm window on a desktop. - -On a desktop the agent opens as a right-side vertical split (see -`cj/--ai-vterm-default-direction'), so this fraction is interpreted -as a window width. Used by `cj/--ai-vterm-default-size' as the size -fallback when `cj/--ai-vterm-last-size' is nil (i.e. the user hasn't -yet toggled off an agent window in this session)." - :type 'number - :group 'ai-vterm) - -(defcustom cj/ai-vterm-laptop-height 0.75 - "Default fraction of frame height for the AI-vterm window on a laptop. - -On a laptop the agent opens as a bottom horizontal split (see -`cj/--ai-vterm-default-direction'), so this fraction is interpreted -as a window height. Used by `cj/--ai-vterm-default-size' as the size -fallback when `cj/--ai-vterm-last-size' is nil." - :type 'number - :group 'ai-vterm) - -(defun cj/--ai-vterm-default-direction () - "Return the host-appropriate default split direction for the agent window. - -`below' on a laptop (bottom horizontal split), `right' on a desktop -(right-side vertical split). Detected via `env-laptop-p'." - (if (env-laptop-p) 'below 'right)) - -(defun cj/--ai-vterm-default-size () - "Return the host-appropriate default size fraction for the agent window. - -`cj/ai-vterm-laptop-height' on a laptop, `cj/ai-vterm-desktop-width' -on a desktop -- pairing with the axis chosen by -`cj/--ai-vterm-default-direction'." - (if (env-laptop-p) - cj/ai-vterm-laptop-height - cj/ai-vterm-desktop-width)) - -(defvar cj/--ai-vterm-last-direction nil - "Last user-chosen direction for the AI-vterm display. - -Symbol: right, below, or left. `above' is never stored -- the agent -window must not be remembered at the top of the frame, so a top -placement falls back to the host default at capture time. nil means no -agent window has been toggled off yet this session, so the default -direction applies. Captured at toggle-off by -`cj/--ai-vterm-capture-state' and consumed by -`cj/--ai-vterm-display-saved'.") - -(defvar cj/--ai-vterm-last-was-bury nil - "Non-nil when the last F9 toggle-off used `bury-buffer'. - -Set by `cj/ai-vterm' in its `toggle-off' branch: t when the agent -window was the only window in the frame (so toggle-off buried -without deleting), nil when the window was deleted. Consumed by -`cj/--ai-vterm-display-saved' to decide between restoring the -buried agent in the current window (the only one) or splitting per -the saved direction.") - -(defvar cj/--ai-vterm-last-hidden-buffer nil - "The agent buffer hidden by the most recent F9 toggle-off. - -Captured in `cj/ai-vterm' just before an agent window is torn down, and -consumed by `cj/--ai-vterm-dispatch' so the next toggle-on reopens the -SAME agent that was on screen rather than whichever agent happens to be -most-recent in `buffer-list'. Without this, hiding one agent and -reopening could surface a different one when several agents are alive -- -the \"the displayed buffer changes\" bug. Falls back to the buffer-list -MRU when nil or when the remembered buffer has been killed.") - -(defvar cj/--ai-vterm-last-size nil - "Last user-chosen body size for the AI-vterm display. - -Positive integer: body-columns when `cj/--ai-vterm-last-direction' -is right or left, body-lines when below or above. nil means use -the host-aware default from `cj/--ai-vterm-default-size' (a float -fraction). - -Body size, not total size, because total-width includes the -right-edge divider when the window has a right sibling but excludes -it when the window is at the frame edge. Capturing total-width -from a rightmost agent (no divider) and replaying into a middle -position (with divider) leaves the body 1 column short -- visible -as 1 col of the sibling buffer peeking through where agent should -have ended. Body-width is divider-independent and matches what the -user actually sees. - -Absolute values rather than fractions because -`display-buffer-in-direction' interprets a float `window-width' / -`window-height' as a fraction of the new window's parent in the -window tree. In a 3+ window layout the parent may be a sub-tree, -and a fraction-of-frame produces the wrong size on replay -(squeezes the other windows). An integer is unambiguous, at the -cost of not auto-scaling if the frame itself resizes.") - -(defun cj/--ai-vterm-capture-state (window) - "Capture WINDOW's direction and size into module-level state. - -Sets `cj/--ai-vterm-last-direction' and `cj/--ai-vterm-last-size' -so a subsequent F9 display can restore the user's chosen orientation -and size. Called at toggle-off (just before the window is torn -down). The default direction is host-aware via -`cj/--ai-vterm-default-direction' (used only when WINDOW fills its -frame and no direction can be inferred). Does nothing when WINDOW -is not live." - (cj/window-toggle-capture-state - window (cj/--ai-vterm-default-direction) - 'cj/--ai-vterm-last-direction - 'cj/--ai-vterm-last-size - '(right below left))) - -(defun cj/--ai-vterm-reuse-existing-agent (buffer _alist) - "Display-buffer action: reuse any window in this frame already showing -an agent buffer. - -Looks up `cj/--ai-vterm-displayed-agent-window' on the selected -frame. When an agent window exists, replaces its buffer with BUFFER -and returns the window. When none exists, returns nil so the next -action in the chain runs. - -This is more specific than `display-buffer-use-some-window', which -would happily steal any non-selected window (e.g. a code window -above the agent split) when the user is focused in agent and -swaps projects via C-F9. The selective lookup here keeps non-agent -windows undisturbed and preserves the user's split geometry across -project changes." - (let ((win (cj/--ai-vterm-displayed-agent-window))) - (when win - (set-window-buffer win buffer) - win))) - -(defun cj/--ai-vterm-reuse-edge-window (buffer _alist) - "Display-buffer action: reuse the existing window forming the target half. - -When the frame already holds a window forming the half the agent would -occupy -- the right column on a desktop, the bottom row on a laptop, per -the saved or default direction -- swap BUFFER into it with -`set-window-buffer' and return that window, rather than splitting a third -window in. The target half is found by `cj/window-at-edge'. - -Returns nil when there is no such half to reuse (a single-window frame, -or a layout split on the other axis), so the chain falls through to -`cj/--ai-vterm-display-saved', which splits a fresh half. Also returns -nil when the edge window is dedicated -- those are not ours to replace. - -Records the displaced buffer through `display-buffer-record-window' -\(type `reuse') before swapping, so the native `quit-restore-window' -called at toggle-off puts that buffer back into the slot instead of -deleting the window -- toggling swaps the slot's buffer between the -displaced buffer and the agent, never changing the window count. - -Runs after `cj/--ai-vterm-reuse-existing-agent', so an agent already on -screen has been handled already; the window reused here always holds a -non-agent buffer, which is replaced (it stays alive, just unshown)." - (let* ((direction (or cj/--ai-vterm-last-direction - (cj/--ai-vterm-default-direction))) - (win (cj/window-at-edge direction))) - (when (and win (not (window-dedicated-p win))) - (display-buffer-record-window 'reuse win buffer) - (set-window-buffer win buffer) - win))) - -(defun cj/--ai-vterm-display-saved (buffer alist) - "Display-buffer action: split per saved direction and size. - -When the prior toggle-off was a bury (single-window state, flagged -via `cj/--ai-vterm-last-was-bury') and the frame is still single- -window, restore the agent into the selected window in place rather -than splitting -- preserves the user's lone-window layout across -F9 toggles. - -Otherwise delegates to `cj/window-toggle-display-saved' against the -F9 state vars, falling back to the host-aware defaults from -`cj/--ai-vterm-default-direction' and `cj/--ai-vterm-default-size'." - (cond - ((and cj/--ai-vterm-last-was-bury (one-window-p)) - (setq cj/--ai-vterm-last-was-bury nil) - (let ((win (selected-window))) - (set-window-buffer win buffer) - win)) - (t - (setq cj/--ai-vterm-last-was-bury nil) - (cj/window-toggle-display-saved - buffer alist - 'cj/--ai-vterm-last-direction (cj/--ai-vterm-default-direction) - 'cj/--ai-vterm-last-size (cj/--ai-vterm-default-size))))) - -(defun cj/--ai-vterm-display-rule-list () - "Return the `display-buffer-alist' entry list installed by this module. - -The single rule routes any buffer whose name starts with \"agent [\" -through four actions in order: - -1. `display-buffer-reuse-window' -- if the same buffer is already - visible in any window, focus that one. -2. `cj/--ai-vterm-reuse-existing-agent' -- otherwise, if any - window in this frame already shows an agent-prefixed buffer, - swap its buffer for the new one (preserves geometry across - project changes via C-F9). -3. `cj/--ai-vterm-reuse-edge-window' -- otherwise, if the frame - already has a window forming the half the agent would occupy - (the right column on a desktop, the bottom row on a laptop), - reuse it instead of splitting a third window in. -4. `cj/--ai-vterm-display-saved' -- otherwise (single-window frame, - or a layout split on the other axis), split per the saved - direction + size from the last toggle-off (or defaults when no - capture has happened this session). - -`display-buffer-in-side-window' is avoided deliberately. Side -windows enforce dedication, which breaks `buffer-move' (C-M-arrows) -and `switch-to-buffer' replacement. The chain above keeps the -resulting window an ordinary window so all standard window commands -work. - -`display-buffer-use-some-window' is also avoided -- it would happily -steal any non-selected window (e.g. a code window above an agent -split) when the user is focused in agent and switches projects." - '(("\\`agent \\[" - (display-buffer-reuse-window - cj/--ai-vterm-reuse-existing-agent - cj/--ai-vterm-reuse-edge-window - cj/--ai-vterm-display-saved) - (inhibit-same-window . t)))) - -(dolist (entry (cj/--ai-vterm-display-rule-list)) - (add-to-list 'display-buffer-alist entry)) - -(defun cj/--ai-vterm-show-or-create (dir name) - "Show or create the AI-vterm buffer for project DIR with buffer NAME. - -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 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 vterm-config.el so -it doesn't fire a bare \"tmux\\n\" before the project-named launch -command runs. - -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)) - (display-buffer existing) - existing) - (t - (when existing - (kill-buffer existing)) - ;; `vterm' calls pop-to-buffer-same-window internally, which - ;; replaces the selected window's buffer (e.g. the dashboard at - ;; fresh startup) before our display-buffer-alist rule has a - ;; chance to route it. `save-window-excursion' reverts that - ;; side-effect; the explicit display-buffer call below then - ;; routes the buffer through the alist into a right-side split. - (save-window-excursion - (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-launch-command dir)) - (vterm-send-return)) - (display-buffer buf) - buf))))) - -(defun cj/--ai-vterm-format-candidate (path &optional sessions) - "Return the display name for PATH in the AI-vterm project picker. - -Appends \" [running]\" when the project's agent buffer exists with -a live process; otherwise \" [detached]\" when PATH's tmux session -name is in SESSIONS (a session that survived an Emacs crash, no -buffer yet); otherwise just the abbreviated path. Path is -abbreviated via `abbreviate-file-name' so it reads as ~/code/foo -rather than the full home-dir form." - (let* ((name (cj/--ai-vterm-buffer-name path)) - (buf (get-buffer name)) - (running (and buf (cj/--ai-vterm-process-live-p buf))) - (detached (and (not running) - (cj/--ai-vterm-session-active-p path sessions))) - (display-path (abbreviate-file-name path))) - (cond - (running (format "%s [running]" display-path)) - (detached (format "%s [detached]" display-path)) - (t display-path)))) - -(defun cj/--ai-vterm-completion-table (alist) - "Return a `completing-read' table over ALIST that pins candidate order. - -`completing-read' over a bare alist lets the front-end (Vertico) -re-sort candidates by recency / length / alpha, which would defeat -the picker's active-sessions-first grouping. Returning -`display-sort-function' and `cycle-sort-function' of `identity' in -the metadata keeps the order ALIST was built in." - (lambda (string predicate action) - (if (eq action 'metadata) - '(metadata (display-sort-function . identity) - (cycle-sort-function . identity)) - (complete-with-action action alist string predicate)))) - -(defun cj/--ai-vterm-pick-project () - "Prompt for an AI-agent project; return its absolute path. - -Candidates come from `cj/--ai-vterm-candidates', ordered by -`cj/--ai-vterm-sort-candidates' so projects with a live tmux session -appear first (then alphabetical by abbreviated path). Display uses -`cj/--ai-vterm-format-candidate', which abbreviates the path and -flags a live session via \" [running]\" (an Emacs vterm buffer is -alive) or \" [detached]\" (the tmux session survived, no buffer). -Signals `user-error' when no candidates exist." - (let ((candidates (cj/--ai-vterm-candidates))) - (unless candidates - (user-error "No AI-agent projects found under %s" - (mapconcat #'identity - (append cj/ai-vterm-project-roots - cj/ai-vterm-container-roots) - ", "))) - (let* ((sessions (cj/--ai-vterm-live-tmux-sessions)) - (sorted (cj/--ai-vterm-sort-candidates candidates sessions)) - (display-alist - (mapcar (lambda (p) - (cons (cj/--ai-vterm-format-candidate p sessions) p)) - sorted)) - (chosen (completing-read - "AI vterm project: " - (cj/--ai-vterm-completion-table display-alist) - nil t))) - (or (cdr (assoc chosen display-alist)) - (expand-file-name chosen))))) - -(defun cj/--ai-vterm-dispatch () - "Compute the F9 (`cj/ai-vterm') action without performing it. - -Returns one of: -- (toggle-off . WINDOW) -- agent is displayed in WINDOW; quit it. -- (redisplay-recent . BUFFER) -- 1+ alive agent buffers; show MRU. -- (pick-project) -- zero alive agent buffers; prompt. - -When 2+ agent buffers are alive, F9 redisplays the most-recently- -selected one rather than opening the project picker. C-F9 is the -explicit \"start a different project\" surface; M-F9 is the explicit -\"switch among existing agents\" surface. F9 keeps a single, simple -job: toggle whichever agent was last in use. - -A pure-decision helper so the dispatch logic is exercisable in tests -without firing real `display-buffer' or `quit-window' calls." - (let ((win (cj/--ai-vterm-displayed-agent-window))) - (cond - (win (cons 'toggle-off win)) - (t - (let ((buffers (cj/--ai-vterm-agent-buffers))) - (cond - (buffers - ;; Reopen the agent the last toggle-off hid (faithful toggle), so - ;; long as it's still alive and among the live agents. Otherwise - ;; fall back to the most-recently-selected agent. - (cons 'redisplay-recent - (if (and (buffer-live-p cj/--ai-vterm-last-hidden-buffer) - (memq cj/--ai-vterm-last-hidden-buffer buffers)) - cj/--ai-vterm-last-hidden-buffer - (car buffers)))) - (t '(pick-project)))))))) - -(defun cj/--ai-vterm-refuse-in-terminal () - "Signal a `user-error' when the current frame is a terminal frame. - -AI-vterm launches a graphical vterm side window, so it is GUI-only. -Each interactive entry point calls this first, so F9 and friends -decline -- with a message in the echo area -- in a terminal frame -instead of launching a vterm. The check is per-frame at command time -rather than at load, so a daemon serving both GUI and terminal frames -keeps the launcher working in its GUI frames and declines only in the -terminal ones." - (when (env-terminal-p) - (user-error "AI-vterm is GUI-only; not available in a terminal frame"))) - -(defun cj/ai-vterm-pick-project (&optional arg) - "Pick an AI-agent project and open or reuse its vterm. - -The project is picked from a filtered completing-read list of dirs -that contain .ai/protocols.org. The vterm buffer is named -\"agent []\" and is routed to a right-side window via -`display-buffer-alist'. Multiple projects coexist as separate -buffers; reinvoking on the same project reuses its existing vterm. - -With prefix ARG, display the buffer without selecting its window. - -Bound to C-F9 -- always shows the project picker, even when an agent -buffer is currently displayed." - (interactive "P") - (cj/--ai-vterm-refuse-in-terminal) - (let* ((dir (cj/--ai-vterm-pick-project)) - (name (cj/--ai-vterm-buffer-name dir)) - (buf (cj/--ai-vterm-show-or-create dir name))) - (unless arg - (let ((win (get-buffer-window buf))) - (when win (select-window win)))) - buf)) - -(defun cj/ai-vterm (&optional arg) - "Smart F9 dispatch for the AI-vterm launcher. - -Behavior depends on the current state: - -- If an AI-vterm buffer is currently displayed in this frame, F9 - quits its window (toggle off, buffer stays alive). -- Else, if exactly one alive AI-vterm buffer exists, F9 re-displays - it (DWIM -- the obvious next step is to look at it). -- Else (zero or 2+), F9 falls through to `cj/ai-vterm-pick-project'. - -With prefix ARG, display the buffer without selecting its window -when a buffer is being shown (no effect on the toggle-off branch). - -See `cj/ai-vterm-pick-project' (C-F9) to force the project picker. -M-F9 (and C-S-F9) close an agent via `cj/ai-vterm-close'." - (interactive "P") - (cj/--ai-vterm-refuse-in-terminal) - (pcase (cj/--ai-vterm-dispatch) - (`(toggle-off . ,win) - ;; Remember which agent we're hiding so the next toggle-on reopens this - ;; same one, not whichever agent is most-recent in `buffer-list'. - (setq cj/--ai-vterm-last-hidden-buffer (window-buffer win)) - (cond - ;; Lone fullscreen agent (e.g. after `C-x 1' inside it): there is no - ;; prior layout for the native undo to restore and deleting would - ;; leave the frame empty. Bury and flag, so the next toggle-on - ;; (`cj/--ai-vterm-display-saved') restores the agent in place at - ;; full frame rather than splitting. Capture geometry for that - ;; restore. `bury-buffer' can no-op when the window's prev-buffer - ;; history holds only the agent (common right after `C-x 1'), so - ;; force a swap to a non-agent buffer to keep the toggle observable. - ((one-window-p) - (cj/--ai-vterm-capture-state win) - (setq cj/--ai-vterm-last-was-bury t) - (bury-buffer (window-buffer win)) - (when (and (window-live-p win) - (cj/--ai-vterm-buffer-p (window-buffer win))) - (with-selected-window win - (switch-to-buffer - (or (cj/--ai-vterm-most-recent-non-agent-buffer) - (other-buffer (window-buffer win) t)))))) - ;; Multi-window: collapse the agent split outright by deleting its - ;; window, so the working buffer (e.g. todo.org) reclaims the space. - ;; F9 is a pure show/hide toggle of THE agent split -- it must never - ;; surface a different agent. `quit-restore-window' can't guarantee - ;; that here: switching among several agents reuses the one slot via - ;; `set-window-buffer' (see `cj/--ai-vterm-reuse-existing-agent'), - ;; which leaves the window's `quit-restore' parameter pointing at the - ;; FIRST agent shown. Once it's stale, `quit-restore-window' falls - ;; back to `switch-to-prev-buffer' and surfaces another agent instead - ;; of removing the window -- exactly the "F9 shows another agent" - ;; bug. `delete-window' is unconditional and slot-history-independent. - ;; Capture geometry first so the next toggle-on splits at the same - ;; size (the user's chosen split width is preserved across the toggle). - (t - (cj/--ai-vterm-capture-state win) - (setq cj/--ai-vterm-last-was-bury nil) - (if (and (window-live-p win) - (> (length (window-list (window-frame win) 'never)) 1)) - (delete-window win) - ;; Degenerate fallback (window became sole between dispatch and - ;; here): swap to a non-agent buffer rather than leave the agent up. - (when (window-live-p win) - (with-selected-window win - (switch-to-buffer - (or (cj/--ai-vterm-most-recent-non-agent-buffer) - (other-buffer (window-buffer win) t)))))))) - nil) - (`(redisplay-recent . ,buf) - (display-buffer buf) - (unless arg - (let ((w (get-buffer-window buf))) - (when w (select-window w)))) - buf) - (`(pick-project) - (cj/ai-vterm-pick-project arg)))) - -;; ----------------------------- Close an agent -------------------------------- - -(defun cj/--ai-vterm-kill-tmux-session (session) - "Kill the tmux SESSION via `tmux kill-session -t SESSION'. - -Returns the process exit status (0 on success), or nil when tmux is -unavailable or already gone -- a session that no longer exists is not -an error worth surfacing, since the goal is just to make sure it's -down." - (condition-case nil - (process-file "tmux" nil nil nil "kill-session" "-t" session) - (error nil))) - -(defun cj/--ai-vterm-close-buffer (buffer) - "Gracefully tear down AI-vterm BUFFER: tmux session, window, buffer. - -Derives the tmux session name from BUFFER's `default-directory' (the -project dir the vterm was created in) and kills it so the agent -process stops. Deletes BUFFER's window when it's shown and isn't the -only window in its frame, then kills BUFFER (suppressing the -process-still-running prompt -- the session is already down). No-op -when BUFFER isn't an AI-vterm buffer." - (when (cj/--ai-vterm-buffer-p buffer) - (cj/--ai-vterm-kill-tmux-session - (cj/--ai-vterm-tmux-session-name - (buffer-local-value 'default-directory buffer))) - (let ((win (get-buffer-window buffer))) - (when (and win (> (length (window-list (window-frame win) 'never)) 1)) - (delete-window win))) - (let ((kill-buffer-query-functions nil)) - (kill-buffer buffer)))) - -(defun cj/--ai-vterm-close-target () - "Return the AI-vterm buffer `cj/ai-vterm-close' should act on, or nil. - -The current buffer when it is an agent buffer; else the sole live -agent buffer; else a `completing-read' choice among the live agent -buffers; nil when none are alive." - (cond - ((cj/--ai-vterm-buffer-p (current-buffer)) (current-buffer)) - (t (let ((buffers (cj/--ai-vterm-agent-buffers))) - (cond - ((null buffers) nil) - ((null (cdr buffers)) (car buffers)) - (t (get-buffer - (completing-read "Close AI vterm: " - (mapcar #'buffer-name buffers) nil t)))))))) - -(defun cj/ai-vterm-close () - "Gracefully close an AI-vterm agent: kill its tmux session and buffer. - -Targets the current agent buffer, the sole live agent, or prompts when -several are alive (see `cj/--ai-vterm-close-target'). Asks for -confirmation first -- this kills the running agent process, which can -interrupt work in progress. Bound to M- (primary) and C-S-." - (interactive) - (cj/--ai-vterm-refuse-in-terminal) - (let ((buffer (cj/--ai-vterm-close-target))) - (unless buffer - (user-error "No AI-vterm agent buffers to close")) - (let ((name (buffer-name buffer))) - (when (y-or-n-p (format "Close agent %s? This kills its tmux session. " - name)) - (cj/--ai-vterm-close-buffer buffer) - (message "Closed agent %s." name))))) - -(keymap-global-set "" #'cj/ai-vterm) -(keymap-global-set "C-" #'cj/ai-vterm-pick-project) -(keymap-global-set "M-" #'cj/ai-vterm-close) -(keymap-global-set "C-S-" #'cj/ai-vterm-close) - -;; vterm binds .. to `vterm--self-insert', so a plain typed -;; while point is inside an agent buffer gets sent to the terminal program -;; instead of toggling the agent -- which bites hard when the agent buffer is -;; the only window in the frame. Re-bind the F9 family in `vterm-mode-map' so -;; the toggle reaches Emacs from there too. (C- / M- aren't in vterm's -;; intercept set, but bind them here as well so the behaviour is uniform.) -(with-eval-after-load 'vterm - (keymap-set vterm-mode-map "" #'cj/ai-vterm) - (keymap-set vterm-mode-map "C-" #'cj/ai-vterm-pick-project) - (keymap-set vterm-mode-map "M-" #'cj/ai-vterm-close) - (keymap-set vterm-mode-map "C-S-" #'cj/ai-vterm-close)) - -;; ---------- emacsclient: keep opened files off the agent vterm ---------- -;; -;; `server-start' (in system-defaults.el) leaves `server-window' nil, so -;; `server-switch-buffer' opens an `emacsclient -n' file in the *selected* -;; window. When the user is typing in the agent vterm, that's the agent -;; window -- so "tell the agent to open X" would replace the agent buffer -;; with X. The function below, wired as `server-window', routes such files -;; into a non-agent window instead (splitting one off the agent when the -;; agent is the only window). emacsclient invocations from anywhere else -;; fall through to `pop-to-buffer' and behave as before. - -(defun cj/--ai-vterm-non-agent-window (&optional exclude) - "Return a window in the selected frame fit to show a non-agent buffer. - -Skips the minibuffer, the EXCLUDE window, dedicated windows, and any -window already showing an AI-vterm agent buffer. Returns nil when no -such window exists." - (seq-find (lambda (w) - (and (not (eq w exclude)) - (not (window-dedicated-p w)) - (not (cj/--ai-vterm-buffer-p (window-buffer w))))) - (window-list (selected-frame) 'never))) - -(defun cj/--ai-vterm-server-display (buffer) - "Display BUFFER for `server-window', keeping it off the agent vterm. - -When the selected window shows an AI-vterm agent buffer, put BUFFER in -a non-agent window (`cj/--ai-vterm-non-agent-window'), splitting a -left-side window off the agent when the agent is the only window, then -select that window. Otherwise hand off to `pop-to-buffer'. Returns -the window BUFFER ends up in -- the value `server-switch-buffer' -expects from a `server-window' function." - (if (cj/--ai-vterm-buffer-p (window-buffer (selected-window))) - (let* ((agent-win (selected-window)) - (target (or (cj/--ai-vterm-non-agent-window agent-win) - (split-window agent-win nil 'left)))) - (set-window-buffer target buffer) - (select-window target)) - (pop-to-buffer buffer) - (selected-window))) - -(defvar server-window) -(with-eval-after-load 'server - (setq server-window #'cj/--ai-vterm-server-display)) - -(provide 'ai-vterm) -;;; ai-vterm.el ends here diff --git a/modules/auto-dim-config.el b/modules/auto-dim-config.el index ebda92c2..c0e6e7a1 100644 --- a/modules/auto-dim-config.el +++ b/modules/auto-dim-config.el @@ -18,179 +18,16 @@ ;; debounce). The dimmed faces (auto-dim-other-buffers and ;; auto-dim-other-buffers-hide) live in the active theme ;; (themes/dupre-faces.el) so they track theme switches. +;; +;; Terminal buffers (ghostel) do not participate in window dimming: ghostel +;; bakes its color palette into the native module per-terminal, not per-window, +;; so there is no per-window color hook to dim through (the vterm engine had +;; one via `vterm--get-color', which this module used to advise). See the +;; terminal-migration follow-up task in todo.org for revisiting this. ;;; Code: -(require 'cl-lib) -(require 'color) - (declare-function auto-dim-other-buffers-mode "auto-dim-other-buffers") -(declare-function adob--update "auto-dim-other-buffers") -(declare-function vterm--get-color "vterm") -(declare-function vterm--invalidate "vterm") -(declare-function vterm--set-size "vterm") -(declare-function vterm--get-margin-width "vterm") -(defvar vterm-min-window-width) -(defvar vterm--term) - -(defvar cj/auto-dim--last-selected-window nil - "Most recent selected window seen by `cj/auto-dim--refresh-vterm-on-command'.") - -(defvar cj/auto-dim--vterm-refresh-timer nil - "Timer used to defer vterm redraws until after auto-dim updates.") - -(defcustom cj/auto-dim-vterm-foreground-blend 0.45 - "Blend amount for dimmed vterm foreground colors. - -0 keeps the original vterm color; 1 uses the -`auto-dim-other-buffers' foreground color." - :type 'number - :group 'auto-dim-other-buffers) - -(defcustom cj/auto-dim-vterm-background-blend 0.7 - "Blend amount for dimmed vterm background colors. - -0 keeps the original vterm color; 1 uses the -`auto-dim-other-buffers' background color." - :type 'number - :group 'auto-dim-other-buffers) - -(defun cj/auto-dim--vterm-buffer-dimmed-p () - "Return non-nil when the current vterm buffer should render dimmed. - -Vterm resolves terminal colors to concrete color strings while redrawing the -buffer, so this integration is buffer-level. If the same vterm buffer is shown -in multiple windows and any one of those windows is selected/undimmed, keep the -buffer bright." - (and (eq major-mode 'vterm-mode) - (let ((windows (get-buffer-window-list (current-buffer) nil 'visible))) - (and windows - (not (catch 'undimmed - (dolist (window windows) - (unless (window-parameter window 'adob--dim) - (throw 'undimmed t))))))))) - -(defun cj/auto-dim--face-color (face attribute fallback-face) - "Return FACE ATTRIBUTE, falling back to FALLBACK-FACE." - (let ((color (face-attribute face attribute nil 'default))) - (if (or (null color) (eq color 'unspecified)) - (face-attribute fallback-face attribute nil 'default) - color))) - -(defun cj/auto-dim--color-rgb (color) - "Return COLOR as a list of RGB floats, or nil if COLOR is unknown." - (cond - ((and (stringp color) - (string-match - "\\`#\\([[:xdigit:]]\\{2\\}\\)\\([[:xdigit:]]\\{2\\}\\)\\([[:xdigit:]]\\{2\\}\\)\\'" - color)) - (mapcar (lambda (index) - (/ (string-to-number (match-string index color) 16) 255.0)) - '(1 2 3))) - ((and (stringp color) - (string-match - "\\`#\\([[:xdigit:]]\\)\\([[:xdigit:]]\\)\\([[:xdigit:]]\\)\\'" - color)) - (mapcar (lambda (index) - (/ (* 17 (string-to-number (match-string index color) 16)) 255.0)) - '(1 2 3))) - (t - (ignore-errors - (mapcar (lambda (component) (/ component 65535.0)) - (color-values color)))))) - -(defun cj/auto-dim--blend-color (color target amount) - "Blend COLOR toward TARGET by AMOUNT and return a hex color string." - (if-let* ((rgb (cj/auto-dim--color-rgb color)) - (target-rgb (cj/auto-dim--color-rgb target))) - (apply #'color-rgb-to-hex - (append - (cl-mapcar - (lambda (source dest) - (+ (* source (- 1 amount)) (* dest amount))) - rgb target-rgb) - '(2))) - color)) - -(defun cj/auto-dim--vterm-dim-color (color foreground-p) - "Return dimmed vterm COLOR. - -When FOREGROUND-P is non-nil, blend toward the dimmed foreground face; otherwise -blend toward the dimmed background face." - (let* ((attribute (if foreground-p :foreground :background)) - (target (cj/auto-dim--face-color 'auto-dim-other-buffers attribute 'default)) - (amount (if foreground-p - cj/auto-dim-vterm-foreground-blend - cj/auto-dim-vterm-background-blend))) - (cj/auto-dim--blend-color color target amount))) - -(defun cj/auto-dim--vterm-get-color (orig-fun index &rest args) - "Advise vterm color lookup ORIG-FUN for dimmed windows. - -INDEX and ARGS are passed through to `vterm--get-color'." - (let ((color (apply orig-fun index args))) - (if (and color (cj/auto-dim--vterm-buffer-dimmed-p)) - (cj/auto-dim--vterm-dim-color color (memq :foreground args)) - color))) - -(defun cj/auto-dim--refresh-vterm-windows (&optional frame) - "Refresh visible vterm buffers in FRAME after dim state changes." - (when (or (fboundp 'vterm--set-size) (fboundp 'vterm--invalidate)) - (dolist (window (window-list frame 'no-minibuf)) - (with-current-buffer (window-buffer window) - (when (eq major-mode 'vterm-mode) - (let ((inhibit-read-only t)) - (if (and (bound-and-true-p vterm--term) - (window-live-p window) - (fboundp 'vterm--get-margin-width)) - (let* ((height (max 2 (window-body-height window))) - (min-width (if (boundp 'vterm-min-window-width) - vterm-min-window-width - 80)) - (width (max min-width - (- (window-body-width window) - (vterm--get-margin-width))))) - ;; `vterm--redraw' only repaints rows libvterm marked dirty. - ;; A resize marks the whole terminal grid dirty, so briefly - ;; nudge height and restore it to force a full repaint after - ;; dim-state changes. - (vterm--set-size vterm--term (1+ height) width) - (vterm--set-size vterm--term height width)) - (when (fboundp 'vterm--invalidate) - (vterm--invalidate))))))))) - -(defun cj/auto-dim--refresh-vterm-after-auto-dim (&optional frame) - "Update auto-dim state, then refresh visible vterm buffers in FRAME." - (setq cj/auto-dim--vterm-refresh-timer nil) - (when (fboundp 'adob--update) - (adob--update)) - (cj/auto-dim--refresh-vterm-windows frame)) - -(defun cj/auto-dim--schedule-vterm-refresh (&optional frame) - "Schedule a deferred vterm refresh for FRAME. - -The delay lets selection-changing commands finish before we recompute -auto-dim state and invalidate vterm." - (when cj/auto-dim--vterm-refresh-timer - (cancel-timer cj/auto-dim--vterm-refresh-timer)) - (setq cj/auto-dim--vterm-refresh-timer - (run-with-timer 0 nil #'cj/auto-dim--refresh-vterm-after-auto-dim frame))) - -(defun cj/auto-dim--refresh-vterm-on-command () - "Refresh visible vterm buffers when selected window changes. - -`window-selection-change-functions' does not catch every selection path used by -windmove/Shift-arrow focus changes in this config, so this post-command hook is -the fallback that makes vterm repaint after auto-dim changes window state." - (let ((window (selected-window))) - (unless (eq window cj/auto-dim--last-selected-window) - (setq cj/auto-dim--last-selected-window window) - (cj/auto-dim--schedule-vterm-refresh)))) - -(defun cj/auto-dim--after-select-window (&rest _) - "Schedule vterm refresh after `select-window'." - (setq cj/auto-dim--last-selected-window (selected-window)) - (cj/auto-dim--schedule-vterm-refresh)) (defun cj/auto-dim--never-dim-dashboard-p (buffer) "Return non-nil when BUFFER is the dashboard, so it stays lit. @@ -212,7 +49,7 @@ focus cue on a split-displayed dashboard, accepted as a fair trade." :custom ;; Dim only non-selected windows within Emacs, not the whole frame when ;; Emacs loses focus -- on Hyprland focus moves to other apps constantly, - ;; and the ai-vterm agents live in their own windows. + ;; and the ai-term agents live in their own windows. (auto-dim-other-buffers-dim-on-focus-out nil) (auto-dim-other-buffers-dim-on-switch-to-minibuffer t) :config @@ -259,14 +96,5 @@ focus cue on a split-displayed dashboard, accepted as a fair trade." #'cj/auto-dim--never-dim-dashboard-p) (auto-dim-other-buffers-mode 1)) -(with-eval-after-load 'vterm - (unless (advice-member-p #'cj/auto-dim--vterm-get-color #'vterm--get-color) - (advice-add #'vterm--get-color :around #'cj/auto-dim--vterm-get-color)) - (unless (advice-member-p #'cj/auto-dim--after-select-window #'select-window) - (advice-add #'select-window :after #'cj/auto-dim--after-select-window)) - (add-hook 'window-selection-change-functions - #'cj/auto-dim--schedule-vterm-refresh) - (add-hook 'post-command-hook #'cj/auto-dim--refresh-vterm-on-command)) - (provide 'auto-dim-config) ;;; auto-dim-config.el ends here diff --git a/modules/cj-window-geometry-lib.el b/modules/cj-window-geometry-lib.el index cc638f76..047fe7c4 100644 --- a/modules/cj-window-geometry-lib.el +++ b/modules/cj-window-geometry-lib.el @@ -5,8 +5,8 @@ ;;; Commentary: ;; Pure helpers for classifying a window's position in its frame and -;; computing body sizes. Shared between `ai-vterm.el' (F9 dispatch) -;; and `vterm-config.el' (F12 dispatch); the geometry- +;; computing body sizes. Shared between `ai-term.el' (F9 dispatch) +;; and `term-config.el' (F12 dispatch); the geometry- ;; preservation pattern in both modules captures direction + body ;; size at toggle-off and replays them on the next toggle-on. ;; diff --git a/modules/cj-window-toggle-lib.el b/modules/cj-window-toggle-lib.el index 9874a134..ba91f5a4 100644 --- a/modules/cj-window-toggle-lib.el +++ b/modules/cj-window-toggle-lib.el @@ -4,8 +4,8 @@ ;;; Commentary: -;; Parameterized helpers used by ai-vterm.el (F9) and -;; vterm-config.el (F12) to capture a window's geometry at +;; Parameterized helpers used by ai-term.el (F9) and +;; term-config.el (F12) to capture a window's geometry at ;; toggle-off and replay it on the next toggle-on. Each consumer ;; holds its own pair of state variables (last-direction symbol + ;; last-size integer/float) and passes the variable symbols to the diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el index 571eb58a..b4e4545d 100644 --- a/modules/dashboard-config.el +++ b/modules/dashboard-config.el @@ -20,6 +20,7 @@ (eval-when-compile (require 'undead-buffers)) (declare-function cj/make-buffer-undead "undead-buffers" (string)) (autoload 'cj/make-buffer-undead "undead-buffers" nil t) +(declare-function ghostel "ghostel" (&optional arg)) ;; ------------------------ Dashboard Bookmarks Override ----------------------- ;; overrides the bookmark insertion from the dashboard package to provide an @@ -76,7 +77,7 @@ Adjust this if the title doesn't appear centered under the banner image.") (list (list "c" #'nerd-icons-faicon "nf-fa-code" "Code" "Switch Project" (lambda () (projectile-switch-project))) (list "d" #'nerd-icons-faicon "nf-fa-folder_o" "Files" "Dirvish File Manager" (lambda () (dirvish user-home-dir))) - (list "t" #'nerd-icons-devicon "nf-dev-terminal" "Terminal" "Launch VTerm" (lambda () (vterm))) + (list "t" #'nerd-icons-devicon "nf-dev-terminal" "Terminal" "Launch Terminal" (lambda () (ghostel))) (list "a" #'nerd-icons-mdicon "nf-md-calendar" "Agenda" "Main Org Agenda" (lambda () (cj/main-agenda-display))) (list "r" #'nerd-icons-faicon "nf-fa-rss_square" "Feeds" "Elfeed Feed Reader" (lambda () (cj/elfeed-open))) (list "b" #'nerd-icons-faicon "nf-fae-book_open_o" "Books" "Calibre Ebook Reader" (lambda () (calibredb))) diff --git a/modules/term-config.el b/modules/term-config.el new file mode 100644 index 00000000..84ba7b3b --- /dev/null +++ b/modules/term-config.el @@ -0,0 +1,396 @@ +;;; term-config.el --- Settings for ghostel and the F12 toggle -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings + +;;; Commentary: +;; +;; Layer: 3 (Domain Workflow). +;; Category: D/P. +;; Load shape: eager. +;; Eager reason: registers terminal keymaps and the F12 toggle. +;; Top-level side effects: defines two keymaps (one under cj/custom-keymap), one +;; global key, two add-hook, package config. +;; Runtime requires: keybindings, seq, subr-x, cj-window-geometry-lib, +;; cj-window-toggle-lib. +;; Direct test load: yes (requires keybindings explicitly). +;; +;; GHOSTEL +;; ghostel is a native Emacs terminal emulator over libghostty-vt (the Ghostty +;; engine). Like a real terminal, in its default semi-char mode most keys are +;; sent to the running program; `ghostel-keymap-exceptions' lists the keys that +;; reach Emacs instead. We add C-; so the personal prefix keymap works inside +;; ghostel buffers. +;; +;; The module degrades gracefully when ghostel is unavailable (D6 of the +;; migration spec): the package installs via use-package, the native module +;; auto-downloads on first use, and ghostel emits its own warning if the module +;; cannot load. A machine without a prebuilt binary needs Zig to build it; the +;; terminal commands stay defined either way. +;; +;; Two ways to lift text out of a terminal, both with the same key story: +;; - C-; x c enters copy-mode via `cj/term-copy-mode-dwim'. When a tmux +;; client is attached (typical -- `cj/term-launch-tmux' auto-starts tmux), +;; sends tmux's prefix C-b [ so the user lands in tmux's own copy-mode with +;; the full pane history available. Without tmux, falls back to +;; `ghostel-copy-mode' (read-only standard-Emacs navigation over the +;; scrollback; M-w copies and stays, q / C-g exit). +;; - C-; x h captures the current tmux pane's full history into a temporary +;; Emacs buffer. +;; In both copy surfaces, M-w copies the active region and stays open so several +;; pieces can be grabbed in a row; C-g / q leave without copying. + +;;; Code: + +(require 'keybindings) +(require 'seq) +(require 'subr-x) +(require 'cj-window-geometry-lib) +(require 'cj-window-toggle-lib) + +(declare-function ghostel "ghostel" (&optional directory)) +(declare-function ghostel-send-string "ghostel" (string)) +(declare-function ghostel-copy-mode "ghostel" ()) +(declare-function ghostel-clear-scrollback "ghostel" ()) +(declare-function ghostel-next-prompt "ghostel" (&optional n)) +(declare-function ghostel-previous-prompt "ghostel" (&optional n)) +(declare-function ghostel-send-next-key "ghostel" ()) +(defvar ghostel-mode-map) +(defvar ghostel-keymap-exceptions) +(defvar ghostel-buffer-name) + +(defvar-keymap cj/term-map + :doc "Personal terminal command map.") +;; Lowercase x picked over T for fewer Shift presses; t is the toggle leaf. +(cj/register-prefix-map "x" cj/term-map) + +;; ----------------------------- tmux history ---------------------------------- + +(defvar-local cj/term-tmux-history--origin-buffer nil + "Buffer active before opening the tmux history buffer.") + +(defvar-local cj/term-tmux-history--origin-window nil + "Window active before opening the tmux history buffer.") + +(defvar-local cj/term-tmux-history--origin-point nil + "Point in the origin buffer before opening the tmux history buffer.") + +(defun cj/term--tmux-output (&rest args) + "Run tmux with ARGS and return its stdout. +Signal `user-error' when tmux exits with a non-zero status." + (with-temp-buffer + (let ((exit-code (apply #'process-file "tmux" nil t nil args))) + (unless (zerop exit-code) + (user-error "tmux failed: %s" (string-trim (buffer-string)))) + (buffer-string)))) + +(defun cj/term--tmux-pane-id-for-tty (tty) + "Return the tmux pane id for client TTY." + (let* ((output (cj/term--tmux-output + "list-clients" "-F" "#{client_tty}\t#{pane_id}")) + (lines (split-string output "\n" t)) + (match (seq-find + (lambda (line) + (let ((fields (split-string line "\t"))) + (equal (car fields) tty))) + lines))) + (unless match + (user-error "No tmux client found for terminal tty %s" tty)) + (cadr (split-string match "\t")))) + +(defun cj/term--tmux-capture-pane (pane-id) + "Return full joined tmux history for PANE-ID." + (cj/term--tmux-output + "capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" pane-id)) + +(defun cj/term--current-tmux-pane-id () + "Return the tmux pane id for the current ghostel buffer." + (unless (eq major-mode 'ghostel-mode) + (user-error "Current buffer is not a ghostel buffer")) + (let* ((proc (get-buffer-process (current-buffer))) + (tty (and proc (process-tty-name proc)))) + (unless (and tty (not (string-empty-p tty))) + (user-error "Could not determine terminal tty")) + (cj/term--tmux-pane-id-for-tty tty))) + +(defvar-keymap cj/term-tmux-history-mode-map + :doc "Keymap for `cj/term-tmux-history-mode'. +M-w copies the active region without leaving the buffer; C-g, , or q +returns to the terminal without copying. RET is left unbound." + "M-w" #'kill-ring-save + "C-g" #'cj/term-tmux-history-quit + "" #'cj/term-tmux-history-quit + "q" #'cj/term-tmux-history-quit) + +(define-derived-mode cj/term-tmux-history-mode special-mode "Tmux History" + "Mode for copying captured tmux pane history with normal Emacs keys." + (setq-local truncate-lines t) + (goto-address-mode 1)) + +(defun cj/term-tmux-history-quit () + "Quit tmux history and return to its origin buffer." + (interactive) + (let ((history-buffer (current-buffer)) + (origin-buffer cj/term-tmux-history--origin-buffer) + (origin-window cj/term-tmux-history--origin-window) + (origin-point cj/term-tmux-history--origin-point)) + (when (buffer-live-p origin-buffer) + (if (window-live-p origin-window) + (progn + (set-window-buffer origin-window origin-buffer) + (select-window origin-window)) + (pop-to-buffer origin-buffer)) + (with-current-buffer origin-buffer + (when (integer-or-marker-p origin-point) + (goto-char origin-point)))) + (when (buffer-live-p history-buffer) + (kill-buffer history-buffer)))) + +(defun cj/term-tmux-history () + "Open full tmux pane history in a temporary Emacs buffer. + +The history buffer uses normal Emacs navigation and selection. `M-w' +copies the active region and stays open, so several pieces can be +copied in a row; `q', `', or `C-g' returns point to the +terminal buffer that launched it. + +The history view replaces the origin terminal buffer in the same window +\(via `switch-to-buffer'), not a split or a popped-up window." + (interactive) + (let* ((origin-buffer (current-buffer)) + (origin-window (selected-window)) + (origin-point (point)) + (pane-id (cj/term--current-tmux-pane-id)) + (history (cj/term--tmux-capture-pane pane-id)) + (buffer (get-buffer-create + (format "*terminal tmux history: %s*" (buffer-name origin-buffer))))) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (erase-buffer) + (insert history)) + (cj/term-tmux-history-mode) + (setq-local cj/term-tmux-history--origin-buffer origin-buffer) + (setq-local cj/term-tmux-history--origin-window origin-window) + (setq-local cj/term-tmux-history--origin-point origin-point) + (goto-char (point-max))) + (switch-to-buffer buffer))) + +;; ----------------------------- copy mode ------------------------------------- + +(defun cj/term--in-tmux-p () + "Return non-nil when the current ghostel buffer has a tmux client attached. +Errors from the pane-id lookup (not in ghostel-mode, no tty, no matching +client, tmux not installed) are treated as nil so callers can use this as a +cheap boolean predicate." + (and (eq major-mode 'ghostel-mode) + (condition-case _ + (and (cj/term--current-tmux-pane-id) t) + (error nil)))) + +(defun cj/term-copy-mode-dwim () + "Enter copy-mode using the engine appropriate to this terminal. + +When tmux is attached, write tmux's default prefix sequence (C-b [) into the +pty so the user lands in tmux's copy-mode with the full pane history. Without +tmux, falls through to `ghostel-copy-mode', a read-only standard-Emacs view of +the scrollback (M-w copies and stays, q / C-g exit)." + (interactive) + (if (cj/term--in-tmux-p) + (ghostel-send-string "\C-b[") + (ghostel-copy-mode))) + +;; ----------------------------- ghostel package ------------------------------- + +(defun cj/turn-off-chrome-for-term () + "Turn off line numbers and hl-line in a terminal buffer." + (hl-line-mode -1) + (display-line-numbers-mode -1)) + +(defun cj/term-launch-tmux () + "Auto-launch tmux in a ghostel buffer unless already inside tmux. + +Skipped when `cj/--ai-term-suppress-tmux' is non-nil so the AI-agent 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-term.el is loaded." + (let ((proc (get-buffer-process (current-buffer)))) + (when (and proc + (not (getenv "TMUX")) + (not (bound-and-true-p cj/--ai-term-suppress-tmux))) + (ghostel-send-string "tmux\n")))) + +(use-package ghostel + :ensure t + :commands (ghostel) + :init + ;; C-; must reach Emacs so the personal prefix keymap works in terminals. + (with-eval-after-load 'ghostel + (add-to-list 'ghostel-keymap-exceptions "C-;")) + :hook + ((ghostel-mode . cj/turn-off-chrome-for-term) + (ghostel-mode . cj/term-launch-tmux)) + :custom + (ghostel-kill-buffer-on-exit t) + ;; Byte analog of the prior 100000-line vterm setting (~100 bytes/line) -- D7. + (ghostel-max-scrollback (* 10 1024 1024))) + +;; ----------------------- F12 toggle (custom) ----------------------- +;; +;; Mirrors the geometry-preservation pattern shared with ai-term.el: capture +;; direction + body size at toggle-off, replay them via a custom display action +;; using frame-edge directions and body-relative sizes so the result is +;; divider-independent and layout-stable. Excludes agent-prefixed buffers, +;; which ai-term.el owns via F9. + +(defcustom cj/term-toggle-window-height 0.7 + "Default fraction of frame height for the F12 terminal window." + :type 'number + :group 'term) + +(defvar cj/--term-toggle-last-direction nil + "Last user-chosen direction for the F12 terminal display. +Symbol: right, left, or below. `above' is never stored. nil means use the +default `below' for F12's traditional bottom split.") + +(defvar cj/--term-toggle-last-size nil + "Last user-chosen body size for the F12 terminal display. +Positive integer: body-cols (right/left) or body-lines (below/above). +nil means fall back to `cj/term-toggle-window-height' as a fraction.") + +(defun cj/--term-toggle-buffer-p (buffer) + "Return non-nil when BUFFER is a terminal buffer F12 should manage. + +Qualifies when BUFFER is alive and has `ghostel-mode' (or its name starts with +the ghostel buffer-name prefix), AND its name does NOT start with the agent +prefix used by ai-term.el." + (and (bufferp buffer) + (buffer-live-p buffer) + (with-current-buffer buffer + (and (or (eq major-mode 'ghostel-mode) + (string-prefix-p (or (bound-and-true-p ghostel-buffer-name) + "*ghostel*") + (buffer-name buffer))) + (not (string-prefix-p "agent [" (buffer-name buffer))))))) + +(defun cj/--term-toggle-buffers () + "Return live F12-managed terminal buffers in `buffer-list' (MRU) order." + (seq-filter #'cj/--term-toggle-buffer-p (buffer-list))) + +(defun cj/--term-toggle-displayed-window (&optional frame) + "Return a window in FRAME currently displaying an F12 terminal buffer, or nil. +FRAME defaults to the selected frame. Minibuffer is excluded." + (seq-find (lambda (w) + (cj/--term-toggle-buffer-p (window-buffer w))) + (window-list (or frame (selected-frame)) 'never))) + +(defun cj/--term-toggle-capture-state (window) + "Capture WINDOW's direction + body size into module-level state. +Default direction is `below' to match F12's traditional bottom split." + (cj/window-toggle-capture-state + window 'below + 'cj/--term-toggle-last-direction + 'cj/--term-toggle-last-size + '(right below left))) + +(defun cj/--term-toggle-display-saved (buffer alist) + "Display-buffer action: split per saved direction and body size. +Delegates to `cj/window-toggle-display-saved' against the F12 state vars, +falling back to `below' and `cj/term-toggle-window-height'." + (cj/window-toggle-display-saved + buffer alist + 'cj/--term-toggle-last-direction 'below + 'cj/--term-toggle-last-size cj/term-toggle-window-height)) + +(defun cj/--term-toggle-display-rule-list () + "Return the `display-buffer-alist' entry list installed by F12. +Routes any terminal buffer satisfying `cj/--term-toggle-buffer-p' through +reuse-window then the saved-geometry action. Excludes agent buffers." + '(((lambda (buffer-or-name _) + (cj/--term-toggle-buffer-p (get-buffer buffer-or-name))) + (display-buffer-reuse-window + cj/--term-toggle-display-saved) + (inhibit-same-window . t)))) + +(dolist (entry (cj/--term-toggle-display-rule-list)) + (add-to-list 'display-buffer-alist entry)) + +(defun cj/--term-toggle-dispatch () + "Compute the F12 (`cj/term-toggle') action without performing it. + +Returns one of: +- (toggle-off . WINDOW) -- terminal displayed in WINDOW; hide it. +- (show-recent . BUFFER) -- terminal alive but not shown; redisplay. +- (create-new) -- no terminal buffer alive; create one." + (let ((win (cj/--term-toggle-displayed-window))) + (cond + (win (cons 'toggle-off win)) + (t + (let ((buffers (cj/--term-toggle-buffers))) + (cond + (buffers (cons 'show-recent (car buffers))) + (t '(create-new)))))))) + +(defun cj/term-toggle () + "Toggle a normal (non-agent) ghostel terminal buffer. + +- If an F12-managed terminal is displayed in this frame, capture its geometry + and delete its window (toggle off). Falls back to burying when it is the + only window in the frame. +- Otherwise, if any F12-managed terminal buffer is alive, display the most + recent one via the saved-geometry action. +- Otherwise, create a new terminal via `(ghostel)' which routes through the + same display action. + +Excludes agent-prefixed buffers; those have their own F9 dispatch via +`cj/ai-term'." + (interactive) + (pcase (cj/--term-toggle-dispatch) + (`(toggle-off . ,win) + (cj/--term-toggle-capture-state win) + (if (one-window-p) + (bury-buffer (window-buffer win)) + (delete-window win)) + nil) + (`(show-recent . ,buf) + (display-buffer buf) + (let ((w (get-buffer-window buf))) + (when w (select-window w))) + buf) + (`(create-new) + (ghostel)))) + +(keymap-global-set "" #'cj/term-toggle) + +;; ----------------------------- prefix menu ----------------------------------- + +(keymap-set cj/term-map "c" #'cj/term-copy-mode-dwim) +(keymap-set cj/term-map "h" #'cj/term-tmux-history) +(keymap-set cj/term-map "l" #'ghostel-clear-scrollback) +(keymap-set cj/term-map "N" #'ghostel) +(keymap-set cj/term-map "n" #'ghostel-next-prompt) +(keymap-set cj/term-map "p" #'ghostel-previous-prompt) +(keymap-set cj/term-map "q" #'ghostel-send-next-key) +(keymap-set cj/term-map "t" #'cj/term-toggle) + +(defun cj/term-install-keys () + "Make `C-;' resolve as the personal keymap inside ghostel buffers, and bind +the F-key toggles so they reach Emacs from inside a terminal buffer." + (when (boundp 'ghostel-mode-map) + (keymap-set ghostel-mode-map "C-;" cj/custom-keymap) + (keymap-set ghostel-mode-map "" #'cj/term-toggle))) + +(cj/term-install-keys) +(with-eval-after-load 'ghostel + (cj/term-install-keys)) + +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-; x" "terminal menu" + "C-; x c" "copy mode (tmux/ghostel)" + "C-; x h" "tmux scrollback history" + "C-; x l" "clear scrollback" + "C-; x N" "new terminal" + "C-; x n" "next prompt" + "C-; x p" "previous prompt" + "C-; x q" "send next key to terminal" + "C-; x t" "toggle terminal")) + +(provide 'term-config) +;;; term-config.el ends here. diff --git a/modules/ui-config.el b/modules/ui-config.el index a4c18421..7afe528b 100644 --- a/modules/ui-config.el +++ b/modules/ui-config.el @@ -111,16 +111,17 @@ When `cj/enable-transparency' is nil, reset alpha to fully opaque." One of `read-only', `overwrite', `modified', or `unmodified' — keys of `cj/buffer-status-colors'. -A live vterm buffer (in `vterm-mode' but NOT `vterm-copy-mode') -reports `unmodified' even though `vterm-mode' sets `buffer-read-only': -keystrokes there go to the terminal process, so from the user's side -the buffer is writeable, and the read-only (orange) cursor would be -misleading. `vterm-copy-mode' is the exception — there the buffer -really is a read-only Emacs buffer the user navigates, so it falls -through to `read-only' and keeps the orange cursor." +A live ghostel terminal (in `ghostel-mode' and an input mode that +forwards keys — semi-char / char / line) reports `unmodified' even +though the buffer is read-only: keystrokes go to the terminal process, +so from the user's side the buffer is writeable and the read-only +(orange) cursor would be misleading. ghostel's `copy' and `emacs' +input modes are the exception — there the buffer really is a read-only +Emacs buffer the user navigates, so it falls through to `read-only' +and keeps the orange cursor." (cond - ((and (eq major-mode 'vterm-mode) - (not (bound-and-true-p vterm-copy-mode))) + ((and (eq major-mode 'ghostel-mode) + (not (memq (bound-and-true-p ghostel--input-mode) '(copy emacs)))) 'unmodified) (buffer-read-only 'read-only) (overwrite-mode 'overwrite) diff --git a/modules/vterm-config.el b/modules/vterm-config.el deleted file mode 100644 index c8a57d30..00000000 --- a/modules/vterm-config.el +++ /dev/null @@ -1,540 +0,0 @@ -;;; vterm-config.el --- Settings for vterm and the F12 toggle -*- lexical-binding: t; coding: utf-8; -*- -;; author Craig Jennings - -;;; Commentary: -;; -;; Layer: 3 (Domain Workflow). -;; Category: D/P. -;; Load shape: eager. -;; Eager reason: registers terminal keymaps and the F12 toggle; a command/hook -;; deferral candidate. -;; Top-level side effects: defines two keymaps (one under cj/custom-keymap), one -;; global key, two add-hook, package config. -;; Runtime requires: keybindings, seq, subr-x, cj-window-geometry-lib, -;; cj-window-toggle-lib. -;; Direct test load: yes (requires keybindings explicitly). -;; -;; VTERM -;; At the moment, vterm behaves like a real terminal. For most keys, vterm will -;; just send them to the process that is currently running. So, C-a may be -;; beginning-of-the-line in a shell, or the prefix key in a screen session. - -;; Two ways to lift text out of a vterm, both with the same key story: -;; - C-; x c enters copy-mode via `cj/vterm-copy-mode-dwim'. When a tmux -;; client is attached to the vterm (typical -- `cj/vterm-launch-tmux' -;; auto-starts tmux), sends tmux's prefix C-b [ so the user lands in -;; tmux's own copy-mode with the full pane history available -;; (history-limit, default 100000 in this config's tmux.conf). Without -;; tmux, falls back to `vterm-copy-mode' against vterm's scrollback. -;; - C-; x h captures the current tmux pane's full history into a temporary -;; Emacs buffer. -;; In all three surfaces (vterm-copy-mode, tmux copy-mode, history buffer), -;; M-w copies the active region and stays open so several pieces can be -;; grabbed in a row; C-g, , or q leaves without copying; RET is -;; unbound -- no special "copy and exit" shortcut. The tmux-side bindings -;; live in ~/code/archsetup/dotfiles/common/.tmux.conf. - -;; ANSI-TERM & TERM -;; I haven't yet found a need for term or ansi-term in my workflows, so I leave -;; them with their default configurations. - -;;; Code: - -(require 'keybindings) -(require 'seq) -(require 'subr-x) -(require 'cj-window-geometry-lib) -(require 'cj-window-toggle-lib) - -;; Declare so `let'-bindings in this file are dynamic (special) rather than -;; lexical. Without this, `(let ((vterm-timer-delay 0)) (vterm-send-string -;; ...))' creates a lexical binding that `vterm-send-string' (in vterm.el) -;; cannot see, so its `accept-process-output' still blocks on the global nil. -(defvar vterm-timer-delay) - -(defvar-keymap cj/vterm-map - :doc "Personal vterm command map.") -;; Lowercase x picked over V for fewer Shift presses; v is the VC menu. -(cj/register-prefix-map "x" cj/vterm-map) - -(defvar-local cj/vterm-tmux-history--origin-buffer nil - "Buffer active before opening the tmux history buffer.") - -(defvar-local cj/vterm-tmux-history--origin-window nil - "Window active before opening the tmux history buffer.") - -(defvar-local cj/vterm-tmux-history--origin-point nil - "Point in the origin buffer before opening the tmux history buffer.") - -(defun cj/vterm--tmux-output (&rest args) - "Run tmux with ARGS and return its stdout. -Signal `user-error' when tmux exits with a non-zero status." - (with-temp-buffer - (let ((exit-code (apply #'process-file "tmux" nil t nil args))) - (unless (zerop exit-code) - (user-error "tmux failed: %s" (string-trim (buffer-string)))) - (buffer-string)))) - -(defun cj/vterm--tmux-pane-id-for-tty (tty) - "Return the tmux pane id for client TTY." - (let* ((output (cj/vterm--tmux-output - "list-clients" "-F" "#{client_tty}\t#{pane_id}")) - (lines (split-string output "\n" t)) - (match (seq-find - (lambda (line) - (let ((fields (split-string line "\t"))) - (equal (car fields) tty))) - lines))) - (unless match - (user-error "No tmux client found for vterm tty %s" tty)) - (cadr (split-string match "\t")))) - -(defun cj/vterm--tmux-capture-pane (pane-id) - "Return full joined tmux history for PANE-ID." - (cj/vterm--tmux-output - "capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" pane-id)) - -(defun cj/vterm--current-tmux-pane-id () - "Return the tmux pane id for the current vterm buffer." - (unless (eq major-mode 'vterm-mode) - (user-error "Current buffer is not a vterm buffer")) - (let* ((proc (get-buffer-process (current-buffer))) - (tty (and proc (process-tty-name proc)))) - (unless (and tty (not (string-empty-p tty))) - (user-error "Could not determine vterm tty")) - (cj/vterm--tmux-pane-id-for-tty tty))) - -(defvar-keymap cj/vterm-tmux-history-mode-map - :doc "Keymap for `cj/vterm-tmux-history-mode'. -M-w copies the active region without leaving the buffer; C-g, , or q -returns to the vterm without copying. RET is left unbound." - "M-w" #'kill-ring-save - "C-g" #'cj/vterm-tmux-history-quit - "" #'cj/vterm-tmux-history-quit - "q" #'cj/vterm-tmux-history-quit) - -(define-derived-mode cj/vterm-tmux-history-mode special-mode "Tmux History" - "Mode for copying captured tmux pane history with normal Emacs keys." - (setq-local truncate-lines t) - (goto-address-mode 1)) - -(defun cj/vterm-tmux-history-quit () - "Quit tmux history and return to its origin buffer." - (interactive) - (let ((history-buffer (current-buffer)) - (origin-buffer cj/vterm-tmux-history--origin-buffer) - (origin-window cj/vterm-tmux-history--origin-window) - (origin-point cj/vterm-tmux-history--origin-point)) - (when (buffer-live-p origin-buffer) - (if (window-live-p origin-window) - (progn - (set-window-buffer origin-window origin-buffer) - (select-window origin-window)) - (pop-to-buffer origin-buffer)) - (with-current-buffer origin-buffer - (when (integer-or-marker-p origin-point) - (goto-char origin-point)))) - (when (buffer-live-p history-buffer) - (kill-buffer history-buffer)))) - -(defun cj/vterm-tmux-history () - "Open full tmux pane history in a temporary Emacs buffer. - -The history buffer uses normal Emacs navigation and selection. `M-w' -copies the active region and stays open, so several pieces can be -copied in a row; `q', `', or `C-g' returns point to the vterm -buffer that launched it. - -The history view replaces the origin vterm buffer in the same window -(via `switch-to-buffer'), not a split or a popped-up window -- reading -past output should keep the agent's frame slot intact, and quit puts -the live terminal back where it was." - (interactive) - (let* ((origin-buffer (current-buffer)) - (origin-window (selected-window)) - (origin-point (point)) - (pane-id (cj/vterm--current-tmux-pane-id)) - (history (cj/vterm--tmux-capture-pane pane-id)) - (buffer (get-buffer-create - (format "*vterm tmux history: %s*" (buffer-name origin-buffer))))) - (with-current-buffer buffer - (let ((inhibit-read-only t)) - (erase-buffer) - (insert history)) - (cj/vterm-tmux-history-mode) - (setq-local cj/vterm-tmux-history--origin-buffer origin-buffer) - (setq-local cj/vterm-tmux-history--origin-window origin-window) - (setq-local cj/vterm-tmux-history--origin-point origin-point) - (goto-char (point-max))) - (switch-to-buffer buffer))) - -(defun cj/vterm-copy-mode-cancel () - "Exit `vterm-copy-mode' without copying." - (interactive) - (unless (bound-and-true-p vterm-copy-mode) - (user-error "This command is effective only in vterm-copy-mode")) - (vterm-copy-mode -1)) - -(defun cj/vterm--in-tmux-p () - "Return non-nil when the current vterm has a tmux client attached. -Errors from the pane-id lookup (not in vterm-mode, no tty, no -matching client, tmux not installed) are treated as nil so callers -can use this as a cheap boolean predicate." - (and (eq major-mode 'vterm-mode) - (condition-case _ - (and (cj/vterm--current-tmux-pane-id) t) - (error nil)))) - -(declare-function vterm-send-string "vterm" (string &optional paste-p)) - -(defun cj/vterm-copy-mode-dwim () - "Enter copy-mode using the engine appropriate to this vterm. - -When tmux is attached to the current vterm, write tmux's default -prefix sequence (C-b [) into the pty so the user lands in tmux's -copy-mode with the full pane history (`history-limit', default -100000) available. The matching tmux keys in -`~/code/archsetup/dotfiles/common/.tmux.conf' mirror this module's -Emacs story: M-w copies and stays, C-g / q / exit, Enter -is unbound. - -Without tmux, falls through to `vterm-copy-mode' which walks only -vterm's own scrollback (effectively just the visible screen, -because tmux redraws via cursor positioning rather than scrolling -new lines through vterm's buffer)." - (interactive) - (if (cj/vterm--in-tmux-p) - (vterm-send-string "\C-b[") - (vterm-copy-mode))) - -(defun cj/vterm--send-mouse-wheel (button) - "Forward a wheel event to the program running in the current vterm. - -BUTTON is the SGR mouse button code: 64 for wheel up, 65 for wheel -down. X / Y coordinates are placeholders (1,1); tmux dispatches -`WheelUpPane' / `WheelDownPane' on the button code and ignores the -position when there is only one pane. - -vterm's keymap binds only `mouse-1' and `mouse-yank-primary' -- -wheel events fall through to Emacs's default scroll behavior, which -moves the window over vterm's scrollback instead of reaching the -pty. Without this forwarding, tmux's `set -g mouse on' never fires -because tmux never sees the events. - -`vterm-timer-delay' is locally pinned to 0 so -`vterm-send-string''s `accept-process-output' returns immediately. -With the buffer-local nil (`vterm-config' sets it for refresh -batching), `accept-process-output' blocks forever when the program -in the pty consumes the event without producing visible output -- -common for TUIs like Claude Code. Result before the pin: spinning -cursor until C-g, no actual scroll." - (let ((vterm-timer-delay 0)) - (vterm-send-string (format "\e[<%d;1;1M" button)))) - -(defun cj/vterm-mouse-wheel-up () - "Forward a wheel-up event to the program running in this vterm." - (interactive) - (cj/vterm--send-mouse-wheel 64)) - -(defun cj/vterm-mouse-wheel-down () - "Forward a wheel-down event to the program running in this vterm." - (interactive) - (cj/vterm--send-mouse-wheel 65)) - -(defun cj/vterm-send-escape () - "Send the ESC byte to the program running in this vterm. - -`' is bound globally to `keyboard-escape-quit' (see -`modules/keybindings.el'), so without this override Emacs swallows -the key before it can reach the pty. Forwarding it here lets tmux -copy-mode cancel, vi-mode exits, and any other in-terminal program -that relies on Escape see the key. - -`vterm-timer-delay' is locally pinned to 0; see -`cj/vterm--send-mouse-wheel' for the hang scenario this avoids." - (interactive) - (let ((vterm-timer-delay 0)) - (vterm-send-string "\e"))) - -(use-package vterm - :defer .5 - :commands (vterm vterm-other-window) - :init - (defvar vterm-keymap-exceptions - '("C-c" "C-x" "C-u" "C-g" "C-h" "C-l" "M-x" "M-o" "C-y" "M-y") - "Exceptions for `vterm-keymap'.") - (add-to-list 'vterm-keymap-exceptions "C-;") - (setq vterm-always-compile-module t) - - (defun cj/turn-off-chrome-for-vterm () - (hl-line-mode -1) - (display-line-numbers-mode -1)) - - (defun cj/vterm-launch-tmux () - "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 (bound-and-true-p cj/--ai-vterm-suppress-tmux))) - (vterm-send-string "tmux\n")))) - :hook - ((vterm-mode . cj/turn-off-chrome-for-vterm) - (vterm-mode . cj/vterm-launch-tmux)) - :bind - (:map vterm-mode-map - ("" . nil) - ("" . nil) - ("" . nil) - ("" . nil) - ("C-c C-t" . nil) - ("C-y" . vterm-yank) - ("" . cj/vterm-mouse-wheel-up) - ("" . cj/vterm-mouse-wheel-down) - ("" . cj/vterm-mouse-wheel-up) - ("" . cj/vterm-mouse-wheel-down) - ("" . cj/vterm-send-escape)) - :custom - (vterm-kill-buffer-on-exit t) - (vterm-max-scrollback 100000) - :config - (setq vterm-timer-delay nil)) - -;; vterm-toggle is kept installed so `M-x vterm-toggle' still works, -;; but F12 below is bound to a custom toggle (`cj/vterm-toggle') that -;; excludes agent-prefixed buffers from its candidate set. -(use-package vterm-toggle - :defer .5 - :config - (setq vterm-toggle-fullscreen-p nil)) - -;; ----------------------- F12 toggle (custom) ----------------------- -;; -;; Replacement for `vterm-toggle' on F12. Two reasons to roll our own: -;; -;; 1. agent exclusion. vterm-toggle picks the most-recently-selected -;; vterm buffer as the toggle target. When the user just used F9 -;; on an agent vterm, the most-recent vterm IS agent, so F12 ends -;; up toggling agent -- which has its own F9 / C-F9 / M-F9 surface -;; in `ai-vterm.el' and shouldn't be affected by F12. The agent -;; exclusion lives in the candidate filter (`cj/--vterm-toggle-buffer-p'). -;; -;; 2. user-modified geometry. vterm-toggle's display rule had a -;; hard-coded `(window-height . 0.7)' that overrode any mouse-resize -;; or M-S-t orientation flip on the next toggle. This module mirrors -;; the geometry-preservation pattern shipped in ai-vterm.el: capture -;; direction + body size at toggle-off, replay them via a custom -;; display action (`cj/--vterm-toggle-display-saved') that uses -;; frame-edge directions and `(body-columns . N)' / `(body-lines . N)' -;; so the result is divider-independent and layout-stable. - -(defcustom cj/vterm-toggle-window-height 0.7 - "Default fraction of frame height for the F12 vterm window. -Used as the size fallback when `cj/--vterm-toggle-last-size' is nil -(i.e. the user hasn't toggled off a vterm yet this session)." - :type 'number - :group 'vterm) - -(defvar cj/--vterm-toggle-last-direction nil - "Last user-chosen direction for the F12 vterm display. -Symbol: right, left, or below. `above' is never stored -- a top -placement falls back to `below' at capture time, so F12 never reopens -from the top. nil means use the default `below' for F12's traditional -bottom split.") - -(defvar cj/--vterm-toggle-last-size nil - "Last user-chosen body size for the F12 vterm display. -Positive integer: body-cols (right/left) or body-lines (below/above). -nil means fall back to `cj/vterm-toggle-window-height' as a fraction.") - -(defun cj/--vterm-toggle-buffer-p (buffer) - "Return non-nil when BUFFER is a vterm buffer F12 should manage. - -Qualifies when BUFFER is alive, has `vterm-mode' (or its name starts -with the vterm-toggle prefix), AND its name does NOT start with the -agent prefix used by ai-vterm.el. The agent exclusion keeps F12 -from grabbing buffers that ai-vterm.el's F9 dispatch owns." - (and (bufferp buffer) - (buffer-live-p buffer) - (with-current-buffer buffer - (and (or (eq major-mode 'vterm-mode) - (string-prefix-p (or (bound-and-true-p vterm-buffer-name) - "*vterm*") - (buffer-name buffer))) - (not (string-prefix-p "agent [" (buffer-name buffer))))))) - -(defun cj/--vterm-toggle-buffers () - "Return live F12-managed vterm buffers in `buffer-list' (MRU) order." - (seq-filter #'cj/--vterm-toggle-buffer-p (buffer-list))) - -(defun cj/--vterm-toggle-displayed-window (&optional frame) - "Return a window in FRAME currently displaying an F12 vterm buffer, or nil. -FRAME defaults to the selected frame. Minibuffer is excluded." - (seq-find (lambda (w) - (cj/--vterm-toggle-buffer-p (window-buffer w))) - (window-list (or frame (selected-frame)) 'never))) - -(defun cj/--vterm-toggle-capture-state (window) - "Capture WINDOW's direction + body size into module-level state. - -Default direction is `below' to match F12's traditional bottom -split when WINDOW fills the frame's root area." - (cj/window-toggle-capture-state - window 'below - 'cj/--vterm-toggle-last-direction - 'cj/--vterm-toggle-last-size - '(right below left))) - -(defun cj/--vterm-toggle-display-saved (buffer alist) - "Display-buffer action: split per saved direction and body size. - -Delegates to `cj/window-toggle-display-saved' against the F12 state -vars, falling back to `below' and `cj/vterm-toggle-window-height'." - (cj/window-toggle-display-saved - buffer alist - 'cj/--vterm-toggle-last-direction 'below - 'cj/--vterm-toggle-last-size cj/vterm-toggle-window-height)) - -(defun cj/--vterm-toggle-display-rule-list () - "Return the `display-buffer-alist' entry list installed by F12. - -Routes any vterm buffer that satisfies `cj/--vterm-toggle-buffer-p' -through two actions: reuse-window (for visible vterm windows) then -the saved-geometry display action. Excludes agent buffers via the -predicate -- those are handled by ai-vterm.el's display rule." - '(((lambda (buffer-or-name _) - (cj/--vterm-toggle-buffer-p (get-buffer buffer-or-name))) - (display-buffer-reuse-window - cj/--vterm-toggle-display-saved) - (inhibit-same-window . t)))) - -(dolist (entry (cj/--vterm-toggle-display-rule-list)) - (add-to-list 'display-buffer-alist entry)) - -(defun cj/--vterm-toggle-dispatch () - "Compute the F12 (`cj/vterm-toggle') action without performing it. - -Returns one of: -- (toggle-off . WINDOW) -- vterm displayed in WINDOW; hide it. -- (show-recent . BUFFER) -- vterm alive but not shown; redisplay. -- (create-new) -- no vterm buffer alive; create one." - (let ((win (cj/--vterm-toggle-displayed-window))) - (cond - (win (cons 'toggle-off win)) - (t - (let ((buffers (cj/--vterm-toggle-buffers))) - (cond - (buffers (cons 'show-recent (car buffers))) - (t '(create-new)))))))) - -(declare-function vterm "vterm" (&optional buffer-name)) - -(defun cj/vterm-toggle () - "Toggle a normal (non-agent) vterm buffer. - -- If an F12-managed vterm is currently displayed in this frame, - capture its geometry and delete its window (toggle off). Falls - back to burying the buffer when the vterm is the only window in - the frame. -- Otherwise, if any F12-managed vterm buffer is alive, display the - most-recent one via the saved-geometry action. -- Otherwise, create a new vterm via `(vterm)' which routes through - the same display action. - -Excludes agent-prefixed vterm buffers; those have their own F9 / -C-F9 / M-F9 dispatch via `cj/ai-vterm'." - (interactive) - (pcase (cj/--vterm-toggle-dispatch) - (`(toggle-off . ,win) - (cj/--vterm-toggle-capture-state win) - (if (one-window-p) - (bury-buffer (window-buffer win)) - (delete-window win)) - nil) - (`(show-recent . ,buf) - (display-buffer buf) - (let ((w (get-buffer-window buf))) - (when w (select-window w))) - buf) - (`(create-new) - (vterm)))) - -(keymap-global-set "" #'cj/vterm-toggle) - -(keymap-set cj/vterm-map "c" #'cj/vterm-copy-mode-dwim) -(keymap-set cj/vterm-map "h" #'cj/vterm-tmux-history) -(keymap-set cj/vterm-map "l" #'vterm-clear-scrollback) -(keymap-set cj/vterm-map "N" #'vterm) -(keymap-set cj/vterm-map "n" #'vterm-next-prompt) -(keymap-set cj/vterm-map "o" #'vterm-other-window) -(keymap-set cj/vterm-map "p" #'vterm-previous-prompt) -(keymap-set cj/vterm-map "q" #'vterm-send-next-key) -(keymap-set cj/vterm-map "r" #'vterm-reset-cursor-point) -(keymap-set cj/vterm-map "t" #'cj/vterm-toggle) - -(defun cj/vterm-install-prefix-key () - "Make `C-;' resolve as the personal keymap inside vterm buffers." - (when (boundp 'vterm-mode-map) - (keymap-set vterm-mode-map "C-;" cj/custom-keymap))) - -(defun cj/vterm-install-copy-mode-cancel-keys () - "Install copy and exit keys in `vterm-copy-mode-map'. - -`M-w' copies the active region without leaving copy-mode, so several -pieces can be copied in a row. `C-g', `', and `q' all leave -copy-mode without copying. vterm's default `RET' / `' -> -`vterm-copy-mode-done' bindings are removed so RET isn't a special -\"copy and exit\" -- matching the tmux history buffer." - (when (boundp 'vterm-copy-mode-map) - (keymap-set vterm-copy-mode-map "M-w" #'kill-ring-save) - (keymap-set vterm-copy-mode-map "C-g" #'cj/vterm-copy-mode-cancel) - (keymap-set vterm-copy-mode-map "" #'cj/vterm-copy-mode-cancel) - (keymap-set vterm-copy-mode-map "q" #'cj/vterm-copy-mode-cancel) - (keymap-unset vterm-copy-mode-map "RET" t) - (keymap-unset vterm-copy-mode-map "" t))) - -(cj/vterm-install-prefix-key) -(cj/vterm-install-copy-mode-cancel-keys) -(with-eval-after-load 'vterm - (cj/vterm-install-prefix-key) - (cj/vterm-install-copy-mode-cancel-keys)) - -(defun cj/--vterm-copy-mode-restore-cursor () - "Force a visible cursor on entry to `vterm-copy-mode'. - -The vterm C module sets `cursor-type' to nil whenever the underlying -TUI sends DECTCEM (`\\e[?25l') to hide the terminal cursor — typical -for full-screen TUIs like Claude Code. In `vterm-copy-mode' the user -is navigating the buffer, not watching the TUI, so the cursor must -be visible. Switches to a `box' so the cursor color and blinking -behavior follow Emacs's normal cursor-face / `blink-cursor-mode' -defaults. On exit, kills the buffer-local override so vterm's normal -cursor-visibility tracking resumes." - (if vterm-copy-mode - (setq-local cursor-type 'box) - (kill-local-variable 'cursor-type))) - -(add-hook 'vterm-copy-mode-hook #'cj/--vterm-copy-mode-restore-cursor) - -(add-hook 'vterm-mode-hook #'goto-address-mode) - -(with-eval-after-load 'which-key - (which-key-add-key-based-replacements - "C-; x" "vterm menu" - "C-; x c" "copy mode (tmux/vterm)" - "C-; x h" "tmux scrollback history" - "C-; x l" "clear vterm scrollback" - "C-; x N" "new vterm" - "C-; x n" "next prompt" - "C-; x o" "vterm other window" - "C-; x p" "previous prompt" - "C-; x q" "send next key to vterm" - "C-; x r" "reset vterm cursor point" - "C-; x t" "toggle vterm")) - -(provide 'vterm-config) -;;; vterm-config.el ends here. diff --git a/tests/test-ai-term--agent-buffers.el b/tests/test-ai-term--agent-buffers.el new file mode 100644 index 00000000..20c661c4 --- /dev/null +++ b/tests/test-ai-term--agent-buffers.el @@ -0,0 +1,59 @@ +;;; test-ai-term--agent-buffers.el --- Tests for cj/--ai-term-agent-buffers -*- lexical-binding: t; -*- + +;;; Commentary: +;; The helper returns the list of buffers whose names start with the +;; literal prefix "agent [". Order is the same order `buffer-list' +;; gives them (most-recently-selected first). Non-agent buffers and +;; buffers whose names merely contain the prefix as a substring are +;; excluded. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(require 'ai-term) +(require 'testutil-ghostel-buffers) + +(ert-deftest test-ai-term--agent-buffers-empty-when-none-exist () + "Boundary: no agent-prefixed buffers anywhere -> empty list." + (cj/test--kill-agent-buffers) + (unwind-protect + (should (null (cj/--ai-term-agent-buffers))) + (cj/test--kill-agent-buffers))) + +(ert-deftest test-ai-term--agent-buffers-returns-only-agent-buffers () + "Normal: filters to only agent-prefixed buffers, leaves others alone." + (cj/test--kill-agent-buffers) + (let ((c1 (get-buffer-create "agent [a]")) + (c2 (get-buffer-create "agent [b]")) + (other (get-buffer-create "regular-buffer"))) + (unwind-protect + (let ((result (cj/--ai-term-agent-buffers))) + (should (memq c1 result)) + (should (memq c2 result)) + (should-not (memq other result)) + (should (= (length result) 2))) + (kill-buffer c1) + (kill-buffer c2) + (kill-buffer other)))) + +(ert-deftest test-ai-term--agent-buffers-anchors-prefix-not-substring () + "Boundary: 'foo agent [bar]' is not an agent buffer -- prefix anchored." + (cj/test--kill-agent-buffers) + (let ((not-agent (get-buffer-create "foo agent [bar]"))) + (unwind-protect + (should-not (memq not-agent (cj/--ai-term-agent-buffers))) + (kill-buffer not-agent)))) + +(ert-deftest test-ai-term--agent-buffers-bare-agent-not-included () + "Boundary: 'agent' alone (no bracket) doesn't match the 'agent [' prefix." + (cj/test--kill-agent-buffers) + (let ((bare (get-buffer-create "agent"))) + (unwind-protect + (should-not (memq bare (cj/--ai-term-agent-buffers))) + (kill-buffer bare)))) + +(provide 'test-ai-term--agent-buffers) +;;; test-ai-term--agent-buffers.el ends here diff --git a/tests/test-ai-term--buffer-name.el b/tests/test-ai-term--buffer-name.el new file mode 100644 index 00000000..b241977d --- /dev/null +++ b/tests/test-ai-term--buffer-name.el @@ -0,0 +1,42 @@ +;;; test-ai-term--buffer-name.el --- Tests for cj/--ai-term-buffer-name -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the buffer-name transform. Given an absolute project +;; directory, the helper returns "agent []". The naming pattern +;; is what the display-buffer-alist rule keys on, so a regression here +;; silently breaks routing to the right side-window. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(ert-deftest test-ai-term--buffer-name-normal-project () + "Normal: a typical project path yields agent []." + (should (equal (cj/--ai-term-buffer-name "/home/cjennings/projects/foo") + "agent [foo]"))) + +(ert-deftest test-ai-term--buffer-name-trailing-slash () + "Boundary: trailing slash collapses before basename extraction." + (should (equal (cj/--ai-term-buffer-name "/home/cjennings/projects/foo/") + "agent [foo]"))) + +(ert-deftest test-ai-term--buffer-name-dot-prefix-dir () + "Boundary: dot-prefix dirs (.emacs.d) preserve the dot in the basename." + (should (equal (cj/--ai-term-buffer-name "/home/cjennings/.emacs.d") + "agent [.emacs.d]"))) + +(ert-deftest test-ai-term--buffer-name-space-in-basename () + "Boundary: a space in the basename round-trips into the buffer name." + (should (equal (cj/--ai-term-buffer-name "/tmp/my work") + "agent [my work]"))) + +(ert-deftest test-ai-term--buffer-name-deeply-nested () + "Normal: only the last path component is used." + (should (equal (cj/--ai-term-buffer-name "/a/b/c/d/e/leaf") + "agent [leaf]"))) + +(provide 'test-ai-term--buffer-name) +;;; test-ai-term--buffer-name.el ends here diff --git a/tests/test-ai-term--candidates.el b/tests/test-ai-term--candidates.el new file mode 100644 index 00000000..a9a392f3 --- /dev/null +++ b/tests/test-ai-term--candidates.el @@ -0,0 +1,139 @@ +;;; test-ai-term--candidates.el --- Tests for cj/--ai-term-candidates -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the project-candidate walker. Two kinds of search root: +;; +;; - project root (a single project dir, e.g. ~/.emacs.d) -- include if it +;; itself contains .ai/protocols.org +;; - container root (e.g. ~/code, ~/projects) -- scan immediate children; +;; include each child that contains .ai/protocols.org +;; +;; Tests build a temp directory tree with fake .ai/protocols.org markers +;; and let-bind the search-root customs at it. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(defun test-ai-term--make-marker (dir) + "Create DIR/.ai/protocols.org so DIR registers as an AI-agent project." + (let ((ai-dir (expand-file-name ".ai" dir))) + (make-directory ai-dir t) + (write-region "" nil (expand-file-name "protocols.org" ai-dir)))) + +(defmacro test-ai-term--with-fixture (root &rest body) + "Bind ROOT to a fresh temp directory; remove on exit; run BODY." + (declare (indent 1) (debug t)) + `(let ((,root (make-temp-file "ai-term-test-" t))) + (unwind-protect + (progn ,@body) + (delete-directory ,root t)))) + +(ert-deftest test-ai-term--candidates-project-root-with-marker () + "Normal: a project root containing .ai/protocols.org is included." + (test-ai-term--with-fixture root + (let ((proj (expand-file-name "emacs-d-fake" root))) + (make-directory proj) + (test-ai-term--make-marker proj) + (let ((cj/ai-term-project-roots (list proj)) + (cj/ai-term-container-roots nil)) + (should (equal (cj/--ai-term-candidates) + (list (expand-file-name proj)))))))) + +(ert-deftest test-ai-term--candidates-project-root-without-marker () + "Boundary: a project root without .ai/protocols.org is excluded." + (test-ai-term--with-fixture root + (let ((proj (expand-file-name "no-ai" root))) + (make-directory proj) + (let ((cj/ai-term-project-roots (list proj)) + (cj/ai-term-container-roots nil)) + (should (null (cj/--ai-term-candidates))))))) + +(ert-deftest test-ai-term--candidates-container-includes-children-with-marker () + "Normal: a container's children with .ai/protocols.org are included." + (test-ai-term--with-fixture root + (let ((container (expand-file-name "code" root)) + (foo (expand-file-name "code/foo" root)) + (bar (expand-file-name "code/bar" root))) + (make-directory container) + (make-directory foo) + (make-directory bar) + (test-ai-term--make-marker foo) + (test-ai-term--make-marker bar) + (let* ((cj/ai-term-project-roots nil) + (cj/ai-term-container-roots (list container)) + (got (sort (cj/--ai-term-candidates) #'string<))) + (should (equal got + (sort (list (expand-file-name foo) + (expand-file-name bar)) + #'string<))))))) + +(ert-deftest test-ai-term--candidates-container-skips-children-without-marker () + "Boundary: a container's children without .ai/protocols.org are skipped." + (test-ai-term--with-fixture root + (let ((container (expand-file-name "code" root)) + (foo (expand-file-name "code/foo" root)) + (bare (expand-file-name "code/bare" root))) + (make-directory container) + (make-directory foo) + (make-directory bare) + (test-ai-term--make-marker foo) + (let ((cj/ai-term-project-roots nil) + (cj/ai-term-container-roots (list container))) + (should (equal (cj/--ai-term-candidates) + (list (expand-file-name foo)))))))) + +(ert-deftest test-ai-term--candidates-container-skips-non-directory-entries () + "Boundary: a container's non-directory entries are ignored." + (test-ai-term--with-fixture root + (let ((container (expand-file-name "code" root)) + (foo (expand-file-name "code/foo" root)) + (stray (expand-file-name "code/README.txt" root))) + (make-directory container) + (make-directory foo) + (test-ai-term--make-marker foo) + (write-region "" nil stray) + (let ((cj/ai-term-project-roots nil) + (cj/ai-term-container-roots (list container))) + (should (equal (cj/--ai-term-candidates) + (list (expand-file-name foo)))))))) + +(ert-deftest test-ai-term--candidates-nonexistent-root-is-skipped () + "Error: a nonexistent search root is skipped silently, no error raised." + (test-ai-term--with-fixture root + (let ((cj/ai-term-project-roots + (list (expand-file-name "does-not-exist" root))) + (cj/ai-term-container-roots + (list (expand-file-name "also-missing" root)))) + (should (null (cj/--ai-term-candidates)))))) + +(ert-deftest test-ai-term--candidates-empty-roots-yield-empty-list () + "Boundary: nil roots yield nil." + (let ((cj/ai-term-project-roots nil) + (cj/ai-term-container-roots nil)) + (should (null (cj/--ai-term-candidates))))) + +(ert-deftest test-ai-term--candidates-mixed-roots () + "Normal: project + container roots combine in one result list." + (test-ai-term--with-fixture root + (let ((emacs-d (expand-file-name "emacs-d" root)) + (container (expand-file-name "code" root)) + (foo (expand-file-name "code/foo" root))) + (make-directory emacs-d) + (make-directory container) + (make-directory foo) + (test-ai-term--make-marker emacs-d) + (test-ai-term--make-marker foo) + (let* ((cj/ai-term-project-roots (list emacs-d)) + (cj/ai-term-container-roots (list container)) + (got (sort (cj/--ai-term-candidates) #'string<))) + (should (equal got + (sort (list (expand-file-name emacs-d) + (expand-file-name foo)) + #'string<))))))) + +(provide 'test-ai-term--candidates) +;;; test-ai-term--candidates.el ends here diff --git a/tests/test-ai-term--capture-state.el b/tests/test-ai-term--capture-state.el new file mode 100644 index 00000000..543f83ad --- /dev/null +++ b/tests/test-ai-term--capture-state.el @@ -0,0 +1,63 @@ +;;; test-ai-term--capture-state.el --- Tests for cj/--ai-term-capture-state -*- lexical-binding: t; -*- + +;;; Commentary: +;; The capture helper writes WINDOW's direction and size to module- +;; level state vars `cj/--ai-term-last-direction' and +;; `cj/--ai-term-last-size'. Called from `cj/ai-term''s toggle-off +;; branch so the next F9 display can restore the user's chosen +;; orientation and size. No-op on a dead window. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(ert-deftest test-ai-term--capture-state-right-split-sets-direction () + "Normal: right-split window -> direction=right, integer body-cols matching window." + (save-window-excursion + (delete-other-windows) + (let ((right (split-window (selected-window) nil 'right)) + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) + (cj/--ai-term-capture-state right) + (should (eq cj/--ai-term-last-direction 'right)) + (should (integerp cj/--ai-term-last-size)) + (should (= cj/--ai-term-last-size (window-body-width right)))))) + +(ert-deftest test-ai-term--capture-state-below-split-sets-direction () + "Normal: below-split window -> direction=below, integer body-lines matching window." + (save-window-excursion + (delete-other-windows) + (let ((below (split-window (selected-window) nil 'below)) + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) + (cj/--ai-term-capture-state below) + (should (eq cj/--ai-term-last-direction 'below)) + (should (integerp cj/--ai-term-last-size)) + (should (= cj/--ai-term-last-size (window-body-height below)))))) + +(ert-deftest test-ai-term--capture-state-noop-on-dead-window () + "Boundary: nil window -> state remains unchanged." + (let ((cj/--ai-term-last-direction 'sentinel-dir) + (cj/--ai-term-last-size 0.123)) + (cj/--ai-term-capture-state nil) + (should (eq cj/--ai-term-last-direction 'sentinel-dir)) + (should (= cj/--ai-term-last-size 0.123)))) + +(ert-deftest test-ai-term--capture-state-noop-on-deleted-window () + "Boundary: deleted window -> state remains unchanged." + (let ((cj/--ai-term-last-direction 'sentinel-dir) + (cj/--ai-term-last-size 0.123) + (dead-win (save-window-excursion + (delete-other-windows) + (let ((w (split-window (selected-window) nil 'right))) + (delete-window w) + w)))) + (cj/--ai-term-capture-state dead-win) + (should (eq cj/--ai-term-last-direction 'sentinel-dir)) + (should (= cj/--ai-term-last-size 0.123)))) + +(provide 'test-ai-term--capture-state) +;;; test-ai-term--capture-state.el ends here diff --git a/tests/test-ai-term--close.el b/tests/test-ai-term--close.el new file mode 100644 index 00000000..654e85f0 --- /dev/null +++ b/tests/test-ai-term--close.el @@ -0,0 +1,86 @@ +;;; test-ai-term--close.el --- Tests for graceful agent close -*- lexical-binding: t; -*- + +;;; Commentary: +;; `cj/ai-term-close' tears an agent down gracefully: kill its tmux +;; session (stopping the agent process), kill the ghostel buffer, and +;; remove its window. These tests cover the pure pieces -- the +;; tmux-kill helper, the per-buffer teardown, and the target selection -- +;; with `process-file' and the prompt mocked at the boundary. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(ert-deftest test-ai-term--kill-tmux-session-runs-kill-session () + "Normal: invokes `tmux kill-session -t '." + (let (captured) + (cl-letf (((symbol-function 'process-file) + (lambda (program &rest args) + (setq captured (cons program args)) + 0))) + (cj/--ai-term-kill-tmux-session "aiv-foo")) + (should (equal (car captured) "tmux")) + (should (member "kill-session" captured)) + (should (member "-t" captured)) + (should (member "aiv-foo" captured)))) + +(ert-deftest test-ai-term--kill-tmux-session-swallows-error () + "Error: returns nil when tmux is unavailable (process-file signals)." + (cl-letf (((symbol-function 'process-file) + (lambda (&rest _) (error "no tmux")))) + (should (null (cj/--ai-term-kill-tmux-session "aiv-foo"))))) + +(ert-deftest test-ai-term--close-buffer-kills-session-and-buffer () + "Normal: derives the session from default-directory, kills it and the buffer." + (let ((buf (get-buffer-create "agent [foo]")) + captured-session) + (with-current-buffer buf (setq-local default-directory "/tmp/foo/")) + (cl-letf (((symbol-function 'cj/--ai-term-kill-tmux-session) + (lambda (s) (setq captured-session s) 0))) + (cj/--ai-term-close-buffer buf)) + (should (equal captured-session "aiv-foo")) + (should-not (buffer-live-p buf)))) + +(ert-deftest test-ai-term--close-buffer-noop-on-non-agent () + "Boundary: does nothing for a buffer that is not an agent buffer." + (let ((buf (get-buffer-create "*not-an-agent*")) + (called nil)) + (unwind-protect + (progn + (cl-letf (((symbol-function 'cj/--ai-term-kill-tmux-session) + (lambda (_s) (setq called t) 0))) + (cj/--ai-term-close-buffer buf)) + (should-not called) + (should (buffer-live-p buf))) + (when (buffer-live-p buf) (kill-buffer buf))))) + +(ert-deftest test-ai-term--close-target-current-agent-buffer () + "Normal: returns the current buffer when it is an agent buffer." + (let ((buf (get-buffer-create "agent [cur]"))) + (unwind-protect + (with-current-buffer buf + (should (eq (cj/--ai-term-close-target) buf))) + (kill-buffer buf)))) + +(ert-deftest test-ai-term--close-target-sole-agent () + "Normal: returns the only live agent buffer when current isn't an agent." + (let ((buf (get-buffer-create "agent [only]"))) + (unwind-protect + (with-temp-buffer + (cl-letf (((symbol-function 'cj/--ai-term-agent-buffers) + (lambda () (list buf)))) + (should (eq (cj/--ai-term-close-target) buf)))) + (kill-buffer buf)))) + +(ert-deftest test-ai-term--close-target-none-returns-nil () + "Boundary: nil when current buffer isn't an agent and none are alive." + (with-temp-buffer + (cl-letf (((symbol-function 'cj/--ai-term-agent-buffers) (lambda () nil))) + (should (null (cj/--ai-term-close-target)))))) + +(provide 'test-ai-term--close) +;;; test-ai-term--close.el ends here diff --git a/tests/test-ai-term--collapse-split.el b/tests/test-ai-term--collapse-split.el new file mode 100644 index 00000000..d7b4ee17 --- /dev/null +++ b/tests/test-ai-term--collapse-split.el @@ -0,0 +1,171 @@ +;;; test-ai-term--collapse-split.el --- F9 collapses the agent split -*- lexical-binding: t; -*- + +;;; Commentary: +;; Regression coverage for the F9 toggle-off behavior Craig reported: with +;; several agents alive, F9 should HIDE the agent split (collapse it back to the +;; working layout) rather than surfacing a different agent. Two cases: +;; +;; - Multi-window: the agent occupies a split. F9 deletes that window so the +;; working buffer reclaims the frame -- never swaps in another agent. The +;; prior `quit-restore-window' path went stale after the slot was reused +;; across agents (C-F9 switching), so it surfaced a different agent. +;; - Single-window: the agent fills the frame. F9 returns to the most-recent +;; NON-agent buffer (the file being worked on), not another agent -- the prior +;; `other-buffer' call could pick another live agent. +;; +;; Also covers the `cj/--ai-term-most-recent-non-agent-buffer' helper. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(require 'ai-term) +(require 'testutil-ghostel-buffers) + +;;; cj/--ai-term-most-recent-non-agent-buffer + +(ert-deftest test-ai-term--most-recent-non-agent-buffer-skips-agents () + "Normal: returns a live non-agent buffer even when agents are most-recent." + (cj/test--kill-agent-buffers) + (let ((work (get-buffer-create "*test-mrna-work*")) + (agent-a (get-buffer-create "agent [mrna-a]")) + (agent-b (get-buffer-create "agent [mrna-b]"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + ;; Make the agents most-recent in this window's history. + (set-window-buffer (selected-window) work) + (set-window-buffer (selected-window) agent-b) + (set-window-buffer (selected-window) agent-a) + (let ((result (cj/--ai-term-most-recent-non-agent-buffer))) + (should (bufferp result)) + (should (buffer-live-p result)) + (should-not (cj/--ai-term-buffer-p result)))) + (when (get-buffer "*test-mrna-work*") (kill-buffer "*test-mrna-work*")) + (cj/test--kill-agent-buffers)))) + +;;; Multi-window: F9 collapses the split + +(ert-deftest test-ai-term--collapse-multi-window-deletes-agent-split () + "Normal/Regression: agent in a bottom split with other agents alive; F9 +collapses the split so the working buffer reclaims the frame, and no agent is +surfaced. Before the fix, `quit-restore-window' could switch the slot to a +different agent (stale quit-restore after slot reuse)." + (cj/test--kill-agent-buffers) + (let ((work (get-buffer-create "*test-collapse-work*")) + (agent-a (get-buffer-create "agent [collapse-a]")) + (agent-b (get-buffer-create "agent [collapse-b]")) + (agent-c (get-buffer-create "agent [collapse-c]")) + (cj/--ai-term-last-was-bury nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (set-window-buffer (selected-window) work) + (let ((agent-win (split-window (selected-window) nil 'below))) + ;; Reuse the slot across agents (as C-F9 switching does) so the + ;; window's prev-buffer history holds another agent. + (set-window-buffer agent-win agent-a) + (set-window-buffer agent-win agent-b) + (set-window-buffer agent-win agent-c) + (select-window agent-win) + (should-not (one-window-p)) + (cj/test--call-as-gui #'cj/ai-term) + (should (one-window-p)) + (should-not (cj/--ai-term-displayed-agent-window)) + (should (eq (window-buffer (selected-window)) work)))) + (when (get-buffer "*test-collapse-work*") (kill-buffer "*test-collapse-work*")) + (cj/test--kill-agent-buffers)))) + +;;; Single-window: F9 returns to a non-agent buffer + +(ert-deftest test-ai-term--collapse-single-window-returns-non-agent () + "Normal/Regression: agent fills the frame, other agents alive; F9 toggles back +to a NON-agent buffer (the working file), never another agent. Before the fix, +`other-buffer' could pick another live agent." + (cj/test--kill-agent-buffers) + (let ((work (get-buffer-create "*test-collapse-sw-work*")) + (agent-a (get-buffer-create "agent [collapse-sw-a]")) + (agent-b (get-buffer-create "agent [collapse-sw-b]")) + (cj/--ai-term-last-was-bury nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + ;; MRU: work, then agent-b, then agent-a (current). `other-buffer' + ;; would pick agent-b; the fix must skip it for a non-agent. + (set-window-buffer (selected-window) work) + (set-window-buffer (selected-window) agent-b) + (set-window-buffer (selected-window) agent-a) + (should (one-window-p)) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) + (cj/test--call-as-gui #'cj/ai-term)) + (should (one-window-p)) + (should-not (cj/--ai-term-buffer-p (window-buffer (selected-window))))) + (when (get-buffer "*test-collapse-sw-work*") (kill-buffer "*test-collapse-sw-work*")) + (cj/test--kill-agent-buffers)))) + +;;; Faithful toggle: reopen the SAME agent that was hidden + +(ert-deftest test-ai-term--dispatch-prefers-last-hidden-agent () + "Regression: dispatch reopens the last-hidden agent, not the buffer-list MRU. +After F9 hides an agent, the next F9 must reopen the SAME one even when a +different agent is ahead of it in `buffer-list'. Falls back to the MRU when +nothing was hidden yet or the remembered buffer was killed." + (cj/test--kill-agent-buffers) + (let ((a1 (get-buffer-create "agent [disp-mru]")) + (a2 (get-buffer-create "agent [disp-shown]")) + (cj/--ai-term-last-hidden-buffer nil)) + (unwind-protect + (cl-letf (((symbol-function 'cj/--ai-term-displayed-agent-window) + (lambda (&optional _f) nil)) + ((symbol-function 'cj/--ai-term-agent-buffers) + (lambda () (list a1 a2)))) ; a1 is the MRU + ;; No memory yet -> falls back to MRU (a1). + (should (equal (cj/--ai-term-dispatch) (cons 'redisplay-recent a1))) + ;; Remember a2 as last hidden -> dispatch prefers it. + (setq cj/--ai-term-last-hidden-buffer a2) + (should (equal (cj/--ai-term-dispatch) (cons 'redisplay-recent a2))) + ;; A killed last-hidden buffer -> falls back to MRU. + (let ((dead (get-buffer-create "agent [disp-dead]"))) + (setq cj/--ai-term-last-hidden-buffer dead) + (kill-buffer dead)) + (should (equal (cj/--ai-term-dispatch) (cons 'redisplay-recent a1)))) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--toggle-roundtrip-reopens-same-agent () + "Regression: hide then show brings back the agent that was on screen. +With several agents alive and a different one most-recent in `buffer-list', +F9 off then F9 on restores the SAME agent that was visible -- not a swap to +another. Reproduces the \"the displayed buffer changes\" report." + (cj/test--kill-agent-buffers) + (let ((work (get-buffer-create "*test-roundtrip-work*")) + (a1 (get-buffer-create "agent [rt-1]")) + (a2 (get-buffer-create "agent [rt-2]")) + (cj/--ai-term-last-was-bury nil) + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil) + (cj/--ai-term-last-hidden-buffer nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (set-window-buffer (selected-window) work) + (let ((agent-win (split-window (selected-window) nil 'below))) + ;; a2 is the visible agent; a1 sits ahead of it in buffer-list. + (set-window-buffer agent-win a1) + (bury-buffer a1) ; a1 stays alive, demoted in MRU + (set-window-buffer agent-win a2) + (select-window agent-win) + (should (eq (window-buffer (cj/--ai-term-displayed-agent-window)) a2)) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) + (cj/test--call-as-gui #'cj/ai-term) ; off + (should-not (cj/--ai-term-displayed-agent-window)) + (cj/test--call-as-gui #'cj/ai-term) ; on -> must be a2 + (should (eq (window-buffer (cj/--ai-term-displayed-agent-window)) + a2))))) + (when (get-buffer "*test-roundtrip-work*") (kill-buffer "*test-roundtrip-work*")) + (cj/test--kill-agent-buffers)))) + +(provide 'test-ai-term--collapse-split) +;;; test-ai-term--collapse-split.el ends here diff --git a/tests/test-ai-term--default-geometry.el b/tests/test-ai-term--default-geometry.el new file mode 100644 index 00000000..833f2ef4 --- /dev/null +++ b/tests/test-ai-term--default-geometry.el @@ -0,0 +1,56 @@ +;;; test-ai-term--default-geometry.el --- Tests for host-aware display defaults -*- lexical-binding: t; -*- + +;;; Commentary: +;; ai-term's default display geometry is host-aware: a laptop opens the +;; agent from the bottom (75% height), a desktop opens it from the right +;; (50% width). `cj/--ai-term-default-direction' and +;; `cj/--ai-term-default-size' encapsulate the `env-laptop-p' branch; +;; they feed the default fallbacks in `cj/--ai-term-capture-state' and +;; `cj/--ai-term-display-saved'. +;; +;; `env-laptop-p' is stubbed per-test so the assertions are deterministic +;; regardless of the host the suite runs on. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(ert-deftest test-ai-term--default-direction-laptop () + "Normal: on a laptop the default direction is `below'." + (cl-letf (((symbol-function 'env-laptop-p) (lambda () t))) + (should (eq (cj/--ai-term-default-direction) 'below)))) + +(ert-deftest test-ai-term--default-direction-desktop () + "Normal: on a desktop the default direction is `right'." + (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) + (should (eq (cj/--ai-term-default-direction) 'right)))) + +(ert-deftest test-ai-term--default-size-laptop () + "Normal: on a laptop the default size is `cj/ai-term-laptop-height'." + (let ((cj/ai-term-laptop-height 0.75) + (cj/ai-term-desktop-width 0.5)) + (cl-letf (((symbol-function 'env-laptop-p) (lambda () t))) + (should (= (cj/--ai-term-default-size) 0.75))))) + +(ert-deftest test-ai-term--default-size-desktop () + "Normal: on a desktop the default size is `cj/ai-term-desktop-width'." + (let ((cj/ai-term-laptop-height 0.75) + (cj/ai-term-desktop-width 0.5)) + (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) + (should (= (cj/--ai-term-default-size) 0.5))))) + +(ert-deftest test-ai-term--default-size-respects-custom-values () + "Boundary: the helper returns the customized values, not the literals." + (let ((cj/ai-term-laptop-height 0.6) + (cj/ai-term-desktop-width 0.33)) + (cl-letf (((symbol-function 'env-laptop-p) (lambda () t))) + (should (= (cj/--ai-term-default-size) 0.6))) + (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) + (should (= (cj/--ai-term-default-size) 0.33))))) + +(provide 'test-ai-term--default-geometry) +;;; test-ai-term--default-geometry.el ends here diff --git a/tests/test-ai-term--dispatch.el b/tests/test-ai-term--dispatch.el new file mode 100644 index 00000000..91b5e1bc --- /dev/null +++ b/tests/test-ai-term--dispatch.el @@ -0,0 +1,70 @@ +;;; test-ai-term--dispatch.el --- Tests for cj/--ai-term-dispatch -*- lexical-binding: t; -*- + +;;; Commentary: +;; The dispatch helper is a pure decision function used by F9. +;; Returns one of (toggle-off . WIN), (redisplay-recent . BUF), +;; or (pick-project) based on whether an agent buffer is currently +;; displayed and whether any alive agent buffers exist. Tests mock +;; the two underlying helpers so the dispatch logic can be exercised +;; without touching real windows. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(require 'ai-term) +(require 'testutil-ghostel-buffers) + +(ert-deftest test-ai-term--dispatch-window-displayed-returns-toggle-off () + "Normal: displayed agent window -> (toggle-off . WIN)." + (let ((sentinel-win 'fake-window)) + (cl-letf (((symbol-function 'cj/--ai-term-displayed-agent-window) + (lambda (&optional _frame) sentinel-win))) + (should (equal (cj/--ai-term-dispatch) + (cons 'toggle-off sentinel-win)))))) + +(ert-deftest test-ai-term--dispatch-no-window-single-buffer-returns-redisplay-recent () + "Normal: no displayed agent, one alive buffer -> redisplay-recent + buffer." + (cj/test--kill-agent-buffers) + (let ((b1 (get-buffer-create "agent [single]"))) + (unwind-protect + (cl-letf (((symbol-function 'cj/--ai-term-displayed-agent-window) + (lambda (&optional _frame) nil)) + ((symbol-function 'cj/--ai-term-agent-buffers) + (lambda () (list b1)))) + (should (equal (cj/--ai-term-dispatch) + (cons 'redisplay-recent b1)))) + (kill-buffer b1)))) + +(ert-deftest test-ai-term--dispatch-no-window-multiple-buffers-returns-redisplay-recent () + "Normal: no displayed agent, 2+ alive buffers -> redisplay-recent + MRU. +F9 redisplays the most-recently-selected agent (head of buffer-list +order) rather than opening the project picker, so the user toggles +THE agent they were last using. Other agents are reachable via M-F9." + (cj/test--kill-agent-buffers) + (let ((b1 (get-buffer-create "agent [a]")) + (b2 (get-buffer-create "agent [b]"))) + (unwind-protect + (cl-letf (((symbol-function 'cj/--ai-term-displayed-agent-window) + (lambda (&optional _frame) nil)) + ((symbol-function 'cj/--ai-term-agent-buffers) + (lambda () (list b1 b2)))) + (should (equal (cj/--ai-term-dispatch) + (cons 'redisplay-recent b1)))) + (kill-buffer b1) + (kill-buffer b2)))) + +(ert-deftest test-ai-term--dispatch-no-window-zero-buffers-returns-pick-project () + "Boundary: no displayed agent, zero alive buffers -> pick-project." + (cj/test--kill-agent-buffers) + (cl-letf (((symbol-function 'cj/--ai-term-displayed-agent-window) + (lambda (&optional _frame) nil)) + ((symbol-function 'cj/--ai-term-agent-buffers) + (lambda () nil))) + (should (equal (cj/--ai-term-dispatch) '(pick-project))))) + +(provide 'test-ai-term--dispatch) +;;; test-ai-term--dispatch.el ends here diff --git a/tests/test-ai-term--display-rule.el b/tests/test-ai-term--display-rule.el new file mode 100644 index 00000000..906a4768 --- /dev/null +++ b/tests/test-ai-term--display-rule.el @@ -0,0 +1,78 @@ +;;; test-ai-term--display-rule.el --- Tests for the AI-term display-buffer rule -*- lexical-binding: t; -*- + +;;; Commentary: +;; The module installs a `display-buffer-alist' entry routing buffers +;; whose names match "\\`agent \\[" to a right-side window. These +;; tests verify the rule reaches the right side and ignores buffers +;; that don't match the prefix. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(defun test-ai-term--cleanup (name) + "Kill buffer NAME if it exists." + (when (get-buffer name) + (kill-buffer name))) + +(defmacro test-ai-term--with-clean-frame (&rest body) + "Run BODY in a context with one window and the AI-term rule loaded." + (declare (indent 0) (debug t)) + `(save-window-excursion + (delete-other-windows) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) + ,@body))) + +(ert-deftest test-ai-term--display-rule-routes-agent-buffer-to-right () + "Normal: on a desktop, \"agent [foo]\" lands in a window to the right. + +The desktop default direction is `right' (see +`cj/--ai-term-default-direction'), so the rule splits the current +window with `(direction . right)' and the new window's left edge +sits at a positive column. `env-laptop-p' is stubbed nil to pin the +desktop branch; on a laptop the agent would land below instead." + (let ((name "agent [display-rule-test]")) + (test-ai-term--cleanup name) + (unwind-protect + (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) + (test-ai-term--with-clean-frame + (let* ((buf (get-buffer-create name)) + (win (display-buffer buf))) + (should (windowp win)) + (should (> (window-left-column win) 0))))) + (test-ai-term--cleanup name)))) + +(ert-deftest test-ai-term--display-rule-skips-non-matching-buffer () + "Boundary: a buffer not named \"agent [...]\" does not match the rule. + +The rule's regex doesn't fire, so `display-buffer' falls back to the +default action -- reuse the current window -- and no rightward split +occurs." + (let ((name "scratch-buffer-no-match")) + (test-ai-term--cleanup name) + (unwind-protect + (test-ai-term--with-clean-frame + (let* ((buf (get-buffer-create name)) + (win (display-buffer buf))) + (should (windowp win)) + (should (= (window-left-column win) 0)))) + (test-ai-term--cleanup name)))) + +(ert-deftest test-ai-term--display-rule-prefix-not-substring () + "Boundary: \"foo agent [bar]\" does not match -- the rule anchors at start." + (let ((name "foo agent [substring-test]")) + (test-ai-term--cleanup name) + (unwind-protect + (test-ai-term--with-clean-frame + (let* ((buf (get-buffer-create name)) + (win (display-buffer buf))) + (should (windowp win)) + (should (= (window-left-column win) 0)))) + (test-ai-term--cleanup name)))) + +(provide 'test-ai-term--display-rule) +;;; test-ai-term--display-rule.el ends here diff --git a/tests/test-ai-term--display-saved.el b/tests/test-ai-term--display-saved.el new file mode 100644 index 00000000..8b689aa6 --- /dev/null +++ b/tests/test-ai-term--display-saved.el @@ -0,0 +1,173 @@ +;;; test-ai-term--display-saved.el --- Tests for the display-saved action -*- lexical-binding: t; -*- + +;;; Commentary: +;; `cj/--ai-term-display-saved' is the split path of the F9 display +;; chain -- it runs only when no agent window and no reusable edge slot +;; exist (a single-window frame, or a layout split on the other axis). +;; It reads `cj/--ai-term-last-direction' + `cj/--ai-term-last-size' +;; (with default fallbacks), builds an alist with direction + the +;; matching size key, strips any conflicting entries that came in via the +;; rule, and delegates to `display-buffer-in-direction'. +;; +;; Tests stub `display-buffer-in-direction' to capture the alist that +;; would have reached it. +;; +;; Multi-window toggle round-trips no longer resplit -- they reuse the +;; existing half (see test-ai-term--reuse-edge-window.el), so the former +;; resplit/body-width-preservation round-trip tests were retired with the +;; swap-the-slot model. The buffer-move teardown test stays here because +;; it exercises the split-window delete path on toggle-off. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(require 'ai-term) +(require 'testutil-ghostel-buffers) + +(ert-deftest test-ai-term--display-saved-uses-desktop-defaults-when-state-nil () + "Normal: nil state on a desktop -> rightmost, size=cj/ai-term-desktop-width. +The cardinal `right' default maps to the frame-edge variant +`rightmost' so agent lands at the frame's right edge regardless of +which window is selected. `env-laptop-p' is stubbed nil to pin the +desktop branch." + (let (received-buf received-alist + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil) + (cj/ai-term-desktop-width 0.5)) + (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)) + ((symbol-function 'display-buffer-in-direction) + (lambda (b a) + (setq received-buf b received-alist a) + 'fake-window))) + (cj/--ai-term-display-saved 'fake-buf '((inhibit-same-window . t)))) + (should (eq received-buf 'fake-buf)) + (should (eq (cdr (assq 'direction received-alist)) 'rightmost)) + (should (= (cdr (assq 'window-width received-alist)) 0.5)) + (should (eq (cdr (assq 'inhibit-same-window received-alist)) t)))) + +(ert-deftest test-ai-term--display-saved-uses-laptop-defaults-when-state-nil () + "Normal: nil state on a laptop -> bottom, size=cj/ai-term-laptop-height. +The cardinal `below' default maps to the frame-edge variant `bottom' +and the size lands on the `window-height' axis. `env-laptop-p' is +stubbed t to pin the laptop branch." + (let (received-alist + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil) + (cj/ai-term-laptop-height 0.75)) + (cl-letf (((symbol-function 'env-laptop-p) (lambda () t)) + ((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/--ai-term-display-saved 'fake-buf '((inhibit-same-window . t)))) + (should (eq (cdr (assq 'direction received-alist)) 'bottom)) + (should (= (cdr (assq 'window-height received-alist)) 0.75)) + (should-not (assq 'window-width received-alist)))) + +(ert-deftest test-ai-term--display-saved-uses-saved-direction-and-size-below () + "Normal: saved direction=below maps to bottom edge; size=0.4 passes through." + (let (received-alist + (cj/--ai-term-last-direction 'below) + (cj/--ai-term-last-size 0.4)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/--ai-term-display-saved 'fake-buf nil)) + (should (eq (cdr (assq 'direction received-alist)) 'bottom)) + (should (= (cdr (assq 'window-height received-alist)) 0.4)) + (should-not (assq 'window-width received-alist)))) + +(ert-deftest test-ai-term--display-saved-uses-saved-direction-and-size-right () + "Normal: saved direction=right maps to rightmost edge; size=0.7 passes through." + (let (received-alist + (cj/--ai-term-last-direction 'right) + (cj/--ai-term-last-size 0.7)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/--ai-term-display-saved 'fake-buf nil)) + (should (eq (cdr (assq 'direction received-alist)) 'rightmost)) + (should (= (cdr (assq 'window-width received-alist)) 0.7)) + (should-not (assq 'window-height received-alist)))) + +(ert-deftest test-ai-term--display-saved-strips-conflicting-alist-entries () + "Boundary: caller-supplied direction/size are stripped, saved values win." + (let (received-alist + (cj/--ai-term-last-direction 'right) + (cj/--ai-term-last-size 0.7)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/--ai-term-display-saved + 'fake-buf + '((direction . below) + (window-width . 0.2) + (window-height . 0.3) + (inhibit-same-window . t)))) + (should (eq (cdr (assq 'direction received-alist)) 'rightmost)) + (should (= (cdr (assq 'window-width received-alist)) 0.7)) + (should (eq (cdr (assq 'inhibit-same-window received-alist)) t)) + ;; window-height should not be in the alist when direction is right + ;; -- the action picks the matching size key based on direction. + (let ((wh-cells (cl-remove-if-not + (lambda (cell) (eq (car-safe cell) 'window-height)) + received-alist))) + (should (null wh-cells))))) + +(ert-deftest test-ai-term--display-saved-passes-buffer-through () + "Normal: BUFFER argument reaches display-buffer-in-direction unchanged." + (let (received-buf + (cj/--ai-term-last-direction 'right) + (cj/--ai-term-last-size 0.5)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (b _a) (setq received-buf b) 'fake-window))) + (cj/--ai-term-display-saved 'sentinel-buffer nil)) + (should (eq received-buf 'sentinel-buffer)))) + +(ert-deftest test-ai-term--toggle-after-buffer-move-no-extra-window () + "Regression: toggle-off must not leak a window even when buffer-move +has cleared the agent window's `quit-restore' parameter. + +Reproduces Craig's repro from 2026-05-09: 3 windows, user uses +buffer-move (C-M-arrows) to relocate agent. buffer-move swaps +buffers between windows and leaves the receiving window with no +record that it was created for the agent buffer. + +Assertion: after toggle-off+toggle-on, the agent is displayed exactly +once and no spurious extra window leaks." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [buffer-move-toggle]") + (left-name "*test-bm-left*") + (right-name "*test-bm-right*")) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((left-buf (get-buffer-create left-name)) + (right-buf (get-buffer-create right-name)) + (agent-buf (get-buffer-create agent-name))) + (set-window-buffer (selected-window) left-buf) + (let* ((right-win (split-window (selected-window) nil 'right)) + (agent-win (split-window (selected-window) nil 'right))) + (set-window-buffer right-win right-buf) + (set-window-buffer agent-win agent-buf) + ;; Mimic buffer-move's effect: agent lives in this + ;; window but quit-restore says nothing about it. + (set-window-parameter agent-win 'quit-restore nil) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list)) + (window-count-before (count-windows))) + (select-window agent-win) + (cj/test--call-as-gui #'cj/ai-term) ; off + (cj/test--call-as-gui #'cj/ai-term) ; on + (should (<= (count-windows) window-count-before)) + ;; Agent must be displayed exactly once. + (let ((agent-windows + (seq-filter + (lambda (w) + (eq (window-buffer w) agent-buf)) + (window-list)))) + (should (= (length agent-windows) 1))))))) + (when (get-buffer left-name) (kill-buffer left-name)) + (when (get-buffer right-name) (kill-buffer right-name)) + (cj/test--kill-agent-buffers)))) + +(provide 'test-ai-term--display-saved) +;;; test-ai-term--display-saved.el ends here diff --git a/tests/test-ai-term--displayed-agent-window.el b/tests/test-ai-term--displayed-agent-window.el new file mode 100644 index 00000000..eeb40ed3 --- /dev/null +++ b/tests/test-ai-term--displayed-agent-window.el @@ -0,0 +1,60 @@ +;;; test-ai-term--displayed-agent-window.el --- Tests for the displayed-window helper -*- lexical-binding: t; -*- + +;;; Commentary: +;; The helper returns a window in the selected frame whose buffer +;; satisfies `cj/--ai-term-buffer-p', or nil when no such window +;; exists. Used by F9 dispatch and M-F9 in-place replacement. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(require 'ai-term) +(require 'testutil-ghostel-buffers) + +(ert-deftest test-ai-term--displayed-agent-window-no-buffers-returns-nil () + "Boundary: no agent buffers anywhere -> nil." + (cj/test--kill-agent-buffers) + (save-window-excursion + (delete-other-windows) + (should-not (cj/--ai-term-displayed-agent-window)))) + +(ert-deftest test-ai-term--displayed-agent-window-not-displayed-returns-nil () + "Boundary: agent buffer exists but not in any window -> nil." + (cj/test--kill-agent-buffers) + (let ((b1 (get-buffer-create "agent [hidden]"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (should-not (cj/--ai-term-displayed-agent-window))) + (kill-buffer b1)))) + +(ert-deftest test-ai-term--displayed-agent-window-returns-window-when-displayed () + "Normal: agent buffer in a window -> returns that window." + (cj/test--kill-agent-buffers) + (let ((b1 (get-buffer-create "agent [shown]"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((win (split-window-right))) + (set-window-buffer win b1) + (let ((result (cj/--ai-term-displayed-agent-window))) + (should (windowp result)) + (should (eq (window-buffer result) b1))))) + (kill-buffer b1)))) + +(ert-deftest test-ai-term--displayed-agent-window-ignores-non-agent-windows () + "Boundary: only a non-agent buffer is displayed -> nil." + (cj/test--kill-agent-buffers) + (let ((other (get-buffer-create "regular-displayed-buffer"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (set-window-buffer (selected-window) other) + (should-not (cj/--ai-term-displayed-agent-window))) + (kill-buffer other)))) + +(provide 'test-ai-term--displayed-agent-window) +;;; test-ai-term--displayed-agent-window.el ends here diff --git a/tests/test-ai-term--f9-in-term.el b/tests/test-ai-term--f9-in-term.el new file mode 100644 index 00000000..53e1c4e7 --- /dev/null +++ b/tests/test-ai-term--f9-in-term.el @@ -0,0 +1,45 @@ +;;; test-ai-term--f9-in-term.el --- F9 reaches Emacs from inside an agent buffer -*- lexical-binding: t; -*- + +;;; Commentary: +;; ghostel's semi-char mode forwards keys not in `ghostel-keymap-exceptions' to +;; the terminal program, so a plain typed while point is in an agent +;; buffer would be sent to the program instead of toggling the agent -- exactly +;; the case when the agent buffer fills the frame. `ai-term.el' re-binds the F9 +;; family in `ghostel-mode-map'. These tests require ghostel (which defines +;; `ghostel-mode-map' and lets ai-term's `with-eval-after-load' fire) BEFORE +;; ai-term, then confirm the bindings landed (and the global ones are intact). +;; `(require 'ghostel)' does not load the native module, so this stays light. + +;;; Code: + +(require 'ert) +(require 'package) + +(setq package-user-dir (expand-file-name "elpa" user-emacs-directory)) +(package-initialize) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ghostel) +(require 'ai-term) + +(ert-deftest test-ai-term-f9-bound-in-ghostel-mode-map () + "Normal: in `ghostel-mode-map' runs the agent toggle." + (should (eq (keymap-lookup ghostel-mode-map "") #'cj/ai-term))) + +(ert-deftest test-ai-term-f9-family-bound-in-ghostel-mode-map () + "Normal: the C-/M-/C-S- F9 variants are bound in `ghostel-mode-map' too. +`M-' and `C-S-' both close an agent via `cj/ai-term-close'." + (should (eq (keymap-lookup ghostel-mode-map "C-") #'cj/ai-term-pick-project)) + (should (eq (keymap-lookup ghostel-mode-map "M-") #'cj/ai-term-close)) + (should (eq (keymap-lookup ghostel-mode-map "C-S-") #'cj/ai-term-close))) + +(ert-deftest test-ai-term-f9-still-bound-globally () + "Normal: the global F9 family bindings are intact. +`' toggles the ai-term agent window; `C-' picks a project +agent; `M-' and `C-S-' close an agent via `cj/ai-term-close'." + (should (eq (lookup-key (current-global-map) (kbd "")) #'cj/ai-term)) + (should (eq (lookup-key (current-global-map) (kbd "C-")) #'cj/ai-term-pick-project)) + (should (eq (lookup-key (current-global-map) (kbd "M-")) #'cj/ai-term-close)) + (should (eq (lookup-key (current-global-map) (kbd "C-S-")) #'cj/ai-term-close))) + +(provide 'test-ai-term--f9-in-term) +;;; test-ai-term--f9-in-term.el ends here diff --git a/tests/test-ai-term--launch-command.el b/tests/test-ai-term--launch-command.el new file mode 100644 index 00000000..246e70a3 --- /dev/null +++ b/tests/test-ai-term--launch-command.el @@ -0,0 +1,94 @@ +;;; test-ai-term--launch-command.el --- Tests for cj/--ai-term-launch-command -*- lexical-binding: t; -*- + +;;; Commentary: +;; The launch command is what gets typed into a fresh ghostel shell to bring +;; up the agent inside a per-project tmux session. The session is named +;; `cj/ai-term-tmux-session-prefix' + the project basename, so a second +;; F9 on the same project reattaches to the running agent rather than +;; spawning a new one, and `tmux ls' output can be filtered to AI-term's +;; own sessions. The trailing `exec bash' keeps the tmux window alive if +;; the agent exits, leaving the session intact for recovery. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(ert-deftest test-ai-term--launch-command-uses-new-session-attach () + "Normal: starts with `tmux new-session -A' so existing sessions reattach." + (let ((cj/ai-term-agent-command "agent")) + (should (string-prefix-p + "tmux new-session -A " + (cj/--ai-term-launch-command "/code/foo"))))) + +(ert-deftest test-ai-term--launch-command-includes-prefixed-session-name () + "Normal: the session name is the prefixed form from the name helper." + (let ((cj/ai-term-agent-command "agent") + (cj/ai-term-tmux-session-prefix "aiv-")) + (should (string-match-p + " -s aiv-foo " + (cj/--ai-term-launch-command "/code/foo"))))) + +(ert-deftest test-ai-term--launch-command-names-window () + "Normal: `-n ' so the agent window is named distinctly." + (let ((cj/ai-term-agent-command "agent") + (cj/ai-term-tmux-window-name "ai")) + (should (string-match-p + " -n ai " + (cj/--ai-term-launch-command "/code/foo"))))) + +(ert-deftest test-ai-term--launch-command-honors-custom-window-name () + "Boundary: a non-default window name is what `-n' gets." + (let ((cj/ai-term-agent-command "agent") + (cj/ai-term-tmux-window-name "agent")) + (should (string-match-p + " -n agent " + (cj/--ai-term-launch-command "/code/foo"))))) + +(ert-deftest test-ai-term--launch-command-includes-start-directory () + "Normal: `-c ' so the new session's first window starts in DIR." + (let ((cj/ai-term-agent-command "agent")) + (should (string-match-p + " -c /code/foo " + (cj/--ai-term-launch-command "/code/foo"))))) + +(ert-deftest test-ai-term--launch-command-includes-agent-command () + "Normal: the configured agent command is in the launched shell command. +The inner command is passed through `shell-quote-argument', so spaces +are escaped (`\\\\ ') -- the regex below accepts either form." + (let ((cj/ai-term-agent-command "agent --some-flag")) + (should (string-match-p + "agent\\(\\\\\\)? --some-flag" + (cj/--ai-term-launch-command "/code/foo"))))) + +(ert-deftest test-ai-term--launch-command-tails-with-exec-bash () + "Boundary: `exec bash' tails so the tmux window survives the agent exiting. +Accepts the post-`shell-quote-argument' shape (`exec\\\\ bash')." + (let ((cj/ai-term-agent-command "agent")) + (should (string-match-p + "exec\\(\\\\\\)? bash" + (cj/--ai-term-launch-command "/code/foo"))))) + +(ert-deftest test-ai-term--launch-command-survives-single-quote-in-agent () + "Normal: a user-customized agent command containing a single quote +shouldn't break the shell parse. `shell-quote-argument' produces a +valid shell token regardless of the embedded quote shape -- the +escaping is implementation-detail, so we assert the literal words +\"hi\" and \"there\" both appear (the space between them may be +escaped as \\\\ )." + (let ((cj/ai-term-agent-command "agent --say 'hi there'")) + (let ((cmd (cj/--ai-term-launch-command "/code/foo"))) + (should (string-match-p "hi\\(\\\\\\)? there" cmd))))) + +(ert-deftest test-ai-term--launch-command-handles-spaces-in-basename () + "Boundary: a basename with whitespace becomes hyphenated before quoting." + (let ((cj/ai-term-agent-command "agent") + (cj/ai-term-tmux-session-prefix "aiv-")) + (should (string-match-p + " -s aiv-my-work " + (cj/--ai-term-launch-command "/code/my work"))))) + +(provide 'test-ai-term--launch-command) +;;; test-ai-term--launch-command.el ends here diff --git a/tests/test-ai-term--live-tmux-sessions.el b/tests/test-ai-term--live-tmux-sessions.el new file mode 100644 index 00000000..1952caed --- /dev/null +++ b/tests/test-ai-term--live-tmux-sessions.el @@ -0,0 +1,71 @@ +;;; test-ai-term--live-tmux-sessions.el --- Tests for cj/--ai-term-live-tmux-sessions -*- lexical-binding: t; -*- + +;;; Commentary: +;; Lists the live tmux sessions that carry the AI-term prefix so the +;; project picker can surface projects whose agent session survived an +;; Emacs crash. tmux being absent or no server running is a normal +;; "nothing to match" outcome, not an error -- the lister returns nil. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(defmacro test-ai-term--with-tmux-list (exit-code output &rest body) + "Run BODY with `process-file' mocked to a tmux list-sessions response. + +EXIT-CODE is what `process-file' returns (or the symbol `error' to +make it signal). OUTPUT is written to the stdout destination buffer." + (declare (indent 2)) + `(cl-letf (((symbol-function 'process-file) + (lambda (_program _infile destination _display &rest _args) + (when (eq ,exit-code 'error) + (error "tmux: command not found")) + (let ((buffer (cond + ((eq destination t) (current-buffer)) + ((bufferp destination) destination) + ((consp destination) + (and (eq (car destination) t) + (current-buffer)))))) + (when (bufferp buffer) + (with-current-buffer buffer (insert ,output)))) + ,exit-code))) + ,@body)) + +(ert-deftest test-ai-term--live-tmux-sessions-filters-to-prefix () + "Normal: only sessions starting with the AI-term prefix come back." + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + (test-ai-term--with-tmux-list 0 "aiv-foo\nrandom-session\naiv-bar\n" + (should (equal (cj/--ai-term-live-tmux-sessions) + '("aiv-foo" "aiv-bar")))))) + +(ert-deftest test-ai-term--live-tmux-sessions-honors-custom-prefix () + "Normal: a non-default prefix is what gets matched." + (let ((cj/ai-term-tmux-session-prefix "em-")) + (test-ai-term--with-tmux-list 0 "em-foo\naiv-bar\nem-baz\n" + (should (equal (cj/--ai-term-live-tmux-sessions) + '("em-foo" "em-baz")))))) + +(ert-deftest test-ai-term--live-tmux-sessions-empty-output-yields-nil () + "Boundary: a running server with no matching sessions yields nil." + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + (test-ai-term--with-tmux-list 0 "other-a\nother-b\n" + (should (null (cj/--ai-term-live-tmux-sessions)))))) + +(ert-deftest test-ai-term--live-tmux-sessions-no-server-yields-nil () + "Error: tmux exits non-zero (no server running) -> nil, not a signal." + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + (test-ai-term--with-tmux-list 1 "no server running on /tmp/tmux-1000/default\n" + (should (null (cj/--ai-term-live-tmux-sessions)))))) + +(ert-deftest test-ai-term--live-tmux-sessions-tmux-missing-yields-nil () + "Error: tmux not installed -> `process-file' signals; lister returns nil." + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + (test-ai-term--with-tmux-list 'error "" + (should (null (cj/--ai-term-live-tmux-sessions)))))) + +(provide 'test-ai-term--live-tmux-sessions) +;;; test-ai-term--live-tmux-sessions.el ends here diff --git a/tests/test-ai-term--pick-project.el b/tests/test-ai-term--pick-project.el new file mode 100644 index 00000000..e6d2f25b --- /dev/null +++ b/tests/test-ai-term--pick-project.el @@ -0,0 +1,117 @@ +;;; test-ai-term--pick-project.el --- Tests for cj/--ai-term-pick-project -*- lexical-binding: t; -*- + +;;; Commentary: +;; The picker presents abbreviated paths to `completing-read' (projects +;; with a live tmux session first, then alphabetical), then returns the +;; absolute path corresponding to the user's choice. An empty candidate +;; set raises a `user-error' rather than offering an empty prompt. The +;; collection is a completion table that pins display order (so Vertico +;; doesn't re-sort and defeat the active-first grouping). + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(defun test-ai-term--collection-strings (collection) + "Return the candidate display strings from a completing-read COLLECTION. +Works whether COLLECTION is an alist or a completion-table function." + (all-completions "" collection)) + +(ert-deftest test-ai-term--pick-project-returns-absolute-path-of-choice () + "Normal: user picks a candidate, picker returns its absolute path." + (cl-letf (((symbol-function 'cj/--ai-term-candidates) + (lambda () '("/home/u/code/foo" "/home/u/code/bar"))) + ((symbol-function 'cj/--ai-term-live-tmux-sessions) + (lambda () nil)) + ((symbol-function 'completing-read) + (lambda (_p collection &rest _) + (seq-find (lambda (s) (string-match-p "bar" s)) + (test-ai-term--collection-strings collection))))) + (should (equal (cj/--ai-term-pick-project) "/home/u/code/bar")))) + +(ert-deftest test-ai-term--pick-project-empty-candidates-raises-user-error () + "Error: no candidates -> user-error rather than empty prompt." + (cl-letf (((symbol-function 'cj/--ai-term-candidates) (lambda () nil))) + (should-error (cj/--ai-term-pick-project) :type 'user-error))) + +(ert-deftest test-ai-term--pick-project-presents-abbreviated-paths () + "Normal: the completing-read collection holds abbreviated display forms." + (let (received-strings) + (cl-letf (((symbol-function 'cj/--ai-term-candidates) + (lambda () (list (expand-file-name "~/code/foo")))) + ((symbol-function 'cj/--ai-term-live-tmux-sessions) + (lambda () nil)) + ((symbol-function 'completing-read) + (lambda (_p collection &rest _) + (setq received-strings (test-ai-term--collection-strings collection)) + (car received-strings)))) + (cj/--ai-term-pick-project) + (should (equal (car received-strings) "~/code/foo"))))) + +(ert-deftest test-ai-term--pick-project-active-sessions-sort-first () + "Normal: a project with a live tmux session leads; it carries [detached]." + (let ((cj/ai-term-tmux-session-prefix "aiv-") + received-strings) + (cl-letf (((symbol-function 'cj/--ai-term-candidates) + (lambda () '("/c/foo" "/c/bar" "/c/baz"))) + ((symbol-function 'cj/--ai-term-live-tmux-sessions) + (lambda () '("aiv-baz"))) + ((symbol-function 'completing-read) + (lambda (_p collection &rest _) + (setq received-strings (test-ai-term--collection-strings collection)) + (car received-strings)))) + (cj/--ai-term-pick-project) + (should (equal received-strings + '("/c/baz [detached]" "/c/bar" "/c/foo")))))) + +(ert-deftest test-ai-term--format-candidate-flags-running-project () + "Normal: a path whose agent buffer has a live process gets a [running] suffix." + (let* ((path (expand-file-name "~/code/already-running")) + (buffer-name (cj/--ai-term-buffer-name path)) + (buf (get-buffer-create buffer-name))) + (unwind-protect + (cl-letf (((symbol-function 'cj/--ai-term-process-live-p) + (lambda (b) (eq b buf)))) + (should (equal (cj/--ai-term-format-candidate path) + (format "%s [running]" (abbreviate-file-name path))))) + (kill-buffer buf)))) + +(ert-deftest test-ai-term--format-candidate-flags-detached-session () + "Normal: no buffer but a matching tmux session -> [detached] suffix." + (let* ((cj/ai-term-tmux-session-prefix "aiv-") + (path (expand-file-name "~/code/has-session")) + (bn (cj/--ai-term-buffer-name path))) + (when (get-buffer bn) (kill-buffer bn)) + (should (equal (cj/--ai-term-format-candidate + path (list (cj/--ai-term-tmux-session-name path))) + (format "%s [detached]" (abbreviate-file-name path)))))) + +(ert-deftest test-ai-term--format-candidate-running-beats-detached () + "Boundary: a live buffer wins over a matching session -> [running], not [detached]." + (let* ((cj/ai-term-tmux-session-prefix "aiv-") + (path (expand-file-name "~/code/both")) + (bn (cj/--ai-term-buffer-name path)) + (buf (get-buffer-create bn))) + (unwind-protect + (cl-letf (((symbol-function 'cj/--ai-term-process-live-p) + (lambda (b) (eq b buf)))) + (should (equal (cj/--ai-term-format-candidate + path (list (cj/--ai-term-tmux-session-name path))) + (format "%s [running]" (abbreviate-file-name path))))) + (kill-buffer buf)))) + +(ert-deftest test-ai-term--format-candidate-omits-flag-when-not-running () + "Boundary: a path with no buffer or no live process -> plain abbreviated path." + (let ((path (expand-file-name "~/code/not-running"))) + ;; Make sure no agent buffer exists for this path. + (let ((bn (cj/--ai-term-buffer-name path))) + (when (get-buffer bn) (kill-buffer bn))) + (should (equal (cj/--ai-term-format-candidate path) + (abbreviate-file-name path))))) + +(provide 'test-ai-term--pick-project) +;;; test-ai-term--pick-project.el ends here diff --git a/tests/test-ai-term--record-mru.el b/tests/test-ai-term--record-mru.el new file mode 100644 index 00000000..e00f6814 --- /dev/null +++ b/tests/test-ai-term--record-mru.el @@ -0,0 +1,48 @@ +;;; test-ai-term--record-mru.el --- Tests for the AI-term project MRU list -*- lexical-binding: t; -*- + +;;; Commentary: +;; `cj/--ai-term-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-term-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-term) + +(ert-deftest test-ai-term--record-mru-pushes-to-front () + "Normal: a freshly recorded dir leads the list, newest first." + (let ((cj/--ai-term-mru nil)) + (cj/--ai-term-record-mru "/c/alpha") + (cj/--ai-term-record-mru "/c/beta") + (should (equal cj/--ai-term-mru '("/c/beta" "/c/alpha"))))) + +(ert-deftest test-ai-term--record-mru-dedups-and-moves-to-front () + "Normal: re-recording a dir moves it to the front with no duplicate." + (let ((cj/--ai-term-mru nil)) + (cj/--ai-term-record-mru "/c/alpha") + (cj/--ai-term-record-mru "/c/beta") + (cj/--ai-term-record-mru "/c/alpha") + (should (equal cj/--ai-term-mru '("/c/alpha" "/c/beta"))))) + +(ert-deftest test-ai-term--record-mru-normalizes-trailing-slash () + "Boundary: `/c/foo' and `/c/foo/' are the same MRU entry." + (let ((cj/--ai-term-mru nil)) + (cj/--ai-term-record-mru "/c/foo/") + (cj/--ai-term-record-mru "/c/foo") + (should (equal cj/--ai-term-mru '("/c/foo"))))) + +(ert-deftest test-ai-term--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-term-mru '("/c/beta" "/c/alpha"))) + (should (= 0 (cj/--ai-term-mru-rank "/c/beta"))) + (should (= 1 (cj/--ai-term-mru-rank "/c/alpha"))) + (should (= 0 (cj/--ai-term-mru-rank "/c/beta/"))) + (should-not (cj/--ai-term-mru-rank "/c/gamma")))) + +(provide 'test-ai-term--record-mru) +;;; test-ai-term--record-mru.el ends here diff --git a/tests/test-ai-term--reuse-edge-window.el b/tests/test-ai-term--reuse-edge-window.el new file mode 100644 index 00000000..c41aab73 --- /dev/null +++ b/tests/test-ai-term--reuse-edge-window.el @@ -0,0 +1,273 @@ +;;; test-ai-term--reuse-edge-window.el --- Tests for edge-window reuse -*- lexical-binding: t; -*- + +;;; Commentary: +;; `cj/--ai-term-reuse-edge-window' is the display-buffer action that +;; reuses the window already forming the half the agent would occupy +;; (the right column on a desktop, the bottom row on a laptop) instead +;; of splitting a third window in. It runs between +;; `cj/--ai-term-reuse-existing-agent' and `cj/--ai-term-display-saved' +;; in the rule chain. +;; +;; Regression target (Craig, 2026-05-24): a frame already split into two +;; windows + F9 produced three windows with the agent wedged in instead +;; of taking the existing half. These tests assert the window *count* +;; stays put -- the dimension the older display-saved tests never checked. +;; +;; Tests build real windows (split-window) and route a fresh agent buffer +;; through the actual `cj/--ai-term-display-rule-list', the same pattern +;; as test-ai-term--display-saved.el. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(require 'ai-term) +(require 'testutil-ghostel-buffers) + +(defun cj/test--displayed-buffer-names () + "Return the buffer names shown in the selected frame, left/top to right/bottom." + (mapcar (lambda (w) (buffer-name (window-buffer w))) + (window-list nil 'never))) + +(ert-deftest test-ai-term--reuse-edge-window-2col-desktop-no-third-window () + "Normal: F9 in a 2-column split reuses the right column, no third window. +Desktop default direction is `right', so the agent takes the existing +right half: the frame stays at two windows [left | agent]." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [edge-2col]") + (left-name "*test-edge-left*") + (right-name "*test-edge-right*") + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) + (let ((left-buf (get-buffer-create left-name)) + (right-buf (get-buffer-create right-name)) + (agent-buf (get-buffer-create agent-name))) + (set-window-buffer (selected-window) left-buf) + (let ((rw (split-window (selected-window) nil 'right))) + (set-window-buffer rw right-buf)) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) + (display-buffer agent-buf)) + (should (= (count-windows) 2)) + (let ((bufs (cj/test--displayed-buffer-names))) + (should (member agent-name bufs)) + (should (member left-name bufs)) + ;; the right column now holds the agent, not the old buffer + (should-not (member right-name bufs)))))) + (when (get-buffer left-name) (kill-buffer left-name)) + (when (get-buffer right-name) (kill-buffer right-name)) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--reuse-edge-window-2row-laptop-no-third-window () + "Normal: F9 in a 2-row split on a laptop reuses the bottom row. +Laptop default direction is `below', so the agent takes the existing +bottom half: the frame stays at two windows." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [edge-2row]") + (top-name "*test-edge-top*") + (bottom-name "*test-edge-bottom*") + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (cl-letf (((symbol-function 'env-laptop-p) (lambda () t))) + (let ((top-buf (get-buffer-create top-name)) + (bottom-buf (get-buffer-create bottom-name)) + (agent-buf (get-buffer-create agent-name))) + (set-window-buffer (selected-window) top-buf) + (let ((bw (split-window (selected-window) nil 'below))) + (set-window-buffer bw bottom-buf)) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) + (display-buffer agent-buf)) + (should (= (count-windows) 2)) + (let ((bufs (cj/test--displayed-buffer-names))) + (should (member agent-name bufs)) + (should (member top-name bufs)) + (should-not (member bottom-name bufs)))))) + (when (get-buffer top-name) (kill-buffer top-name)) + (when (get-buffer bottom-name) (kill-buffer bottom-name)) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--reuse-edge-window-single-window-splits () + "Boundary: a single-window frame still splits to create the half. +No existing edge window to reuse, so the display-saved path runs and +the frame goes from one window to two with the agent present." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [edge-single]") + (sole-name "*test-edge-sole*") + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) + (let ((sole-buf (get-buffer-create sole-name)) + (agent-buf (get-buffer-create agent-name))) + (set-window-buffer (selected-window) sole-buf) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) + (display-buffer agent-buf)) + (should (= (count-windows) 2)) + (should (member agent-name (cj/test--displayed-buffer-names)))))) + (when (get-buffer sole-name) (kill-buffer sole-name)) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--reuse-edge-window-axis-mismatch-falls-through () + "Error/Boundary: a top/bottom split on a desktop has no right half. +Desktop direction is `right' but the frame is split horizontally, so no +single full-height right column exists to reuse. The chain falls +through to display-saved, which splits a right column -- agent still +ends up displayed." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [edge-mismatch]") + (top-name "*test-edge-mm-top*") + (bottom-name "*test-edge-mm-bottom*") + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) + (let ((top-buf (get-buffer-create top-name)) + (bottom-buf (get-buffer-create bottom-name)) + (agent-buf (get-buffer-create agent-name))) + (set-window-buffer (selected-window) top-buf) + (let ((bw (split-window (selected-window) nil 'below))) + (set-window-buffer bw bottom-buf)) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) + (display-buffer agent-buf)) + ;; No half to reuse, so a fresh column is split: three windows. + (should (member agent-name (cj/test--displayed-buffer-names))) + (should (member top-name (cj/test--displayed-buffer-names))) + (should (member bottom-name (cj/test--displayed-buffer-names)))))) + (when (get-buffer top-name) (kill-buffer top-name)) + (when (get-buffer bottom-name) (kill-buffer bottom-name)) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--reuse-edge-window-toggle-off-collapses-split () + "Normal: toggle-off after a slot reuse collapses the agent split. +=| 1 | 2 |= + show agent -> =| 1 | A |=; toggle off -> =| 1 |= (one +window). F9 always collapses the agent split back to the working layout +regardless of how the agent window came to be -- it deletes the agent +window rather than restoring the displaced buffer into a kept slot." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [edge-restore]") + (left-name "*test-restore-left*") + (right-name "*test-restore-right*") + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) + (let ((left-buf (get-buffer-create left-name)) + (right-buf (get-buffer-create right-name)) + (agent-buf (get-buffer-create agent-name))) + (set-window-buffer (selected-window) left-buf) + (let ((rw (split-window (selected-window) nil 'right))) + (set-window-buffer rw right-buf)) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) + (display-buffer agent-buf) + (should (= (count-windows) 2)) + (should (member agent-name (cj/test--displayed-buffer-names))) + ;; Toggle off -> the agent window is deleted, leaving the + ;; working buffer at full frame. + (cj/test--call-as-gui #'cj/ai-term) + (should (= (count-windows) 1)) + (let ((bufs (cj/test--displayed-buffer-names))) + (should (member left-name bufs)) + (should-not (member agent-name bufs))))))) + (when (get-buffer left-name) (kill-buffer left-name)) + (when (get-buffer right-name) (kill-buffer right-name)) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--reuse-edge-window-cycle-collapses-then-resplits () + "Normal: on/off/on cycle collapses on off and re-splits at the same width. +=| 1 | 2 |= -> on =| 1 | A |= (2 windows) -> off =| 1 |= (1 window, +collapsed) -> on =| 1 | A |= (2 windows again), with the agent re-split at +the width captured at toggle-off -- the user's chosen split width is +preserved across the toggle (respect-split-width)." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [edge-cycle]") + (left-name "*test-cycle-left*") + (right-name "*test-cycle-right*") + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) + (let ((left-buf (get-buffer-create left-name)) + (right-buf (get-buffer-create right-name)) + (agent-buf (get-buffer-create agent-name)) + slot-width) + (set-window-buffer (selected-window) left-buf) + (let ((rw (split-window (selected-window) nil 'right))) + (set-window-buffer rw right-buf)) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) + ;; on -- agent takes the existing right slot + (display-buffer agent-buf) + (should (= (count-windows) 2)) + (setq slot-width + (window-body-width (cj/--ai-term-displayed-agent-window))) + ;; off -- the split collapses to a single window + (cj/test--call-as-gui #'cj/ai-term) + (should (= (count-windows) 1)) + (should-not (cj/--ai-term-displayed-agent-window)) + ;; on again -- re-split at the captured width + (cj/test--call-as-gui #'cj/ai-term) + (should (= (count-windows) 2)) + (let ((win (cj/--ai-term-displayed-agent-window))) + (should (windowp win)) + (should (eq (window-buffer win) agent-buf)) + (should (= (window-body-width win) slot-width))))))) + (when (get-buffer left-name) (kill-buffer left-name)) + (when (get-buffer right-name) (kill-buffer right-name)) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--reuse-edge-window-toggle-keeps-same-agent-with-multiple () + "Regression: with two agents alive, toggle-off then on restores the SAME +agent, not a different one. Toggle-off must not bury the agent to the end +of the buffer list -- if it does, `cj/--ai-term-dispatch' re-shows the +most-recent agent, which would now be the other one." + (cj/test--kill-agent-buffers) + (let ((a1-name "agent [multi-1]") + (a2-name "agent [multi-2]") + (left-name "*test-multi-left*") + (right-name "*test-multi-right*") + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) + (let ((a1 (get-buffer-create a1-name)) + (a2 (get-buffer-create a2-name)) + (left-buf (get-buffer-create left-name)) + (right-buf (get-buffer-create right-name))) + ;; Make A2 the most-recent agent. + (bury-buffer a1) + (set-window-buffer (selected-window) left-buf) + (let ((rw (split-window (selected-window) nil 'right))) + (set-window-buffer rw right-buf)) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) + (display-buffer a2) ; | left | A2 | + (should (eq (window-buffer (cj/--ai-term-displayed-agent-window)) + a2)) + (cj/test--call-as-gui #'cj/ai-term) ; off -> | left | right | + (should-not (cj/--ai-term-displayed-agent-window)) + (cj/test--call-as-gui #'cj/ai-term) ; on -> must bring A2 back + (should (eq (window-buffer (cj/--ai-term-displayed-agent-window)) + a2)))))) + (when (get-buffer left-name) (kill-buffer left-name)) + (when (get-buffer right-name) (kill-buffer right-name)) + (cj/test--kill-agent-buffers)))) + +(provide 'test-ai-term--reuse-edge-window) +;;; test-ai-term--reuse-edge-window.el ends here diff --git a/tests/test-ai-term--reuse-existing-agent.el b/tests/test-ai-term--reuse-existing-agent.el new file mode 100644 index 00000000..3f0c6449 --- /dev/null +++ b/tests/test-ai-term--reuse-existing-agent.el @@ -0,0 +1,99 @@ +;;; test-ai-term--reuse-existing-agent.el --- Tests for reuse-existing-agent action -*- lexical-binding: t; -*- + +;;; Commentary: +;; The action looks for any window in the selected frame whose buffer +;; satisfies `cj/--ai-term-buffer-p'. When found, swaps that +;; window's buffer for the one being displayed and returns the +;; window. When not found, returns nil so the next action in the +;; chain runs. +;; +;; This is the action that keeps C-F9 (project-switch) from stealing +;; a non-agent window when the user is focused inside agent. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(require 'ai-term) +(require 'testutil-ghostel-buffers) + +(ert-deftest test-ai-term--reuse-existing-agent-swaps-buffer-when-window-exists () + "Normal: an agent window exists -> swap its buffer, return the window." + (cj/test--kill-agent-buffers) + (save-window-excursion + (delete-other-windows) + (let ((existing (get-buffer-create "agent [existing]")) + (new-buf (get-buffer-create "agent [new]")) + (split (split-window (selected-window) nil 'right))) + (unwind-protect + (progn + (set-window-buffer split existing) + (let ((result (cj/--ai-term-reuse-existing-agent new-buf nil))) + (should (eq result split)) + (should (eq (window-buffer split) new-buf)))) + (kill-buffer existing) + (kill-buffer new-buf))))) + +(ert-deftest test-ai-term--reuse-existing-agent-returns-nil-when-no-agent-window () + "Boundary: no agent window in frame -> nil (chain continues to next action)." + (cj/test--kill-agent-buffers) + (save-window-excursion + (delete-other-windows) + (let ((new-buf (get-buffer-create "agent [no-existing]"))) + (unwind-protect + (should (null (cj/--ai-term-reuse-existing-agent new-buf nil))) + (kill-buffer new-buf))))) + +(ert-deftest test-ai-term--reuse-existing-agent-leaves-non-agent-windows-alone () + "Boundary: only non-agent windows in frame -> nil; other windows untouched." + (cj/test--kill-agent-buffers) + (save-window-excursion + (delete-other-windows) + (let ((code-buf (get-buffer-create "*test-code-buffer*")) + (new-agent (get-buffer-create "agent [new-here]")) + (other-win (split-window (selected-window) nil 'right))) + (unwind-protect + (progn + (set-window-buffer (selected-window) code-buf) + (set-window-buffer other-win code-buf) + (let ((result (cj/--ai-term-reuse-existing-agent + new-agent nil))) + (should (null result)) + (should (eq (window-buffer (selected-window)) code-buf)) + (should (eq (window-buffer other-win) code-buf)))) + (kill-buffer code-buf) + (kill-buffer new-agent))))) + +(ert-deftest test-ai-term--reuse-existing-agent-preserves-non-agent-window-when-swapping () + "Normal: swap agent window only; the other window keeps its buffer. + +This is the C-F9-from-agent regression: with agent at the bottom +and code on top, switching projects must replace the bottom window's +buffer, not the top window's." + (cj/test--kill-agent-buffers) + (save-window-excursion + (delete-other-windows) + (let* ((code-buf (get-buffer-create "*test-code-top*")) + (agent-a (get-buffer-create "agent [a]")) + (agent-b (get-buffer-create "agent [b]")) + (top-win (selected-window)) + (bottom-win (split-window top-win nil 'below))) + (unwind-protect + (progn + (set-window-buffer top-win code-buf) + (set-window-buffer bottom-win agent-a) + ;; Focus the agent window -- this is the regression scenario. + (select-window bottom-win) + (let ((result (cj/--ai-term-reuse-existing-agent + agent-b nil))) + (should (eq result bottom-win)) + (should (eq (window-buffer bottom-win) agent-b)) + (should (eq (window-buffer top-win) code-buf)))) + (kill-buffer code-buf) + (kill-buffer agent-a) + (kill-buffer agent-b))))) + +(provide 'test-ai-term--reuse-existing-agent) +;;; test-ai-term--reuse-existing-agent.el ends here diff --git a/tests/test-ai-term--server-display.el b/tests/test-ai-term--server-display.el new file mode 100644 index 00000000..b3d32dc8 --- /dev/null +++ b/tests/test-ai-term--server-display.el @@ -0,0 +1,127 @@ +;;; test-ai-term--server-display.el --- Tests for emacsclient window routing -*- lexical-binding: t; -*- + +;;; Commentary: +;; `cj/--ai-term-server-display' is wired as `server-window' so a file +;; opened via `emacsclient -n' (e.g. when Craig tells the agent to open +;; something) doesn't land on top of the agent terminal. When the selected +;; window shows an `agent [...]' buffer, the file goes to a non-agent +;; window instead -- splitting one off the agent if it is the only window. +;; `cj/--ai-term-non-agent-window' picks that window. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(require 'ai-term) +(require 'server) +(require 'testutil-ghostel-buffers) + +(ert-deftest test-ai-term--non-agent-window-finds-code-window () + "Normal: agent on the right, code on the left -> returns the code window." + (cj/test--kill-agent-buffers) + (let ((agent (get-buffer-create "agent [proj]")) + (code (get-buffer-create "code.el"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (set-window-buffer (selected-window) code) + (let ((right (split-window-right))) + (set-window-buffer right agent) + (let ((found (cj/--ai-term-non-agent-window right))) + (should (windowp found)) + (should (eq (window-buffer found) code))))) + (kill-buffer agent) + (kill-buffer code)))) + +(ert-deftest test-ai-term--non-agent-window-none-when-only-agent () + "Boundary: the agent window is the only one -> nil." + (cj/test--kill-agent-buffers) + (let ((agent (get-buffer-create "agent [solo]"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (set-window-buffer (selected-window) agent) + (should-not (cj/--ai-term-non-agent-window (selected-window)))) + (kill-buffer agent)))) + +(ert-deftest test-ai-term--non-agent-window-skips-dedicated () + "Boundary: a dedicated non-agent window is not a valid target." + (cj/test--kill-agent-buffers) + (let ((agent (get-buffer-create "agent [proj]")) + (side (get-buffer-create "*dedicated-side*"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (set-window-buffer (selected-window) agent) + (let ((w (split-window-right))) + (set-window-buffer w side) + (set-window-dedicated-p w t) + (unwind-protect + (should-not (cj/--ai-term-non-agent-window (selected-window))) + (set-window-dedicated-p w nil)))) + (kill-buffer agent) + (kill-buffer side)))) + +(ert-deftest test-ai-term--server-display-routes-around-agent () + "Normal: selected window is the agent -> the file lands in the other +window and the agent window keeps the agent buffer." + (cj/test--kill-agent-buffers) + (let ((agent (get-buffer-create "agent [proj]")) + (code (get-buffer-create "code.el")) + (file (get-buffer-create "opened-by-emacsclient.el"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (set-window-buffer (selected-window) code) + (let ((agent-win (split-window-right))) + (set-window-buffer agent-win agent) + (select-window agent-win) + (cj/--ai-term-server-display file) + (should (eq (window-buffer agent-win) agent)) + (should (get-buffer-window file)) + (should-not (eq (get-buffer-window file) agent-win)))) + (kill-buffer agent) + (kill-buffer code) + (kill-buffer file)))) + +(ert-deftest test-ai-term--server-display-splits-when-agent-is-only-window () + "Boundary: the agent is the only window -> a window is split off for the +file; the agent window keeps the agent buffer." + (cj/test--kill-agent-buffers) + (let ((agent (get-buffer-create "agent [solo]")) + (file (get-buffer-create "opened.el"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (set-window-buffer (selected-window) agent) + (let ((agent-win (selected-window))) + (cj/--ai-term-server-display file) + (should (= 2 (length (window-list (selected-frame) 'never)))) + (should (eq (window-buffer agent-win) agent)) + (should (eq (window-buffer (get-buffer-window file)) file)))) + (kill-buffer agent) + (kill-buffer file)))) + +(ert-deftest test-ai-term--server-display-passthrough-when-not-agent () + "Normal: selected window is a regular buffer -> the file is displayed +normally and nothing special happens (no agent window to protect)." + (cj/test--kill-agent-buffers) + (let ((code (get-buffer-create "code.el")) + (file (get-buffer-create "opened.el"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (set-window-buffer (selected-window) code) + (cj/--ai-term-server-display file) + (should (get-buffer-window file))) + (kill-buffer code) + (kill-buffer file)))) + +(ert-deftest test-ai-term--server-window-wired-to-helper () + "Normal: the module sets `server-window' to its display function." + (should (eq server-window #'cj/--ai-term-server-display))) + +(provide 'test-ai-term--server-display) +;;; test-ai-term--server-display.el ends here diff --git a/tests/test-ai-term--show-or-create.el b/tests/test-ai-term--show-or-create.el new file mode 100644 index 00000000..c6653dcd --- /dev/null +++ b/tests/test-ai-term--show-or-create.el @@ -0,0 +1,155 @@ +;;; test-ai-term--show-or-create.el --- Tests for cj/--ai-term-show-or-create -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests the show-or-create branching: +;; +;; - buffer absent -> ghostel called, agent command + newline sent +;; - buffer present, live -> ghostel not called, buffer displayed +;; - buffer present, dead -> old buffer killed, ghostel recreates +;; +;; ghostel functions are stubbed so the test does no process spawning and +;; never loads the native module. Production calls (ghostel) with no name and +;; relies on the dynamically bound `ghostel-buffer-name'; the mock honors that. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +;; ghostel isn't loaded in batch -- provide stubs so cl-letf has overrides. +(unless (fboundp 'ghostel) + (defun ghostel (&optional _arg) nil)) +(unless (fboundp 'ghostel-send-string) + (defun ghostel-send-string (_s) nil)) + +(defmacro test-ai-term--with-mock-ghostel (vars &rest body) + "Run BODY with ghostel + ghostel-send-string mocked. + +VARS is a plist of capture variable names: :calls (buffer names ghostel +was asked to create), :strings (sent strings), :default-dir. The mocked +`ghostel' creates and returns a buffer named after the dynamically bound +`ghostel-buffer-name', mirroring the real entry point." + (declare (indent 1) (debug t)) + (let ((calls (plist-get vars :calls)) + (strings (plist-get vars :strings)) + (ddir (plist-get vars :default-dir))) + `(let ((,calls '()) + (,strings '()) + (,ddir nil)) + (cl-letf (((symbol-function 'ghostel) + (lambda (&optional _arg) + (setq ,ddir default-directory) + (let ((b (get-buffer-create ghostel-buffer-name))) + (push (buffer-name b) ,calls) + b))) + ((symbol-function 'ghostel-send-string) + (lambda (s) (push s ,strings)))) + ,@body)))) + +(defun test-ai-term--cleanup (name) + "Kill buffer NAME if it exists." + (when (get-buffer name) + (kill-buffer name))) + +(ert-deftest test-ai-term--show-or-create-creates-when-buffer-missing () + "Normal: no existing buffer -> ghostel called once, launch cmd + newline +sent, the project recorded at the front of the MRU list." + (let ((name "agent [normal-create-test]") + (cj/--ai-term-mru nil)) + (test-ai-term--cleanup name) + (unwind-protect + (test-ai-term--with-mock-ghostel (:calls calls :strings strings + :default-dir ddir) + (cj/--ai-term-show-or-create "/tmp/some-project" name) + (should (equal calls (list name))) + (should (equal (reverse strings) + (list (cj/--ai-term-launch-command "/tmp/some-project") + "\n"))) + (should (equal ddir "/tmp/some-project")) + (should (equal (car cj/--ai-term-mru) "/tmp/some-project"))) + (test-ai-term--cleanup name)))) + +(ert-deftest test-ai-term--show-or-create-displays-existing-when-process-live () + "Normal: buffer exists with live process -> ghostel not called." + (let ((name "agent [reuse-test]")) + (test-ai-term--cleanup name) + (unwind-protect + (let ((buf (get-buffer-create name))) + (cl-letf (((symbol-function 'cj/--ai-term-process-live-p) + (lambda (b) (and (eq b buf) t)))) + (test-ai-term--with-mock-ghostel (:calls calls :strings strings + :default-dir _ddir) + (cj/--ai-term-show-or-create "/tmp/reuse" name) + (should (null calls)) + (should (null strings))))) + (test-ai-term--cleanup name)))) + +(ert-deftest test-ai-term--show-or-create-recreates-when-process-dead () + "Boundary: buffer exists with dead process -> killed and recreated." + (let ((name "agent [dead-test]")) + (test-ai-term--cleanup name) + (unwind-protect + (let ((stale (get-buffer-create name))) + (cl-letf (((symbol-function 'cj/--ai-term-process-live-p) + (lambda (_b) nil))) + (test-ai-term--with-mock-ghostel (:calls calls :strings strings + :default-dir _ddir) + (cj/--ai-term-show-or-create "/tmp/dead" name) + (should (equal calls (list name))) + (should (equal (reverse strings) + (list (cj/--ai-term-launch-command "/tmp/dead") + "\n"))) + (should-not (buffer-live-p stale))))) + (test-ai-term--cleanup name)))) + +(ert-deftest test-ai-term--show-or-create-preserves-selected-window () + "Regression: ghostel's same-window switch must not bury the dashboard. + +Real `ghostel' switches the selected window to its buffer as a side-effect of +construction. On a fresh-boot frame (one window showing the dashboard), that +side-effect would otherwise leave the original window pointing at the new +agent buffer. The wrapper runs `(ghostel)' inside `save-window-excursion' so +the original window state is restored before `display-buffer' fires, leaving +the dashboard put and letting the alist place agent into a fresh split. + +This test stubs `ghostel' to mimic the same-window side-effect and asserts the +originally-selected window still shows its original buffer afterward." + (let ((agent-name "agent [preserve-window-test]") + (orig-name "*test-original-buffer*")) + (test-ai-term--cleanup agent-name) + (when (get-buffer orig-name) (kill-buffer orig-name)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((orig-buf (get-buffer-create orig-name)) + (orig-win (selected-window))) + (set-window-buffer orig-win orig-buf) + (cl-letf + (((symbol-function 'ghostel) + (lambda (&optional _arg) + (let ((buf (get-buffer-create ghostel-buffer-name))) + (set-window-buffer (selected-window) buf) + buf))) + ((symbol-function 'ghostel-send-string) + (lambda (_s) nil))) + (cj/--ai-term-show-or-create "/tmp/preserve" agent-name) + (should (eq (window-buffer orig-win) orig-buf))))) + (test-ai-term--cleanup agent-name) + (when (get-buffer orig-name) (kill-buffer orig-name))))) + +(ert-deftest test-ai-term--show-or-create-returns-buffer () + "Normal: return value is the ghostel buffer named after the project." + (let ((name "agent [return-test]")) + (test-ai-term--cleanup name) + (unwind-protect + (test-ai-term--with-mock-ghostel (:calls _c :strings _s :default-dir _d) + (let ((result (cj/--ai-term-show-or-create "/tmp/return" name))) + (should (bufferp result)) + (should (equal (buffer-name result) name)))) + (test-ai-term--cleanup name)))) + +(provide 'test-ai-term--show-or-create) +;;; test-ai-term--show-or-create.el ends here diff --git a/tests/test-ai-term--single-window-toggle.el b/tests/test-ai-term--single-window-toggle.el new file mode 100644 index 00000000..aa507f03 --- /dev/null +++ b/tests/test-ai-term--single-window-toggle.el @@ -0,0 +1,186 @@ +;;; test-ai-term--single-window-toggle.el --- F9 toggle round-trip when agent is the only window -*- lexical-binding: t; -*- + +;;; Commentary: +;; Regression coverage for the bug where toggling off a single-window +;; agent (bury) then toggling on again redisplays the agent in a side +;; split instead of restoring the full-frame layout. +;; +;; The fix introduces a `cj/--ai-term-last-was-bury' flag set at +;; toggle-off when `one-window-p' was true. At toggle-on the display +;; action consumes the flag and, if the frame is still single-window, +;; replaces the current window's buffer in place rather than calling +;; `display-buffer-in-direction'. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(require 'ai-term) +(require 'testutil-ghostel-buffers) + +;;; Normal Cases + +(ert-deftest test-ai-term--single-window-toggle-normal-roundtrip-preserves-fullscreen () + "Normal: agent in the only window, F9 (off), F9 (on) -> still single window with agent. + +Reproduces Craig's report. Before the original fix the toggle-on path +fell through to `display-buffer-in-direction', which split the lone +window into two and left the agent in a side panel. Before the +follow-up fix the toggle-off path could no-op entirely when +`bury-buffer' couldn't find a buffer to switch to, so the user saw +\"F9 does nothing\". The dispatcher now forces the swap to a non- +agent buffer after bury so the toggle-off is observable in real and +batch use both." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [single-window-roundtrip]") + (cj/--ai-term-last-was-bury nil) + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((agent-buf (get-buffer-create agent-name))) + (set-window-buffer (selected-window) agent-buf) + (should (one-window-p)) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) + ;; Toggle off -- the dispatcher's force-swap should put the + ;; window on a non-agent buffer. + (cj/test--call-as-gui #'cj/ai-term) + (should (one-window-p)) + (should-not (cj/--ai-term-displayed-agent-window)) + (should (eq cj/--ai-term-last-was-bury t)) + ;; Toggle on -- should restore agent in the same lone window. + (cj/test--call-as-gui #'cj/ai-term) + (should (one-window-p)) + (let ((win (cj/--ai-term-displayed-agent-window))) + (should (windowp win)) + (should (eq (window-buffer win) agent-buf))) + ;; Flag consumed by the display-saved action. + (should-not cj/--ai-term-last-was-bury)))) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--single-window-toggle-off-swaps-window-buffer () + "Normal: toggle-off in single-window state forces the window onto a non- +agent buffer when `bury-buffer' itself didn't swap. + +Catches the regression Craig reported after the original fix shipped: +F9 in a lone-window agent did nothing visible. The fix layer here +ensures the displayed buffer changes -- so the next F9 sees an empty +agent-window state and can route through the display-saved path." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [bury-swap-observable]") + (cj/--ai-term-last-was-bury nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let* ((agent-buf (get-buffer-create agent-name)) + (win (selected-window))) + (set-window-buffer win agent-buf) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) + (cj/test--call-as-gui #'cj/ai-term)) + (should (window-live-p win)) + (should-not (cj/--ai-term-buffer-p (window-buffer win))))) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--single-window-toggle-normal-flag-set-on-bury () + "Normal: single-window toggle-off sets the bury flag." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [bury-flag-set]") + (cj/--ai-term-last-was-bury nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((agent-buf (get-buffer-create agent-name))) + (set-window-buffer (selected-window) agent-buf) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) + (cj/test--call-as-gui #'cj/ai-term) + (should (eq cj/--ai-term-last-was-bury t))))) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--single-window-toggle-normal-flag-cleared-on-multi-window-off () + "Normal: multi-window toggle-off clears the bury flag. +Mirrors the existing `delete-window' branch of the dispatcher -- +the flag should not carry over a prior bury into a delete-window +toggle-off." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [bury-flag-clear]") + (left-name "*test-sw-left*") + (cj/--ai-term-last-was-bury t)) ; stale t from prior bury + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((agent-buf (get-buffer-create agent-name)) + (left-buf (get-buffer-create left-name))) + (set-window-buffer (selected-window) left-buf) + (let* ((agent-win (split-window (selected-window) nil 'right)) + (display-buffer-alist (cj/--ai-term-display-rule-list))) + (set-window-buffer agent-win agent-buf) + (select-window agent-win) + (cj/test--call-as-gui #'cj/ai-term) + (should-not cj/--ai-term-last-was-bury)))) + (when (get-buffer left-name) (kill-buffer left-name)) + (cj/test--kill-agent-buffers)))) + +;;; Boundary Cases + +(ert-deftest test-ai-term--single-window-toggle-boundary-flag-respected-only-when-still-one-window () + "Boundary: if the frame got split between toggle-off and toggle-on, the +saved-direction split applies as usual. The flag is a fast-path for the +genuine single-window case, not an override for every redisplay." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [flag-fallback]") + (cj/--ai-term-last-was-bury t) ; flag pretends prior bury + (cj/--ai-term-last-direction 'right) + (cj/--ai-term-last-size 40)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let* ((other-buf (get-buffer-create "*test-sw-other*")) + (agent-buf (get-buffer-create agent-name))) + (set-window-buffer (selected-window) other-buf) + ;; Frame is split (two windows) -- single-window precondition + ;; for the flag no longer holds. + (split-window-right) + (should-not (one-window-p)) + (let (received-buf + (display-buffer-alist (cj/--ai-term-display-rule-list))) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (b _a) + (setq received-buf b) + (selected-window)))) + (cj/--ai-term-display-saved agent-buf nil)) + ;; The saved-direction split path ran (display-buffer-in-direction + ;; was called) rather than the in-place fast path. + (should (eq received-buf agent-buf)) + ;; And the flag is cleared either way. + (should-not cj/--ai-term-last-was-bury)))) + (when (get-buffer "*test-sw-other*") (kill-buffer "*test-sw-other*")) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--single-window-toggle-boundary-flag-not-set-when-bury-not-used () + "Boundary: a fresh dispatcher run with the agent displayed multi-window leaves +the flag nil (no spurious set)." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [bury-flag-untouched]") + (cj/--ai-term-last-was-bury nil)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((agent-buf (get-buffer-create agent-name)) + (left-buf (get-buffer-create "*test-sw-untouched-left*"))) + (set-window-buffer (selected-window) left-buf) + (let* ((agent-win (split-window (selected-window) nil 'right)) + (display-buffer-alist (cj/--ai-term-display-rule-list))) + (set-window-buffer agent-win agent-buf) + (select-window agent-win) + (cj/test--call-as-gui #'cj/ai-term) + (should-not cj/--ai-term-last-was-bury)))) + (when (get-buffer "*test-sw-untouched-left*") + (kill-buffer "*test-sw-untouched-left*")) + (cj/test--kill-agent-buffers)))) + +(provide 'test-ai-term--single-window-toggle) +;;; test-ai-term--single-window-toggle.el ends here diff --git a/tests/test-ai-term--sort-candidates.el b/tests/test-ai-term--sort-candidates.el new file mode 100644 index 00000000..f1f6155f --- /dev/null +++ b/tests/test-ai-term--sort-candidates.el @@ -0,0 +1,74 @@ +;;; test-ai-term--sort-candidates.el --- Tests for cj/--ai-term-sort-candidates -*- lexical-binding: t; -*- + +;;; 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 the active group, projects opened this +;; session (`cj/--ai-term-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: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(ert-deftest test-ai-term--sort-candidates-active-first-then-alpha () + "Normal: the one project with a live session leads; the rest go alpha." + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + (should (equal (cj/--ai-term-sort-candidates + '("/c/foo" "/c/bar" "/c/baz") + '("aiv-bar")) + '("/c/bar" "/c/baz" "/c/foo"))))) + +(ert-deftest test-ai-term--sort-candidates-multiple-active-each-group-alpha () + "Normal: both groups sort alphabetically internally." + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + (should (equal (cj/--ai-term-sort-candidates + '("/c/foo" "/c/bar" "/c/baz") + '("aiv-foo" "aiv-bar")) + '("/c/bar" "/c/foo" "/c/baz"))))) + +(ert-deftest test-ai-term--sort-candidates-no-sessions-is-plain-alpha () + "Boundary: nil session set -> a plain alphabetical list." + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + (should (equal (cj/--ai-term-sort-candidates + '("/c/foo" "/c/bar") nil) + '("/c/bar" "/c/foo"))))) + +(ert-deftest test-ai-term--sort-candidates-empty-dirs-yields-nil () + "Boundary: no candidates -> nil." + (should (null (cj/--ai-term-sort-candidates nil '("aiv-foo"))))) + +(ert-deftest test-ai-term--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-term-tmux-session-prefix "aiv-") + (cj/--ai-term-mru '("/c/baz" "/c/foo"))) + (should (equal (cj/--ai-term-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-term--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-term-tmux-session-prefix "aiv-") + (cj/--ai-term-mru '("/c/zed"))) + (should (equal (cj/--ai-term-sort-candidates + '("/c/foo" "/c/zed" "/c/bar") + '("aiv-foo")) + '("/c/foo" "/c/bar" "/c/zed"))))) + +(ert-deftest test-ai-term--session-active-p-matches-by-derived-name () + "Normal: a dir is active when its derived session name is in the set." + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + (should (cj/--ai-term-session-active-p "/c/foo" '("aiv-bar" "aiv-foo"))) + (should-not (cj/--ai-term-session-active-p "/c/qux" '("aiv-bar" "aiv-foo"))) + (should-not (cj/--ai-term-session-active-p "/c/foo" nil)))) + +(provide 'test-ai-term--sort-candidates) +;;; test-ai-term--sort-candidates.el ends here diff --git a/tests/test-ai-term--tmux-session-name.el b/tests/test-ai-term--tmux-session-name.el new file mode 100644 index 00000000..db8e836f --- /dev/null +++ b/tests/test-ai-term--tmux-session-name.el @@ -0,0 +1,65 @@ +;;; test-ai-term--tmux-session-name.el --- Tests for cj/--ai-term-tmux-session-name -*- lexical-binding: t; -*- + +;;; Commentary: +;; The tmux session name is `cj/ai-term-tmux-session-prefix' followed by +;; 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-term'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: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(ert-deftest test-ai-term--tmux-session-name-normal-project () + "Normal: basename gets the configured prefix." + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + (should (equal (cj/--ai-term-tmux-session-name "/home/cjennings/projects/foo") + "aiv-foo")))) + +(ert-deftest test-ai-term--tmux-session-name-trailing-slash () + "Boundary: trailing slash collapses before basename extraction." + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + (should (equal (cj/--ai-term-tmux-session-name "/home/cjennings/projects/foo/") + "aiv-foo")))) + +(ert-deftest test-ai-term--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-term-tmux-session-prefix "aiv-")) + (should (equal (cj/--ai-term-tmux-session-name "/home/cjennings/.emacs.d") + "aiv-_emacs_d")))) + +(ert-deftest test-ai-term--tmux-session-name-colon-becomes-underscore () + "Boundary: `:' is also disallowed by tmux in session names -> `_'." + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + (should (equal (cj/--ai-term-tmux-session-name "/tmp/a:b") + "aiv-a_b")))) + +(ert-deftest test-ai-term--tmux-session-name-space-becomes-hyphen () + "Boundary: a space in the basename is replaced with a hyphen." + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + (should (equal (cj/--ai-term-tmux-session-name "/tmp/my work") + "aiv-my-work")))) + +(ert-deftest test-ai-term--tmux-session-name-multiple-spaces-collapse () + "Boundary: a run of whitespace collapses to a single hyphen." + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + (should (equal (cj/--ai-term-tmux-session-name "/tmp/a b\tc") + "aiv-a-b-c")))) + +(ert-deftest test-ai-term--tmux-session-name-honors-custom-prefix () + "Normal: a non-default prefix is what gets prepended." + (let ((cj/ai-term-tmux-session-prefix "em-")) + (should (equal (cj/--ai-term-tmux-session-name "/home/cjennings/projects/foo") + "em-foo")))) + +(provide 'test-ai-term--tmux-session-name) +;;; test-ai-term--tmux-session-name.el ends here diff --git a/tests/test-ai-vterm--agent-buffers.el b/tests/test-ai-vterm--agent-buffers.el deleted file mode 100644 index 57d01730..00000000 --- a/tests/test-ai-vterm--agent-buffers.el +++ /dev/null @@ -1,59 +0,0 @@ -;;; test-ai-vterm--agent-buffers.el --- Tests for cj/--ai-vterm-agent-buffers -*- lexical-binding: t; -*- - -;;; Commentary: -;; The helper returns the list of buffers whose names start with the -;; literal prefix "agent [". Order is the same order `buffer-list' -;; gives them (most-recently-selected first). Non-agent buffers and -;; buffers whose names merely contain the prefix as a substring are -;; excluded. - -;;; Code: - -(require 'ert) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(require 'ai-vterm) -(require 'testutil-vterm-buffers) - -(ert-deftest test-ai-vterm--agent-buffers-empty-when-none-exist () - "Boundary: no agent-prefixed buffers anywhere -> empty list." - (cj/test--kill-agent-buffers) - (unwind-protect - (should (null (cj/--ai-vterm-agent-buffers))) - (cj/test--kill-agent-buffers))) - -(ert-deftest test-ai-vterm--agent-buffers-returns-only-agent-buffers () - "Normal: filters to only agent-prefixed buffers, leaves others alone." - (cj/test--kill-agent-buffers) - (let ((c1 (get-buffer-create "agent [a]")) - (c2 (get-buffer-create "agent [b]")) - (other (get-buffer-create "regular-buffer"))) - (unwind-protect - (let ((result (cj/--ai-vterm-agent-buffers))) - (should (memq c1 result)) - (should (memq c2 result)) - (should-not (memq other result)) - (should (= (length result) 2))) - (kill-buffer c1) - (kill-buffer c2) - (kill-buffer other)))) - -(ert-deftest test-ai-vterm--agent-buffers-anchors-prefix-not-substring () - "Boundary: 'foo agent [bar]' is not an agent buffer -- prefix anchored." - (cj/test--kill-agent-buffers) - (let ((not-agent (get-buffer-create "foo agent [bar]"))) - (unwind-protect - (should-not (memq not-agent (cj/--ai-vterm-agent-buffers))) - (kill-buffer not-agent)))) - -(ert-deftest test-ai-vterm--agent-buffers-bare-agent-not-included () - "Boundary: 'agent' alone (no bracket) doesn't match the 'agent [' prefix." - (cj/test--kill-agent-buffers) - (let ((bare (get-buffer-create "agent"))) - (unwind-protect - (should-not (memq bare (cj/--ai-vterm-agent-buffers))) - (kill-buffer bare)))) - -(provide 'test-ai-vterm--agent-buffers) -;;; test-ai-vterm--agent-buffers.el ends here diff --git a/tests/test-ai-vterm--buffer-name.el b/tests/test-ai-vterm--buffer-name.el deleted file mode 100644 index 2ebe91ee..00000000 --- a/tests/test-ai-vterm--buffer-name.el +++ /dev/null @@ -1,42 +0,0 @@ -;;; test-ai-vterm--buffer-name.el --- Tests for cj/--ai-vterm-buffer-name -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for the buffer-name transform. Given an absolute project -;; directory, the helper returns "agent []". The naming pattern -;; is what the display-buffer-alist rule keys on, so a regression here -;; silently breaks routing to the right side-window. - -;;; Code: - -(require 'ert) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) - -(ert-deftest test-ai-vterm--buffer-name-normal-project () - "Normal: a typical project path yields agent []." - (should (equal (cj/--ai-vterm-buffer-name "/home/cjennings/projects/foo") - "agent [foo]"))) - -(ert-deftest test-ai-vterm--buffer-name-trailing-slash () - "Boundary: trailing slash collapses before basename extraction." - (should (equal (cj/--ai-vterm-buffer-name "/home/cjennings/projects/foo/") - "agent [foo]"))) - -(ert-deftest test-ai-vterm--buffer-name-dot-prefix-dir () - "Boundary: dot-prefix dirs (.emacs.d) preserve the dot in the basename." - (should (equal (cj/--ai-vterm-buffer-name "/home/cjennings/.emacs.d") - "agent [.emacs.d]"))) - -(ert-deftest test-ai-vterm--buffer-name-space-in-basename () - "Boundary: a space in the basename round-trips into the buffer name." - (should (equal (cj/--ai-vterm-buffer-name "/tmp/my work") - "agent [my work]"))) - -(ert-deftest test-ai-vterm--buffer-name-deeply-nested () - "Normal: only the last path component is used." - (should (equal (cj/--ai-vterm-buffer-name "/a/b/c/d/e/leaf") - "agent [leaf]"))) - -(provide 'test-ai-vterm--buffer-name) -;;; test-ai-vterm--buffer-name.el ends here diff --git a/tests/test-ai-vterm--candidates.el b/tests/test-ai-vterm--candidates.el deleted file mode 100644 index be9041ce..00000000 --- a/tests/test-ai-vterm--candidates.el +++ /dev/null @@ -1,139 +0,0 @@ -;;; test-ai-vterm--candidates.el --- Tests for cj/--ai-vterm-candidates -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests for the project-candidate walker. Two kinds of search root: -;; -;; - project root (a single project dir, e.g. ~/.emacs.d) -- include if it -;; itself contains .ai/protocols.org -;; - container root (e.g. ~/code, ~/projects) -- scan immediate children; -;; include each child that contains .ai/protocols.org -;; -;; Tests build a temp directory tree with fake .ai/protocols.org markers -;; and let-bind the search-root customs at it. - -;;; Code: - -(require 'ert) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) - -(defun test-ai-vterm--make-marker (dir) - "Create DIR/.ai/protocols.org so DIR registers as an AI-agent project." - (let ((ai-dir (expand-file-name ".ai" dir))) - (make-directory ai-dir t) - (write-region "" nil (expand-file-name "protocols.org" ai-dir)))) - -(defmacro test-ai-vterm--with-fixture (root &rest body) - "Bind ROOT to a fresh temp directory; remove on exit; run BODY." - (declare (indent 1) (debug t)) - `(let ((,root (make-temp-file "ai-vterm-test-" t))) - (unwind-protect - (progn ,@body) - (delete-directory ,root t)))) - -(ert-deftest test-ai-vterm--candidates-project-root-with-marker () - "Normal: a project root containing .ai/protocols.org is included." - (test-ai-vterm--with-fixture root - (let ((proj (expand-file-name "emacs-d-fake" root))) - (make-directory proj) - (test-ai-vterm--make-marker proj) - (let ((cj/ai-vterm-project-roots (list proj)) - (cj/ai-vterm-container-roots nil)) - (should (equal (cj/--ai-vterm-candidates) - (list (expand-file-name proj)))))))) - -(ert-deftest test-ai-vterm--candidates-project-root-without-marker () - "Boundary: a project root without .ai/protocols.org is excluded." - (test-ai-vterm--with-fixture root - (let ((proj (expand-file-name "no-ai" root))) - (make-directory proj) - (let ((cj/ai-vterm-project-roots (list proj)) - (cj/ai-vterm-container-roots nil)) - (should (null (cj/--ai-vterm-candidates))))))) - -(ert-deftest test-ai-vterm--candidates-container-includes-children-with-marker () - "Normal: a container's children with .ai/protocols.org are included." - (test-ai-vterm--with-fixture root - (let ((container (expand-file-name "code" root)) - (foo (expand-file-name "code/foo" root)) - (bar (expand-file-name "code/bar" root))) - (make-directory container) - (make-directory foo) - (make-directory bar) - (test-ai-vterm--make-marker foo) - (test-ai-vterm--make-marker bar) - (let* ((cj/ai-vterm-project-roots nil) - (cj/ai-vterm-container-roots (list container)) - (got (sort (cj/--ai-vterm-candidates) #'string<))) - (should (equal got - (sort (list (expand-file-name foo) - (expand-file-name bar)) - #'string<))))))) - -(ert-deftest test-ai-vterm--candidates-container-skips-children-without-marker () - "Boundary: a container's children without .ai/protocols.org are skipped." - (test-ai-vterm--with-fixture root - (let ((container (expand-file-name "code" root)) - (foo (expand-file-name "code/foo" root)) - (bare (expand-file-name "code/bare" root))) - (make-directory container) - (make-directory foo) - (make-directory bare) - (test-ai-vterm--make-marker foo) - (let ((cj/ai-vterm-project-roots nil) - (cj/ai-vterm-container-roots (list container))) - (should (equal (cj/--ai-vterm-candidates) - (list (expand-file-name foo)))))))) - -(ert-deftest test-ai-vterm--candidates-container-skips-non-directory-entries () - "Boundary: a container's non-directory entries are ignored." - (test-ai-vterm--with-fixture root - (let ((container (expand-file-name "code" root)) - (foo (expand-file-name "code/foo" root)) - (stray (expand-file-name "code/README.txt" root))) - (make-directory container) - (make-directory foo) - (test-ai-vterm--make-marker foo) - (write-region "" nil stray) - (let ((cj/ai-vterm-project-roots nil) - (cj/ai-vterm-container-roots (list container))) - (should (equal (cj/--ai-vterm-candidates) - (list (expand-file-name foo)))))))) - -(ert-deftest test-ai-vterm--candidates-nonexistent-root-is-skipped () - "Error: a nonexistent search root is skipped silently, no error raised." - (test-ai-vterm--with-fixture root - (let ((cj/ai-vterm-project-roots - (list (expand-file-name "does-not-exist" root))) - (cj/ai-vterm-container-roots - (list (expand-file-name "also-missing" root)))) - (should (null (cj/--ai-vterm-candidates)))))) - -(ert-deftest test-ai-vterm--candidates-empty-roots-yield-empty-list () - "Boundary: nil roots yield nil." - (let ((cj/ai-vterm-project-roots nil) - (cj/ai-vterm-container-roots nil)) - (should (null (cj/--ai-vterm-candidates))))) - -(ert-deftest test-ai-vterm--candidates-mixed-roots () - "Normal: project + container roots combine in one result list." - (test-ai-vterm--with-fixture root - (let ((emacs-d (expand-file-name "emacs-d" root)) - (container (expand-file-name "code" root)) - (foo (expand-file-name "code/foo" root))) - (make-directory emacs-d) - (make-directory container) - (make-directory foo) - (test-ai-vterm--make-marker emacs-d) - (test-ai-vterm--make-marker foo) - (let* ((cj/ai-vterm-project-roots (list emacs-d)) - (cj/ai-vterm-container-roots (list container)) - (got (sort (cj/--ai-vterm-candidates) #'string<))) - (should (equal got - (sort (list (expand-file-name emacs-d) - (expand-file-name foo)) - #'string<))))))) - -(provide 'test-ai-vterm--candidates) -;;; test-ai-vterm--candidates.el ends here diff --git a/tests/test-ai-vterm--capture-state.el b/tests/test-ai-vterm--capture-state.el deleted file mode 100644 index 88a7784b..00000000 --- a/tests/test-ai-vterm--capture-state.el +++ /dev/null @@ -1,63 +0,0 @@ -;;; test-ai-vterm--capture-state.el --- Tests for cj/--ai-vterm-capture-state -*- lexical-binding: t; -*- - -;;; Commentary: -;; The capture helper writes WINDOW's direction and size to module- -;; level state vars `cj/--ai-vterm-last-direction' and -;; `cj/--ai-vterm-last-size'. Called from `cj/ai-vterm''s toggle-off -;; branch so the next F9 display can restore the user's chosen -;; orientation and size. No-op on a dead window. - -;;; Code: - -(require 'ert) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) - -(ert-deftest test-ai-vterm--capture-state-right-split-sets-direction () - "Normal: right-split window -> direction=right, integer body-cols matching window." - (save-window-excursion - (delete-other-windows) - (let ((right (split-window (selected-window) nil 'right)) - (cj/--ai-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil)) - (cj/--ai-vterm-capture-state right) - (should (eq cj/--ai-vterm-last-direction 'right)) - (should (integerp cj/--ai-vterm-last-size)) - (should (= cj/--ai-vterm-last-size (window-body-width right)))))) - -(ert-deftest test-ai-vterm--capture-state-below-split-sets-direction () - "Normal: below-split window -> direction=below, integer body-lines matching window." - (save-window-excursion - (delete-other-windows) - (let ((below (split-window (selected-window) nil 'below)) - (cj/--ai-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil)) - (cj/--ai-vterm-capture-state below) - (should (eq cj/--ai-vterm-last-direction 'below)) - (should (integerp cj/--ai-vterm-last-size)) - (should (= cj/--ai-vterm-last-size (window-body-height below)))))) - -(ert-deftest test-ai-vterm--capture-state-noop-on-dead-window () - "Boundary: nil window -> state remains unchanged." - (let ((cj/--ai-vterm-last-direction 'sentinel-dir) - (cj/--ai-vterm-last-size 0.123)) - (cj/--ai-vterm-capture-state nil) - (should (eq cj/--ai-vterm-last-direction 'sentinel-dir)) - (should (= cj/--ai-vterm-last-size 0.123)))) - -(ert-deftest test-ai-vterm--capture-state-noop-on-deleted-window () - "Boundary: deleted window -> state remains unchanged." - (let ((cj/--ai-vterm-last-direction 'sentinel-dir) - (cj/--ai-vterm-last-size 0.123) - (dead-win (save-window-excursion - (delete-other-windows) - (let ((w (split-window (selected-window) nil 'right))) - (delete-window w) - w)))) - (cj/--ai-vterm-capture-state dead-win) - (should (eq cj/--ai-vterm-last-direction 'sentinel-dir)) - (should (= cj/--ai-vterm-last-size 0.123)))) - -(provide 'test-ai-vterm--capture-state) -;;; test-ai-vterm--capture-state.el ends here diff --git a/tests/test-ai-vterm--close.el b/tests/test-ai-vterm--close.el deleted file mode 100644 index eb89bcc2..00000000 --- a/tests/test-ai-vterm--close.el +++ /dev/null @@ -1,86 +0,0 @@ -;;; test-ai-vterm--close.el --- Tests for graceful agent close -*- lexical-binding: t; -*- - -;;; Commentary: -;; `cj/ai-vterm-close' tears an agent down gracefully: kill its tmux -;; session (stopping the agent process), kill the vterm buffer, and -;; remove its window. These tests cover the pure pieces -- the -;; tmux-kill helper, the per-buffer teardown, and the target selection -- -;; with `process-file' and the prompt mocked at the boundary. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) - -(ert-deftest test-ai-vterm--kill-tmux-session-runs-kill-session () - "Normal: invokes `tmux kill-session -t '." - (let (captured) - (cl-letf (((symbol-function 'process-file) - (lambda (program &rest args) - (setq captured (cons program args)) - 0))) - (cj/--ai-vterm-kill-tmux-session "aiv-foo")) - (should (equal (car captured) "tmux")) - (should (member "kill-session" captured)) - (should (member "-t" captured)) - (should (member "aiv-foo" captured)))) - -(ert-deftest test-ai-vterm--kill-tmux-session-swallows-error () - "Error: returns nil when tmux is unavailable (process-file signals)." - (cl-letf (((symbol-function 'process-file) - (lambda (&rest _) (error "no tmux")))) - (should (null (cj/--ai-vterm-kill-tmux-session "aiv-foo"))))) - -(ert-deftest test-ai-vterm--close-buffer-kills-session-and-buffer () - "Normal: derives the session from default-directory, kills it and the buffer." - (let ((buf (get-buffer-create "agent [foo]")) - captured-session) - (with-current-buffer buf (setq-local default-directory "/tmp/foo/")) - (cl-letf (((symbol-function 'cj/--ai-vterm-kill-tmux-session) - (lambda (s) (setq captured-session s) 0))) - (cj/--ai-vterm-close-buffer buf)) - (should (equal captured-session "aiv-foo")) - (should-not (buffer-live-p buf)))) - -(ert-deftest test-ai-vterm--close-buffer-noop-on-non-agent () - "Boundary: does nothing for a buffer that is not an agent buffer." - (let ((buf (get-buffer-create "*not-an-agent*")) - (called nil)) - (unwind-protect - (progn - (cl-letf (((symbol-function 'cj/--ai-vterm-kill-tmux-session) - (lambda (_s) (setq called t) 0))) - (cj/--ai-vterm-close-buffer buf)) - (should-not called) - (should (buffer-live-p buf))) - (when (buffer-live-p buf) (kill-buffer buf))))) - -(ert-deftest test-ai-vterm--close-target-current-agent-buffer () - "Normal: returns the current buffer when it is an agent buffer." - (let ((buf (get-buffer-create "agent [cur]"))) - (unwind-protect - (with-current-buffer buf - (should (eq (cj/--ai-vterm-close-target) buf))) - (kill-buffer buf)))) - -(ert-deftest test-ai-vterm--close-target-sole-agent () - "Normal: returns the only live agent buffer when current isn't an agent." - (let ((buf (get-buffer-create "agent [only]"))) - (unwind-protect - (with-temp-buffer - (cl-letf (((symbol-function 'cj/--ai-vterm-agent-buffers) - (lambda () (list buf)))) - (should (eq (cj/--ai-vterm-close-target) buf)))) - (kill-buffer buf)))) - -(ert-deftest test-ai-vterm--close-target-none-returns-nil () - "Boundary: nil when current buffer isn't an agent and none are alive." - (with-temp-buffer - (cl-letf (((symbol-function 'cj/--ai-vterm-agent-buffers) (lambda () nil))) - (should (null (cj/--ai-vterm-close-target)))))) - -(provide 'test-ai-vterm--close) -;;; test-ai-vterm--close.el ends here diff --git a/tests/test-ai-vterm--collapse-split.el b/tests/test-ai-vterm--collapse-split.el deleted file mode 100644 index ad299e47..00000000 --- a/tests/test-ai-vterm--collapse-split.el +++ /dev/null @@ -1,171 +0,0 @@ -;;; test-ai-vterm--collapse-split.el --- F9 collapses the agent split -*- lexical-binding: t; -*- - -;;; Commentary: -;; Regression coverage for the F9 toggle-off behavior Craig reported: with -;; several agents alive, F9 should HIDE the agent split (collapse it back to the -;; working layout) rather than surfacing a different agent. Two cases: -;; -;; - Multi-window: the agent occupies a split. F9 deletes that window so the -;; working buffer reclaims the frame -- never swaps in another agent. The -;; prior `quit-restore-window' path went stale after the slot was reused -;; across agents (C-F9 switching), so it surfaced a different agent. -;; - Single-window: the agent fills the frame. F9 returns to the most-recent -;; NON-agent buffer (the file being worked on), not another agent -- the prior -;; `other-buffer' call could pick another live agent. -;; -;; Also covers the `cj/--ai-vterm-most-recent-non-agent-buffer' helper. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(require 'ai-vterm) -(require 'testutil-vterm-buffers) - -;;; cj/--ai-vterm-most-recent-non-agent-buffer - -(ert-deftest test-ai-vterm--most-recent-non-agent-buffer-skips-agents () - "Normal: returns a live non-agent buffer even when agents are most-recent." - (cj/test--kill-agent-buffers) - (let ((work (get-buffer-create "*test-mrna-work*")) - (agent-a (get-buffer-create "agent [mrna-a]")) - (agent-b (get-buffer-create "agent [mrna-b]"))) - (unwind-protect - (save-window-excursion - (delete-other-windows) - ;; Make the agents most-recent in this window's history. - (set-window-buffer (selected-window) work) - (set-window-buffer (selected-window) agent-b) - (set-window-buffer (selected-window) agent-a) - (let ((result (cj/--ai-vterm-most-recent-non-agent-buffer))) - (should (bufferp result)) - (should (buffer-live-p result)) - (should-not (cj/--ai-vterm-buffer-p result)))) - (when (get-buffer "*test-mrna-work*") (kill-buffer "*test-mrna-work*")) - (cj/test--kill-agent-buffers)))) - -;;; Multi-window: F9 collapses the split - -(ert-deftest test-ai-vterm--collapse-multi-window-deletes-agent-split () - "Normal/Regression: agent in a bottom split with other agents alive; F9 -collapses the split so the working buffer reclaims the frame, and no agent is -surfaced. Before the fix, `quit-restore-window' could switch the slot to a -different agent (stale quit-restore after slot reuse)." - (cj/test--kill-agent-buffers) - (let ((work (get-buffer-create "*test-collapse-work*")) - (agent-a (get-buffer-create "agent [collapse-a]")) - (agent-b (get-buffer-create "agent [collapse-b]")) - (agent-c (get-buffer-create "agent [collapse-c]")) - (cj/--ai-vterm-last-was-bury nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (set-window-buffer (selected-window) work) - (let ((agent-win (split-window (selected-window) nil 'below))) - ;; Reuse the slot across agents (as C-F9 switching does) so the - ;; window's prev-buffer history holds another agent. - (set-window-buffer agent-win agent-a) - (set-window-buffer agent-win agent-b) - (set-window-buffer agent-win agent-c) - (select-window agent-win) - (should-not (one-window-p)) - (cj/test--call-as-gui #'cj/ai-vterm) - (should (one-window-p)) - (should-not (cj/--ai-vterm-displayed-agent-window)) - (should (eq (window-buffer (selected-window)) work)))) - (when (get-buffer "*test-collapse-work*") (kill-buffer "*test-collapse-work*")) - (cj/test--kill-agent-buffers)))) - -;;; Single-window: F9 returns to a non-agent buffer - -(ert-deftest test-ai-vterm--collapse-single-window-returns-non-agent () - "Normal/Regression: agent fills the frame, other agents alive; F9 toggles back -to a NON-agent buffer (the working file), never another agent. Before the fix, -`other-buffer' could pick another live agent." - (cj/test--kill-agent-buffers) - (let ((work (get-buffer-create "*test-collapse-sw-work*")) - (agent-a (get-buffer-create "agent [collapse-sw-a]")) - (agent-b (get-buffer-create "agent [collapse-sw-b]")) - (cj/--ai-vterm-last-was-bury nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - ;; MRU: work, then agent-b, then agent-a (current). `other-buffer' - ;; would pick agent-b; the fix must skip it for a non-agent. - (set-window-buffer (selected-window) work) - (set-window-buffer (selected-window) agent-b) - (set-window-buffer (selected-window) agent-a) - (should (one-window-p)) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - (cj/test--call-as-gui #'cj/ai-vterm)) - (should (one-window-p)) - (should-not (cj/--ai-vterm-buffer-p (window-buffer (selected-window))))) - (when (get-buffer "*test-collapse-sw-work*") (kill-buffer "*test-collapse-sw-work*")) - (cj/test--kill-agent-buffers)))) - -;;; Faithful toggle: reopen the SAME agent that was hidden - -(ert-deftest test-ai-vterm--dispatch-prefers-last-hidden-agent () - "Regression: dispatch reopens the last-hidden agent, not the buffer-list MRU. -After F9 hides an agent, the next F9 must reopen the SAME one even when a -different agent is ahead of it in `buffer-list'. Falls back to the MRU when -nothing was hidden yet or the remembered buffer was killed." - (cj/test--kill-agent-buffers) - (let ((a1 (get-buffer-create "agent [disp-mru]")) - (a2 (get-buffer-create "agent [disp-shown]")) - (cj/--ai-vterm-last-hidden-buffer nil)) - (unwind-protect - (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-agent-window) - (lambda (&optional _f) nil)) - ((symbol-function 'cj/--ai-vterm-agent-buffers) - (lambda () (list a1 a2)))) ; a1 is the MRU - ;; No memory yet -> falls back to MRU (a1). - (should (equal (cj/--ai-vterm-dispatch) (cons 'redisplay-recent a1))) - ;; Remember a2 as last hidden -> dispatch prefers it. - (setq cj/--ai-vterm-last-hidden-buffer a2) - (should (equal (cj/--ai-vterm-dispatch) (cons 'redisplay-recent a2))) - ;; A killed last-hidden buffer -> falls back to MRU. - (let ((dead (get-buffer-create "agent [disp-dead]"))) - (setq cj/--ai-vterm-last-hidden-buffer dead) - (kill-buffer dead)) - (should (equal (cj/--ai-vterm-dispatch) (cons 'redisplay-recent a1)))) - (cj/test--kill-agent-buffers)))) - -(ert-deftest test-ai-vterm--toggle-roundtrip-reopens-same-agent () - "Regression: hide then show brings back the agent that was on screen. -With several agents alive and a different one most-recent in `buffer-list', -F9 off then F9 on restores the SAME agent that was visible -- not a swap to -another. Reproduces the \"the displayed buffer changes\" report." - (cj/test--kill-agent-buffers) - (let ((work (get-buffer-create "*test-roundtrip-work*")) - (a1 (get-buffer-create "agent [rt-1]")) - (a2 (get-buffer-create "agent [rt-2]")) - (cj/--ai-vterm-last-was-bury nil) - (cj/--ai-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil) - (cj/--ai-vterm-last-hidden-buffer nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (set-window-buffer (selected-window) work) - (let ((agent-win (split-window (selected-window) nil 'below))) - ;; a2 is the visible agent; a1 sits ahead of it in buffer-list. - (set-window-buffer agent-win a1) - (bury-buffer a1) ; a1 stays alive, demoted in MRU - (set-window-buffer agent-win a2) - (select-window agent-win) - (should (eq (window-buffer (cj/--ai-vterm-displayed-agent-window)) a2)) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - (cj/test--call-as-gui #'cj/ai-vterm) ; off - (should-not (cj/--ai-vterm-displayed-agent-window)) - (cj/test--call-as-gui #'cj/ai-vterm) ; on -> must be a2 - (should (eq (window-buffer (cj/--ai-vterm-displayed-agent-window)) - a2))))) - (when (get-buffer "*test-roundtrip-work*") (kill-buffer "*test-roundtrip-work*")) - (cj/test--kill-agent-buffers)))) - -(provide 'test-ai-vterm--collapse-split) -;;; test-ai-vterm--collapse-split.el ends here diff --git a/tests/test-ai-vterm--default-geometry.el b/tests/test-ai-vterm--default-geometry.el deleted file mode 100644 index f8ec08c9..00000000 --- a/tests/test-ai-vterm--default-geometry.el +++ /dev/null @@ -1,56 +0,0 @@ -;;; test-ai-vterm--default-geometry.el --- Tests for host-aware display defaults -*- lexical-binding: t; -*- - -;;; Commentary: -;; ai-vterm's default display geometry is host-aware: a laptop opens the -;; agent from the bottom (75% height), a desktop opens it from the right -;; (50% width). `cj/--ai-vterm-default-direction' and -;; `cj/--ai-vterm-default-size' encapsulate the `env-laptop-p' branch; -;; they feed the default fallbacks in `cj/--ai-vterm-capture-state' and -;; `cj/--ai-vterm-display-saved'. -;; -;; `env-laptop-p' is stubbed per-test so the assertions are deterministic -;; regardless of the host the suite runs on. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) - -(ert-deftest test-ai-vterm--default-direction-laptop () - "Normal: on a laptop the default direction is `below'." - (cl-letf (((symbol-function 'env-laptop-p) (lambda () t))) - (should (eq (cj/--ai-vterm-default-direction) 'below)))) - -(ert-deftest test-ai-vterm--default-direction-desktop () - "Normal: on a desktop the default direction is `right'." - (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) - (should (eq (cj/--ai-vterm-default-direction) 'right)))) - -(ert-deftest test-ai-vterm--default-size-laptop () - "Normal: on a laptop the default size is `cj/ai-vterm-laptop-height'." - (let ((cj/ai-vterm-laptop-height 0.75) - (cj/ai-vterm-desktop-width 0.5)) - (cl-letf (((symbol-function 'env-laptop-p) (lambda () t))) - (should (= (cj/--ai-vterm-default-size) 0.75))))) - -(ert-deftest test-ai-vterm--default-size-desktop () - "Normal: on a desktop the default size is `cj/ai-vterm-desktop-width'." - (let ((cj/ai-vterm-laptop-height 0.75) - (cj/ai-vterm-desktop-width 0.5)) - (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) - (should (= (cj/--ai-vterm-default-size) 0.5))))) - -(ert-deftest test-ai-vterm--default-size-respects-custom-values () - "Boundary: the helper returns the customized values, not the literals." - (let ((cj/ai-vterm-laptop-height 0.6) - (cj/ai-vterm-desktop-width 0.33)) - (cl-letf (((symbol-function 'env-laptop-p) (lambda () t))) - (should (= (cj/--ai-vterm-default-size) 0.6))) - (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) - (should (= (cj/--ai-vterm-default-size) 0.33))))) - -(provide 'test-ai-vterm--default-geometry) -;;; test-ai-vterm--default-geometry.el ends here diff --git a/tests/test-ai-vterm--dispatch.el b/tests/test-ai-vterm--dispatch.el deleted file mode 100644 index 94b02123..00000000 --- a/tests/test-ai-vterm--dispatch.el +++ /dev/null @@ -1,70 +0,0 @@ -;;; test-ai-vterm--dispatch.el --- Tests for cj/--ai-vterm-dispatch -*- lexical-binding: t; -*- - -;;; Commentary: -;; The dispatch helper is a pure decision function used by F9. -;; Returns one of (toggle-off . WIN), (redisplay-recent . BUF), -;; or (pick-project) based on whether an agent buffer is currently -;; displayed and whether any alive agent buffers exist. Tests mock -;; the two underlying helpers so the dispatch logic can be exercised -;; without touching real windows. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(require 'ai-vterm) -(require 'testutil-vterm-buffers) - -(ert-deftest test-ai-vterm--dispatch-window-displayed-returns-toggle-off () - "Normal: displayed agent window -> (toggle-off . WIN)." - (let ((sentinel-win 'fake-window)) - (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-agent-window) - (lambda (&optional _frame) sentinel-win))) - (should (equal (cj/--ai-vterm-dispatch) - (cons 'toggle-off sentinel-win)))))) - -(ert-deftest test-ai-vterm--dispatch-no-window-single-buffer-returns-redisplay-recent () - "Normal: no displayed agent, one alive buffer -> redisplay-recent + buffer." - (cj/test--kill-agent-buffers) - (let ((b1 (get-buffer-create "agent [single]"))) - (unwind-protect - (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-agent-window) - (lambda (&optional _frame) nil)) - ((symbol-function 'cj/--ai-vterm-agent-buffers) - (lambda () (list b1)))) - (should (equal (cj/--ai-vterm-dispatch) - (cons 'redisplay-recent b1)))) - (kill-buffer b1)))) - -(ert-deftest test-ai-vterm--dispatch-no-window-multiple-buffers-returns-redisplay-recent () - "Normal: no displayed agent, 2+ alive buffers -> redisplay-recent + MRU. -F9 redisplays the most-recently-selected agent (head of buffer-list -order) rather than opening the project picker, so the user toggles -THE agent they were last using. Other agents are reachable via M-F9." - (cj/test--kill-agent-buffers) - (let ((b1 (get-buffer-create "agent [a]")) - (b2 (get-buffer-create "agent [b]"))) - (unwind-protect - (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-agent-window) - (lambda (&optional _frame) nil)) - ((symbol-function 'cj/--ai-vterm-agent-buffers) - (lambda () (list b1 b2)))) - (should (equal (cj/--ai-vterm-dispatch) - (cons 'redisplay-recent b1)))) - (kill-buffer b1) - (kill-buffer b2)))) - -(ert-deftest test-ai-vterm--dispatch-no-window-zero-buffers-returns-pick-project () - "Boundary: no displayed agent, zero alive buffers -> pick-project." - (cj/test--kill-agent-buffers) - (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-agent-window) - (lambda (&optional _frame) nil)) - ((symbol-function 'cj/--ai-vterm-agent-buffers) - (lambda () nil))) - (should (equal (cj/--ai-vterm-dispatch) '(pick-project))))) - -(provide 'test-ai-vterm--dispatch) -;;; test-ai-vterm--dispatch.el ends here diff --git a/tests/test-ai-vterm--display-rule.el b/tests/test-ai-vterm--display-rule.el deleted file mode 100644 index 9b70134a..00000000 --- a/tests/test-ai-vterm--display-rule.el +++ /dev/null @@ -1,78 +0,0 @@ -;;; test-ai-vterm--display-rule.el --- Tests for the AI-vterm display-buffer rule -*- lexical-binding: t; -*- - -;;; Commentary: -;; The module installs a `display-buffer-alist' entry routing buffers -;; whose names match "\\`agent \\[" to a right-side window. These -;; tests verify the rule reaches the right side and ignores buffers -;; that don't match the prefix. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) - -(defun test-ai-vterm--cleanup (name) - "Kill buffer NAME if it exists." - (when (get-buffer name) - (kill-buffer name))) - -(defmacro test-ai-vterm--with-clean-frame (&rest body) - "Run BODY in a context with one window and the AI-vterm rule loaded." - (declare (indent 0) (debug t)) - `(save-window-excursion - (delete-other-windows) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - ,@body))) - -(ert-deftest test-ai-vterm--display-rule-routes-agent-buffer-to-right () - "Normal: on a desktop, \"agent [foo]\" lands in a window to the right. - -The desktop default direction is `right' (see -`cj/--ai-vterm-default-direction'), so the rule splits the current -window with `(direction . right)' and the new window's left edge -sits at a positive column. `env-laptop-p' is stubbed nil to pin the -desktop branch; on a laptop the agent would land below instead." - (let ((name "agent [display-rule-test]")) - (test-ai-vterm--cleanup name) - (unwind-protect - (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) - (test-ai-vterm--with-clean-frame - (let* ((buf (get-buffer-create name)) - (win (display-buffer buf))) - (should (windowp win)) - (should (> (window-left-column win) 0))))) - (test-ai-vterm--cleanup name)))) - -(ert-deftest test-ai-vterm--display-rule-skips-non-matching-buffer () - "Boundary: a buffer not named \"agent [...]\" does not match the rule. - -The rule's regex doesn't fire, so `display-buffer' falls back to the -default action -- reuse the current window -- and no rightward split -occurs." - (let ((name "scratch-buffer-no-match")) - (test-ai-vterm--cleanup name) - (unwind-protect - (test-ai-vterm--with-clean-frame - (let* ((buf (get-buffer-create name)) - (win (display-buffer buf))) - (should (windowp win)) - (should (= (window-left-column win) 0)))) - (test-ai-vterm--cleanup name)))) - -(ert-deftest test-ai-vterm--display-rule-prefix-not-substring () - "Boundary: \"foo agent [bar]\" does not match -- the rule anchors at start." - (let ((name "foo agent [substring-test]")) - (test-ai-vterm--cleanup name) - (unwind-protect - (test-ai-vterm--with-clean-frame - (let* ((buf (get-buffer-create name)) - (win (display-buffer buf))) - (should (windowp win)) - (should (= (window-left-column win) 0)))) - (test-ai-vterm--cleanup name)))) - -(provide 'test-ai-vterm--display-rule) -;;; test-ai-vterm--display-rule.el ends here diff --git a/tests/test-ai-vterm--display-saved.el b/tests/test-ai-vterm--display-saved.el deleted file mode 100644 index 0cf59a29..00000000 --- a/tests/test-ai-vterm--display-saved.el +++ /dev/null @@ -1,173 +0,0 @@ -;;; test-ai-vterm--display-saved.el --- Tests for the display-saved action -*- lexical-binding: t; -*- - -;;; Commentary: -;; `cj/--ai-vterm-display-saved' is the split path of the F9 display -;; chain -- it runs only when no agent window and no reusable edge slot -;; exist (a single-window frame, or a layout split on the other axis). -;; It reads `cj/--ai-vterm-last-direction' + `cj/--ai-vterm-last-size' -;; (with default fallbacks), builds an alist with direction + the -;; matching size key, strips any conflicting entries that came in via the -;; rule, and delegates to `display-buffer-in-direction'. -;; -;; Tests stub `display-buffer-in-direction' to capture the alist that -;; would have reached it. -;; -;; Multi-window toggle round-trips no longer resplit -- they reuse the -;; existing half (see test-ai-vterm--reuse-edge-window.el), so the former -;; resplit/body-width-preservation round-trip tests were retired with the -;; swap-the-slot model. The buffer-move teardown test stays here because -;; it exercises the split-window delete path on toggle-off. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(require 'ai-vterm) -(require 'testutil-vterm-buffers) - -(ert-deftest test-ai-vterm--display-saved-uses-desktop-defaults-when-state-nil () - "Normal: nil state on a desktop -> rightmost, size=cj/ai-vterm-desktop-width. -The cardinal `right' default maps to the frame-edge variant -`rightmost' so agent lands at the frame's right edge regardless of -which window is selected. `env-laptop-p' is stubbed nil to pin the -desktop branch." - (let (received-buf received-alist - (cj/--ai-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil) - (cj/ai-vterm-desktop-width 0.5)) - (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil)) - ((symbol-function 'display-buffer-in-direction) - (lambda (b a) - (setq received-buf b received-alist a) - 'fake-window))) - (cj/--ai-vterm-display-saved 'fake-buf '((inhibit-same-window . t)))) - (should (eq received-buf 'fake-buf)) - (should (eq (cdr (assq 'direction received-alist)) 'rightmost)) - (should (= (cdr (assq 'window-width received-alist)) 0.5)) - (should (eq (cdr (assq 'inhibit-same-window received-alist)) t)))) - -(ert-deftest test-ai-vterm--display-saved-uses-laptop-defaults-when-state-nil () - "Normal: nil state on a laptop -> bottom, size=cj/ai-vterm-laptop-height. -The cardinal `below' default maps to the frame-edge variant `bottom' -and the size lands on the `window-height' axis. `env-laptop-p' is -stubbed t to pin the laptop branch." - (let (received-alist - (cj/--ai-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil) - (cj/ai-vterm-laptop-height 0.75)) - (cl-letf (((symbol-function 'env-laptop-p) (lambda () t)) - ((symbol-function 'display-buffer-in-direction) - (lambda (_b a) (setq received-alist a) 'fake-window))) - (cj/--ai-vterm-display-saved 'fake-buf '((inhibit-same-window . t)))) - (should (eq (cdr (assq 'direction received-alist)) 'bottom)) - (should (= (cdr (assq 'window-height received-alist)) 0.75)) - (should-not (assq 'window-width received-alist)))) - -(ert-deftest test-ai-vterm--display-saved-uses-saved-direction-and-size-below () - "Normal: saved direction=below maps to bottom edge; size=0.4 passes through." - (let (received-alist - (cj/--ai-vterm-last-direction 'below) - (cj/--ai-vterm-last-size 0.4)) - (cl-letf (((symbol-function 'display-buffer-in-direction) - (lambda (_b a) (setq received-alist a) 'fake-window))) - (cj/--ai-vterm-display-saved 'fake-buf nil)) - (should (eq (cdr (assq 'direction received-alist)) 'bottom)) - (should (= (cdr (assq 'window-height received-alist)) 0.4)) - (should-not (assq 'window-width received-alist)))) - -(ert-deftest test-ai-vterm--display-saved-uses-saved-direction-and-size-right () - "Normal: saved direction=right maps to rightmost edge; size=0.7 passes through." - (let (received-alist - (cj/--ai-vterm-last-direction 'right) - (cj/--ai-vterm-last-size 0.7)) - (cl-letf (((symbol-function 'display-buffer-in-direction) - (lambda (_b a) (setq received-alist a) 'fake-window))) - (cj/--ai-vterm-display-saved 'fake-buf nil)) - (should (eq (cdr (assq 'direction received-alist)) 'rightmost)) - (should (= (cdr (assq 'window-width received-alist)) 0.7)) - (should-not (assq 'window-height received-alist)))) - -(ert-deftest test-ai-vterm--display-saved-strips-conflicting-alist-entries () - "Boundary: caller-supplied direction/size are stripped, saved values win." - (let (received-alist - (cj/--ai-vterm-last-direction 'right) - (cj/--ai-vterm-last-size 0.7)) - (cl-letf (((symbol-function 'display-buffer-in-direction) - (lambda (_b a) (setq received-alist a) 'fake-window))) - (cj/--ai-vterm-display-saved - 'fake-buf - '((direction . below) - (window-width . 0.2) - (window-height . 0.3) - (inhibit-same-window . t)))) - (should (eq (cdr (assq 'direction received-alist)) 'rightmost)) - (should (= (cdr (assq 'window-width received-alist)) 0.7)) - (should (eq (cdr (assq 'inhibit-same-window received-alist)) t)) - ;; window-height should not be in the alist when direction is right - ;; -- the action picks the matching size key based on direction. - (let ((wh-cells (cl-remove-if-not - (lambda (cell) (eq (car-safe cell) 'window-height)) - received-alist))) - (should (null wh-cells))))) - -(ert-deftest test-ai-vterm--display-saved-passes-buffer-through () - "Normal: BUFFER argument reaches display-buffer-in-direction unchanged." - (let (received-buf - (cj/--ai-vterm-last-direction 'right) - (cj/--ai-vterm-last-size 0.5)) - (cl-letf (((symbol-function 'display-buffer-in-direction) - (lambda (b _a) (setq received-buf b) 'fake-window))) - (cj/--ai-vterm-display-saved 'sentinel-buffer nil)) - (should (eq received-buf 'sentinel-buffer)))) - -(ert-deftest test-ai-vterm--toggle-after-buffer-move-no-extra-window () - "Regression: toggle-off must not leak a window even when buffer-move -has cleared the agent window's `quit-restore' parameter. - -Reproduces Craig's repro from 2026-05-09: 3 windows, user uses -buffer-move (C-M-arrows) to relocate agent. buffer-move swaps -buffers between windows and leaves the receiving window with no -record that it was created for the agent buffer. - -Assertion: after toggle-off+toggle-on, the agent is displayed exactly -once and no spurious extra window leaks." - (cj/test--kill-agent-buffers) - (let ((agent-name "agent [buffer-move-toggle]") - (left-name "*test-bm-left*") - (right-name "*test-bm-right*")) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (let ((left-buf (get-buffer-create left-name)) - (right-buf (get-buffer-create right-name)) - (agent-buf (get-buffer-create agent-name))) - (set-window-buffer (selected-window) left-buf) - (let* ((right-win (split-window (selected-window) nil 'right)) - (agent-win (split-window (selected-window) nil 'right))) - (set-window-buffer right-win right-buf) - (set-window-buffer agent-win agent-buf) - ;; Mimic buffer-move's effect: agent lives in this - ;; window but quit-restore says nothing about it. - (set-window-parameter agent-win 'quit-restore nil) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list)) - (window-count-before (count-windows))) - (select-window agent-win) - (cj/test--call-as-gui #'cj/ai-vterm) ; off - (cj/test--call-as-gui #'cj/ai-vterm) ; on - (should (<= (count-windows) window-count-before)) - ;; Agent must be displayed exactly once. - (let ((agent-windows - (seq-filter - (lambda (w) - (eq (window-buffer w) agent-buf)) - (window-list)))) - (should (= (length agent-windows) 1))))))) - (when (get-buffer left-name) (kill-buffer left-name)) - (when (get-buffer right-name) (kill-buffer right-name)) - (cj/test--kill-agent-buffers)))) - -(provide 'test-ai-vterm--display-saved) -;;; test-ai-vterm--display-saved.el ends here diff --git a/tests/test-ai-vterm--displayed-agent-window.el b/tests/test-ai-vterm--displayed-agent-window.el deleted file mode 100644 index f36ca9f5..00000000 --- a/tests/test-ai-vterm--displayed-agent-window.el +++ /dev/null @@ -1,60 +0,0 @@ -;;; test-ai-vterm--displayed-agent-window.el --- Tests for the displayed-window helper -*- lexical-binding: t; -*- - -;;; Commentary: -;; The helper returns a window in the selected frame whose buffer -;; satisfies `cj/--ai-vterm-buffer-p', or nil when no such window -;; exists. Used by F9 dispatch and M-F9 in-place replacement. - -;;; Code: - -(require 'ert) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(require 'ai-vterm) -(require 'testutil-vterm-buffers) - -(ert-deftest test-ai-vterm--displayed-agent-window-no-buffers-returns-nil () - "Boundary: no agent buffers anywhere -> nil." - (cj/test--kill-agent-buffers) - (save-window-excursion - (delete-other-windows) - (should-not (cj/--ai-vterm-displayed-agent-window)))) - -(ert-deftest test-ai-vterm--displayed-agent-window-not-displayed-returns-nil () - "Boundary: agent buffer exists but not in any window -> nil." - (cj/test--kill-agent-buffers) - (let ((b1 (get-buffer-create "agent [hidden]"))) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (should-not (cj/--ai-vterm-displayed-agent-window))) - (kill-buffer b1)))) - -(ert-deftest test-ai-vterm--displayed-agent-window-returns-window-when-displayed () - "Normal: agent buffer in a window -> returns that window." - (cj/test--kill-agent-buffers) - (let ((b1 (get-buffer-create "agent [shown]"))) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (let ((win (split-window-right))) - (set-window-buffer win b1) - (let ((result (cj/--ai-vterm-displayed-agent-window))) - (should (windowp result)) - (should (eq (window-buffer result) b1))))) - (kill-buffer b1)))) - -(ert-deftest test-ai-vterm--displayed-agent-window-ignores-non-agent-windows () - "Boundary: only a non-agent buffer is displayed -> nil." - (cj/test--kill-agent-buffers) - (let ((other (get-buffer-create "regular-displayed-buffer"))) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (set-window-buffer (selected-window) other) - (should-not (cj/--ai-vterm-displayed-agent-window))) - (kill-buffer other)))) - -(provide 'test-ai-vterm--displayed-agent-window) -;;; test-ai-vterm--displayed-agent-window.el ends here diff --git a/tests/test-ai-vterm--f9-in-vterm.el b/tests/test-ai-vterm--f9-in-vterm.el deleted file mode 100644 index ec67ac9b..00000000 --- a/tests/test-ai-vterm--f9-in-vterm.el +++ /dev/null @@ -1,47 +0,0 @@ -;;; test-ai-vterm--f9-in-vterm.el --- F9 reaches Emacs from inside an agent buffer -*- lexical-binding: t; -*- - -;;; Commentary: -;; vterm binds .. to `vterm--self-insert', so a plain typed -;; while point is in an agent buffer is sent to the terminal program instead -;; of toggling the agent -- which is exactly the case when the agent buffer -;; fills the frame. `ai-vterm.el' re-binds the F9 family in `vterm-mode-map'. -;; These tests load real vterm so `vterm-mode-map' exists, then confirm the -;; bindings landed (and the global ones are still there). - -;;; Code: - -(require 'ert) -(require 'package) - -(setq package-user-dir (expand-file-name "elpa" user-emacs-directory)) -(package-initialize) -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'vterm) -(require 'ai-vterm) - -(ert-deftest test-ai-vterm-f9-bound-in-vterm-mode-map () - "Normal: in `vterm-mode-map' runs the agent toggle, not `vterm--self-insert'." - (should (eq (keymap-lookup vterm-mode-map "") #'cj/ai-vterm))) - -(ert-deftest test-ai-vterm-f9-family-bound-in-vterm-mode-map () - "Normal: the C-/M-/C-S- F9 variants are bound in `vterm-mode-map' too. -`M-' and `C-S-' both close an agent via `cj/ai-vterm-close'." - (should (eq (keymap-lookup vterm-mode-map "C-") #'cj/ai-vterm-pick-project)) - (should (eq (keymap-lookup vterm-mode-map "M-") #'cj/ai-vterm-close)) - (should (eq (keymap-lookup vterm-mode-map "C-S-") #'cj/ai-vterm-close))) - -(ert-deftest test-ai-vterm-f9-not-self-insert-in-vterm () - "Boundary: vterm's default -> `vterm--self-insert' was overridden." - (should-not (eq (keymap-lookup vterm-mode-map "") 'vterm--self-insert))) - -(ert-deftest test-ai-vterm-f9-still-bound-globally () - "Normal: the global F9 family bindings are intact. -`' toggles the ai-vterm agent window; `C-' picks a project -agent; `M-' and `C-S-' close an agent via `cj/ai-vterm-close'." - (should (eq (lookup-key (current-global-map) (kbd "")) #'cj/ai-vterm)) - (should (eq (lookup-key (current-global-map) (kbd "C-")) #'cj/ai-vterm-pick-project)) - (should (eq (lookup-key (current-global-map) (kbd "M-")) #'cj/ai-vterm-close)) - (should (eq (lookup-key (current-global-map) (kbd "C-S-")) #'cj/ai-vterm-close))) - -(provide 'test-ai-vterm--f9-in-vterm) -;;; test-ai-vterm--f9-in-vterm.el ends here diff --git a/tests/test-ai-vterm--launch-command.el b/tests/test-ai-vterm--launch-command.el deleted file mode 100644 index bac36d4e..00000000 --- a/tests/test-ai-vterm--launch-command.el +++ /dev/null @@ -1,94 +0,0 @@ -;;; 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 the agent inside a per-project tmux session. The session is named -;; `cj/ai-vterm-tmux-session-prefix' + the project basename, so a second -;; F9 on the same project reattaches to the running agent rather than -;; spawning a new one, and `tmux ls' output can be filtered to AI-vterm's -;; own sessions. The trailing `exec bash' keeps the tmux window alive if -;; the agent 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-agent-command "agent")) - (should (string-prefix-p - "tmux new-session -A " - (cj/--ai-vterm-launch-command "/code/foo"))))) - -(ert-deftest test-ai-vterm--launch-command-includes-prefixed-session-name () - "Normal: the session name is the prefixed form from the name helper." - (let ((cj/ai-vterm-agent-command "agent") - (cj/ai-vterm-tmux-session-prefix "aiv-")) - (should (string-match-p - " -s aiv-foo " - (cj/--ai-vterm-launch-command "/code/foo"))))) - -(ert-deftest test-ai-vterm--launch-command-names-window () - "Normal: `-n ' so the agent window is named distinctly." - (let ((cj/ai-vterm-agent-command "agent") - (cj/ai-vterm-tmux-window-name "ai")) - (should (string-match-p - " -n ai " - (cj/--ai-vterm-launch-command "/code/foo"))))) - -(ert-deftest test-ai-vterm--launch-command-honors-custom-window-name () - "Boundary: a non-default window name is what `-n' gets." - (let ((cj/ai-vterm-agent-command "agent") - (cj/ai-vterm-tmux-window-name "agent")) - (should (string-match-p - " -n agent " - (cj/--ai-vterm-launch-command "/code/foo"))))) - -(ert-deftest test-ai-vterm--launch-command-includes-start-directory () - "Normal: `-c ' so the new session's first window starts in DIR." - (let ((cj/ai-vterm-agent-command "agent")) - (should (string-match-p - " -c /code/foo " - (cj/--ai-vterm-launch-command "/code/foo"))))) - -(ert-deftest test-ai-vterm--launch-command-includes-agent-command () - "Normal: the configured agent command is in the launched shell command. -The inner command is passed through `shell-quote-argument', so spaces -are escaped (`\\\\ ') -- the regex below accepts either form." - (let ((cj/ai-vterm-agent-command "agent --some-flag")) - (should (string-match-p - "agent\\(\\\\\\)? --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 the agent exiting. -Accepts the post-`shell-quote-argument' shape (`exec\\\\ bash')." - (let ((cj/ai-vterm-agent-command "agent")) - (should (string-match-p - "exec\\(\\\\\\)? bash" - (cj/--ai-vterm-launch-command "/code/foo"))))) - -(ert-deftest test-ai-vterm--launch-command-survives-single-quote-in-agent () - "Normal: a user-customized agent command containing a single quote -shouldn't break the shell parse. `shell-quote-argument' produces a -valid shell token regardless of the embedded quote shape -- the -escaping is implementation-detail, so we assert the literal words -\"hi\" and \"there\" both appear (the space between them may be -escaped as \\\\ )." - (let ((cj/ai-vterm-agent-command "agent --say 'hi there'")) - (let ((cmd (cj/--ai-vterm-launch-command "/code/foo"))) - (should (string-match-p "hi\\(\\\\\\)? there" cmd))))) - -(ert-deftest test-ai-vterm--launch-command-handles-spaces-in-basename () - "Boundary: a basename with whitespace becomes hyphenated before quoting." - (let ((cj/ai-vterm-agent-command "agent") - (cj/ai-vterm-tmux-session-prefix "aiv-")) - (should (string-match-p - " -s aiv-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--live-tmux-sessions.el b/tests/test-ai-vterm--live-tmux-sessions.el deleted file mode 100644 index e00b0018..00000000 --- a/tests/test-ai-vterm--live-tmux-sessions.el +++ /dev/null @@ -1,71 +0,0 @@ -;;; test-ai-vterm--live-tmux-sessions.el --- Tests for cj/--ai-vterm-live-tmux-sessions -*- lexical-binding: t; -*- - -;;; Commentary: -;; Lists the live tmux sessions that carry the AI-vterm prefix so the -;; project picker can surface projects whose agent session survived an -;; Emacs crash. tmux being absent or no server running is a normal -;; "nothing to match" outcome, not an error -- the lister returns nil. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) - -(defmacro test-ai-vterm--with-tmux-list (exit-code output &rest body) - "Run BODY with `process-file' mocked to a tmux list-sessions response. - -EXIT-CODE is what `process-file' returns (or the symbol `error' to -make it signal). OUTPUT is written to the stdout destination buffer." - (declare (indent 2)) - `(cl-letf (((symbol-function 'process-file) - (lambda (_program _infile destination _display &rest _args) - (when (eq ,exit-code 'error) - (error "tmux: command not found")) - (let ((buffer (cond - ((eq destination t) (current-buffer)) - ((bufferp destination) destination) - ((consp destination) - (and (eq (car destination) t) - (current-buffer)))))) - (when (bufferp buffer) - (with-current-buffer buffer (insert ,output)))) - ,exit-code))) - ,@body)) - -(ert-deftest test-ai-vterm--live-tmux-sessions-filters-to-prefix () - "Normal: only sessions starting with the AI-vterm prefix come back." - (let ((cj/ai-vterm-tmux-session-prefix "aiv-")) - (test-ai-vterm--with-tmux-list 0 "aiv-foo\nrandom-session\naiv-bar\n" - (should (equal (cj/--ai-vterm-live-tmux-sessions) - '("aiv-foo" "aiv-bar")))))) - -(ert-deftest test-ai-vterm--live-tmux-sessions-honors-custom-prefix () - "Normal: a non-default prefix is what gets matched." - (let ((cj/ai-vterm-tmux-session-prefix "em-")) - (test-ai-vterm--with-tmux-list 0 "em-foo\naiv-bar\nem-baz\n" - (should (equal (cj/--ai-vterm-live-tmux-sessions) - '("em-foo" "em-baz")))))) - -(ert-deftest test-ai-vterm--live-tmux-sessions-empty-output-yields-nil () - "Boundary: a running server with no matching sessions yields nil." - (let ((cj/ai-vterm-tmux-session-prefix "aiv-")) - (test-ai-vterm--with-tmux-list 0 "other-a\nother-b\n" - (should (null (cj/--ai-vterm-live-tmux-sessions)))))) - -(ert-deftest test-ai-vterm--live-tmux-sessions-no-server-yields-nil () - "Error: tmux exits non-zero (no server running) -> nil, not a signal." - (let ((cj/ai-vterm-tmux-session-prefix "aiv-")) - (test-ai-vterm--with-tmux-list 1 "no server running on /tmp/tmux-1000/default\n" - (should (null (cj/--ai-vterm-live-tmux-sessions)))))) - -(ert-deftest test-ai-vterm--live-tmux-sessions-tmux-missing-yields-nil () - "Error: tmux not installed -> `process-file' signals; lister returns nil." - (let ((cj/ai-vterm-tmux-session-prefix "aiv-")) - (test-ai-vterm--with-tmux-list 'error "" - (should (null (cj/--ai-vterm-live-tmux-sessions)))))) - -(provide 'test-ai-vterm--live-tmux-sessions) -;;; test-ai-vterm--live-tmux-sessions.el ends here diff --git a/tests/test-ai-vterm--pick-project.el b/tests/test-ai-vterm--pick-project.el deleted file mode 100644 index f332589a..00000000 --- a/tests/test-ai-vterm--pick-project.el +++ /dev/null @@ -1,117 +0,0 @@ -;;; test-ai-vterm--pick-project.el --- Tests for cj/--ai-vterm-pick-project -*- lexical-binding: t; -*- - -;;; Commentary: -;; The picker presents abbreviated paths to `completing-read' (projects -;; with a live tmux session first, then alphabetical), then returns the -;; absolute path corresponding to the user's choice. An empty candidate -;; set raises a `user-error' rather than offering an empty prompt. The -;; collection is a completion table that pins display order (so Vertico -;; doesn't re-sort and defeat the active-first grouping). - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) - -(defun test-ai-vterm--collection-strings (collection) - "Return the candidate display strings from a completing-read COLLECTION. -Works whether COLLECTION is an alist or a completion-table function." - (all-completions "" collection)) - -(ert-deftest test-ai-vterm--pick-project-returns-absolute-path-of-choice () - "Normal: user picks a candidate, picker returns its absolute path." - (cl-letf (((symbol-function 'cj/--ai-vterm-candidates) - (lambda () '("/home/u/code/foo" "/home/u/code/bar"))) - ((symbol-function 'cj/--ai-vterm-live-tmux-sessions) - (lambda () nil)) - ((symbol-function 'completing-read) - (lambda (_p collection &rest _) - (seq-find (lambda (s) (string-match-p "bar" s)) - (test-ai-vterm--collection-strings collection))))) - (should (equal (cj/--ai-vterm-pick-project) "/home/u/code/bar")))) - -(ert-deftest test-ai-vterm--pick-project-empty-candidates-raises-user-error () - "Error: no candidates -> user-error rather than empty prompt." - (cl-letf (((symbol-function 'cj/--ai-vterm-candidates) (lambda () nil))) - (should-error (cj/--ai-vterm-pick-project) :type 'user-error))) - -(ert-deftest test-ai-vterm--pick-project-presents-abbreviated-paths () - "Normal: the completing-read collection holds abbreviated display forms." - (let (received-strings) - (cl-letf (((symbol-function 'cj/--ai-vterm-candidates) - (lambda () (list (expand-file-name "~/code/foo")))) - ((symbol-function 'cj/--ai-vterm-live-tmux-sessions) - (lambda () nil)) - ((symbol-function 'completing-read) - (lambda (_p collection &rest _) - (setq received-strings (test-ai-vterm--collection-strings collection)) - (car received-strings)))) - (cj/--ai-vterm-pick-project) - (should (equal (car received-strings) "~/code/foo"))))) - -(ert-deftest test-ai-vterm--pick-project-active-sessions-sort-first () - "Normal: a project with a live tmux session leads; it carries [detached]." - (let ((cj/ai-vterm-tmux-session-prefix "aiv-") - received-strings) - (cl-letf (((symbol-function 'cj/--ai-vterm-candidates) - (lambda () '("/c/foo" "/c/bar" "/c/baz"))) - ((symbol-function 'cj/--ai-vterm-live-tmux-sessions) - (lambda () '("aiv-baz"))) - ((symbol-function 'completing-read) - (lambda (_p collection &rest _) - (setq received-strings (test-ai-vterm--collection-strings collection)) - (car received-strings)))) - (cj/--ai-vterm-pick-project) - (should (equal received-strings - '("/c/baz [detached]" "/c/bar" "/c/foo")))))) - -(ert-deftest test-ai-vterm--format-candidate-flags-running-project () - "Normal: a path whose agent buffer has a live process gets a [running] suffix." - (let* ((path (expand-file-name "~/code/already-running")) - (buffer-name (cj/--ai-vterm-buffer-name path)) - (buf (get-buffer-create buffer-name))) - (unwind-protect - (cl-letf (((symbol-function 'cj/--ai-vterm-process-live-p) - (lambda (b) (eq b buf)))) - (should (equal (cj/--ai-vterm-format-candidate path) - (format "%s [running]" (abbreviate-file-name path))))) - (kill-buffer buf)))) - -(ert-deftest test-ai-vterm--format-candidate-flags-detached-session () - "Normal: no buffer but a matching tmux session -> [detached] suffix." - (let* ((cj/ai-vterm-tmux-session-prefix "aiv-") - (path (expand-file-name "~/code/has-session")) - (bn (cj/--ai-vterm-buffer-name path))) - (when (get-buffer bn) (kill-buffer bn)) - (should (equal (cj/--ai-vterm-format-candidate - path (list (cj/--ai-vterm-tmux-session-name path))) - (format "%s [detached]" (abbreviate-file-name path)))))) - -(ert-deftest test-ai-vterm--format-candidate-running-beats-detached () - "Boundary: a live buffer wins over a matching session -> [running], not [detached]." - (let* ((cj/ai-vterm-tmux-session-prefix "aiv-") - (path (expand-file-name "~/code/both")) - (bn (cj/--ai-vterm-buffer-name path)) - (buf (get-buffer-create bn))) - (unwind-protect - (cl-letf (((symbol-function 'cj/--ai-vterm-process-live-p) - (lambda (b) (eq b buf)))) - (should (equal (cj/--ai-vterm-format-candidate - path (list (cj/--ai-vterm-tmux-session-name path))) - (format "%s [running]" (abbreviate-file-name path))))) - (kill-buffer buf)))) - -(ert-deftest test-ai-vterm--format-candidate-omits-flag-when-not-running () - "Boundary: a path with no buffer or no live process -> plain abbreviated path." - (let ((path (expand-file-name "~/code/not-running"))) - ;; Make sure no agent buffer exists for this path. - (let ((bn (cj/--ai-vterm-buffer-name path))) - (when (get-buffer bn) (kill-buffer bn))) - (should (equal (cj/--ai-vterm-format-candidate path) - (abbreviate-file-name path))))) - -(provide 'test-ai-vterm--pick-project) -;;; test-ai-vterm--pick-project.el ends here diff --git a/tests/test-ai-vterm--record-mru.el b/tests/test-ai-vterm--record-mru.el deleted file mode 100644 index 16db4eea..00000000 --- a/tests/test-ai-vterm--record-mru.el +++ /dev/null @@ -1,48 +0,0 @@ -;;; 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--reuse-edge-window.el b/tests/test-ai-vterm--reuse-edge-window.el deleted file mode 100644 index eb1b1d75..00000000 --- a/tests/test-ai-vterm--reuse-edge-window.el +++ /dev/null @@ -1,273 +0,0 @@ -;;; test-ai-vterm--reuse-edge-window.el --- Tests for edge-window reuse -*- lexical-binding: t; -*- - -;;; Commentary: -;; `cj/--ai-vterm-reuse-edge-window' is the display-buffer action that -;; reuses the window already forming the half the agent would occupy -;; (the right column on a desktop, the bottom row on a laptop) instead -;; of splitting a third window in. It runs between -;; `cj/--ai-vterm-reuse-existing-agent' and `cj/--ai-vterm-display-saved' -;; in the rule chain. -;; -;; Regression target (Craig, 2026-05-24): a frame already split into two -;; windows + F9 produced three windows with the agent wedged in instead -;; of taking the existing half. These tests assert the window *count* -;; stays put -- the dimension the older display-saved tests never checked. -;; -;; Tests build real windows (split-window) and route a fresh agent buffer -;; through the actual `cj/--ai-vterm-display-rule-list', the same pattern -;; as test-ai-vterm--display-saved.el. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(require 'ai-vterm) -(require 'testutil-vterm-buffers) - -(defun cj/test--displayed-buffer-names () - "Return the buffer names shown in the selected frame, left/top to right/bottom." - (mapcar (lambda (w) (buffer-name (window-buffer w))) - (window-list nil 'never))) - -(ert-deftest test-ai-vterm--reuse-edge-window-2col-desktop-no-third-window () - "Normal: F9 in a 2-column split reuses the right column, no third window. -Desktop default direction is `right', so the agent takes the existing -right half: the frame stays at two windows [left | agent]." - (cj/test--kill-agent-buffers) - (let ((agent-name "agent [edge-2col]") - (left-name "*test-edge-left*") - (right-name "*test-edge-right*") - (cj/--ai-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) - (let ((left-buf (get-buffer-create left-name)) - (right-buf (get-buffer-create right-name)) - (agent-buf (get-buffer-create agent-name))) - (set-window-buffer (selected-window) left-buf) - (let ((rw (split-window (selected-window) nil 'right))) - (set-window-buffer rw right-buf)) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - (display-buffer agent-buf)) - (should (= (count-windows) 2)) - (let ((bufs (cj/test--displayed-buffer-names))) - (should (member agent-name bufs)) - (should (member left-name bufs)) - ;; the right column now holds the agent, not the old buffer - (should-not (member right-name bufs)))))) - (when (get-buffer left-name) (kill-buffer left-name)) - (when (get-buffer right-name) (kill-buffer right-name)) - (cj/test--kill-agent-buffers)))) - -(ert-deftest test-ai-vterm--reuse-edge-window-2row-laptop-no-third-window () - "Normal: F9 in a 2-row split on a laptop reuses the bottom row. -Laptop default direction is `below', so the agent takes the existing -bottom half: the frame stays at two windows." - (cj/test--kill-agent-buffers) - (let ((agent-name "agent [edge-2row]") - (top-name "*test-edge-top*") - (bottom-name "*test-edge-bottom*") - (cj/--ai-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (cl-letf (((symbol-function 'env-laptop-p) (lambda () t))) - (let ((top-buf (get-buffer-create top-name)) - (bottom-buf (get-buffer-create bottom-name)) - (agent-buf (get-buffer-create agent-name))) - (set-window-buffer (selected-window) top-buf) - (let ((bw (split-window (selected-window) nil 'below))) - (set-window-buffer bw bottom-buf)) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - (display-buffer agent-buf)) - (should (= (count-windows) 2)) - (let ((bufs (cj/test--displayed-buffer-names))) - (should (member agent-name bufs)) - (should (member top-name bufs)) - (should-not (member bottom-name bufs)))))) - (when (get-buffer top-name) (kill-buffer top-name)) - (when (get-buffer bottom-name) (kill-buffer bottom-name)) - (cj/test--kill-agent-buffers)))) - -(ert-deftest test-ai-vterm--reuse-edge-window-single-window-splits () - "Boundary: a single-window frame still splits to create the half. -No existing edge window to reuse, so the display-saved path runs and -the frame goes from one window to two with the agent present." - (cj/test--kill-agent-buffers) - (let ((agent-name "agent [edge-single]") - (sole-name "*test-edge-sole*") - (cj/--ai-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) - (let ((sole-buf (get-buffer-create sole-name)) - (agent-buf (get-buffer-create agent-name))) - (set-window-buffer (selected-window) sole-buf) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - (display-buffer agent-buf)) - (should (= (count-windows) 2)) - (should (member agent-name (cj/test--displayed-buffer-names)))))) - (when (get-buffer sole-name) (kill-buffer sole-name)) - (cj/test--kill-agent-buffers)))) - -(ert-deftest test-ai-vterm--reuse-edge-window-axis-mismatch-falls-through () - "Error/Boundary: a top/bottom split on a desktop has no right half. -Desktop direction is `right' but the frame is split horizontally, so no -single full-height right column exists to reuse. The chain falls -through to display-saved, which splits a right column -- agent still -ends up displayed." - (cj/test--kill-agent-buffers) - (let ((agent-name "agent [edge-mismatch]") - (top-name "*test-edge-mm-top*") - (bottom-name "*test-edge-mm-bottom*") - (cj/--ai-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) - (let ((top-buf (get-buffer-create top-name)) - (bottom-buf (get-buffer-create bottom-name)) - (agent-buf (get-buffer-create agent-name))) - (set-window-buffer (selected-window) top-buf) - (let ((bw (split-window (selected-window) nil 'below))) - (set-window-buffer bw bottom-buf)) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - (display-buffer agent-buf)) - ;; No half to reuse, so a fresh column is split: three windows. - (should (member agent-name (cj/test--displayed-buffer-names))) - (should (member top-name (cj/test--displayed-buffer-names))) - (should (member bottom-name (cj/test--displayed-buffer-names)))))) - (when (get-buffer top-name) (kill-buffer top-name)) - (when (get-buffer bottom-name) (kill-buffer bottom-name)) - (cj/test--kill-agent-buffers)))) - -(ert-deftest test-ai-vterm--reuse-edge-window-toggle-off-collapses-split () - "Normal: toggle-off after a slot reuse collapses the agent split. -=| 1 | 2 |= + show agent -> =| 1 | A |=; toggle off -> =| 1 |= (one -window). F9 always collapses the agent split back to the working layout -regardless of how the agent window came to be -- it deletes the agent -window rather than restoring the displaced buffer into a kept slot." - (cj/test--kill-agent-buffers) - (let ((agent-name "agent [edge-restore]") - (left-name "*test-restore-left*") - (right-name "*test-restore-right*") - (cj/--ai-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) - (let ((left-buf (get-buffer-create left-name)) - (right-buf (get-buffer-create right-name)) - (agent-buf (get-buffer-create agent-name))) - (set-window-buffer (selected-window) left-buf) - (let ((rw (split-window (selected-window) nil 'right))) - (set-window-buffer rw right-buf)) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - (display-buffer agent-buf) - (should (= (count-windows) 2)) - (should (member agent-name (cj/test--displayed-buffer-names))) - ;; Toggle off -> the agent window is deleted, leaving the - ;; working buffer at full frame. - (cj/test--call-as-gui #'cj/ai-vterm) - (should (= (count-windows) 1)) - (let ((bufs (cj/test--displayed-buffer-names))) - (should (member left-name bufs)) - (should-not (member agent-name bufs))))))) - (when (get-buffer left-name) (kill-buffer left-name)) - (when (get-buffer right-name) (kill-buffer right-name)) - (cj/test--kill-agent-buffers)))) - -(ert-deftest test-ai-vterm--reuse-edge-window-cycle-collapses-then-resplits () - "Normal: on/off/on cycle collapses on off and re-splits at the same width. -=| 1 | 2 |= -> on =| 1 | A |= (2 windows) -> off =| 1 |= (1 window, -collapsed) -> on =| 1 | A |= (2 windows again), with the agent re-split at -the width captured at toggle-off -- the user's chosen split width is -preserved across the toggle (respect-split-width)." - (cj/test--kill-agent-buffers) - (let ((agent-name "agent [edge-cycle]") - (left-name "*test-cycle-left*") - (right-name "*test-cycle-right*") - (cj/--ai-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) - (let ((left-buf (get-buffer-create left-name)) - (right-buf (get-buffer-create right-name)) - (agent-buf (get-buffer-create agent-name)) - slot-width) - (set-window-buffer (selected-window) left-buf) - (let ((rw (split-window (selected-window) nil 'right))) - (set-window-buffer rw right-buf)) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - ;; on -- agent takes the existing right slot - (display-buffer agent-buf) - (should (= (count-windows) 2)) - (setq slot-width - (window-body-width (cj/--ai-vterm-displayed-agent-window))) - ;; off -- the split collapses to a single window - (cj/test--call-as-gui #'cj/ai-vterm) - (should (= (count-windows) 1)) - (should-not (cj/--ai-vterm-displayed-agent-window)) - ;; on again -- re-split at the captured width - (cj/test--call-as-gui #'cj/ai-vterm) - (should (= (count-windows) 2)) - (let ((win (cj/--ai-vterm-displayed-agent-window))) - (should (windowp win)) - (should (eq (window-buffer win) agent-buf)) - (should (= (window-body-width win) slot-width))))))) - (when (get-buffer left-name) (kill-buffer left-name)) - (when (get-buffer right-name) (kill-buffer right-name)) - (cj/test--kill-agent-buffers)))) - -(ert-deftest test-ai-vterm--reuse-edge-window-toggle-keeps-same-agent-with-multiple () - "Regression: with two agents alive, toggle-off then on restores the SAME -agent, not a different one. Toggle-off must not bury the agent to the end -of the buffer list -- if it does, `cj/--ai-vterm-dispatch' re-shows the -most-recent agent, which would now be the other one." - (cj/test--kill-agent-buffers) - (let ((a1-name "agent [multi-1]") - (a2-name "agent [multi-2]") - (left-name "*test-multi-left*") - (right-name "*test-multi-right*") - (cj/--ai-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) - (let ((a1 (get-buffer-create a1-name)) - (a2 (get-buffer-create a2-name)) - (left-buf (get-buffer-create left-name)) - (right-buf (get-buffer-create right-name))) - ;; Make A2 the most-recent agent. - (bury-buffer a1) - (set-window-buffer (selected-window) left-buf) - (let ((rw (split-window (selected-window) nil 'right))) - (set-window-buffer rw right-buf)) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - (display-buffer a2) ; | left | A2 | - (should (eq (window-buffer (cj/--ai-vterm-displayed-agent-window)) - a2)) - (cj/test--call-as-gui #'cj/ai-vterm) ; off -> | left | right | - (should-not (cj/--ai-vterm-displayed-agent-window)) - (cj/test--call-as-gui #'cj/ai-vterm) ; on -> must bring A2 back - (should (eq (window-buffer (cj/--ai-vterm-displayed-agent-window)) - a2)))))) - (when (get-buffer left-name) (kill-buffer left-name)) - (when (get-buffer right-name) (kill-buffer right-name)) - (cj/test--kill-agent-buffers)))) - -(provide 'test-ai-vterm--reuse-edge-window) -;;; test-ai-vterm--reuse-edge-window.el ends here diff --git a/tests/test-ai-vterm--reuse-existing-agent.el b/tests/test-ai-vterm--reuse-existing-agent.el deleted file mode 100644 index e6848014..00000000 --- a/tests/test-ai-vterm--reuse-existing-agent.el +++ /dev/null @@ -1,99 +0,0 @@ -;;; test-ai-vterm--reuse-existing-agent.el --- Tests for reuse-existing-agent action -*- lexical-binding: t; -*- - -;;; Commentary: -;; The action looks for any window in the selected frame whose buffer -;; satisfies `cj/--ai-vterm-buffer-p'. When found, swaps that -;; window's buffer for the one being displayed and returns the -;; window. When not found, returns nil so the next action in the -;; chain runs. -;; -;; This is the action that keeps C-F9 (project-switch) from stealing -;; a non-agent window when the user is focused inside agent. - -;;; Code: - -(require 'ert) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(require 'ai-vterm) -(require 'testutil-vterm-buffers) - -(ert-deftest test-ai-vterm--reuse-existing-agent-swaps-buffer-when-window-exists () - "Normal: an agent window exists -> swap its buffer, return the window." - (cj/test--kill-agent-buffers) - (save-window-excursion - (delete-other-windows) - (let ((existing (get-buffer-create "agent [existing]")) - (new-buf (get-buffer-create "agent [new]")) - (split (split-window (selected-window) nil 'right))) - (unwind-protect - (progn - (set-window-buffer split existing) - (let ((result (cj/--ai-vterm-reuse-existing-agent new-buf nil))) - (should (eq result split)) - (should (eq (window-buffer split) new-buf)))) - (kill-buffer existing) - (kill-buffer new-buf))))) - -(ert-deftest test-ai-vterm--reuse-existing-agent-returns-nil-when-no-agent-window () - "Boundary: no agent window in frame -> nil (chain continues to next action)." - (cj/test--kill-agent-buffers) - (save-window-excursion - (delete-other-windows) - (let ((new-buf (get-buffer-create "agent [no-existing]"))) - (unwind-protect - (should (null (cj/--ai-vterm-reuse-existing-agent new-buf nil))) - (kill-buffer new-buf))))) - -(ert-deftest test-ai-vterm--reuse-existing-agent-leaves-non-agent-windows-alone () - "Boundary: only non-agent windows in frame -> nil; other windows untouched." - (cj/test--kill-agent-buffers) - (save-window-excursion - (delete-other-windows) - (let ((code-buf (get-buffer-create "*test-code-buffer*")) - (new-agent (get-buffer-create "agent [new-here]")) - (other-win (split-window (selected-window) nil 'right))) - (unwind-protect - (progn - (set-window-buffer (selected-window) code-buf) - (set-window-buffer other-win code-buf) - (let ((result (cj/--ai-vterm-reuse-existing-agent - new-agent nil))) - (should (null result)) - (should (eq (window-buffer (selected-window)) code-buf)) - (should (eq (window-buffer other-win) code-buf)))) - (kill-buffer code-buf) - (kill-buffer new-agent))))) - -(ert-deftest test-ai-vterm--reuse-existing-agent-preserves-non-agent-window-when-swapping () - "Normal: swap agent window only; the other window keeps its buffer. - -This is the C-F9-from-agent regression: with agent at the bottom -and code on top, switching projects must replace the bottom window's -buffer, not the top window's." - (cj/test--kill-agent-buffers) - (save-window-excursion - (delete-other-windows) - (let* ((code-buf (get-buffer-create "*test-code-top*")) - (agent-a (get-buffer-create "agent [a]")) - (agent-b (get-buffer-create "agent [b]")) - (top-win (selected-window)) - (bottom-win (split-window top-win nil 'below))) - (unwind-protect - (progn - (set-window-buffer top-win code-buf) - (set-window-buffer bottom-win agent-a) - ;; Focus the agent window -- this is the regression scenario. - (select-window bottom-win) - (let ((result (cj/--ai-vterm-reuse-existing-agent - agent-b nil))) - (should (eq result bottom-win)) - (should (eq (window-buffer bottom-win) agent-b)) - (should (eq (window-buffer top-win) code-buf)))) - (kill-buffer code-buf) - (kill-buffer agent-a) - (kill-buffer agent-b))))) - -(provide 'test-ai-vterm--reuse-existing-agent) -;;; test-ai-vterm--reuse-existing-agent.el ends here diff --git a/tests/test-ai-vterm--server-display.el b/tests/test-ai-vterm--server-display.el deleted file mode 100644 index 1d0d1001..00000000 --- a/tests/test-ai-vterm--server-display.el +++ /dev/null @@ -1,127 +0,0 @@ -;;; test-ai-vterm--server-display.el --- Tests for emacsclient window routing -*- lexical-binding: t; -*- - -;;; Commentary: -;; `cj/--ai-vterm-server-display' is wired as `server-window' so a file -;; opened via `emacsclient -n' (e.g. when Craig tells the agent to open -;; something) doesn't land on top of the agent vterm. When the selected -;; window shows an `agent [...]' buffer, the file goes to a non-agent -;; window instead -- splitting one off the agent if it is the only window. -;; `cj/--ai-vterm-non-agent-window' picks that window. - -;;; Code: - -(require 'ert) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(require 'ai-vterm) -(require 'server) -(require 'testutil-vterm-buffers) - -(ert-deftest test-ai-vterm--non-agent-window-finds-code-window () - "Normal: agent on the right, code on the left -> returns the code window." - (cj/test--kill-agent-buffers) - (let ((agent (get-buffer-create "agent [proj]")) - (code (get-buffer-create "code.el"))) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (set-window-buffer (selected-window) code) - (let ((right (split-window-right))) - (set-window-buffer right agent) - (let ((found (cj/--ai-vterm-non-agent-window right))) - (should (windowp found)) - (should (eq (window-buffer found) code))))) - (kill-buffer agent) - (kill-buffer code)))) - -(ert-deftest test-ai-vterm--non-agent-window-none-when-only-agent () - "Boundary: the agent window is the only one -> nil." - (cj/test--kill-agent-buffers) - (let ((agent (get-buffer-create "agent [solo]"))) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (set-window-buffer (selected-window) agent) - (should-not (cj/--ai-vterm-non-agent-window (selected-window)))) - (kill-buffer agent)))) - -(ert-deftest test-ai-vterm--non-agent-window-skips-dedicated () - "Boundary: a dedicated non-agent window is not a valid target." - (cj/test--kill-agent-buffers) - (let ((agent (get-buffer-create "agent [proj]")) - (side (get-buffer-create "*dedicated-side*"))) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (set-window-buffer (selected-window) agent) - (let ((w (split-window-right))) - (set-window-buffer w side) - (set-window-dedicated-p w t) - (unwind-protect - (should-not (cj/--ai-vterm-non-agent-window (selected-window))) - (set-window-dedicated-p w nil)))) - (kill-buffer agent) - (kill-buffer side)))) - -(ert-deftest test-ai-vterm--server-display-routes-around-agent () - "Normal: selected window is the agent -> the file lands in the other -window and the agent window keeps the agent buffer." - (cj/test--kill-agent-buffers) - (let ((agent (get-buffer-create "agent [proj]")) - (code (get-buffer-create "code.el")) - (file (get-buffer-create "opened-by-emacsclient.el"))) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (set-window-buffer (selected-window) code) - (let ((agent-win (split-window-right))) - (set-window-buffer agent-win agent) - (select-window agent-win) - (cj/--ai-vterm-server-display file) - (should (eq (window-buffer agent-win) agent)) - (should (get-buffer-window file)) - (should-not (eq (get-buffer-window file) agent-win)))) - (kill-buffer agent) - (kill-buffer code) - (kill-buffer file)))) - -(ert-deftest test-ai-vterm--server-display-splits-when-agent-is-only-window () - "Boundary: the agent is the only window -> a window is split off for the -file; the agent window keeps the agent buffer." - (cj/test--kill-agent-buffers) - (let ((agent (get-buffer-create "agent [solo]")) - (file (get-buffer-create "opened.el"))) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (set-window-buffer (selected-window) agent) - (let ((agent-win (selected-window))) - (cj/--ai-vterm-server-display file) - (should (= 2 (length (window-list (selected-frame) 'never)))) - (should (eq (window-buffer agent-win) agent)) - (should (eq (window-buffer (get-buffer-window file)) file)))) - (kill-buffer agent) - (kill-buffer file)))) - -(ert-deftest test-ai-vterm--server-display-passthrough-when-not-agent () - "Normal: selected window is a regular buffer -> the file is displayed -normally and nothing special happens (no agent window to protect)." - (cj/test--kill-agent-buffers) - (let ((code (get-buffer-create "code.el")) - (file (get-buffer-create "opened.el"))) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (set-window-buffer (selected-window) code) - (cj/--ai-vterm-server-display file) - (should (get-buffer-window file))) - (kill-buffer code) - (kill-buffer file)))) - -(ert-deftest test-ai-vterm--server-window-wired-to-helper () - "Normal: the module sets `server-window' to its display function." - (should (eq server-window #'cj/--ai-vterm-server-display))) - -(provide 'test-ai-vterm--server-display) -;;; test-ai-vterm--server-display.el ends here diff --git a/tests/test-ai-vterm--show-or-create.el b/tests/test-ai-vterm--show-or-create.el deleted file mode 100644 index 01083f84..00000000 --- a/tests/test-ai-vterm--show-or-create.el +++ /dev/null @@ -1,163 +0,0 @@ -;;; test-ai-vterm--show-or-create.el --- Tests for cj/--ai-vterm-show-or-create -*- lexical-binding: t; -*- - -;;; Commentary: -;; Tests the show-or-create branching: -;; -;; - buffer absent -> vterm called, agent command sent -;; - buffer present, live -> vterm not called, buffer displayed -;; - buffer present, dead -> old buffer killed, vterm recreates -;; -;; vterm functions are stubbed so the test does no process spawning. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) - -;; vterm isn't loaded in batch -- provide stubs so cl-letf has overrides. -(unless (fboundp 'vterm) - (defun vterm (&optional _name) nil)) -(unless (fboundp 'vterm-send-string) - (defun vterm-send-string (_s &optional _) nil)) -(unless (fboundp 'vterm-send-return) - (defun vterm-send-return () nil)) - -(defmacro test-ai-vterm--with-mock-vterm (vars &rest body) - "Run BODY with vterm + send-string + send-return mocked. - -VARS is a plist of capture variable names: :calls, :strings, :returns, -:default-dir. The test references these names directly inside BODY." - (declare (indent 1) (debug t)) - (let ((calls (plist-get vars :calls)) - (strings (plist-get vars :strings)) - (returns (plist-get vars :returns)) - (ddir (plist-get vars :default-dir))) - `(let ((,calls '()) - (,strings '()) - (,returns 0) - (,ddir nil)) - (cl-letf (((symbol-function 'vterm) - (lambda (&optional name) - (push name ,calls) - (setq ,ddir default-directory) - (with-current-buffer (get-buffer-create name) - (current-buffer)))) - ((symbol-function 'vterm-send-string) - (lambda (s &optional _) (push s ,strings))) - ((symbol-function 'vterm-send-return) - (lambda () (cl-incf ,returns)))) - ,@body)))) - -(defun test-ai-vterm--cleanup (name) - "Kill buffer NAME if it exists." - (when (get-buffer name) - (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, 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 - :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-launch-command "/tmp/some-project")))) - (should (= returns 1)) - (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 () - "Normal: buffer exists with live process -> vterm not called." - (let ((name "agent [reuse-test]")) - (test-ai-vterm--cleanup name) - (unwind-protect - (let ((buf (get-buffer-create name))) - (cl-letf (((symbol-function 'cj/--ai-vterm-process-live-p) - (lambda (b) (and (eq b buf) t)))) - (test-ai-vterm--with-mock-vterm (:calls calls :strings strings - :returns returns :default-dir _ddir) - (cj/--ai-vterm-show-or-create "/tmp/reuse" name) - (should (null calls)) - (should (null strings)) - (should (= returns 0))))) - (test-ai-vterm--cleanup name)))) - -(ert-deftest test-ai-vterm--show-or-create-recreates-when-process-dead () - "Boundary: buffer exists with dead process -> killed and recreated." - (let ((name "agent [dead-test]")) - (test-ai-vterm--cleanup name) - (unwind-protect - (let ((stale (get-buffer-create name))) - (cl-letf (((symbol-function 'cj/--ai-vterm-process-live-p) - (lambda (_b) nil))) - (test-ai-vterm--with-mock-vterm (:calls calls :strings strings - :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-launch-command "/tmp/dead")))) - (should (= returns 1)) - (should-not (buffer-live-p stale))))) - (test-ai-vterm--cleanup name)))) - -(ert-deftest test-ai-vterm--show-or-create-preserves-selected-window () - "Regression: vterm's pop-to-buffer-same-window must not bury the dashboard. - -Real `vterm' replaces the selected window's buffer as a side-effect of -construction. On a fresh-boot frame (one window showing the dashboard), -that side-effect previously left the original window pointing at the new -agent buffer; the dashboard was buried, the alist-routed split then -created a second window also showing agent. The wrapper must restore -the original window state before `display-buffer' fires so dashboard -stays put and the alist places agent into a fresh right-side split. - -This test stubs `vterm' to mimic the pop-to-buffer-same-window side-effect -and asserts the originally-selected window still shows its original buffer -after `cj/--ai-vterm-show-or-create' returns." - (let ((agent-name "agent [preserve-window-test]") - (orig-name "*test-original-buffer*")) - (test-ai-vterm--cleanup agent-name) - (when (get-buffer orig-name) (kill-buffer orig-name)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (let ((orig-buf (get-buffer-create orig-name)) - (orig-win (selected-window))) - (set-window-buffer orig-win orig-buf) - (cl-letf - (((symbol-function 'vterm) - (lambda (&optional name) - (let ((buf (get-buffer-create name))) - (set-window-buffer (selected-window) buf) - buf))) - ((symbol-function 'vterm-send-string) - (lambda (_s &optional _) nil)) - ((symbol-function 'vterm-send-return) - (lambda () nil))) - (cj/--ai-vterm-show-or-create "/tmp/preserve" agent-name) - (should (eq (window-buffer orig-win) orig-buf))))) - (test-ai-vterm--cleanup agent-name) - (when (get-buffer orig-name) (kill-buffer orig-name))))) - -(ert-deftest test-ai-vterm--show-or-create-returns-buffer () - "Normal: return value is the vterm buffer." - (let ((name "agent [return-test]")) - (test-ai-vterm--cleanup name) - (unwind-protect - (test-ai-vterm--with-mock-vterm (:calls _c :strings _s - :returns _r :default-dir _d) - (let ((result (cj/--ai-vterm-show-or-create "/tmp/return" name))) - (should (bufferp result)) - (should (equal (buffer-name result) name)))) - (test-ai-vterm--cleanup name)))) - -(provide 'test-ai-vterm--show-or-create) -;;; test-ai-vterm--show-or-create.el ends here diff --git a/tests/test-ai-vterm--single-window-toggle.el b/tests/test-ai-vterm--single-window-toggle.el deleted file mode 100644 index 928656f2..00000000 --- a/tests/test-ai-vterm--single-window-toggle.el +++ /dev/null @@ -1,186 +0,0 @@ -;;; test-ai-vterm--single-window-toggle.el --- F9 toggle round-trip when agent is the only window -*- lexical-binding: t; -*- - -;;; Commentary: -;; Regression coverage for the bug where toggling off a single-window -;; agent (bury) then toggling on again redisplays the agent in a side -;; split instead of restoring the full-frame layout. -;; -;; The fix introduces a `cj/--ai-vterm-last-was-bury' flag set at -;; toggle-off when `one-window-p' was true. At toggle-on the display -;; action consumes the flag and, if the frame is still single-window, -;; replaces the current window's buffer in place rather than calling -;; `display-buffer-in-direction'. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(require 'ai-vterm) -(require 'testutil-vterm-buffers) - -;;; Normal Cases - -(ert-deftest test-ai-vterm--single-window-toggle-normal-roundtrip-preserves-fullscreen () - "Normal: agent in the only window, F9 (off), F9 (on) -> still single window with agent. - -Reproduces Craig's report. Before the original fix the toggle-on path -fell through to `display-buffer-in-direction', which split the lone -window into two and left the agent in a side panel. Before the -follow-up fix the toggle-off path could no-op entirely when -`bury-buffer' couldn't find a buffer to switch to, so the user saw -\"F9 does nothing\". The dispatcher now forces the swap to a non- -agent buffer after bury so the toggle-off is observable in real and -batch use both." - (cj/test--kill-agent-buffers) - (let ((agent-name "agent [single-window-roundtrip]") - (cj/--ai-vterm-last-was-bury nil) - (cj/--ai-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (let ((agent-buf (get-buffer-create agent-name))) - (set-window-buffer (selected-window) agent-buf) - (should (one-window-p)) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - ;; Toggle off -- the dispatcher's force-swap should put the - ;; window on a non-agent buffer. - (cj/test--call-as-gui #'cj/ai-vterm) - (should (one-window-p)) - (should-not (cj/--ai-vterm-displayed-agent-window)) - (should (eq cj/--ai-vterm-last-was-bury t)) - ;; Toggle on -- should restore agent in the same lone window. - (cj/test--call-as-gui #'cj/ai-vterm) - (should (one-window-p)) - (let ((win (cj/--ai-vterm-displayed-agent-window))) - (should (windowp win)) - (should (eq (window-buffer win) agent-buf))) - ;; Flag consumed by the display-saved action. - (should-not cj/--ai-vterm-last-was-bury)))) - (cj/test--kill-agent-buffers)))) - -(ert-deftest test-ai-vterm--single-window-toggle-off-swaps-window-buffer () - "Normal: toggle-off in single-window state forces the window onto a non- -agent buffer when `bury-buffer' itself didn't swap. - -Catches the regression Craig reported after the original fix shipped: -F9 in a lone-window agent did nothing visible. The fix layer here -ensures the displayed buffer changes -- so the next F9 sees an empty -agent-window state and can route through the display-saved path." - (cj/test--kill-agent-buffers) - (let ((agent-name "agent [bury-swap-observable]") - (cj/--ai-vterm-last-was-bury nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (let* ((agent-buf (get-buffer-create agent-name)) - (win (selected-window))) - (set-window-buffer win agent-buf) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - (cj/test--call-as-gui #'cj/ai-vterm)) - (should (window-live-p win)) - (should-not (cj/--ai-vterm-buffer-p (window-buffer win))))) - (cj/test--kill-agent-buffers)))) - -(ert-deftest test-ai-vterm--single-window-toggle-normal-flag-set-on-bury () - "Normal: single-window toggle-off sets the bury flag." - (cj/test--kill-agent-buffers) - (let ((agent-name "agent [bury-flag-set]") - (cj/--ai-vterm-last-was-bury nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (let ((agent-buf (get-buffer-create agent-name))) - (set-window-buffer (selected-window) agent-buf) - (let ((display-buffer-alist (cj/--ai-vterm-display-rule-list))) - (cj/test--call-as-gui #'cj/ai-vterm) - (should (eq cj/--ai-vterm-last-was-bury t))))) - (cj/test--kill-agent-buffers)))) - -(ert-deftest test-ai-vterm--single-window-toggle-normal-flag-cleared-on-multi-window-off () - "Normal: multi-window toggle-off clears the bury flag. -Mirrors the existing `delete-window' branch of the dispatcher -- -the flag should not carry over a prior bury into a delete-window -toggle-off." - (cj/test--kill-agent-buffers) - (let ((agent-name "agent [bury-flag-clear]") - (left-name "*test-sw-left*") - (cj/--ai-vterm-last-was-bury t)) ; stale t from prior bury - (unwind-protect - (save-window-excursion - (delete-other-windows) - (let ((agent-buf (get-buffer-create agent-name)) - (left-buf (get-buffer-create left-name))) - (set-window-buffer (selected-window) left-buf) - (let* ((agent-win (split-window (selected-window) nil 'right)) - (display-buffer-alist (cj/--ai-vterm-display-rule-list))) - (set-window-buffer agent-win agent-buf) - (select-window agent-win) - (cj/test--call-as-gui #'cj/ai-vterm) - (should-not cj/--ai-vterm-last-was-bury)))) - (when (get-buffer left-name) (kill-buffer left-name)) - (cj/test--kill-agent-buffers)))) - -;;; Boundary Cases - -(ert-deftest test-ai-vterm--single-window-toggle-boundary-flag-respected-only-when-still-one-window () - "Boundary: if the frame got split between toggle-off and toggle-on, the -saved-direction split applies as usual. The flag is a fast-path for the -genuine single-window case, not an override for every redisplay." - (cj/test--kill-agent-buffers) - (let ((agent-name "agent [flag-fallback]") - (cj/--ai-vterm-last-was-bury t) ; flag pretends prior bury - (cj/--ai-vterm-last-direction 'right) - (cj/--ai-vterm-last-size 40)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (let* ((other-buf (get-buffer-create "*test-sw-other*")) - (agent-buf (get-buffer-create agent-name))) - (set-window-buffer (selected-window) other-buf) - ;; Frame is split (two windows) -- single-window precondition - ;; for the flag no longer holds. - (split-window-right) - (should-not (one-window-p)) - (let (received-buf - (display-buffer-alist (cj/--ai-vterm-display-rule-list))) - (cl-letf (((symbol-function 'display-buffer-in-direction) - (lambda (b _a) - (setq received-buf b) - (selected-window)))) - (cj/--ai-vterm-display-saved agent-buf nil)) - ;; The saved-direction split path ran (display-buffer-in-direction - ;; was called) rather than the in-place fast path. - (should (eq received-buf agent-buf)) - ;; And the flag is cleared either way. - (should-not cj/--ai-vterm-last-was-bury)))) - (when (get-buffer "*test-sw-other*") (kill-buffer "*test-sw-other*")) - (cj/test--kill-agent-buffers)))) - -(ert-deftest test-ai-vterm--single-window-toggle-boundary-flag-not-set-when-bury-not-used () - "Boundary: a fresh dispatcher run with the agent displayed multi-window leaves -the flag nil (no spurious set)." - (cj/test--kill-agent-buffers) - (let ((agent-name "agent [bury-flag-untouched]") - (cj/--ai-vterm-last-was-bury nil)) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (let ((agent-buf (get-buffer-create agent-name)) - (left-buf (get-buffer-create "*test-sw-untouched-left*"))) - (set-window-buffer (selected-window) left-buf) - (let* ((agent-win (split-window (selected-window) nil 'right)) - (display-buffer-alist (cj/--ai-vterm-display-rule-list))) - (set-window-buffer agent-win agent-buf) - (select-window agent-win) - (cj/test--call-as-gui #'cj/ai-vterm) - (should-not cj/--ai-vterm-last-was-bury)))) - (when (get-buffer "*test-sw-untouched-left*") - (kill-buffer "*test-sw-untouched-left*")) - (cj/test--kill-agent-buffers)))) - -(provide 'test-ai-vterm--single-window-toggle) -;;; test-ai-vterm--single-window-toggle.el ends here diff --git a/tests/test-ai-vterm--sort-candidates.el b/tests/test-ai-vterm--sort-candidates.el deleted file mode 100644 index 26953604..00000000 --- a/tests/test-ai-vterm--sort-candidates.el +++ /dev/null @@ -1,74 +0,0 @@ -;;; test-ai-vterm--sort-candidates.el --- Tests for cj/--ai-vterm-sort-candidates -*- lexical-binding: t; -*- - -;;; 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 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: - -(require 'ert) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) - -(ert-deftest test-ai-vterm--sort-candidates-active-first-then-alpha () - "Normal: the one project with a live session leads; the rest go alpha." - (let ((cj/ai-vterm-tmux-session-prefix "aiv-")) - (should (equal (cj/--ai-vterm-sort-candidates - '("/c/foo" "/c/bar" "/c/baz") - '("aiv-bar")) - '("/c/bar" "/c/baz" "/c/foo"))))) - -(ert-deftest test-ai-vterm--sort-candidates-multiple-active-each-group-alpha () - "Normal: both groups sort alphabetically internally." - (let ((cj/ai-vterm-tmux-session-prefix "aiv-")) - (should (equal (cj/--ai-vterm-sort-candidates - '("/c/foo" "/c/bar" "/c/baz") - '("aiv-foo" "aiv-bar")) - '("/c/bar" "/c/foo" "/c/baz"))))) - -(ert-deftest test-ai-vterm--sort-candidates-no-sessions-is-plain-alpha () - "Boundary: nil session set -> a plain alphabetical list." - (let ((cj/ai-vterm-tmux-session-prefix "aiv-")) - (should (equal (cj/--ai-vterm-sort-candidates - '("/c/foo" "/c/bar") nil) - '("/c/bar" "/c/foo"))))) - -(ert-deftest test-ai-vterm--sort-candidates-empty-dirs-yields-nil () - "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-")) - (should (cj/--ai-vterm-session-active-p "/c/foo" '("aiv-bar" "aiv-foo"))) - (should-not (cj/--ai-vterm-session-active-p "/c/qux" '("aiv-bar" "aiv-foo"))) - (should-not (cj/--ai-vterm-session-active-p "/c/foo" nil)))) - -(provide 'test-ai-vterm--sort-candidates) -;;; test-ai-vterm--sort-candidates.el ends here diff --git a/tests/test-ai-vterm--terminal-guard.el b/tests/test-ai-vterm--terminal-guard.el deleted file mode 100644 index 5a7971bf..00000000 --- a/tests/test-ai-vterm--terminal-guard.el +++ /dev/null @@ -1,78 +0,0 @@ -;;; test-ai-vterm--terminal-guard.el --- Tests for the terminal-frame guard -*- lexical-binding: t; -*- - -;;; Commentary: -;; AI-vterm launches a graphical vterm side window, so it is GUI-only. -;; `cj/--ai-vterm-refuse-in-terminal' signals a `user-error' when the -;; current frame is a terminal frame; each interactive entry point -;; (`cj/ai-vterm', `cj/ai-vterm-pick-project', `cj/ai-vterm-close') -;; calls it first so F9 and friends decline -- with a message -- in a -;; terminal frame instead of launching a vterm. The check is per-frame -;; at command time, not at load, so a daemon serving both GUI and -;; terminal frames keeps the launcher working in its GUI frames. -;; -;; `env-terminal-p' is mocked so the tests are deterministic regardless -;; of whether the run itself is graphical (batch runs are terminal). - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(require 'ai-vterm) - -;; ---------------------------- the guard helper ---------------------------- - -(ert-deftest test-ai-vterm--refuse-in-terminal-errors-in-terminal-frame () - "Error: terminal frame -> `user-error', so the command declines." - (cl-letf (((symbol-function 'env-terminal-p) (lambda () t))) - (should-error (cj/--ai-vterm-refuse-in-terminal) :type 'user-error))) - -(ert-deftest test-ai-vterm--refuse-in-terminal-passes-in-gui-frame () - "Normal: GUI frame -> returns nil, no error, command proceeds." - (cl-letf (((symbol-function 'env-terminal-p) (lambda () nil))) - (should-not (cj/--ai-vterm-refuse-in-terminal)))) - -;; ------------------- the three interactive entry points ------------------- - -(ert-deftest test-ai-vterm-f9-declines-in-terminal-without-dispatching () - "Error: F9 in a terminal frame errors and never reaches dispatch." - (let ((dispatched nil)) - (cl-letf (((symbol-function 'env-terminal-p) (lambda () t)) - ((symbol-function 'cj/--ai-vterm-dispatch) - (lambda () (setq dispatched t) '(pick-project)))) - (should-error (cj/ai-vterm) :type 'user-error) - (should-not dispatched)))) - -(ert-deftest test-ai-vterm-pick-project-declines-in-terminal-without-prompting () - "Error: C-F9 in a terminal frame errors and never reaches the picker." - (let ((prompted nil)) - (cl-letf (((symbol-function 'env-terminal-p) (lambda () t)) - ((symbol-function 'cj/--ai-vterm-pick-project) - (lambda () (setq prompted t) "/tmp"))) - (should-error (cj/ai-vterm-pick-project) :type 'user-error) - (should-not prompted)))) - -(ert-deftest test-ai-vterm-close-declines-in-terminal-without-targeting () - "Error: M-F9 in a terminal frame errors and never reaches close-target." - (let ((targeted nil)) - (cl-letf (((symbol-function 'env-terminal-p) (lambda () t)) - ((symbol-function 'cj/--ai-vterm-close-target) - (lambda () (setq targeted t) nil))) - (should-error (cj/ai-vterm-close) :type 'user-error) - (should-not targeted)))) - -(ert-deftest test-ai-vterm-f9-passes-guard-in-gui-frame () - "Normal: F9 in a GUI frame passes the guard and reaches dispatch." - (let ((dispatched nil)) - (cl-letf (((symbol-function 'env-terminal-p) (lambda () nil)) - ((symbol-function 'cj/--ai-vterm-dispatch) - (lambda () (setq dispatched t) '(pick-project))) - ((symbol-function 'cj/ai-vterm-pick-project) - (lambda (&optional _arg) nil))) - (cj/ai-vterm) - (should dispatched)))) - -(provide 'test-ai-vterm--terminal-guard) -;;; test-ai-vterm--terminal-guard.el ends here diff --git a/tests/test-ai-vterm--tmux-session-name.el b/tests/test-ai-vterm--tmux-session-name.el deleted file mode 100644 index 073dc312..00000000 --- a/tests/test-ai-vterm--tmux-session-name.el +++ /dev/null @@ -1,65 +0,0 @@ -;;; test-ai-vterm--tmux-session-name.el --- Tests for cj/--ai-vterm-tmux-session-name -*- lexical-binding: t; -*- - -;;; Commentary: -;; The tmux session name is `cj/ai-vterm-tmux-session-prefix' followed by -;; 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. 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: - -(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: basename gets the configured prefix." - (let ((cj/ai-vterm-tmux-session-prefix "aiv-")) - (should (equal (cj/--ai-vterm-tmux-session-name "/home/cjennings/projects/foo") - "aiv-foo")))) - -(ert-deftest test-ai-vterm--tmux-session-name-trailing-slash () - "Boundary: trailing slash collapses before basename extraction." - (let ((cj/ai-vterm-tmux-session-prefix "aiv-")) - (should (equal (cj/--ai-vterm-tmux-session-name "/home/cjennings/projects/foo/") - "aiv-foo")))) - -(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")))) - -(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." - (let ((cj/ai-vterm-tmux-session-prefix "aiv-")) - (should (equal (cj/--ai-vterm-tmux-session-name "/tmp/my work") - "aiv-my-work")))) - -(ert-deftest test-ai-vterm--tmux-session-name-multiple-spaces-collapse () - "Boundary: a run of whitespace collapses to a single hyphen." - (let ((cj/ai-vterm-tmux-session-prefix "aiv-")) - (should (equal (cj/--ai-vterm-tmux-session-name "/tmp/a b\tc") - "aiv-a-b-c")))) - -(ert-deftest test-ai-vterm--tmux-session-name-honors-custom-prefix () - "Normal: a non-default prefix is what gets prepended." - (let ((cj/ai-vterm-tmux-session-prefix "em-")) - (should (equal (cj/--ai-vterm-tmux-session-name "/home/cjennings/projects/foo") - "em-foo")))) - -(provide 'test-ai-vterm--tmux-session-name) -;;; test-ai-vterm--tmux-session-name.el ends here diff --git a/tests/test-auto-dim-config.el b/tests/test-auto-dim-config.el index 4648f062..532e7dfa 100644 --- a/tests/test-auto-dim-config.el +++ b/tests/test-auto-dim-config.el @@ -6,6 +6,10 @@ ;; fringe from the dimmed faces to avoid flicker on this non-pgtk build, and ;; enable the global mode. Guarded with `skip-unless' because the fork lives ;; in ~/code and may be absent on a clean checkout. +;; +;; The vterm dim-integration tests were removed when the terminal engine moved +;; to ghostel: ghostel bakes its palette per-terminal (no per-window color +;; hook), so terminal buffers no longer participate in window dimming. ;;; Code: @@ -30,108 +34,6 @@ (when (fboundp 'auto-dim-other-buffers-mode) (auto-dim-other-buffers-mode -1)))) -(ert-deftest test-auto-dim-config-vterm-dimmed-p-all-windows-dimmed () - "Normal: a vterm buffer is dimmed when all displayed windows are dimmed." - (skip-unless (file-directory-p test-auto-dim--fork)) - (require 'auto-dim-config) - (let ((major-mode 'vterm-mode)) - (cl-letf (((symbol-function 'get-buffer-window-list) - (lambda (&rest _) '(left right))) - ((symbol-function 'window-parameter) - (lambda (window parameter) - (and (eq parameter 'adob--dim) - (memq window '(left right)))))) - (should (cj/auto-dim--vterm-buffer-dimmed-p))))) - -(ert-deftest test-auto-dim-config-vterm-dimmed-p-undimmed-window-keeps-buffer-bright () - "Normal: a selected/undimmed vterm window keeps the buffer bright." - (skip-unless (file-directory-p test-auto-dim--fork)) - (require 'auto-dim-config) - (let ((major-mode 'vterm-mode)) - (cl-letf (((symbol-function 'get-buffer-window-list) - (lambda (&rest _) '(left right))) - ((symbol-function 'window-parameter) - (lambda (window parameter) - (and (eq parameter 'adob--dim) - (eq window 'right))))) - (should-not (cj/auto-dim--vterm-buffer-dimmed-p))))) - -(ert-deftest test-auto-dim-config-vterm-get-color-dims-only-dimmed-vterm-buffers () - "Normal: vterm color advice dims only buffers marked dimmed." - (skip-unless (file-directory-p test-auto-dim--fork)) - (require 'auto-dim-config) - (let ((major-mode 'vterm-mode) - (cj/auto-dim-vterm-foreground-blend 1.0)) - (cl-letf (((symbol-function 'cj/auto-dim--vterm-buffer-dimmed-p) - (lambda () t)) - ((symbol-function 'cj/auto-dim--face-color) - (lambda (&rest _) "#555555"))) - (should (equal "#555555" - (cj/auto-dim--vterm-get-color - (lambda (&rest _) "#ffffff") 7 :foreground)))) - (cl-letf (((symbol-function 'cj/auto-dim--vterm-buffer-dimmed-p) - (lambda () nil))) - (should (equal "#ffffff" - (cj/auto-dim--vterm-get-color - (lambda (&rest _) "#ffffff") 7 :foreground)))))) - -(ert-deftest test-auto-dim-config-vterm-post-command-schedules-refresh-on-window-change () - "Normal: post-command vterm refresh schedules only after selection changes." - (skip-unless (file-directory-p test-auto-dim--fork)) - (require 'auto-dim-config) - (let ((cj/auto-dim--last-selected-window 'old) - (calls 0)) - (cl-letf (((symbol-function 'selected-window) - (lambda () 'new)) - ((symbol-function 'cj/auto-dim--schedule-vterm-refresh) - (lambda (&optional _) (setq calls (1+ calls))))) - (cj/auto-dim--refresh-vterm-on-command) - (cj/auto-dim--refresh-vterm-on-command)) - (should (eq cj/auto-dim--last-selected-window 'new)) - (should (= calls 1)))) - -(ert-deftest test-auto-dim-config-vterm-refresh-runs-auto-dim-before-invalidate () - "Normal: deferred vterm refresh updates auto-dim before invalidating vterm." - (skip-unless (file-directory-p test-auto-dim--fork)) - (require 'auto-dim-config) - (let (events) - (cl-letf (((symbol-function 'adob--update) - (lambda () (push 'adob events))) - ((symbol-function 'cj/auto-dim--refresh-vterm-windows) - (lambda (&optional _) (push 'vterm events)))) - (cj/auto-dim--refresh-vterm-after-auto-dim)) - (should (equal events '(vterm adob))))) - -(ert-deftest test-auto-dim-config-vterm-refresh-nudges-size-for-full-redraw () - "Normal: vterm refresh nudges size to force full-grid redraw." - (skip-unless (file-directory-p test-auto-dim--fork)) - (require 'auto-dim-config) - (let ((calls nil) - (vterm-min-window-width 80)) - (with-temp-buffer - (setq major-mode 'vterm-mode) - (setq-local vterm--term 'term) - (let ((buffer (current-buffer))) - (cl-letf (((symbol-function 'window-list) - (lambda (&rest _) '(vterm-window))) - ((symbol-function 'window-buffer) - (lambda (_) buffer)) - ((symbol-function 'window-live-p) - (lambda (_) t)) - ((symbol-function 'window-body-height) - (lambda (_) 24)) - ((symbol-function 'window-body-width) - (lambda (_) 100)) - ((symbol-function 'vterm--get-margin-width) - (lambda () 3)) - ((symbol-function 'vterm--set-size) - (lambda (term height width) - (push (list term height width) calls)))) - (cj/auto-dim--refresh-vterm-windows)))) - (should (equal (nreverse calls) - '((term 25 97) - (term 24 97)))))) - (ert-deftest test-auto-dim-config-never-dim-dashboard-exempts-dashboard () "Normal: the *dashboard* buffer is exempt from dimming." (skip-unless (file-directory-p test-auto-dim--fork)) diff --git a/tests/test-dashboard-config-launchers.el b/tests/test-dashboard-config-launchers.el index cb925075..0ac37f87 100644 --- a/tests/test-dashboard-config-launchers.el +++ b/tests/test-dashboard-config-launchers.el @@ -83,7 +83,7 @@ Slack and Linear sharing the last row." (let ((map (make-sparse-keymap)) (calls nil)) (cl-letf (((symbol-function 'projectile-switch-project) (lambda (&rest _) (push 'code calls))) ((symbol-function 'dirvish) (lambda (&rest _) (push 'files calls))) - ((symbol-function 'vterm) (lambda (&rest _) (push 'term calls))) + ((symbol-function 'ghostel) (lambda (&rest _) (push 'term calls))) ((symbol-function 'cj/main-agenda-display) (lambda (&rest _) (push 'agenda calls))) ((symbol-function 'cj/elfeed-open) (lambda (&rest _) (push 'feeds calls))) ((symbol-function 'calibredb) (lambda (&rest _) (push 'books calls))) diff --git a/tests/test-init-module-headers.el b/tests/test-init-module-headers.el index ef5a7132..2680a19c 100644 --- a/tests/test-init-module-headers.el +++ b/tests/test-init-module-headers.el @@ -56,7 +56,6 @@ "selection-framework" "modeline-config" "mousetrap-mode" - "popper-config" "dashboard-config" "nerd-icons-config" ;; Batch 5 — Dev entry-points, diff, help, lint, VC (Layer 2) @@ -96,7 +95,7 @@ "hugo-config" ;; Batch 8 — Domain / integration / optional modules (Layer 2-4) "ai-config" - "ai-vterm" + "ai-term" "browser-config" "calendar-sync" "calibredb-epub-config" @@ -130,7 +129,7 @@ "tramp-config" "transcription-config" "video-audio-recording" - "vterm-config" + "term-config" "weather-config" "wrap-up") "Modules annotated with the load-graph header contract. diff --git a/tests/test-term-tmux-history.el b/tests/test-term-tmux-history.el new file mode 100644 index 00000000..2c9c38f8 --- /dev/null +++ b/tests/test-term-tmux-history.el @@ -0,0 +1,312 @@ +;;; test-term-tmux-history.el --- Tests for term-config tmux history + menu UX -*- lexical-binding: t; -*- + +;;; Commentary: +;; Exercises the term-config (ghostel) terminal UX: the Emacs-owned tmux +;; history buffer, the copy-mode-dwim engine pick, the tmux pane-id / +;; attached-client predicates, and the C-; x menu bindings. +;; +;; ghostel is required (which defines `ghostel-mode-map' / +;; `ghostel-keymap-exceptions' and lets term-config's `with-eval-after-load' +;; fire) before term-config. `(require 'ghostel)' does not load the native +;; module; tmux is mocked via `process-file', so nothing spawns. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'package) + +(setq package-user-dir (expand-file-name "elpa" user-emacs-directory)) +(package-initialize) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(setq load-prefer-newer t) +(require 'ghostel) +(require 'term-config) +(require 'testutil-ghostel-buffers) + +(defmacro test-term-tmux-history--with-tmux-mock (responses &rest body) + "Run BODY with `process-file' mocked for tmux RESPONSES. + +RESPONSES is an alist of (ARGS EXIT-CODE OUTPUT)." + (declare (indent 1)) + `(let ((calls nil)) + (cl-letf (((symbol-function 'process-file) + (lambda (program _infile destination _display &rest args) + (push (cons program args) calls) + (let* ((entry (seq-find + (lambda (candidate) + (equal (car candidate) args)) + ,responses)) + (exit-code (or (cadr entry) 1)) + (output (or (caddr entry) ""))) + (when destination + (let ((buffer (cond + ((eq destination t) (current-buffer)) + ((bufferp destination) destination) + ((consp destination) (car destination))))) + (when (bufferp buffer) + (with-current-buffer buffer + (insert output))))) + exit-code)))) + ,@body))) + +(ert-deftest test-term-tmux-history--pane-id-for-tty-matches-client () + "Normal: current terminal pty maps to the active pane for that tmux client." + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/1\t%1\n/dev/pts/8\t%8\n")) + (should (equal (cj/term--tmux-pane-id-for-tty "/dev/pts/8") "%8")))) + +(ert-deftest test-term-tmux-history--capture-pane-uses-full-history () + "Normal: capture asks tmux for joined full pane history." + (test-term-tmux-history--with-tmux-mock + '((("capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" "%8") 0 + "first line\nsecond line\n")) + (should (equal (cj/term--tmux-capture-pane "%8") + "first line\nsecond line\n")))) + +(ert-deftest test-term-tmux-history-open-renders-read-only-history-buffer () + "Normal: command renders tmux history in a normal Emacs buffer." + (let ((origin (cj/test--make-fake-ghostel-buffer "*test-term-history-origin*"))) + (unwind-protect + (save-window-excursion + (switch-to-buffer origin) + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/8\t%8\n") + (("capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" "%8") 0 + "history http://example.com\n")) + (cj/term-tmux-history) + (should (eq major-mode 'cj/term-tmux-history-mode)) + (should buffer-read-only) + (should (string-match-p "history http://example.com" + (buffer-string)))))) + (cj/test--kill-buffers-matching-prefix "*terminal tmux history") + (when (buffer-live-p origin) + (kill-buffer origin))))) + +(ert-deftest test-term-tmux-history-replaces-origin-buffer-in-same-window () + "Normal: the history view replaces the origin in the selected window. + +`cj/term-tmux-history' uses `switch-to-buffer' so reading scrollback keeps +the terminal's frame slot rather than splitting or popping a new window." + (let ((origin (cj/test--make-fake-ghostel-buffer "*test-term-history-inplace*"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (switch-to-buffer origin) + (let ((win (selected-window))) + (should (eq (window-buffer win) origin)) + (should (one-window-p)) + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/8\t%8\n") + (("capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" "%8") 0 + "scrollback line\n")) + (cj/term-tmux-history))) + (should (one-window-p)) + (should (eq (selected-window) win)) + (should (string-prefix-p + "*terminal tmux history:" + (buffer-name (window-buffer win)))))) + (cj/test--kill-buffers-matching-prefix "*terminal tmux history") + (when (buffer-live-p origin) + (kill-buffer origin))))) + +(ert-deftest test-term-tmux-history-quit-returns-to-origin () + "Normal: q / / C-g (cj/term-tmux-history-quit) kills the history +buffer and restores the origin buffer, window, and point." + (let ((origin (get-buffer-create "*test-term-history-return*"))) + (unwind-protect + (let ((history (get-buffer-create "*terminal tmux history: test*"))) + (with-current-buffer origin + (erase-buffer) + (insert "origin") + (goto-char (point-min))) + (switch-to-buffer origin) + (let ((origin-window (selected-window))) + (with-current-buffer history + (cj/term-tmux-history-mode) + (let ((inhibit-read-only t)) + (insert "alpha\nbeta\ngamma\n")) + (setq-local cj/term-tmux-history--origin-buffer origin) + (setq-local cj/term-tmux-history--origin-window origin-window) + (setq-local cj/term-tmux-history--origin-point (point-min)) + (cj/term-tmux-history-quit)) + (should-not (buffer-live-p history)) + (should (eq (current-buffer) origin)) + (should (= (point) (point-min))))) + (when (buffer-live-p origin) + (kill-buffer origin))))) + +(ert-deftest test-term-tmux-history-mode-keymap () + "Normal: in the history buffer M-w copies without quitting; q, , +and C-g quit back to the terminal; RET is left unbound (no special exit)." + (should (eq (keymap-lookup cj/term-tmux-history-mode-map "M-w") + #'kill-ring-save)) + (should (eq (keymap-lookup cj/term-tmux-history-mode-map "q") + #'cj/term-tmux-history-quit)) + (should (eq (keymap-lookup cj/term-tmux-history-mode-map "") + #'cj/term-tmux-history-quit)) + (should (eq (keymap-lookup cj/term-tmux-history-mode-map "C-g") + #'cj/term-tmux-history-quit)) + (should-not (keymap-lookup cj/term-tmux-history-mode-map "RET"))) + +(ert-deftest test-term-keymap-includes-history-and-copy-bindings () + "Normal: the personal terminal map owns the high-level UX commands, and C-; +reaches Emacs inside ghostel buffers so the prefix works there." + (should (member "C-;" ghostel-keymap-exceptions)) + (should (eq (keymap-lookup cj/custom-keymap "x h") #'cj/term-tmux-history)) + (should (eq (keymap-lookup cj/custom-keymap "x c") #'cj/term-copy-mode-dwim)) + (should (equal (keymap-lookup ghostel-mode-map "C-;") cj/custom-keymap)) + (should (eq (keymap-lookup ghostel-mode-map "C-; x h") #'cj/term-tmux-history)) + (should (eq (keymap-lookup ghostel-mode-map "C-; x c") #'cj/term-copy-mode-dwim))) + +(ert-deftest test-term-keymap-prompt-navigation () + "Normal: n/p navigate prompts, capital N creates a new terminal buffer." + (should (eq (keymap-lookup cj/custom-keymap "x n") #'ghostel-next-prompt)) + (should (eq (keymap-lookup cj/custom-keymap "x p") #'ghostel-previous-prompt)) + (should (eq (keymap-lookup cj/custom-keymap "x N") #'ghostel))) + +(ert-deftest test-term-current-tmux-pane-id-rejects-non-ghostel-buffer () + "Error: pane-id lookup refuses a buffer that is not in `ghostel-mode'." + (with-temp-buffer + (should-error (cj/term--current-tmux-pane-id) :type 'user-error))) + +(ert-deftest test-term-current-tmux-pane-id-accepts-agent-named-buffer () + "Normal: an agent-named ghostel buffer resolves by process TTY. + +The pane lookup keys off the live process TTY, never the buffer name, so a +buffer named `agent [repo]' (ai-term.el's naming) resolves like any other +ghostel-mode terminal." + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/1\t%1\n/dev/pts/8\t%8\n")) + (should (equal (cj/term--current-tmux-pane-id) "%8"))))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-term-in-tmux-p-true-when-client-attached () + "Normal: predicate returns t when tmux reports a client for our tty." + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/8\t%8\n")) + (should (cj/term--in-tmux-p))))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-term-in-tmux-p-nil-when-no-matching-client () + "Boundary: predicate returns nil when tmux runs but our tty has no client." + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/1\t%1\n")) + (should-not (cj/term--in-tmux-p))))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-term-in-tmux-p-nil-when-tmux-fails () + "Error: predicate swallows tmux failures and returns nil." + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 1 + "no server running")) + (should-not (cj/term--in-tmux-p))))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-term-in-tmux-p-nil-when-not-ghostel-mode () + "Boundary: predicate refuses non-ghostel buffers without calling tmux." + (with-temp-buffer + (let ((tmux-called nil)) + (cl-letf (((symbol-function 'process-file) + (lambda (&rest _) (setq tmux-called t) 0))) + (should-not (cj/term--in-tmux-p)) + (should-not tmux-called))))) + +(ert-deftest test-term-copy-mode-dwim-sends-tmux-prefix-when-attached () + "Normal: with tmux attached, dwim writes C-b [ into the pty so tmux enters +its own copy-mode against the full pane history." + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]")) + (sent nil) + (copy-mode-called nil)) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8")) + ((symbol-function 'ghostel-send-string) + (lambda (s) (push s sent))) + ((symbol-function 'ghostel-copy-mode) + (lambda () (setq copy-mode-called t)))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/8\t%8\n")) + (cj/term-copy-mode-dwim) + (should (equal sent '("\C-b["))) + (should-not copy-mode-called)))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-term-copy-mode-dwim-falls-back-without-tmux () + "Boundary: without tmux, dwim calls `ghostel-copy-mode' and sends nothing." + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]")) + (sent nil) + (copy-mode-called nil)) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8")) + ((symbol-function 'ghostel-send-string) + (lambda (s) (push s sent))) + ((symbol-function 'ghostel-copy-mode) + (lambda () (setq copy-mode-called t)))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 1 + "no server running")) + (cj/term-copy-mode-dwim) + (should-not sent) + (should copy-mode-called)))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(provide 'test-term-tmux-history) +;;; test-term-tmux-history.el ends here diff --git a/tests/test-term-toggle--buffer-filter.el b/tests/test-term-toggle--buffer-filter.el new file mode 100644 index 00000000..2c96ecb3 --- /dev/null +++ b/tests/test-term-toggle--buffer-filter.el @@ -0,0 +1,94 @@ +;;; test-term-toggle--buffer-filter.el --- Tests for F12's buffer filter -*- lexical-binding: t; -*- + +;;; Commentary: +;; Three closely-related helpers determine which terminal buffers F12 +;; manages: the predicate `cj/--term-toggle-buffer-p', the MRU list +;; `cj/--term-toggle-buffers', and the per-frame window finder +;; `cj/--term-toggle-displayed-window'. All three exclude agent- +;; prefixed buffers so agent has its own F9 surface. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(require 'term-config) +(require 'testutil-ghostel-buffers) + +(defun test-term-toggle--cleanup () + "Kill leftover agent- and *test-term- prefixed buffers." + (cj/test--kill-agent-buffers) + (cj/test--kill-test-term-buffers)) + +(ert-deftest test-term-toggle--buffer-p-accepts-ghostel-mode () + "Normal: a ghostel-mode buffer with non-agent name qualifies." + (test-term-toggle--cleanup) + (let ((buf (cj/test--make-fake-ghostel-buffer "*test-term-1*"))) + (unwind-protect + (should (cj/--term-toggle-buffer-p buf)) + (kill-buffer buf)))) + +(ert-deftest test-term-toggle--buffer-p-rejects-agent () + "Boundary: agent-prefixed terminal buffers are excluded from F12's set." + (test-term-toggle--cleanup) + (let ((buf (cj/test--make-fake-ghostel-buffer "agent [project-a]"))) + (unwind-protect + (should-not (cj/--term-toggle-buffer-p buf)) + (kill-buffer buf)))) + +(ert-deftest test-term-toggle--buffer-p-rejects-non-terminal () + "Boundary: a regular buffer (not ghostel-mode, no terminal name prefix) -> nil." + (test-term-toggle--cleanup) + (let ((buf (get-buffer-create "*test-term-regular*"))) + (unwind-protect + (should-not (cj/--term-toggle-buffer-p buf)) + (kill-buffer buf)))) + +(ert-deftest test-term-toggle--buffer-p-rejects-dead-buffer () + "Boundary: nil and dead buffers -> nil." + (should-not (cj/--term-toggle-buffer-p nil)) + (let ((buf (cj/test--make-fake-ghostel-buffer "*test-term-dead*"))) + (kill-buffer buf) + (should-not (cj/--term-toggle-buffer-p buf)))) + +(ert-deftest test-term-toggle--buffers-filters-agent () + "Normal: returns terminal buffers but excludes agent-prefixed ones." + (test-term-toggle--cleanup) + (let ((normal (cj/test--make-fake-ghostel-buffer "*test-term-normal*")) + (agent (cj/test--make-fake-ghostel-buffer "agent [for-test]"))) + (unwind-protect + (let ((result (cj/--term-toggle-buffers))) + (should (memq normal result)) + (should-not (memq agent result))) + (kill-buffer normal) + (kill-buffer agent)))) + +(ert-deftest test-term-toggle--displayed-window-finds-terminal () + "Normal: terminal in a window -> returns that window." + (test-term-toggle--cleanup) + (let ((vt (cj/test--make-fake-ghostel-buffer "*test-term-shown*"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((win (split-window-right))) + (set-window-buffer win vt) + (let ((result (cj/--term-toggle-displayed-window))) + (should (windowp result)) + (should (eq (window-buffer result) vt))))) + (kill-buffer vt)))) + +(ert-deftest test-term-toggle--displayed-window-skips-agent () + "Boundary: only an agent terminal is displayed -> nil (agent not F12-managed)." + (test-term-toggle--cleanup) + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [skip-test]"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((win (split-window-right))) + (set-window-buffer win agent) + (should-not (cj/--term-toggle-displayed-window)))) + (kill-buffer agent)))) + +(provide 'test-term-toggle--buffer-filter) +;;; test-term-toggle--buffer-filter.el ends here diff --git a/tests/test-term-toggle--dispatch.el b/tests/test-term-toggle--dispatch.el new file mode 100644 index 00000000..f13c2840 --- /dev/null +++ b/tests/test-term-toggle--dispatch.el @@ -0,0 +1,53 @@ +;;; test-term-toggle--dispatch.el --- Tests for cj/--term-toggle-dispatch -*- lexical-binding: t; -*- + +;;; Commentary: +;; Pure decision helper for F12. Returns one of (toggle-off . WIN), +;; (show-recent . BUFFER), or (create-new) based on whether a terminal +;; window is currently displayed and whether any terminal buffers are +;; alive. Mocking the underlying helpers keeps the dispatch logic +;; exercisable without touching real windows. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(require 'term-config) +(require 'testutil-ghostel-buffers) + +(ert-deftest test-term-toggle--dispatch-window-displayed-returns-toggle-off () + "Normal: displayed terminal window -> (toggle-off . WIN)." + (let ((sentinel-win 'fake-window)) + (cl-letf (((symbol-function 'cj/--term-toggle-displayed-window) + (lambda (&optional _frame) sentinel-win))) + (should (equal (cj/--term-toggle-dispatch) + (cons 'toggle-off sentinel-win)))))) + +(ert-deftest test-term-toggle--dispatch-no-window-buffer-alive-returns-show-recent () + "Normal: no displayed terminal, at least one alive -> show-recent + first." + (cj/test--kill-test-term-buffers) + (let ((b1 (get-buffer-create "*test-term-mru-1*")) + (b2 (get-buffer-create "*test-term-mru-2*"))) + (unwind-protect + (cl-letf (((symbol-function 'cj/--term-toggle-displayed-window) + (lambda (&optional _frame) nil)) + ((symbol-function 'cj/--term-toggle-buffers) + (lambda () (list b1 b2)))) + (should (equal (cj/--term-toggle-dispatch) + (cons 'show-recent b1)))) + (kill-buffer b1) + (kill-buffer b2)))) + +(ert-deftest test-term-toggle--dispatch-no-window-no-buffer-returns-create-new () + "Boundary: nothing displayed, no alive terminals -> create-new." + (cj/test--kill-test-term-buffers) + (cl-letf (((symbol-function 'cj/--term-toggle-displayed-window) + (lambda (&optional _frame) nil)) + ((symbol-function 'cj/--term-toggle-buffers) + (lambda () nil))) + (should (equal (cj/--term-toggle-dispatch) '(create-new))))) + +(provide 'test-term-toggle--dispatch) +;;; test-term-toggle--dispatch.el ends here diff --git a/tests/test-term-toggle--display.el b/tests/test-term-toggle--display.el new file mode 100644 index 00000000..0943a488 --- /dev/null +++ b/tests/test-term-toggle--display.el @@ -0,0 +1,87 @@ +;;; test-term-toggle--display.el --- Tests for the F12 display-saved action -*- lexical-binding: t; -*- + +;;; Commentary: +;; Covers the F12-side equivalents of the ai-term display tests: +;; geometry capture (window-direction, window-size with 'below +;; default), capture-state writing module-level vars, and the custom +;; display action mapping cardinal -> edge directions. Tests stub +;; `display-buffer-in-direction' to capture the alist that would +;; have reached it. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'term-config) + +(ert-deftest test-term-toggle--capture-state-records-direction-and-size () + "Normal: capture-state writes direction and integer body size." + (save-window-excursion + (delete-other-windows) + (let ((below (split-window (selected-window) nil 'below)) + (cj/--term-toggle-last-direction nil) + (cj/--term-toggle-last-size nil)) + (cj/--term-toggle-capture-state below) + (should (eq cj/--term-toggle-last-direction 'below)) + (should (integerp cj/--term-toggle-last-size)) + (should (= cj/--term-toggle-last-size (window-body-height below)))))) + +(ert-deftest test-term-toggle--capture-state-noop-on-dead-window () + "Boundary: nil window -> state remains unchanged." + (let ((cj/--term-toggle-last-direction 'sentinel) + (cj/--term-toggle-last-size 0.123)) + (cj/--term-toggle-capture-state nil) + (should (eq cj/--term-toggle-last-direction 'sentinel)) + (should (= cj/--term-toggle-last-size 0.123)))) + +(ert-deftest test-term-toggle--display-saved-defaults-when-state-nil () + "Normal: nil state -> direction=bottom, size=cj/term-toggle-window-height." + (let (received-alist + (cj/--term-toggle-last-direction nil) + (cj/--term-toggle-last-size nil) + (cj/term-toggle-window-height 0.7)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/--term-toggle-display-saved 'fake-buf '((inhibit-same-window . t)))) + (should (eq (cdr (assq 'direction received-alist)) 'bottom)) + (should (= (cdr (assq 'window-height received-alist)) 0.7)) + (should (eq (cdr (assq 'inhibit-same-window received-alist)) t)))) + +(ert-deftest test-term-toggle--display-saved-maps-cardinal-to-edge () + "Normal: saved 'below maps to bottom edge; integer size wraps in body-lines." + (let (received-alist + (cj/--term-toggle-last-direction 'below) + (cj/--term-toggle-last-size 12)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/--term-toggle-display-saved 'fake-buf nil)) + (should (eq (cdr (assq 'direction received-alist)) 'bottom)) + (should (equal (cdr (assq 'window-height received-alist)) + '(body-lines . 12))) + (should-not (assq 'window-width received-alist)))) + +(ert-deftest test-term-toggle--display-saved-strips-conflicting-alist-entries () + "Boundary: caller-supplied direction/size are stripped, saved values win." + (let (received-alist + (cj/--term-toggle-last-direction 'right) + (cj/--term-toggle-last-size 30)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/--term-toggle-display-saved + 'fake-buf + '((direction . above) + (window-width . 0.2) + (window-height . 0.3) + (inhibit-same-window . t)))) + (should (eq (cdr (assq 'direction received-alist)) 'rightmost)) + (should (equal (cdr (assq 'window-width received-alist)) + '(body-columns . 30))) + (let ((wh-cells (cl-remove-if-not + (lambda (cell) (eq (car-safe cell) 'window-height)) + received-alist))) + (should (null wh-cells))))) + +(provide 'test-term-toggle--display) +;;; test-term-toggle--display.el ends here diff --git a/tests/test-ui-config--buffer-cursor-state.el b/tests/test-ui-config--buffer-cursor-state.el index ead05741..85286586 100644 --- a/tests/test-ui-config--buffer-cursor-state.el +++ b/tests/test-ui-config--buffer-cursor-state.el @@ -3,12 +3,12 @@ ;;; Commentary: ;; `cj/--buffer-cursor-state' picks the buffer-state symbol that ;; `cj/set-cursor-color-according-to-mode' maps to a cursor color via -;; `cj/buffer-status-colors'. The subtle case: a live vterm buffer is -;; technically `buffer-read-only' (the `vterm-mode' body sets it) but the -;; user can type into it -- keystrokes go to the terminal process -- so it -;; must report a writeable state, not `read-only'. `vterm-copy-mode' is -;; the exception: there the buffer really is a read-only Emacs buffer the -;; user navigates, so `read-only' (the orange cursor) is correct and kept. +;; `cj/buffer-status-colors'. The subtle case: a live ghostel terminal is +;; technically `buffer-read-only' but the user types into it -- keystrokes go +;; to the terminal process -- so it must report a writeable state, not +;; `read-only'. ghostel's `copy' / `emacs' input modes are the exception: +;; there the buffer really is a read-only Emacs buffer the user navigates, so +;; `read-only' (the orange cursor) is correct and kept. ;;; Code: @@ -18,9 +18,9 @@ (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) (add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) (setq load-prefer-newer t) -(defvar vterm-copy-mode nil) +(defvar ghostel--input-mode nil) (require 'ui-config) -(require 'testutil-vterm-buffers) +(require 'testutil-ghostel-buffers) (ert-deftest test-ui-config-buffer-cursor-state-readwrite-unmodified () "Normal: a clean writeable buffer reports `unmodified'." @@ -47,40 +47,40 @@ (overwrite-mode 1) (should (eq (cj/--buffer-cursor-state) 'overwrite)))) -(ert-deftest test-ui-config-buffer-cursor-state-live-vterm-is-writeable () - "Boundary: a live vterm buffer is `buffer-read-only' but reports a +(ert-deftest test-ui-config-buffer-cursor-state-live-ghostel-is-writeable () + "Boundary: a live ghostel buffer is `buffer-read-only' but reports a writeable state -- the user types into the terminal process there, so the read-only (orange) cursor would be misleading." - (let ((buf (cj/test--make-fake-vterm-buffer "*test-vterm-cursor-state*"))) + (let ((buf (cj/test--make-fake-ghostel-buffer "*test-ghostel-cursor-state*"))) (unwind-protect (with-current-buffer buf - (setq buffer-read-only t) ; `vterm-mode' does this - (setq-local vterm-copy-mode nil) + (setq buffer-read-only t) ; ghostel keeps the buffer read-only + (setq-local ghostel--input-mode 'semi-char) (should-not (eq (cj/--buffer-cursor-state) 'read-only))) (when (buffer-live-p buf) (kill-buffer buf))))) -(ert-deftest test-ui-config-buffer-cursor-state-vterm-copy-mode-is-read-only () - "Boundary: in `vterm-copy-mode' the vterm buffer is a read-only Emacs -buffer the user navigates, so `read-only' (orange) is kept." - (let ((buf (cj/test--make-fake-vterm-buffer "*test-vterm-cursor-state-copy*"))) +(ert-deftest test-ui-config-buffer-cursor-state-ghostel-copy-mode-is-read-only () + "Boundary: in ghostel `copy' mode the buffer is a read-only Emacs buffer +the user navigates, so `read-only' (orange) is kept." + (let ((buf (cj/test--make-fake-ghostel-buffer "*test-ghostel-cursor-state-copy*"))) (unwind-protect (with-current-buffer buf (setq buffer-read-only t) - (setq-local vterm-copy-mode t) + (setq-local ghostel--input-mode 'copy) (should (eq (cj/--buffer-cursor-state) 'read-only))) (when (buffer-live-p buf) (kill-buffer buf))))) -(ert-deftest test-ui-config-set-cursor-color-live-vterm-not-orange () - "Normal: in a live vterm the cursor-color hook picks a writeable color, -not the read-only orange -- even though the vterm buffer is read-only. -`display-graphic-p' is stubbed t so the function reaches its work body -in batch mode (the live function no-ops on TTY frames by design)." - (let ((buf (cj/test--make-fake-vterm-buffer "*test-vterm-cursor-color*")) +(ert-deftest test-ui-config-set-cursor-color-live-ghostel-not-orange () + "Normal: in a live ghostel terminal the cursor-color hook picks a writeable +color, not the read-only orange -- even though the buffer is read-only. +`display-graphic-p' is stubbed t so the function reaches its work body in +batch mode (the live function no-ops on TTY frames by design)." + (let ((buf (cj/test--make-fake-ghostel-buffer "*test-ghostel-cursor-color*")) (applied 'unset)) (unwind-protect (with-current-buffer buf (setq buffer-read-only t) - (setq-local vterm-copy-mode nil) + (setq-local ghostel--input-mode 'semi-char) (let ((cj/-cursor-last-color nil) (cj/-cursor-last-buffer nil)) (cl-letf (((symbol-function 'display-graphic-p) (lambda () t)) diff --git a/tests/test-vterm-copy-mode-cursor.el b/tests/test-vterm-copy-mode-cursor.el deleted file mode 100644 index c549a44f..00000000 --- a/tests/test-vterm-copy-mode-cursor.el +++ /dev/null @@ -1,145 +0,0 @@ -;;; test-vterm-copy-mode-cursor.el --- Tests for cursor visibility in vterm-copy-mode -*- lexical-binding: t; -*- - -;;; Commentary: -;; vterm's C module sets `cursor-type' to nil when the underlying TUI -;; sends DECTCEM (`\e[?25l'). Most full-screen TUIs (Claude Code, htop, -;; etc.) hide the cursor on startup. In `vterm-copy-mode' the user is -;; navigating the buffer, not watching the TUI, so the cursor must be -;; forced visible -- the hook in `vterm-config.el' handles that. On -;; exit, the buffer-local override is killed so the live terminal goes -;; back to the TUI's chosen cursor state. - -;;; Code: - -(require 'ert) -(require 'cl-lib) -(require 'package) - -(setq package-user-dir (expand-file-name "elpa" user-emacs-directory)) -(package-initialize) -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(defvar vterm-copy-mode nil) -(require 'vterm-config) -(require 'vterm) - -(defmacro test-vterm-copy-mode-cursor--in-fake-vterm-buffer (&rest body) - "Run BODY in a temp buffer pretending to be a live vterm. -Stubs `vterm--enter-copy-mode' and `vterm--exit-copy-mode' so toggling -`vterm-copy-mode' doesn't try to talk to a real vterm process." - (declare (indent 0)) - `(cl-letf (((symbol-function 'vterm--enter-copy-mode) #'ignore) - ((symbol-function 'vterm--exit-copy-mode) #'ignore)) - (with-temp-buffer - (setq-local major-mode 'vterm-mode) - ,@body))) - -(ert-deftest test-vterm-copy-mode-cursor-restored-on-enter () - "Normal: entering copy-mode with cursor-type nil sets a visible cursor." - (with-temp-buffer - (setq-local cursor-type nil) - (let ((vterm-copy-mode t)) - (cj/--vterm-copy-mode-restore-cursor)) - (should (equal cursor-type 'box)))) - -(ert-deftest test-vterm-copy-mode-cursor-restored-when-prior-was-hbar () - "Boundary: entering copy-mode overrides any prior cursor-type with the block." - (with-temp-buffer - (setq-local cursor-type 'hbar) - (let ((vterm-copy-mode t)) - (cj/--vterm-copy-mode-restore-cursor)) - (should (equal cursor-type 'box)))) - -(ert-deftest test-vterm-copy-mode-cursor-override-killed-on-exit () - "Normal: exiting copy-mode kills the buffer-local cursor-type override." - (with-temp-buffer - (setq-local cursor-type 'box) - (should (local-variable-p 'cursor-type)) - (let ((vterm-copy-mode nil)) - (cj/--vterm-copy-mode-restore-cursor)) - (should-not (local-variable-p 'cursor-type)))) - -(ert-deftest test-vterm-copy-mode-cursor-hook-installed () - "Normal: the cursor-restoration hook is registered on vterm-copy-mode-hook." - (should (memq #'cj/--vterm-copy-mode-restore-cursor - vterm-copy-mode-hook))) - -(ert-deftest test-vterm-copy-mode-cursor-end-to-end-via-mode-toggle () - "Normal: toggling `vterm-copy-mode' on then off via the real minor mode -command produces the visible cursor on entry and removes the override on -exit. This exercises the full path -- mode body, hook registration, our -restore function -- not just the helper in isolation." - (test-vterm-copy-mode-cursor--in-fake-vterm-buffer - (setq-local cursor-type nil) - ;; Enter copy-mode through the actual minor-mode command, not by - ;; let-binding the variable. This fires `vterm-copy-mode-hook'. - (vterm-copy-mode 1) - (should (eq vterm-copy-mode t)) - (should (equal cursor-type 'box)) - ;; Exit through the same path. - (vterm-copy-mode -1) - (should (eq vterm-copy-mode nil)) - (should-not (local-variable-p 'cursor-type)))) - -(ert-deftest test-vterm-copy-mode-cursor-end-to-end-via-copy-done () - "Normal: `vterm-copy-mode-done' toggles copy-mode off and triggers cursor -restoration. No key is bound to it in this config (M-w copies and stays; -RET is unbound), but it stays reachable via \\[execute-extended-command] -and its exit path must still restore the cursor." - (test-vterm-copy-mode-cursor--in-fake-vterm-buffer - (setq-local cursor-type nil) - (vterm-copy-mode 1) - (should (eq vterm-copy-mode t)) - (should (equal cursor-type 'box)) - (insert "selectable text on this line") - (set-mark (point-min)) - (goto-char (point-max)) - (vterm-copy-mode-done nil) - (should (eq vterm-copy-mode nil)) - (should-not (local-variable-p 'cursor-type)))) - -(ert-deftest test-vterm-copy-mode-cursor-end-to-end-via-cancel () - "Normal: `cj/vterm-copy-mode-cancel' (C-g / binding) toggles -copy-mode off and triggers cursor restoration even when no region was -selected -- the cancel path skips the kill-ring step entirely." - (test-vterm-copy-mode-cursor--in-fake-vterm-buffer - (setq-local cursor-type nil) - (vterm-copy-mode 1) - (should (equal cursor-type 'box)) - (cj/vterm-copy-mode-cancel) - (should (eq vterm-copy-mode nil)) - (should-not (local-variable-p 'cursor-type)))) - -(ert-deftest test-vterm-copy-mode-cursor-end-to-end-via-copy-done-no-region () - "Boundary: `vterm-copy-mode-done' called with no active region falls -into its line-selection branch. The branch calls vterm-internal -helpers that aren't safe in a fake buffer, so stub them to point-min / -point-max. The exit-and-fire-hook chain at the function's tail must -still run; cursor restoration must still happen." - (cl-letf (((symbol-function 'vterm--get-beginning-of-line) - (lambda (&rest _) (point-min))) - ((symbol-function 'vterm--get-end-of-line) - (lambda (&rest _) (point-max)))) - (test-vterm-copy-mode-cursor--in-fake-vterm-buffer - (insert "line content") - (setq-local cursor-type nil) - (vterm-copy-mode 1) - (should (equal cursor-type 'box)) - (deactivate-mark) - (should-not (use-region-p)) - (vterm-copy-mode-done nil) - (should (eq vterm-copy-mode nil)) - (should-not (local-variable-p 'cursor-type))))) - -(ert-deftest test-vterm-copy-mode-cursor-survives-multiple-cycles () - "Boundary: enter/exit/enter/exit cycles don't accumulate buffer-local -state. The cursor goes back and forth cleanly." - (test-vterm-copy-mode-cursor--in-fake-vterm-buffer - (setq-local cursor-type nil) - (dotimes (_ 3) - (vterm-copy-mode 1) - (should (equal cursor-type 'box)) - (vterm-copy-mode -1) - (should-not (local-variable-p 'cursor-type))))) - -(provide 'test-vterm-copy-mode-cursor) -;;; test-vterm-copy-mode-cursor.el ends here diff --git a/tests/test-vterm-tmux-history.el b/tests/test-vterm-tmux-history.el deleted file mode 100644 index 88bd5593..00000000 --- a/tests/test-vterm-tmux-history.el +++ /dev/null @@ -1,383 +0,0 @@ -;;; test-vterm-tmux-history.el --- Tests for tmux history capture UX -*- lexical-binding: t; -*- - -;;; Commentary: -;; Exercises the Emacs-owned history buffer used to copy text from the -;; current tmux pane without entering tmux copy-mode. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(setq load-prefer-newer t) -(defvar vterm-mode-map (make-sparse-keymap)) -(defvar vterm-copy-mode-map (make-sparse-keymap)) -(keymap-set vterm-mode-map "C-c C-t" #'ignore) -(require 'vterm-config) -(require 'testutil-vterm-buffers) - -(defmacro test-vterm-tmux-history--with-tmux-mock (responses &rest body) - "Run BODY with `process-file' mocked for tmux RESPONSES. - -RESPONSES is an alist of (ARGS EXIT-CODE OUTPUT)." - (declare (indent 1)) - `(let ((calls nil)) - (cl-letf (((symbol-function 'process-file) - (lambda (program _infile destination _display &rest args) - (push (cons program args) calls) - (let* ((entry (seq-find - (lambda (candidate) - (equal (car candidate) args)) - ,responses)) - (exit-code (or (cadr entry) 1)) - (output (or (caddr entry) ""))) - (when destination - (let ((buffer (cond - ((eq destination t) (current-buffer)) - ((bufferp destination) destination) - ((consp destination) (car destination))))) - (when (bufferp buffer) - (with-current-buffer buffer - (insert output))))) - exit-code)))) - ,@body))) - -(ert-deftest test-vterm-tmux-history--pane-id-for-tty-matches-client () - "Normal: current vterm pty maps to the active pane for that tmux client." - (test-vterm-tmux-history--with-tmux-mock - '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 - "/dev/pts/1\t%1\n/dev/pts/8\t%8\n")) - (should (equal (cj/vterm--tmux-pane-id-for-tty "/dev/pts/8") "%8")))) - -(ert-deftest test-vterm-tmux-history--capture-pane-uses-full-history () - "Normal: capture asks tmux for joined full pane history." - (test-vterm-tmux-history--with-tmux-mock - '((("capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" "%8") 0 - "first line\nsecond line\n")) - (should (equal (cj/vterm--tmux-capture-pane "%8") - "first line\nsecond line\n")))) - -(ert-deftest test-vterm-tmux-history-open-renders-read-only-history-buffer () - "Normal: command renders tmux history in a normal Emacs buffer." - (let ((origin (cj/test--make-fake-vterm-buffer "*test-vterm-history-origin*"))) - (unwind-protect - (save-window-excursion - (switch-to-buffer origin) - (cl-letf (((symbol-function 'get-buffer-process) - (lambda (_buffer) 'fake-process)) - ((symbol-function 'process-tty-name) - (lambda (_process) "/dev/pts/8"))) - (test-vterm-tmux-history--with-tmux-mock - '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 - "/dev/pts/8\t%8\n") - (("capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" "%8") 0 - "history http://example.com\n")) - (cj/vterm-tmux-history) - (should (eq major-mode 'cj/vterm-tmux-history-mode)) - (should buffer-read-only) - (should (string-match-p "history http://example.com" - (buffer-string)))))) - (cj/test--kill-buffers-matching-prefix "*vterm tmux history") - (when (buffer-live-p origin) - (kill-buffer origin))))) - -(ert-deftest test-vterm-tmux-history-replaces-origin-buffer-in-same-window () - "Normal: the history view replaces the origin in the selected window. - -Before the in-place change, `cj/vterm-tmux-history' used `pop-to-buffer' -which could split or hand the buffer to a different window. The fix -uses `switch-to-buffer' so reading scrollback keeps the agent's frame -slot." - (let ((origin (cj/test--make-fake-vterm-buffer "*test-vterm-history-inplace*"))) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (switch-to-buffer origin) - (let ((win (selected-window))) - (should (eq (window-buffer win) origin)) - (should (one-window-p)) - (cl-letf (((symbol-function 'get-buffer-process) - (lambda (_buffer) 'fake-process)) - ((symbol-function 'process-tty-name) - (lambda (_process) "/dev/pts/8"))) - (test-vterm-tmux-history--with-tmux-mock - '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 - "/dev/pts/8\t%8\n") - (("capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" "%8") 0 - "scrollback line\n")) - (cj/vterm-tmux-history))) - ;; Same window, no split, history buffer now in the slot. - (should (one-window-p)) - (should (eq (selected-window) win)) - (should (string-prefix-p - "*vterm tmux history:" - (buffer-name (window-buffer win)))))) - (cj/test--kill-buffers-matching-prefix "*vterm tmux history") - (when (buffer-live-p origin) - (kill-buffer origin))))) - -(ert-deftest test-vterm-tmux-history-quit-returns-to-origin () - "Normal: q / / C-g (cj/vterm-tmux-history-quit) kills the history -buffer and restores the origin buffer, window, and point." - (let ((origin (get-buffer-create "*test-vterm-history-return*"))) - (unwind-protect - (let ((history (get-buffer-create "*vterm tmux history: test*"))) - (with-current-buffer origin - (erase-buffer) - (insert "origin") - (goto-char (point-min))) - (switch-to-buffer origin) - (let ((origin-window (selected-window))) - (with-current-buffer history - (cj/vterm-tmux-history-mode) - (let ((inhibit-read-only t)) - (insert "alpha\nbeta\ngamma\n")) - (setq-local cj/vterm-tmux-history--origin-buffer origin) - (setq-local cj/vterm-tmux-history--origin-window origin-window) - (setq-local cj/vterm-tmux-history--origin-point (point-min)) - (cj/vterm-tmux-history-quit)) - (should-not (buffer-live-p history)) - (should (eq (current-buffer) origin)) - (should (= (point) (point-min))))) - (when (buffer-live-p origin) - (kill-buffer origin))))) - -(ert-deftest test-vterm-tmux-history-mode-keymap () - "Normal: in the history buffer M-w copies without quitting; q, , -and C-g quit back to the vterm; RET is left unbound (no special exit)." - (should (eq (keymap-lookup cj/vterm-tmux-history-mode-map "M-w") - #'kill-ring-save)) - (should (eq (keymap-lookup cj/vterm-tmux-history-mode-map "q") - #'cj/vterm-tmux-history-quit)) - (should (eq (keymap-lookup cj/vterm-tmux-history-mode-map "") - #'cj/vterm-tmux-history-quit)) - (should (eq (keymap-lookup cj/vterm-tmux-history-mode-map "C-g") - #'cj/vterm-tmux-history-quit)) - (should-not (keymap-lookup cj/vterm-tmux-history-mode-map "RET"))) - -(ert-deftest test-vterm-keymap-includes-history-and-copy-bindings () - "Normal: personal vterm map owns the high-level vterm UX commands. -`C-; x c' resolves to `cj/vterm-copy-mode-dwim' so the binding can pick -the right copy-mode engine (tmux when attached, vterm otherwise)." - (should (member "C-;" vterm-keymap-exceptions)) - (should-not (eq (keymap-lookup cj/custom-keymap "X c") #'vterm-copy-mode)) - (should (eq (keymap-lookup cj/custom-keymap "x h") #'cj/vterm-tmux-history)) - (should (eq (keymap-lookup cj/custom-keymap "x c") #'cj/vterm-copy-mode-dwim)) - (should (equal (keymap-lookup vterm-mode-map "C-;") cj/custom-keymap)) - (should (eq (keymap-lookup vterm-mode-map "C-; x h") #'cj/vterm-tmux-history)) - (should (eq (keymap-lookup vterm-mode-map "C-; x c") #'cj/vterm-copy-mode-dwim)) - (should-not (keymap-lookup vterm-mode-map "C-c C-t"))) - -(ert-deftest test-vterm-keymap-prompt-navigation () - "Normal: n/p navigate prompts, capital N creates a new vterm buffer." - (should (eq (keymap-lookup cj/custom-keymap "x n") #'vterm-next-prompt)) - (should (eq (keymap-lookup cj/custom-keymap "x p") #'vterm-previous-prompt)) - (should (eq (keymap-lookup cj/custom-keymap "x N") #'vterm))) - -(ert-deftest test-vterm-pause-not-bound-to-copy-mode () - "Normal: is no longer wired as a vterm-copy-mode entry point. -The personal `C-; x c' binding is the canonical entry; is rare on -modern keyboards and was redundant." - (let ((binding (keymap-lookup vterm-mode-map ""))) - (should-not (eq binding #'vterm-copy-mode)))) - -(ert-deftest test-vterm-copy-mode-keys () - "Normal: copy mode mirrors the history buffer -- M-w copies without -leaving; C-g, , and q leave without copying; RET is unbound." - (should (eq (keymap-lookup vterm-copy-mode-map "M-w") - #'kill-ring-save)) - (should (eq (keymap-lookup vterm-copy-mode-map "C-g") - #'cj/vterm-copy-mode-cancel)) - (should (eq (keymap-lookup vterm-copy-mode-map "") - #'cj/vterm-copy-mode-cancel)) - (should (eq (keymap-lookup vterm-copy-mode-map "q") - #'cj/vterm-copy-mode-cancel)) - (should-not (keymap-lookup vterm-copy-mode-map "RET")) - (should-not (keymap-lookup vterm-copy-mode-map ""))) - -(ert-deftest test-vterm-copy-mode-cancel-errors-outside-copy-mode () - "Error: `cj/vterm-copy-mode-cancel' refuses to run when not in copy mode." - (with-temp-buffer - (should-error (cj/vterm-copy-mode-cancel) :type 'user-error))) - -(ert-deftest test-vterm-current-tmux-pane-id-rejects-non-vterm-buffer () - "Error: pane-id lookup refuses a buffer that is not in `vterm-mode'." - (with-temp-buffer - (should-error (cj/vterm--current-tmux-pane-id) :type 'user-error))) - -(ert-deftest test-vterm-current-tmux-pane-id-accepts-ai-vterm-named-buffer () - "Normal: an AI-vterm-named buffer still resolves by process TTY. - -The copy path belongs to `vterm-mode', not to `*vterm*'-named buffers. -A buffer named like `agent [repo]' (ai-vterm.el's naming) is a -`vterm-mode' buffer and must inherit tmux history copy. The pane lookup -keys off the live process TTY, never the buffer name -- so the -AI-vterm name neither helps nor blocks resolution." - (let ((agent (cj/test--make-fake-vterm-buffer "agent [emacs.d]"))) - (unwind-protect - (with-current-buffer agent - (cl-letf (((symbol-function 'get-buffer-process) - (lambda (_buffer) 'fake-process)) - ((symbol-function 'process-tty-name) - (lambda (_process) "/dev/pts/8"))) - (test-vterm-tmux-history--with-tmux-mock - '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 - "/dev/pts/1\t%1\n/dev/pts/8\t%8\n")) - (should (equal (cj/vterm--current-tmux-pane-id) "%8"))))) - (when (buffer-live-p agent) - (kill-buffer agent))))) - -(ert-deftest test-vterm-in-tmux-p-true-when-client-attached () - "Normal: predicate returns t when tmux reports a client for our tty." - (let ((agent (cj/test--make-fake-vterm-buffer "agent [emacs.d]"))) - (unwind-protect - (with-current-buffer agent - (cl-letf (((symbol-function 'get-buffer-process) - (lambda (_buffer) 'fake-process)) - ((symbol-function 'process-tty-name) - (lambda (_process) "/dev/pts/8"))) - (test-vterm-tmux-history--with-tmux-mock - '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 - "/dev/pts/8\t%8\n")) - (should (cj/vterm--in-tmux-p))))) - (when (buffer-live-p agent) - (kill-buffer agent))))) - -(ert-deftest test-vterm-in-tmux-p-nil-when-no-matching-client () - "Boundary: predicate returns nil when tmux runs but our tty has no client." - (let ((agent (cj/test--make-fake-vterm-buffer "agent [emacs.d]"))) - (unwind-protect - (with-current-buffer agent - (cl-letf (((symbol-function 'get-buffer-process) - (lambda (_buffer) 'fake-process)) - ((symbol-function 'process-tty-name) - (lambda (_process) "/dev/pts/8"))) - (test-vterm-tmux-history--with-tmux-mock - '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 - "/dev/pts/1\t%1\n")) - (should-not (cj/vterm--in-tmux-p))))) - (when (buffer-live-p agent) - (kill-buffer agent))))) - -(ert-deftest test-vterm-in-tmux-p-nil-when-tmux-fails () - "Error: predicate swallows tmux failures and returns nil." - (let ((agent (cj/test--make-fake-vterm-buffer "agent [emacs.d]"))) - (unwind-protect - (with-current-buffer agent - (cl-letf (((symbol-function 'get-buffer-process) - (lambda (_buffer) 'fake-process)) - ((symbol-function 'process-tty-name) - (lambda (_process) "/dev/pts/8"))) - (test-vterm-tmux-history--with-tmux-mock - '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 1 - "no server running")) - (should-not (cj/vterm--in-tmux-p))))) - (when (buffer-live-p agent) - (kill-buffer agent))))) - -(ert-deftest test-vterm-in-tmux-p-nil-when-not-vterm-mode () - "Boundary: predicate refuses non-vterm buffers without calling tmux." - (with-temp-buffer - (let ((tmux-called nil)) - (cl-letf (((symbol-function 'process-file) - (lambda (&rest _) (setq tmux-called t) 0))) - (should-not (cj/vterm--in-tmux-p)) - (should-not tmux-called))))) - -(ert-deftest test-vterm-copy-mode-dwim-sends-tmux-prefix-when-attached () - "Normal: with tmux attached, dwim writes C-b [ into the pty. -The literal control-B + open-bracket bytes reach tmux which then enters -its own copy-mode against the full pane history." - (let ((agent (cj/test--make-fake-vterm-buffer "agent [emacs.d]")) - (sent nil) - (copy-mode-called nil)) - (unwind-protect - (with-current-buffer agent - (cl-letf (((symbol-function 'get-buffer-process) - (lambda (_buffer) 'fake-process)) - ((symbol-function 'process-tty-name) - (lambda (_process) "/dev/pts/8")) - ((symbol-function 'vterm-send-string) - (lambda (s &optional _paste-p) (push s sent))) - ((symbol-function 'vterm-copy-mode) - (lambda (&optional _arg) (setq copy-mode-called t)))) - (test-vterm-tmux-history--with-tmux-mock - '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 - "/dev/pts/8\t%8\n")) - (cj/vterm-copy-mode-dwim) - (should (equal sent '("\C-b["))) - (should-not copy-mode-called)))) - (when (buffer-live-p agent) - (kill-buffer agent))))) - -(ert-deftest test-vterm-copy-mode-dwim-falls-back-without-tmux () - "Boundary: without tmux, dwim calls `vterm-copy-mode' and sends nothing." - (let ((agent (cj/test--make-fake-vterm-buffer "agent [emacs.d]")) - (sent nil) - (copy-mode-called nil)) - (unwind-protect - (with-current-buffer agent - (cl-letf (((symbol-function 'get-buffer-process) - (lambda (_buffer) 'fake-process)) - ((symbol-function 'process-tty-name) - (lambda (_process) "/dev/pts/8")) - ((symbol-function 'vterm-send-string) - (lambda (s &optional _paste-p) (push s sent))) - ((symbol-function 'vterm-copy-mode) - (lambda (&optional _arg) (setq copy-mode-called t)))) - (test-vterm-tmux-history--with-tmux-mock - '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 1 - "no server running")) - (cj/vterm-copy-mode-dwim) - (should-not sent) - (should copy-mode-called)))) - (when (buffer-live-p agent) - (kill-buffer agent))))) - -(ert-deftest test-vterm-mouse-wheel-up-sends-sgr-button-64 () - "Normal: wheel-up emits the SGR mouse-wheel-up sequence (button 64)." - (let ((sent nil)) - (cl-letf (((symbol-function 'vterm-send-string) - (lambda (s &optional _paste-p) (push s sent)))) - (cj/vterm-mouse-wheel-up) - (should (equal sent '("\e[<64;1;1M")))))) - -(ert-deftest test-vterm-mouse-wheel-down-sends-sgr-button-65 () - "Normal: wheel-down emits the SGR mouse-wheel-down sequence (button 65)." - (let ((sent nil)) - (cl-letf (((symbol-function 'vterm-send-string) - (lambda (s &optional _paste-p) (push s sent)))) - (cj/vterm-mouse-wheel-down) - (should (equal sent '("\e[<65;1;1M")))))) - -(ert-deftest test-vterm-send-escape-writes-esc-byte () - "Normal: `cj/vterm-send-escape' forwards a literal ESC byte to the pty so -tmux copy-mode, vi-mode exits, etc., can see the key past Emacs's global -`' → `keyboard-escape-quit' binding." - (let ((sent nil)) - (cl-letf (((symbol-function 'vterm-send-string) - (lambda (s &optional _paste-p) (push s sent)))) - (cj/vterm-send-escape) - (should (equal sent '("\e")))))) - -(ert-deftest test-vterm-escape-binding-installed-on-vterm-mode-map () - "Normal: `' in `vterm-mode-map' routes through `cj/vterm-send-escape'." - (should (eq (keymap-lookup vterm-mode-map "") - #'cj/vterm-send-escape))) - -(ert-deftest test-vterm-wheel-bindings-installed-on-vterm-mode-map () - "Normal: wheel-up / wheel-down (and X11 mouse-4 / mouse-5) route to the -forwarding commands so tmux can see them via `set -g mouse on'." - (should (eq (keymap-lookup vterm-mode-map "") - #'cj/vterm-mouse-wheel-up)) - (should (eq (keymap-lookup vterm-mode-map "") - #'cj/vterm-mouse-wheel-down)) - (should (eq (keymap-lookup vterm-mode-map "") - #'cj/vterm-mouse-wheel-up)) - (should (eq (keymap-lookup vterm-mode-map "") - #'cj/vterm-mouse-wheel-down))) - -(provide 'test-vterm-tmux-history) -;;; test-vterm-tmux-history.el ends here diff --git a/tests/test-vterm-toggle--buffer-filter.el b/tests/test-vterm-toggle--buffer-filter.el deleted file mode 100644 index d6fd2c8c..00000000 --- a/tests/test-vterm-toggle--buffer-filter.el +++ /dev/null @@ -1,94 +0,0 @@ -;;; test-vterm-toggle--buffer-filter.el --- Tests for F12's buffer filter -*- lexical-binding: t; -*- - -;;; Commentary: -;; Three closely-related helpers determine which vterm buffers F12 -;; manages: the predicate `cj/--vterm-toggle-buffer-p', the MRU list -;; `cj/--vterm-toggle-buffers', and the per-frame window finder -;; `cj/--vterm-toggle-displayed-window'. All three exclude agent- -;; prefixed buffers so agent has its own F9 surface. - -;;; Code: - -(require 'ert) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(require 'vterm-config) -(require 'testutil-vterm-buffers) - -(defun test-vterm-toggle--cleanup () - "Kill leftover agent- and *test-vterm- prefixed buffers." - (cj/test--kill-agent-buffers) - (cj/test--kill-test-vterm-buffers)) - -(ert-deftest test-vterm-toggle--buffer-p-accepts-vterm-mode () - "Normal: a vterm-mode buffer with non-agent name qualifies." - (test-vterm-toggle--cleanup) - (let ((buf (cj/test--make-fake-vterm-buffer "*test-vterm-1*"))) - (unwind-protect - (should (cj/--vterm-toggle-buffer-p buf)) - (kill-buffer buf)))) - -(ert-deftest test-vterm-toggle--buffer-p-rejects-agent () - "Boundary: agent-prefixed vterm buffers are excluded from F12's set." - (test-vterm-toggle--cleanup) - (let ((buf (cj/test--make-fake-vterm-buffer "agent [project-a]"))) - (unwind-protect - (should-not (cj/--vterm-toggle-buffer-p buf)) - (kill-buffer buf)))) - -(ert-deftest test-vterm-toggle--buffer-p-rejects-non-vterm () - "Boundary: a regular buffer (not vterm-mode, no vterm name prefix) -> nil." - (test-vterm-toggle--cleanup) - (let ((buf (get-buffer-create "*test-vterm-regular*"))) - (unwind-protect - (should-not (cj/--vterm-toggle-buffer-p buf)) - (kill-buffer buf)))) - -(ert-deftest test-vterm-toggle--buffer-p-rejects-dead-buffer () - "Boundary: nil and dead buffers -> nil." - (should-not (cj/--vterm-toggle-buffer-p nil)) - (let ((buf (cj/test--make-fake-vterm-buffer "*test-vterm-dead*"))) - (kill-buffer buf) - (should-not (cj/--vterm-toggle-buffer-p buf)))) - -(ert-deftest test-vterm-toggle--buffers-filters-agent () - "Normal: returns vterm buffers but excludes agent-prefixed ones." - (test-vterm-toggle--cleanup) - (let ((normal (cj/test--make-fake-vterm-buffer "*test-vterm-normal*")) - (agent (cj/test--make-fake-vterm-buffer "agent [for-test]"))) - (unwind-protect - (let ((result (cj/--vterm-toggle-buffers))) - (should (memq normal result)) - (should-not (memq agent result))) - (kill-buffer normal) - (kill-buffer agent)))) - -(ert-deftest test-vterm-toggle--displayed-window-finds-vterm () - "Normal: vterm in a window -> returns that window." - (test-vterm-toggle--cleanup) - (let ((vt (cj/test--make-fake-vterm-buffer "*test-vterm-shown*"))) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (let ((win (split-window-right))) - (set-window-buffer win vt) - (let ((result (cj/--vterm-toggle-displayed-window))) - (should (windowp result)) - (should (eq (window-buffer result) vt))))) - (kill-buffer vt)))) - -(ert-deftest test-vterm-toggle--displayed-window-skips-agent () - "Boundary: only an agent vterm is displayed -> nil (agent not F12-managed)." - (test-vterm-toggle--cleanup) - (let ((agent (cj/test--make-fake-vterm-buffer "agent [skip-test]"))) - (unwind-protect - (save-window-excursion - (delete-other-windows) - (let ((win (split-window-right))) - (set-window-buffer win agent) - (should-not (cj/--vterm-toggle-displayed-window)))) - (kill-buffer agent)))) - -(provide 'test-vterm-toggle--buffer-filter) -;;; test-vterm-toggle--buffer-filter.el ends here diff --git a/tests/test-vterm-toggle--dispatch.el b/tests/test-vterm-toggle--dispatch.el deleted file mode 100644 index 7e87f2b1..00000000 --- a/tests/test-vterm-toggle--dispatch.el +++ /dev/null @@ -1,53 +0,0 @@ -;;; test-vterm-toggle--dispatch.el --- Tests for cj/--vterm-toggle-dispatch -*- lexical-binding: t; -*- - -;;; Commentary: -;; Pure decision helper for F12. Returns one of (toggle-off . WIN), -;; (show-recent . BUFFER), or (create-new) based on whether a vterm -;; window is currently displayed and whether any vterm buffers are -;; alive. Mocking the underlying helpers keeps the dispatch logic -;; exercisable without touching real windows. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) -(require 'vterm-config) -(require 'testutil-vterm-buffers) - -(ert-deftest test-vterm-toggle--dispatch-window-displayed-returns-toggle-off () - "Normal: displayed vterm window -> (toggle-off . WIN)." - (let ((sentinel-win 'fake-window)) - (cl-letf (((symbol-function 'cj/--vterm-toggle-displayed-window) - (lambda (&optional _frame) sentinel-win))) - (should (equal (cj/--vterm-toggle-dispatch) - (cons 'toggle-off sentinel-win)))))) - -(ert-deftest test-vterm-toggle--dispatch-no-window-buffer-alive-returns-show-recent () - "Normal: no displayed vterm, at least one alive -> show-recent + first." - (cj/test--kill-test-vterm-buffers) - (let ((b1 (get-buffer-create "*test-vterm-mru-1*")) - (b2 (get-buffer-create "*test-vterm-mru-2*"))) - (unwind-protect - (cl-letf (((symbol-function 'cj/--vterm-toggle-displayed-window) - (lambda (&optional _frame) nil)) - ((symbol-function 'cj/--vterm-toggle-buffers) - (lambda () (list b1 b2)))) - (should (equal (cj/--vterm-toggle-dispatch) - (cons 'show-recent b1)))) - (kill-buffer b1) - (kill-buffer b2)))) - -(ert-deftest test-vterm-toggle--dispatch-no-window-no-buffer-returns-create-new () - "Boundary: nothing displayed, no alive vterms -> create-new." - (cj/test--kill-test-vterm-buffers) - (cl-letf (((symbol-function 'cj/--vterm-toggle-displayed-window) - (lambda (&optional _frame) nil)) - ((symbol-function 'cj/--vterm-toggle-buffers) - (lambda () nil))) - (should (equal (cj/--vterm-toggle-dispatch) '(create-new))))) - -(provide 'test-vterm-toggle--dispatch) -;;; test-vterm-toggle--dispatch.el ends here diff --git a/tests/test-vterm-toggle--display.el b/tests/test-vterm-toggle--display.el deleted file mode 100644 index 69bf2360..00000000 --- a/tests/test-vterm-toggle--display.el +++ /dev/null @@ -1,87 +0,0 @@ -;;; test-vterm-toggle--display.el --- Tests for the F12 display-saved action -*- lexical-binding: t; -*- - -;;; Commentary: -;; Covers the F12-side equivalents of the ai-vterm display tests: -;; geometry capture (window-direction, window-size with 'below -;; default), capture-state writing module-level vars, and the custom -;; display action mapping cardinal -> edge directions. Tests stub -;; `display-buffer-in-direction' to capture the alist that would -;; have reached it. - -;;; Code: - -(require 'ert) -(require 'cl-lib) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'vterm-config) - -(ert-deftest test-vterm-toggle--capture-state-records-direction-and-size () - "Normal: capture-state writes direction and integer body size." - (save-window-excursion - (delete-other-windows) - (let ((below (split-window (selected-window) nil 'below)) - (cj/--vterm-toggle-last-direction nil) - (cj/--vterm-toggle-last-size nil)) - (cj/--vterm-toggle-capture-state below) - (should (eq cj/--vterm-toggle-last-direction 'below)) - (should (integerp cj/--vterm-toggle-last-size)) - (should (= cj/--vterm-toggle-last-size (window-body-height below)))))) - -(ert-deftest test-vterm-toggle--capture-state-noop-on-dead-window () - "Boundary: nil window -> state remains unchanged." - (let ((cj/--vterm-toggle-last-direction 'sentinel) - (cj/--vterm-toggle-last-size 0.123)) - (cj/--vterm-toggle-capture-state nil) - (should (eq cj/--vterm-toggle-last-direction 'sentinel)) - (should (= cj/--vterm-toggle-last-size 0.123)))) - -(ert-deftest test-vterm-toggle--display-saved-defaults-when-state-nil () - "Normal: nil state -> direction=bottom, size=cj/vterm-toggle-window-height." - (let (received-alist - (cj/--vterm-toggle-last-direction nil) - (cj/--vterm-toggle-last-size nil) - (cj/vterm-toggle-window-height 0.7)) - (cl-letf (((symbol-function 'display-buffer-in-direction) - (lambda (_b a) (setq received-alist a) 'fake-window))) - (cj/--vterm-toggle-display-saved 'fake-buf '((inhibit-same-window . t)))) - (should (eq (cdr (assq 'direction received-alist)) 'bottom)) - (should (= (cdr (assq 'window-height received-alist)) 0.7)) - (should (eq (cdr (assq 'inhibit-same-window received-alist)) t)))) - -(ert-deftest test-vterm-toggle--display-saved-maps-cardinal-to-edge () - "Normal: saved 'below maps to bottom edge; integer size wraps in body-lines." - (let (received-alist - (cj/--vterm-toggle-last-direction 'below) - (cj/--vterm-toggle-last-size 12)) - (cl-letf (((symbol-function 'display-buffer-in-direction) - (lambda (_b a) (setq received-alist a) 'fake-window))) - (cj/--vterm-toggle-display-saved 'fake-buf nil)) - (should (eq (cdr (assq 'direction received-alist)) 'bottom)) - (should (equal (cdr (assq 'window-height received-alist)) - '(body-lines . 12))) - (should-not (assq 'window-width received-alist)))) - -(ert-deftest test-vterm-toggle--display-saved-strips-conflicting-alist-entries () - "Boundary: caller-supplied direction/size are stripped, saved values win." - (let (received-alist - (cj/--vterm-toggle-last-direction 'right) - (cj/--vterm-toggle-last-size 30)) - (cl-letf (((symbol-function 'display-buffer-in-direction) - (lambda (_b a) (setq received-alist a) 'fake-window))) - (cj/--vterm-toggle-display-saved - 'fake-buf - '((direction . above) - (window-width . 0.2) - (window-height . 0.3) - (inhibit-same-window . t)))) - (should (eq (cdr (assq 'direction received-alist)) 'rightmost)) - (should (equal (cdr (assq 'window-width received-alist)) - '(body-columns . 30))) - (let ((wh-cells (cl-remove-if-not - (lambda (cell) (eq (car-safe cell) 'window-height)) - received-alist))) - (should (null wh-cells))))) - -(provide 'test-vterm-toggle--display) -;;; test-vterm-toggle--display.el ends here diff --git a/tests/testutil-ghostel-buffers.el b/tests/testutil-ghostel-buffers.el new file mode 100644 index 00000000..52fb27e0 --- /dev/null +++ b/tests/testutil-ghostel-buffers.el @@ -0,0 +1,49 @@ +;;; testutil-ghostel-buffers.el --- Shared helpers for ghostel/agent buffer tests -*- lexical-binding: t; -*- + +;;; Commentary: +;; Cleanup helpers and a fake-ghostel constructor used across the +;; ai-term and term-toggle test files. Replaces the older +;; testutil-vterm-buffers helpers when the terminal engine moved from +;; vterm to ghostel. + +;;; Code: + +(require 'cl-lib) + +(defun cj/test--call-as-gui (fn) + "Call FN, stubbing `env-terminal-p' to return nil (a GUI frame). + +The terminal refuse-guard was dropped when ghostel replaced vterm (ghostel +renders in TTY frames too), so this no longer gates behavior; it is kept as a +thin passthrough so window-behavior tests written against the old guard keep +working unchanged." + (cl-letf (((symbol-function 'env-terminal-p) (lambda () nil))) + (funcall fn))) + +(defun cj/test--kill-buffers-matching-prefix (prefix) + "Kill all live buffers whose name starts with PREFIX." + (dolist (b (buffer-list)) + (when (string-prefix-p prefix (buffer-name b)) + (kill-buffer b)))) + +(defun cj/test--kill-agent-buffers () + "Kill all live buffers whose name matches the AI-term prefix \"agent [\"." + (cj/test--kill-buffers-matching-prefix "agent [")) + +(defun cj/test--kill-test-term-buffers () + "Kill all live buffers whose name starts with \"*test-term\"." + (cj/test--kill-buffers-matching-prefix "*test-term")) + +(defun cj/test--make-fake-ghostel-buffer (name) + "Return a buffer named NAME with `major-mode' set to `ghostel-mode'. + +Avoids actually launching a ghostel process by setting the mode +buffer-locally. Used by tests that need a buffer satisfying the +ghostel-mode predicate without the side-effects of `(ghostel)'." + (let ((buf (get-buffer-create name))) + (with-current-buffer buf + (setq-local major-mode 'ghostel-mode)) + buf)) + +(provide 'testutil-ghostel-buffers) +;;; testutil-ghostel-buffers.el ends here diff --git a/tests/testutil-vterm-buffers.el b/tests/testutil-vterm-buffers.el deleted file mode 100644 index 17f0a69a..00000000 --- a/tests/testutil-vterm-buffers.el +++ /dev/null @@ -1,51 +0,0 @@ -;;; testutil-vterm-buffers.el --- Shared helpers for vterm/agent buffer tests -*- lexical-binding: t; -*- - -;;; Commentary: -;; Cleanup helpers and a fake-vterm constructor used across the -;; ai-vterm and vterm-toggle test files. Before this module, each -;; test file re-implemented the same `(dolist (b (buffer-list)) -;; (when (string-prefix-p ...) (kill-buffer b)))' loop with a -;; different prefix. - -;;; Code: - -(require 'cl-lib) - -(defun cj/test--call-as-gui (fn) - "Call FN with `env-terminal-p' stubbed to return nil (a GUI frame). - -The AI-vterm interactive commands refuse to run in a terminal frame -via `cj/--ai-vterm-refuse-in-terminal'. A batch test run is itself a -terminal frame, so tests that exercise the GUI-frame window behavior -of those commands call them through this helper to present a GUI -context." - (cl-letf (((symbol-function 'env-terminal-p) (lambda () nil))) - (funcall fn))) - -(defun cj/test--kill-buffers-matching-prefix (prefix) - "Kill all live buffers whose name starts with PREFIX." - (dolist (b (buffer-list)) - (when (string-prefix-p prefix (buffer-name b)) - (kill-buffer b)))) - -(defun cj/test--kill-agent-buffers () - "Kill all live buffers whose name matches the AI-vterm prefix \"agent [\"." - (cj/test--kill-buffers-matching-prefix "agent [")) - -(defun cj/test--kill-test-vterm-buffers () - "Kill all live buffers whose name starts with \"*test-vterm\"." - (cj/test--kill-buffers-matching-prefix "*test-vterm")) - -(defun cj/test--make-fake-vterm-buffer (name) - "Return a buffer named NAME with `major-mode' set to `vterm-mode'. - -Avoids actually launching a vterm process by setting the mode -buffer-locally. Used by tests that need a buffer satisfying the -vterm-mode predicate without the side-effects of `(vterm)'." - (let ((buf (get-buffer-create name))) - (with-current-buffer buf - (setq-local major-mode 'vterm-mode)) - buf)) - -(provide 'testutil-vterm-buffers) -;;; testutil-vterm-buffers.el ends here diff --git a/todo.org b/todo.org index 8aa12d5b..cec82d6c 100644 --- a/todo.org +++ b/todo.org @@ -172,23 +172,88 @@ What we're verifying: emoji glyphs + fonts apply in a GUI frame even when the fi - in the GUI frame, open a buffer with an emoji and check it renders, and M-S-f / fonts look right Expected: emoji renders and fonts are applied in the GUI frame. -*** AI-vterm declines in a terminal frame, still launches in a GUI frame -What we're verifying: the per-frame guard makes the F9 family decline — message only, no vterm — in a terminal frame, while a GUI frame still launches the agent. +*** ghostel migration: Claude Code TUI in a GUI frame +What we're verifying: an agent runs in ghostel with good rendering (the reason for the engine swap). +- restart Emacs (the migration changes load order + a use-package :config block) +- in a GUI frame press F9, pick a project, let Claude stream a long response (big diff or file read) +Expected: colors look right (not washed out), no flicker/strobing during the stream, box-drawing and the cursor render correctly. + +*** ghostel migration: Claude Code TUI in a TTY frame (replaces the old refuse test) +What we're verifying: D4 dropped the GUI-only guard, so F9 now launches in a terminal frame too. - emacsclient -t (TTY frame, off the running daemon) -- in the TTY frame, press F9 (also try C-F9 and M-F9) -- emacsclient -c (then a GUI frame) -- in the GUI frame, press F9 and pick a project -Expected: in the TTY frame the echo area shows "AI-vterm is GUI-only; not available in a terminal frame" and no vterm opens; in the GUI frame the project picker opens and the agent launches as before. - -** TODO [#B] Consolidate to EAT as the single terminal :terminal:eval: +- in the TTY frame press F9 and pick a project +Expected: the agent launches and renders as text + color in the TTY (no echo-area refusal message); inline images are absent, which is expected. + +*** ghostel migration: F9 / C-F9 / M-F9 dispatch +What we're verifying: the agent dispatch behaves as it did on vterm. +- F9 toggles the agent window off/on; C-F9 always opens the project picker; M-F9 closes (kills the tmux session) after confirm +- press F9 from inside an agent buffer (full-frame) — it should toggle, not get swallowed by the terminal +Expected: each chord does its job from both normal and agent buffers. + +*** ghostel migration: tmux integration + C-; x menu +What we're verifying: the tmux machinery ported intact. +- launch an agent; M-x list it — runs in tmux session aiv- +- second F9 on the same project reattaches (no duplicate session) +- C-; x h captures the tmux pane history into an Emacs buffer; C-; x c enters tmux copy-mode +- C-; x l clears scrollback; C-; x n / p navigate prompts +Expected: all menu commands work against the ghostel buffer; history capture + copy-mode behave as before. + +*** ghostel migration: copy-mode parity + mouse wheel +What we're verifying: copy/selection and wheel scrolling survived the engine swap. +- in a ghostel buffer enter copy-mode (C-; x c without tmux, or the tmux path with tmux); M-w copies and stays; q / C-g exit +- mouse-wheel scroll inside tmux, inside Claude Code, and inside lazygit +Expected: M-w copies without leaving; q/C-g exit; the wheel scrolls the program (this replaces the removed vterm wheel-forwarding — confirm ghostel's native SGR mouse covers it). + +*** ghostel migration: other TUIs + ssh +What we're verifying: general terminal workloads render. +- run lazygit, htop/btop, a heavy-output build, and ssh to a remote host in a ghostel terminal (F12) +Expected: each renders and behaves correctly; ssh out works (if a remote lacks xterm-ghostty terminfo, note it — ghostel-ssh-install-terminfo / ghostel-term is the lever). + +*** ghostel migration: F12 general terminal + dashboard launcher +What we're verifying: F12 manages non-agent terminals only, and the dashboard launcher uses ghostel. +- F12 opens/toggles a general terminal; confirm it does NOT grab an agent buffer; resize it, toggle off and on — geometry is preserved +- from the dashboard press t (Terminal) — opens a ghostel terminal (tooltip reads "Launch Terminal") +Expected: F12 excludes agent buffers and keeps saved geometry; the dashboard launches ghostel. + +*** ghostel migration: crash recovery +What we're verifying: the aiv- tmux session survives an Emacs crash and reattaches. +- with a live agent, kill Emacs (not the tmux session); restart Emacs; F9 → project picker +Expected: the project shows "[detached]" and reattaches to the surviving tmux session. + +** DOING [#B] Migrate all terminals from vterm to ghostel :terminal:ghostel: :PROPERTIES: -:LAST_REVIEWED: 2026-06-02 +:LAST_REVIEWED: 2026-06-04 :END: -Evaluate whether EAT can be the one terminal for all usage and, if it holds up, switch to it from vterm. Reference: [[file:docs/2026-05-25-emacs-terminal-comparison.org][docs/2026-05-25-emacs-terminal-comparison.org]] (vterm vs eat vs ghostel research). +Replace vterm with ghostel (libghostty-vt) as the single terminal engine across every workflow, and rename ai-vterm → ai-term. References: [[file:docs/2026-05-25-emacs-terminal-comparison.org][docs/2026-05-25-emacs-terminal-comparison.org]] (vterm vs eat vs ghostel research); migration spec [[file:docs/design/vterm-to-ghostel-migration-spec.org][docs/design/vterm-to-ghostel-migration-spec.org]] (READY; external review incorporated 2026-06-04, D1-D7 agreed). Build in 5 phases (0-4); see the spec's Implementation tasks block. + +Decisions D1-D7 are settled in the spec's Agreed-decisions section. Build order below; each phase stays green (suite + byte-compile) at every step. + +*** 2026-06-04 Thu @ 23:57:09 -0500 Phase 0 done: characterization baseline green +=make test= green except the 5 documented pre-existing failures (4 test-dupre-theme, 1 test-init-module-headers), none terminal-related. Characterization coverage already present + green for all six must-survive behaviors: vterm-toggle--dispatch/display/buffer-filter, vterm-tmux-history, ai-vterm--show-or-create/launch-command/f9-in-vterm, ui-config--buffer-cursor-state + vterm-copy-mode-cursor, dashboard-config-launchers. Add a characterization test before any behavior change in later phases if a gap appears. + +*** 2026-06-05 Fri @ 00:38:34 -0500 Phase 1 done: ghostel + term-config.el +=modules/term-config.el= written (full port of vterm-config: tmux history/copy-mode-dwim preserved via process-tty-name + ghostel-send-string; F12 toggle + display rule + geometry; cj/term-map C-; x menu → ghostel commands; which-key "terminal menu"; ghostel-max-scrollback 10MB; C-; added to ghostel-keymap-exceptions; F12 + C-; in ghostel-mode-map; use-package ghostel guarded per D6). Dropped: mouse-wheel SGR forwarding, vterm-timer-delay hacks, copy-mode cursor hook, goto-address hook. ghostel installed into elpa (MELPA + auto-downloaded native module). Tests: test-term-toggle--{dispatch,display,buffer-filter} + test-term-tmux-history (16) ported with a ghostel stub in testutil-ghostel-buffers; all green. + +*** 2026-06-05 Fri @ 00:38:34 -0500 Phase 2 done: ai-vterm→ai-term on ghostel +=modules/ai-vterm.el= → =modules/ai-term.el=: 6 vterm call sites swapped to ghostel (buffer named via let-bound ghostel-buffer-name + pinned ghostel-buffer-name-function so OSC titles don't rename agent buffers); F9/C-F9/M-F9 on global + ghostel-mode-map; refuse-in-terminal guard removed (D4 — F9 launches in TTY frames); tmux-suppression invariant preserved (cj/--ai-term-suppress-tmux). 23 ai-vterm tests renamed → test-ai-term--* (terminal-guard test deleted, obsolete); show-or-create + f9-in-term rewritten for ghostel; all green. ui-config cursor-state ported (ghostel-mode + ghostel--input-mode; copy/emacs = read-only, else writeable) + its test. init.el now requires term-config + ai-term; vterm-config.el + ai-vterm.el deleted. Full suite green except the 5 documented pre-existing failures (4 dupre-theme, 1 init-module-headers/popper-config-missing — both unrelated). validate-modules ✓; full early-init+init smoke clean (no ghostel/term/ai-term errors). vterm package still installed (Phase 4) — dashboard "Launch VTerm" + dormant auto-dim still reference it until Phase 3/4. Restart Emacs to pick up ghostel (load-order + use-package :config change). + +*** TODO [#B] Phase 2: rename ai-vterm→ai-term on ghostel :terminal:ghostel: +Swap the 6 vterm call sites; F9 family on global + ghostel-mode-map; drop refuse-in-terminal guard (D4); preserve the tmux-suppression invariant. Rename engine-agnostic tests after green; rework coupled tests; add D4 + F12-excludes-agent regression tests. -Goal: a single terminal engine across every workflow, including covering what eshell is used for today. +*** 2026-06-05 Fri @ 00:50:58 -0500 Phase 3 done: satellites ported to ghostel +Deleted auto-dim's vterm color-advice + redraw integration (~165 lines; D1 — terminals don't dim, ghostel bakes its palette per-terminal so there's no per-window color hook); dashboard launcher → =(ghostel)= + "Launch Terminal" label; cj-window-geometry/toggle-lib doc comments; module-inventory + init-load-graph doc refs. (ui-config cursor-state + init.el requires landed in Phase 2.) Trimmed test-auto-dim-config (dropped the 6 vterm tests) + updated the dashboard-launcher test stub. Incidental: removed the stale =popper-config= entry from the test-init-module-headers allowlist (the file doesn't exist + isn't required) — fixes the long-standing pre-existing test failure. -Open question to settle first: eshell is a shell, EAT is a terminal emulator, so EAT can't literally replace eshell. EAT's eshell story is =eat-eshell-mode=, which makes eshell use EAT for terminal display (full-screen programs run inside eshell). So "use EAT for what eshell does" most likely means one terminal engine everywhere: EAT for standalone terminal buffers (replacing vterm) plus =eat-eshell-mode= as eshell's visual backend, keeping eshell as the shell. Confirm that reading versus dropping eshell entirely for EAT + zsh. +*** 2026-06-05 Fri @ 00:50:58 -0500 Phase 4 done: vterm + vterm-toggle removed +=package-delete='d vterm + vterm-toggle from elpa. No vterm refs remain in modules/init except intentional historical comments. Suite green except the 4 pre-existing dupre-theme failures (the popper-config one is now fixed). validate-modules ✓; full early-init+init batch smoke = INIT-SMOKE-OK. The migration parent stays DOING until Craig restarts Emacs and walks the ghostel manual-verify matrix under "Emacs Manual Testing and Validation". + +*** TODO [#C] Follow-up: theme ghostel ANSI faces in dupre :terminal:ghostel:dupre: +D2 — set the 16 ghostel-color-* + ghostel-default faces in dupre-faces/palette. + +*** TODO [#C] Follow-up: evaluate ghostel-eshell + ghostel-compile :terminal:ghostel:eval: +D3 — ghostel-eshell as eshell visual backend; ghostel-compile against F4 dev-fkeys. + +*** TODO [#C] Revisit auto-dim for ghostel terminals :terminal:ghostel:auto-dim: +D1 shipped "terminal buffers don't participate in unfocused-window dimming" because ghostel resolves its color palette to hex and pushes it into the native module per-terminal (=ghostel--apply-palette= / =ghostel--set-palette=), so there's no per-window color hook like vterm's =vterm--get-color= advice. The spike confirmed buffer-local =face-remap= does NOT dim a ghostel buffer. Investigate the alternatives: (a) keep no-dim (current); (b) buffer-wide dim on focus-loss by re-pushing a palette blended toward bg via =ghostel-sync-theme= + redraw (per-buffer, forces a repaint, only coherent when the buffer is in one window — measure the flicker cost); (c) check whether ghostel gains/exposes a per-window dim hook upstream. Acceptance: a measured decision, not just left at the v1 default. Context: [[file:docs/design/vterm-to-ghostel-migration-spec.org][migration spec]] D1. *** 2026-05-26 Tue @ 15:15:43 -0500 Direction confirmed; Claude Code in eat needs a caveat Craig confirmed the consolidation: one terminal engine everywhere — eat for standalone terminal buffers (replacing vterm) plus =eat-eshell-mode= as eshell's visual backend, keeping eshell as the shell. Not dropping eshell for eat + zsh. @@ -202,6 +267,18 @@ Eval plan (from the research doc): install EAT alongside vterm, run the same wor *** 2026-06-02 Tue @ 14:12:48 -0500 Audit: eval plan not yet run; back to TODO Task audit found no eval work recorded since the 2026-05-26 direction-confirmed note. The test matrix above is unrun, so the task isn't actively in progress — moved DOING back to TODO until the eval starts. +*** 2026-06-04 Thu @ 22:40:27 -0500 Pivot: ghostel as the single engine (not eat) +Direction changed from eat-everyday + ghostel-for-Claude to ghostel-for-everything, and the task is now a migration rather than an eval. Rationale: ghostel is claude-code.el's most-faithful Claude TUI renderer and the fastest engine (81 vs vterm 34 vs eat 4.9 MB/s), and an audit confirmed it exposes an analog for every vterm primitive this config uses (=ghostel-send-string=, =ghostel-keymap-exceptions=, =ghostel-copy-mode=, =ghostel-clear-scrollback=, =ghostel-send-next-key=, =ghostel-next-prompt= / =ghostel-previous-prompt=, =ghostel-max-scrollback=, =ghostel-kill-buffer-on-exit=). eat's washed colors, the scroll-pop / stuck-input bug under Claude Code, and slowest throughput made it the weaker single-engine pick; one engine beats running two. Surface audited: 2 main modules (=vterm-config.el=, =ai-vterm.el=) + 4 satellites (=auto-dim-config.el= is the heavy one) + ~35 test files + init.el. Next: spike ghostel read-only to answer the open migration questions (auto-dim rework — ARCHITECTURE.md forbids the around-redraw color advice vterm uses; tmux pane-id via =process-tty-name= on a ghostel process; buffer naming; TTY-frame behavior; copy-mode keybinding parity), then write the migration spec under =docs/design/= and review it. + +*** 2026-06-04 Thu @ 23:17:54 -0500 Spec review: not ready until decisions and handoff shape are closed +Ran the spec-review workflow against [[file:docs/design/vterm-to-ghostel-migration-spec.org][docs/design/vterm-to-ghostel-migration-spec.org]] and wrote a companion review file (incorporated and deleted 2026-06-04). Verdict: =Not ready=. Direction is sound, but the draft still has open D1-D5 decisions, lacks the workflow-required =Implementation phases= section and acceptance criteria, and needs explicit ghostel package/native-module failure behavior before implementation tasks can be emitted. + +*** 2026-06-04 Thu @ 23:24:28 -0500 Spec-response: review incorporated, raised to READY +Folded the external review via spec-response. Craig accepted D1-D5; baked them plus D6 (module-failure = degrade-with-warning, modifying the reviewer's fail-loud) and D7 (=ghostel-max-scrollback= 10 MB) into a new Agreed-decisions section. Added Implementation phases (0-4), Acceptance criteria, Dependency/module-failure behavior, Test strategy, per-phase key/menu ownership, the tmux-suppression contract, and an Implementation-tasks drop-in block. Status DRAFT → READY; review file deleted. Build is now unblocked. + +*** 2026-06-04 Thu @ 23:30:18 -0500 External re-review: ready +Re-reviewed [[file:docs/design/vterm-to-ghostel-migration-spec.org][docs/design/vterm-to-ghostel-migration-spec.org]] after incorporation. Verdict: =Ready=. No further blocking review notes; implementation can start from the phase plan and acceptance criteria in the spec. + ** PROJECT [#B] Implement ai-kb :feature:ai:kb: Build v1 of the AI knowledge base per [[file:docs/design/ai-kb.org][docs/design/ai-kb.org]] (Ready; six reviews incorporated, all decisions resolved 2026-05-24). Step 1 splits into 1a (the safe write path — minimum usable) and 1b (retrieval, maintenance, push), since =remember= depends on =index=+=lint= and the adapter depends on =remember=. Step 2 is the Emacs layer: a full org-roam profile on switch, the human-edit safety model (same write path as the agent), and the browsing surface. Step 3 and the LLM-Wiki layer are vNext. Children are ordered by build sequence; the server bootstrap is the prerequisite. -- cgit v1.2.3