diff options
58 files changed, 2796 insertions, 3027 deletions
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 =<escape>= 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. +- *<escape> global rebind*: vterm needed a custom escape forwarder because + =<escape>= 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. @@ -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-vterm.el b/modules/ai-term.el index 4f086636..85b84a12 100644 --- a/modules/ai-vterm.el +++ b/modules/ai-term.el @@ -1,4 +1,4 @@ -;;; ai-vterm.el --- In-Emacs AI-agent launcher with vertical-split vterm -*- lexical-binding: t; -*- +;;; ai-term.el --- In-Emacs AI-agent launcher with vertical-split terminal -*- lexical-binding: t; -*- ;; Author: Craig Jennings <c@cjennings.net> @@ -7,7 +7,7 @@ ;; Layer: 3 (Domain Workflow). ;; Category: D. ;; Load shape: eager. -;; Eager reason: registers four global keys for the AI-agent vterm launcher; a +;; 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, @@ -15,7 +15,7 @@ ;; 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 +;; ~/projects/* containing .ai/protocols.org), opens or reuses a terminal ;; buffer named "agent [<basename>]", 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 @@ -24,23 +24,23 @@ ;; 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 +;; `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 -;; "<cj/ai-vterm-tmux-session-prefix><basename>" (default prefix "aiv-"). -;; The prefix lets `tmux ls' be filtered to AI-vterm's own sessions, so +;; "<cj/ai-term-tmux-session-prefix><basename>" (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 vterm buffer exists), the rest follow in +;; "[running]" when a live terminal buffer exists), the rest follow in ;; alphabetical order. ;; ;; Four F-key entry points: ;; -;; - F9 `cj/ai-vterm' -- DWIM dispatch. If an agent buffer is +;; - 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 @@ -48,15 +48,15 @@ ;; 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 +;; - 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-vterm-close' -- gracefully close an agent: kill its -;; tmux session (stopping the agent process), then its vterm +;; - 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-vterm-close' -- same close command, second binding. +;; - 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.) ;; @@ -72,65 +72,66 @@ (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) +(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-vterm nil - "In-Emacs AI-agent launcher with vertical-split vterm." +(defgroup ai-term nil + "In-Emacs AI-agent launcher with a vertical-split ghostel terminal." :group 'tools) -(defcustom cj/ai-vterm-agent-command +(defcustom cj/ai-term-agent-command "claude \"Read .ai/protocols.org and follow all instructions.\"" - "Shell command sent to a fresh AI-vterm to start the agent. + "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-vterm) + :group 'ai-term) -(defvar cj/--ai-vterm-suppress-tmux nil - "When non-nil, the generic vterm tmux-launch hook skips its auto-tmux step. +(defvar cj/--ai-term-suppress-tmux nil + "When non-nil, the generic ghostel 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 +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-vterm-project-roots +(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-vterm) + :group 'ai-term) -(defcustom cj/ai-vterm-container-roots +(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-vterm) + :group 'ai-term) -(defcustom cj/ai-vterm-tmux-session-prefix "aiv-" - "Prefix prepended to tmux session names AI-vterm creates. +(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-vterm's own sessions -- +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-vterm\"." +default \"aiv-\" is short for \"ai-term\"." :type 'string - :group 'ai-vterm) + :group 'ai-term) -(defcustom cj/ai-vterm-tmux-window-name "ai" - "Name given to the first tmux window in an AI-vterm session. +(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 @@ -138,98 +139,98 @@ 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) + :group 'ai-term) -(defconst cj/--ai-vterm-name-prefix "agent [" - "Buffer-name prefix shared by all AI-vterm buffers. +(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-vterm-buffer-name' and detection in -`cj/--ai-vterm-buffer-p'. The display-buffer-alist rule keys on the +`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-vterm-buffer-name (dir) - "Return the AI-vterm buffer name for project directory DIR. +(defun cj/--ai-term-buffer-name (dir) + "Return the AI-term buffer name for project directory DIR. The name pattern is \"agent [<basename>]\". 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 + cj/--ai-term-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. +(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-vterm-name-prefix' (\"agent [\"). The check is anchored at +`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-vterm-name-prefix (buffer-name buffer)))) + (string-prefix-p cj/--ai-term-name-prefix (buffer-name buffer)))) -(defun cj/--ai-vterm-agent-buffers () - "Return the live AI-vterm buffers in `buffer-list' order. +(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-vterm buffers are filtered out via -`cj/--ai-vterm-buffer-p'." - (seq-filter #'cj/--ai-vterm-buffer-p (buffer-list))) +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-vterm-most-recent-non-agent-buffer () +(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-vterm agent buffer (per -`cj/--ai-vterm-buffer-p') and is not an internal buffer (name starting +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-vterm-buffer-p b)) + (not (cj/--ai-term-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. +(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-vterm-buffer-p (window-buffer w))) + (cj/--ai-term-buffer-p (window-buffer w))) (window-list (or frame (selected-frame)) 'never))) -(defun cj/--ai-vterm-tmux-session-name (dir) +(defun cj/--ai-term-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 +`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-vterm-session-active-p' and the crash-recovery picker +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-vterm's own sessions (see -`cj/--ai-vterm-live-tmux-sessions')." - (concat cj/ai-vterm-tmux-session-prefix +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-vterm-live-tmux-sessions () - "Return live tmux session names that carry the AI-vterm prefix. +(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-vterm-tmux-session-prefix', or nil when tmux is not installed, +`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-vterm-tmux-session-prefix) + (let* ((prefix cj/ai-term-tmux-session-prefix) (exit nil) (output (with-temp-buffer (setq exit (condition-case nil @@ -242,105 +243,105 @@ alphabetical list." (seq-filter (lambda (name) (string-prefix-p prefix name)) (split-string output "\n" t))))) -(defun cj/--ai-vterm-session-active-p (dir sessions) +(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-vterm-live-tmux-sessions' (or nil). +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-vterm-tmux-session-name' never needs reversing." - (and (member (cj/--ai-vterm-tmux-session-name dir) sessions) t)) +`cj/--ai-term-tmux-session-name' never needs reversing." + (and (member (cj/--ai-term-tmux-session-name dir) sessions) t)) -(defun cj/--ai-vterm-launch-command (dir) +(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-vterm-tmux-session-name'; the first window is named -`cj/ai-vterm-tmux-window-name' (default \"ai\") so a later hand-opened +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 - <cj/ai-vterm-agent-command>; exec bash + <cj/ai-term-agent-command>; exec bash so the tmux window survives the AI command exiting -- the session stays alive with a bare bash prompt for recovery, and reattach works the same way." - (let ((session (cj/--ai-vterm-tmux-session-name dir)) + (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-vterm-agent-command' don't break the literal single-quote + ;; `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-vterm-tmux-window-name) + (shell-quote-argument cj/ai-term-tmux-window-name) (shell-quote-argument start-dir) (shell-quote-argument - (concat cj/ai-vterm-agent-command "; exec bash"))))) + (concat cj/ai-term-agent-command "; exec bash"))))) -(defun cj/--ai-vterm-has-marker-p (dir) +(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-vterm-candidates () +(defun cj/--ai-term-candidates () "Return the list of AI-agent project paths. -Each entry of `cj/ai-vterm-project-roots' contributes itself when it +Each entry of `cj/ai-term-project-roots' contributes itself when it exists and contains .ai/protocols.org. Each entry of -`cj/ai-vterm-container-roots' contributes its immediate child +`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-vterm-project-roots) + (dolist (root cj/ai-term-project-roots) (let ((expanded (expand-file-name root))) (when (and (file-directory-p expanded) - (cj/--ai-vterm-has-marker-p expanded)) + (cj/--ai-term-has-marker-p expanded)) (push expanded result)))) - (dolist (root cj/ai-vterm-container-roots) + (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-vterm-has-marker-p child)) + (cj/--ai-term-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. +(defvar cj/--ai-term-mru nil + "Project dirs opened via the AI-term 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 +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-vterm-record-mru (dir) - "Move DIR to the front of `cj/--ai-vterm-mru'. +(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-vterm-mru (cons d (delete d cj/--ai-vterm-mru))))) + (setq cj/--ai-term-mru (cons d (delete d cj/--ai-term-mru))))) -(defun cj/--ai-vterm-mru-rank (dir) - "Return DIR's index in `cj/--ai-vterm-mru', or nil when it isn't there. +(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-vterm-record-mru' stores +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-vterm-mru + (seq-position cj/--ai-term-mru (directory-file-name (expand-file-name dir)))) -(defun cj/--ai-vterm-sort-candidates (dirs sessions) +(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-vterm-session-active-p') come first, ordered most-recently- -opened first (per `cj/--ai-vterm-mru'); active dirs not opened yet this +`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 @@ -349,98 +350,98 @@ the active group alphabetical too." (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))) + (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-vterm-session-active-p d sessions))) + (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-vterm-process-live-p (buffer) +(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-vterm-desktop-width 0.5 - "Default fraction of frame width for the AI-vterm window on a desktop. +(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-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 +`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-vterm) + :group 'ai-term) -(defcustom cj/ai-vterm-laptop-height 0.75 - "Default fraction of frame height for the AI-vterm window on a laptop. +(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-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." +`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-vterm) + :group 'ai-term) -(defun cj/--ai-vterm-default-direction () +(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-vterm-default-size () +(defun cj/--ai-term-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' +`cj/ai-term-laptop-height' on a laptop, `cj/ai-term-desktop-width' on a desktop -- pairing with the axis chosen by -`cj/--ai-vterm-default-direction'." +`cj/--ai-term-default-direction'." (if (env-laptop-p) - cj/ai-vterm-laptop-height - cj/ai-vterm-desktop-width)) + cj/ai-term-laptop-height + cj/ai-term-desktop-width)) -(defvar cj/--ai-vterm-last-direction nil - "Last user-chosen direction for the AI-vterm display. +(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-vterm-capture-state' and consumed by -`cj/--ai-vterm-display-saved'.") +`cj/--ai-term-capture-state' and consumed by +`cj/--ai-term-display-saved'.") -(defvar cj/--ai-vterm-last-was-bury nil +(defvar cj/--ai-term-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 +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-vterm-display-saved' to decide between restoring the +`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-vterm-last-hidden-buffer nil +(defvar cj/--ai-term-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 +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-vterm-last-size nil - "Last user-chosen body size for the AI-vterm display. +(defvar cj/--ai-term-last-size nil + "Last user-chosen body size for the AI-term display. -Positive integer: body-columns when `cj/--ai-vterm-last-direction' +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-vterm-default-size' (a float +the host-aware default from `cj/--ai-term-default-size' (a float fraction). Body size, not total size, because total-width includes the @@ -460,27 +461,27 @@ 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) +(defun cj/--ai-term-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' +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-vterm-default-direction' (used only when WINDOW fills its +`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-vterm-default-direction) - 'cj/--ai-vterm-last-direction - 'cj/--ai-vterm-last-size + window (cj/--ai-term-default-direction) + 'cj/--ai-term-last-direction + 'cj/--ai-term-last-size '(right below left))) -(defun cj/--ai-vterm-reuse-existing-agent (buffer _alist) +(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-vterm-displayed-agent-window' on the selected +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. @@ -491,12 +492,12 @@ 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))) + (let ((win (cj/--ai-term-displayed-agent-window))) (when win (set-window-buffer win buffer) win))) -(defun cj/--ai-vterm-reuse-edge-window (buffer _alist) +(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 @@ -507,7 +508,7 @@ 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 +`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' @@ -516,43 +517,43 @@ 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 +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-vterm-last-direction - (cj/--ai-vterm-default-direction))) + (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-vterm-display-saved (buffer alist) +(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-vterm-last-was-bury') and the frame is still single- +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-vterm-default-direction' and `cj/--ai-vterm-default-size'." +`cj/--ai-term-default-direction' and `cj/--ai-term-default-size'." (cond - ((and cj/--ai-vterm-last-was-bury (one-window-p)) - (setq cj/--ai-vterm-last-was-bury nil) + ((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-vterm-last-was-bury nil) + (setq cj/--ai-term-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))))) + 'cj/--ai-term-last-direction (cj/--ai-term-default-direction) + 'cj/--ai-term-last-size (cj/--ai-term-default-size))))) -(defun cj/--ai-vterm-display-rule-list () +(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 [\" @@ -560,15 +561,15 @@ 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 +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-vterm-reuse-edge-window' -- otherwise, if the frame +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-vterm-display-saved' -- otherwise (single-window frame, +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). @@ -584,59 +585,67 @@ 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) + 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-vterm-display-rule-list)) +(dolist (entry (cj/--ai-term-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. +(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 vterm in DIR and send the -project's tmux launch command (see `cj/--ai-vterm-launch-command') so +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-vterm-suppress-tmux' around `(vterm)' -suppresses the generic tmux-launch hook in vterm-config.el so +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-vterm-mru' (whichever branch runs) so the +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-vterm-record-mru dir) + (cj/--ai-term-record-mru dir) (let ((existing (get-buffer name))) (cond - ((and existing (cj/--ai-vterm-process-live-p existing)) + ((and existing (cj/--ai-term-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. + ;; `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) - (cj/--ai-vterm-suppress-tmux t)) - (vterm name))) + (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 - (vterm-send-string (cj/--ai-vterm-launch-command dir)) - (vterm-send-return)) + (ghostel-send-string (cj/--ai-term-launch-command dir)) + (ghostel-send-string "\n")) (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. +(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 @@ -644,18 +653,18 @@ 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)) + (let* ((name (cj/--ai-term-buffer-name path)) (buf (get-buffer name)) - (running (and buf (cj/--ai-vterm-process-live-p buf))) + (running (and buf (cj/--ai-term-process-live-p buf))) (detached (and (not running) - (cj/--ai-vterm-session-active-p path sessions))) + (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-vterm-completion-table (alist) +(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) @@ -669,38 +678,38 @@ the metadata keeps the order ALIST was built in." (cycle-sort-function . identity)) (complete-with-action action alist string predicate)))) -(defun cj/--ai-vterm-pick-project () +(defun cj/--ai-term-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 +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-vterm-format-candidate', which abbreviates the path and -flags a live session via \" [running]\" (an Emacs vterm buffer is +`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-vterm-candidates))) + (let ((candidates (cj/--ai-term-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) + (append cj/ai-term-project-roots + cj/ai-term-container-roots) ", "))) - (let* ((sessions (cj/--ai-vterm-live-tmux-sessions)) - (sorted (cj/--ai-vterm-sort-candidates candidates sessions)) + (let* ((sessions (cj/--ai-term-live-tmux-sessions)) + (sorted (cj/--ai-term-sort-candidates candidates sessions)) (display-alist (mapcar (lambda (p) - (cons (cj/--ai-vterm-format-candidate p sessions) p)) + (cons (cj/--ai-term-format-candidate p sessions) p)) sorted)) (chosen (completing-read - "AI vterm project: " - (cj/--ai-vterm-completion-table display-alist) + "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-vterm-dispatch () - "Compute the F9 (`cj/ai-vterm') action without performing it. +(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. @@ -715,107 +724,95 @@ 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))) + (let ((win (cj/--ai-term-displayed-agent-window))) (cond (win (cons 'toggle-off win)) (t - (let ((buffers (cj/--ai-vterm-agent-buffers))) + (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-vterm-last-hidden-buffer) - (memq cj/--ai-vterm-last-hidden-buffer buffers)) - cj/--ai-vterm-last-hidden-buffer + (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-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. +(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 vterm buffer is named +that contain .ai/protocols.org. The terminal buffer is named \"agent [<basename>]\" 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. +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." +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") - (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))) + (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-vterm (&optional arg) - "Smart F9 dispatch for the AI-vterm launcher. +(defun cj/ai-term (&optional arg) + "Smart F9 dispatch for the AI-term launcher. Behavior depends on the current state: -- If an AI-vterm buffer is currently displayed in this frame, F9 +- 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-vterm buffer exists, F9 re-displays +- 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-vterm-pick-project'. +- 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-vterm-pick-project' (C-F9) to force the project picker. -M-F9 (and C-S-F9) close an agent via `cj/ai-vterm-close'." +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") - (cj/--ai-vterm-refuse-in-terminal) - (pcase (cj/--ai-vterm-dispatch) + (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-vterm-last-hidden-buffer (window-buffer win)) + (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-vterm-display-saved') restores the agent in place at + ;; (`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-vterm-capture-state win) - (setq cj/--ai-vterm-last-was-bury t) + (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-vterm-buffer-p (window-buffer win))) + (cj/--ai-term-buffer-p (window-buffer win))) (with-selected-window win (switch-to-buffer - (or (cj/--ai-vterm-most-recent-non-agent-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-vterm-reuse-existing-agent'), + ;; `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 @@ -824,8 +821,8 @@ M-F9 (and C-S-F9) close an agent via `cj/ai-vterm-close'." ;; 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) + (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) @@ -834,7 +831,7 @@ M-F9 (and C-S-F9) close an agent via `cj/ai-vterm-close'." (when (window-live-p win) (with-selected-window win (switch-to-buffer - (or (cj/--ai-vterm-most-recent-non-agent-buffer) + (or (cj/--ai-term-most-recent-non-agent-buffer) (other-buffer (window-buffer win) t)))))))) nil) (`(redisplay-recent . ,buf) @@ -844,11 +841,11 @@ M-F9 (and C-S-F9) close an agent via `cj/ai-vterm-close'." (when w (select-window w)))) buf) (`(pick-project) - (cj/ai-vterm-pick-project arg)))) + (cj/ai-term-pick-project arg)))) ;; ----------------------------- Close an agent -------------------------------- -(defun cj/--ai-vterm-kill-tmux-session (session) +(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 @@ -859,18 +856,18 @@ down." (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. +(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 vterm was created in) and kills it so the agent +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-vterm buffer." - (when (cj/--ai-vterm-buffer-p buffer) - (cj/--ai-vterm-kill-tmux-session - (cj/--ai-vterm-tmux-session-name +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)) @@ -878,92 +875,91 @@ when BUFFER isn't an AI-vterm buffer." (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. +(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-vterm-buffer-p (current-buffer)) (current-buffer)) - (t (let ((buffers (cj/--ai-vterm-agent-buffers))) + ((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 vterm: " + (completing-read "Close AI terminal: " (mapcar #'buffer-name buffers) nil t)))))))) -(defun cj/ai-vterm-close () - "Gracefully close an AI-vterm agent: kill its tmux session and buffer. +(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-vterm-close-target'). Asks for +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-<f9> (primary) and C-S-<f9>." (interactive) - (cj/--ai-vterm-refuse-in-terminal) - (let ((buffer (cj/--ai-vterm-close-target))) + (let ((buffer (cj/--ai-term-close-target))) (unless buffer - (user-error "No AI-vterm agent buffers to close")) + (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-vterm-close-buffer buffer) + (cj/--ai-term-close-buffer buffer) (message "Closed agent %s." name))))) -(keymap-global-set "<f9>" #'cj/ai-vterm) -(keymap-global-set "C-<f9>" #'cj/ai-vterm-pick-project) -(keymap-global-set "M-<f9>" #'cj/ai-vterm-close) -(keymap-global-set "C-S-<f9>" #'cj/ai-vterm-close) - -;; vterm binds <f1>..<f12> to `vterm--self-insert', so a plain <f9> 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-<f9> / M-<f9> 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 "<f9>" #'cj/ai-vterm) - (keymap-set vterm-mode-map "C-<f9>" #'cj/ai-vterm-pick-project) - (keymap-set vterm-mode-map "M-<f9>" #'cj/ai-vterm-close) - (keymap-set vterm-mode-map "C-S-<f9>" #'cj/ai-vterm-close)) - -;; ---------- emacsclient: keep opened files off the agent vterm ---------- +(keymap-global-set "<f9>" #'cj/ai-term) +(keymap-global-set "C-<f9>" #'cj/ai-term-pick-project) +(keymap-global-set "M-<f9>" #'cj/ai-term-close) +(keymap-global-set "C-S-<f9>" #'cj/ai-term-close) + +;; ghostel's semi-char mode forwards keys not in `ghostel-keymap-exceptions' to +;; the terminal program, so a plain <f9> 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-<f9> / M-<f9> are bound here as well so the behaviour is uniform.) +(with-eval-after-load 'ghostel + (keymap-set ghostel-mode-map "<f9>" #'cj/ai-term) + (keymap-set ghostel-mode-map "C-<f9>" #'cj/ai-term-pick-project) + (keymap-set ghostel-mode-map "M-<f9>" #'cj/ai-term-close) + (keymap-set ghostel-mode-map "C-S-<f9>" #'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 vterm, that's the agent +;; 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-vterm-non-agent-window (&optional exclude) +(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-vterm agent buffer. Returns nil when no +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-vterm-buffer-p (window-buffer w))))) + (not (cj/--ai-term-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. +(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-vterm agent buffer, put BUFFER in -a non-agent window (`cj/--ai-vterm-non-agent-window'), splitting a +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-vterm-buffer-p (window-buffer (selected-window))) + (if (cj/--ai-term-buffer-p (window-buffer (selected-window))) (let* ((agent-win (selected-window)) - (target (or (cj/--ai-vterm-non-agent-window agent-win) + (target (or (cj/--ai-term-non-agent-window agent-win) (split-window agent-win nil 'left)))) (set-window-buffer target buffer) (select-window target)) @@ -972,7 +968,7 @@ expects from a `server-window' function." (defvar server-window) (with-eval-after-load 'server - (setq server-window #'cj/--ai-vterm-server-display)) + (setq server-window #'cj/--ai-term-server-display)) -(provide 'ai-vterm) -;;; ai-vterm.el ends here +(provide 'ai-term) +;;; ai-term.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 <c@cjennings.net> + +;;; 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, <escape>, or q +returns to the terminal without copying. RET is left unbound." + "M-w" #'kill-ring-save + "C-g" #'cj/term-tmux-history-quit + "<escape>" #'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', `<escape>', 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 "<f12>" #'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 "<f12>" #'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 <c@cjennings.net> - -;;; 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, <escape>, 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, <escape>, or q -returns to the vterm without copying. RET is left unbound." - "M-w" #'kill-ring-save - "C-g" #'cj/vterm-tmux-history-quit - "<escape>" #'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', `<escape>', 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 / <escape> 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. - -`<escape>' 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 - ("<f8>" . nil) - ("<f9>" . nil) - ("<f10>" . nil) - ("<f12>" . nil) - ("C-c C-t" . nil) - ("C-y" . vterm-yank) - ("<wheel-up>" . cj/vterm-mouse-wheel-up) - ("<wheel-down>" . cj/vterm-mouse-wheel-down) - ("<mouse-4>" . cj/vterm-mouse-wheel-up) - ("<mouse-5>" . cj/vterm-mouse-wheel-down) - ("<escape>" . 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 "<f12>" #'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', `<escape>', and `q' all leave -copy-mode without copying. vterm's default `RET' / `<return>' -> -`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 "<escape>" #'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 "<return>" 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-vterm--agent-buffers.el b/tests/test-ai-term--agent-buffers.el index 57d01730..20c661c4 100644 --- a/tests/test-ai-vterm--agent-buffers.el +++ b/tests/test-ai-term--agent-buffers.el @@ -1,4 +1,4 @@ -;;; test-ai-vterm--agent-buffers.el --- Tests for cj/--ai-vterm-agent-buffers -*- lexical-binding: t; -*- +;;; 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 @@ -13,24 +13,24 @@ (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) +(require 'ai-term) +(require 'testutil-ghostel-buffers) -(ert-deftest test-ai-vterm--agent-buffers-empty-when-none-exist () +(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-vterm-agent-buffers))) + (should (null (cj/--ai-term-agent-buffers))) (cj/test--kill-agent-buffers))) -(ert-deftest test-ai-vterm--agent-buffers-returns-only-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-vterm-agent-buffers))) + (let ((result (cj/--ai-term-agent-buffers))) (should (memq c1 result)) (should (memq c2 result)) (should-not (memq other result)) @@ -39,21 +39,21 @@ (kill-buffer c2) (kill-buffer other)))) -(ert-deftest test-ai-vterm--agent-buffers-anchors-prefix-not-substring () +(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-vterm-agent-buffers))) + (should-not (memq not-agent (cj/--ai-term-agent-buffers))) (kill-buffer not-agent)))) -(ert-deftest test-ai-vterm--agent-buffers-bare-agent-not-included () +(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-vterm-agent-buffers))) + (should-not (memq bare (cj/--ai-term-agent-buffers))) (kill-buffer bare)))) -(provide 'test-ai-vterm--agent-buffers) -;;; test-ai-vterm--agent-buffers.el ends here +(provide 'test-ai-term--agent-buffers) +;;; test-ai-term--agent-buffers.el ends here diff --git a/tests/test-ai-vterm--buffer-name.el b/tests/test-ai-term--buffer-name.el index 2ebe91ee..b241977d 100644 --- a/tests/test-ai-vterm--buffer-name.el +++ b/tests/test-ai-term--buffer-name.el @@ -1,4 +1,4 @@ -;;; test-ai-vterm--buffer-name.el --- Tests for cj/--ai-vterm-buffer-name -*- lexical-binding: t; -*- +;;; 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 @@ -11,32 +11,32 @@ (require 'ert) (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) +(require 'ai-term) -(ert-deftest test-ai-vterm--buffer-name-normal-project () +(ert-deftest test-ai-term--buffer-name-normal-project () "Normal: a typical project path yields agent [<basename>]." - (should (equal (cj/--ai-vterm-buffer-name "/home/cjennings/projects/foo") + (should (equal (cj/--ai-term-buffer-name "/home/cjennings/projects/foo") "agent [foo]"))) -(ert-deftest test-ai-vterm--buffer-name-trailing-slash () +(ert-deftest test-ai-term--buffer-name-trailing-slash () "Boundary: trailing slash collapses before basename extraction." - (should (equal (cj/--ai-vterm-buffer-name "/home/cjennings/projects/foo/") + (should (equal (cj/--ai-term-buffer-name "/home/cjennings/projects/foo/") "agent [foo]"))) -(ert-deftest test-ai-vterm--buffer-name-dot-prefix-dir () +(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-vterm-buffer-name "/home/cjennings/.emacs.d") + (should (equal (cj/--ai-term-buffer-name "/home/cjennings/.emacs.d") "agent [.emacs.d]"))) -(ert-deftest test-ai-vterm--buffer-name-space-in-basename () +(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-vterm-buffer-name "/tmp/my work") + (should (equal (cj/--ai-term-buffer-name "/tmp/my work") "agent [my work]"))) -(ert-deftest test-ai-vterm--buffer-name-deeply-nested () +(ert-deftest test-ai-term--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") + (should (equal (cj/--ai-term-buffer-name "/a/b/c/d/e/leaf") "agent [leaf]"))) -(provide 'test-ai-vterm--buffer-name) -;;; test-ai-vterm--buffer-name.el ends here +(provide 'test-ai-term--buffer-name) +;;; test-ai-term--buffer-name.el ends here diff --git a/tests/test-ai-vterm--candidates.el b/tests/test-ai-term--candidates.el index be9041ce..a9a392f3 100644 --- a/tests/test-ai-vterm--candidates.el +++ b/tests/test-ai-term--candidates.el @@ -1,4 +1,4 @@ -;;; test-ai-vterm--candidates.el --- Tests for cj/--ai-vterm-candidates -*- lexical-binding: t; -*- +;;; 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: @@ -16,124 +16,124 @@ (require 'ert) (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) +(require 'ai-term) -(defun test-ai-vterm--make-marker (dir) +(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-vterm--with-fixture (root &rest body) +(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-vterm-test-" t))) + `(let ((,root (make-temp-file "ai-term-test-" t))) (unwind-protect (progn ,@body) (delete-directory ,root t)))) -(ert-deftest test-ai-vterm--candidates-project-root-with-marker () +(ert-deftest test-ai-term--candidates-project-root-with-marker () "Normal: a project root containing .ai/protocols.org is included." - (test-ai-vterm--with-fixture root + (test-ai-term--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) + (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-vterm--candidates-project-root-without-marker () +(ert-deftest test-ai-term--candidates-project-root-without-marker () "Boundary: a project root without .ai/protocols.org is excluded." - (test-ai-vterm--with-fixture root + (test-ai-term--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))))))) + (let ((cj/ai-term-project-roots (list proj)) + (cj/ai-term-container-roots nil)) + (should (null (cj/--ai-term-candidates))))))) -(ert-deftest test-ai-vterm--candidates-container-includes-children-with-marker () +(ert-deftest test-ai-term--candidates-container-includes-children-with-marker () "Normal: a container's children with .ai/protocols.org are included." - (test-ai-vterm--with-fixture root + (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-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<))) + (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-vterm--candidates-container-skips-children-without-marker () +(ert-deftest test-ai-term--candidates-container-skips-children-without-marker () "Boundary: a container's children without .ai/protocols.org are skipped." - (test-ai-vterm--with-fixture root + (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-vterm--make-marker foo) - (let ((cj/ai-vterm-project-roots nil) - (cj/ai-vterm-container-roots (list container))) - (should (equal (cj/--ai-vterm-candidates) + (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-vterm--candidates-container-skips-non-directory-entries () +(ert-deftest test-ai-term--candidates-container-skips-non-directory-entries () "Boundary: a container's non-directory entries are ignored." - (test-ai-vterm--with-fixture root + (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-vterm--make-marker foo) + (test-ai-term--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) + (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-vterm--candidates-nonexistent-root-is-skipped () +(ert-deftest test-ai-term--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 + (test-ai-term--with-fixture root + (let ((cj/ai-term-project-roots (list (expand-file-name "does-not-exist" root))) - (cj/ai-vterm-container-roots + (cj/ai-term-container-roots (list (expand-file-name "also-missing" root)))) - (should (null (cj/--ai-vterm-candidates)))))) + (should (null (cj/--ai-term-candidates)))))) -(ert-deftest test-ai-vterm--candidates-empty-roots-yield-empty-list () +(ert-deftest test-ai-term--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))))) + (let ((cj/ai-term-project-roots nil) + (cj/ai-term-container-roots nil)) + (should (null (cj/--ai-term-candidates))))) -(ert-deftest test-ai-vterm--candidates-mixed-roots () +(ert-deftest test-ai-term--candidates-mixed-roots () "Normal: project + container roots combine in one result list." - (test-ai-vterm--with-fixture root + (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-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<))) + (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-vterm--candidates) -;;; test-ai-vterm--candidates.el ends here +(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-vterm--close.el b/tests/test-ai-term--close.el index eb89bcc2..654e85f0 100644 --- a/tests/test-ai-vterm--close.el +++ b/tests/test-ai-term--close.el @@ -1,8 +1,8 @@ -;;; test-ai-vterm--close.el --- Tests for graceful agent close -*- lexical-binding: t; -*- +;;; test-ai-term--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 +;; `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. @@ -13,74 +13,74 @@ (require 'cl-lib) (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) +(require 'ai-term) -(ert-deftest test-ai-vterm--kill-tmux-session-runs-kill-session () +(ert-deftest test-ai-term--kill-tmux-session-runs-kill-session () "Normal: invokes `tmux kill-session -t <session>'." (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")) + (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-vterm--kill-tmux-session-swallows-error () +(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-vterm-kill-tmux-session "aiv-foo"))))) + (should (null (cj/--ai-term-kill-tmux-session "aiv-foo"))))) -(ert-deftest test-ai-vterm--close-buffer-kills-session-and-buffer () +(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-vterm-kill-tmux-session) + (cl-letf (((symbol-function 'cj/--ai-term-kill-tmux-session) (lambda (s) (setq captured-session s) 0))) - (cj/--ai-vterm-close-buffer buf)) + (cj/--ai-term-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 () +(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-vterm-kill-tmux-session) + (cl-letf (((symbol-function 'cj/--ai-term-kill-tmux-session) (lambda (_s) (setq called t) 0))) - (cj/--ai-vterm-close-buffer buf)) + (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-vterm--close-target-current-agent-buffer () +(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-vterm-close-target) buf))) + (should (eq (cj/--ai-term-close-target) buf))) (kill-buffer buf)))) -(ert-deftest test-ai-vterm--close-target-sole-agent () +(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-vterm-agent-buffers) + (cl-letf (((symbol-function 'cj/--ai-term-agent-buffers) (lambda () (list buf)))) - (should (eq (cj/--ai-vterm-close-target) buf)))) + (should (eq (cj/--ai-term-close-target) buf)))) (kill-buffer buf)))) -(ert-deftest test-ai-vterm--close-target-none-returns-nil () +(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-vterm-agent-buffers) (lambda () nil))) - (should (null (cj/--ai-vterm-close-target)))))) + (cl-letf (((symbol-function 'cj/--ai-term-agent-buffers) (lambda () nil))) + (should (null (cj/--ai-term-close-target)))))) -(provide 'test-ai-vterm--close) -;;; test-ai-vterm--close.el ends here +(provide 'test-ai-term--close) +;;; test-ai-term--close.el ends here diff --git a/tests/test-ai-vterm--collapse-split.el b/tests/test-ai-term--collapse-split.el index ad299e47..d7b4ee17 100644 --- a/tests/test-ai-vterm--collapse-split.el +++ b/tests/test-ai-term--collapse-split.el @@ -1,4 +1,4 @@ -;;; test-ai-vterm--collapse-split.el --- F9 collapses the agent split -*- lexical-binding: t; -*- +;;; 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 @@ -13,7 +13,7 @@ ;; 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. +;; Also covers the `cj/--ai-term-most-recent-non-agent-buffer' helper. ;;; Code: @@ -22,12 +22,12 @@ (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) +(require 'ai-term) +(require 'testutil-ghostel-buffers) -;;; cj/--ai-vterm-most-recent-non-agent-buffer +;;; cj/--ai-term-most-recent-non-agent-buffer -(ert-deftest test-ai-vterm--most-recent-non-agent-buffer-skips-agents () +(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*")) @@ -40,16 +40,16 @@ (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))) + (let ((result (cj/--ai-term-most-recent-non-agent-buffer))) (should (bufferp result)) (should (buffer-live-p result)) - (should-not (cj/--ai-vterm-buffer-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-vterm--collapse-multi-window-deletes-agent-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 @@ -59,7 +59,7 @@ different agent (stale quit-restore after slot reuse)." (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)) + (cj/--ai-term-last-was-bury nil)) (unwind-protect (save-window-excursion (delete-other-windows) @@ -72,16 +72,16 @@ different agent (stale quit-restore after slot reuse)." (set-window-buffer agent-win agent-c) (select-window agent-win) (should-not (one-window-p)) - (cj/test--call-as-gui #'cj/ai-vterm) + (cj/test--call-as-gui #'cj/ai-term) (should (one-window-p)) - (should-not (cj/--ai-vterm-displayed-agent-window)) + (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-vterm--collapse-single-window-returns-non-agent () +(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." @@ -89,7 +89,7 @@ to a NON-agent buffer (the working file), never another agent. Before the fix, (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)) + (cj/--ai-term-last-was-bury nil)) (unwind-protect (save-window-excursion (delete-other-windows) @@ -99,16 +99,16 @@ to a NON-agent buffer (the working file), never another agent. Before the fix, (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)) + (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-vterm-buffer-p (window-buffer (selected-window))))) + (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-vterm--dispatch-prefers-last-hidden-agent () +(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 @@ -116,25 +116,25 @@ 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)) + (cj/--ai-term-last-hidden-buffer nil)) (unwind-protect - (cl-letf (((symbol-function 'cj/--ai-vterm-displayed-agent-window) + (cl-letf (((symbol-function 'cj/--ai-term-displayed-agent-window) (lambda (&optional _f) nil)) - ((symbol-function 'cj/--ai-vterm-agent-buffers) + ((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-vterm-dispatch) (cons 'redisplay-recent a1))) + (should (equal (cj/--ai-term-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))) + (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-vterm-last-hidden-buffer dead) + (setq cj/--ai-term-last-hidden-buffer dead) (kill-buffer dead)) - (should (equal (cj/--ai-vterm-dispatch) (cons 'redisplay-recent a1)))) + (should (equal (cj/--ai-term-dispatch) (cons 'redisplay-recent a1)))) (cj/test--kill-agent-buffers)))) -(ert-deftest test-ai-vterm--toggle-roundtrip-reopens-same-agent () +(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 @@ -143,10 +143,10 @@ another. Reproduces the \"the displayed buffer changes\" report." (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)) + (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) @@ -157,15 +157,15 @@ another. Reproduces the \"the displayed buffer changes\" report." (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)) + (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-vterm--collapse-split) -;;; test-ai-vterm--collapse-split.el ends here +(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-vterm--dispatch.el b/tests/test-ai-term--dispatch.el index 94b02123..91b5e1bc 100644 --- a/tests/test-ai-vterm--dispatch.el +++ b/tests/test-ai-term--dispatch.el @@ -1,4 +1,4 @@ -;;; test-ai-vterm--dispatch.el --- Tests for cj/--ai-vterm-dispatch -*- lexical-binding: t; -*- +;;; 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. @@ -15,31 +15,31 @@ (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) +(require 'ai-term) +(require 'testutil-ghostel-buffers) -(ert-deftest test-ai-vterm--dispatch-window-displayed-returns-toggle-off () +(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-vterm-displayed-agent-window) + (cl-letf (((symbol-function 'cj/--ai-term-displayed-agent-window) (lambda (&optional _frame) sentinel-win))) - (should (equal (cj/--ai-vterm-dispatch) + (should (equal (cj/--ai-term-dispatch) (cons 'toggle-off sentinel-win)))))) -(ert-deftest test-ai-vterm--dispatch-no-window-single-buffer-returns-redisplay-recent () +(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-vterm-displayed-agent-window) + (cl-letf (((symbol-function 'cj/--ai-term-displayed-agent-window) (lambda (&optional _frame) nil)) - ((symbol-function 'cj/--ai-vterm-agent-buffers) + ((symbol-function 'cj/--ai-term-agent-buffers) (lambda () (list b1)))) - (should (equal (cj/--ai-vterm-dispatch) + (should (equal (cj/--ai-term-dispatch) (cons 'redisplay-recent b1)))) (kill-buffer b1)))) -(ert-deftest test-ai-vterm--dispatch-no-window-multiple-buffers-returns-redisplay-recent () +(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 @@ -48,23 +48,23 @@ THE agent they were last using. Other agents are reachable via M-F9." (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) + (cl-letf (((symbol-function 'cj/--ai-term-displayed-agent-window) (lambda (&optional _frame) nil)) - ((symbol-function 'cj/--ai-vterm-agent-buffers) + ((symbol-function 'cj/--ai-term-agent-buffers) (lambda () (list b1 b2)))) - (should (equal (cj/--ai-vterm-dispatch) + (should (equal (cj/--ai-term-dispatch) (cons 'redisplay-recent b1)))) (kill-buffer b1) (kill-buffer b2)))) -(ert-deftest test-ai-vterm--dispatch-no-window-zero-buffers-returns-pick-project () +(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-vterm-displayed-agent-window) + (cl-letf (((symbol-function 'cj/--ai-term-displayed-agent-window) (lambda (&optional _frame) nil)) - ((symbol-function 'cj/--ai-vterm-agent-buffers) + ((symbol-function 'cj/--ai-term-agent-buffers) (lambda () nil))) - (should (equal (cj/--ai-vterm-dispatch) '(pick-project))))) + (should (equal (cj/--ai-term-dispatch) '(pick-project))))) -(provide 'test-ai-vterm--dispatch) -;;; test-ai-vterm--dispatch.el ends here +(provide 'test-ai-term--dispatch) +;;; test-ai-term--dispatch.el ends here diff --git a/tests/test-ai-vterm--display-rule.el b/tests/test-ai-term--display-rule.el index 9b70134a..906a4768 100644 --- a/tests/test-ai-vterm--display-rule.el +++ b/tests/test-ai-term--display-rule.el @@ -1,4 +1,4 @@ -;;; test-ai-vterm--display-rule.el --- Tests for the AI-vterm display-buffer rule -*- lexical-binding: t; -*- +;;; 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 @@ -12,67 +12,67 @@ (require 'cl-lib) (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) +(require 'ai-term) -(defun test-ai-vterm--cleanup (name) +(defun test-ai-term--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." +(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-vterm-display-rule-list))) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) ,@body))) -(ert-deftest test-ai-vterm--display-rule-routes-agent-buffer-to-right () +(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-vterm-default-direction'), so the rule splits the current +`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-vterm--cleanup name) + (test-ai-term--cleanup name) (unwind-protect (cl-letf (((symbol-function 'env-laptop-p) (lambda () nil))) - (test-ai-vterm--with-clean-frame + (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-vterm--cleanup name)))) + (test-ai-term--cleanup name)))) -(ert-deftest test-ai-vterm--display-rule-skips-non-matching-buffer () +(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-vterm--cleanup name) + (test-ai-term--cleanup name) (unwind-protect - (test-ai-vterm--with-clean-frame + (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-vterm--cleanup name)))) + (test-ai-term--cleanup name)))) -(ert-deftest test-ai-vterm--display-rule-prefix-not-substring () +(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-vterm--cleanup name) + (test-ai-term--cleanup name) (unwind-protect - (test-ai-vterm--with-clean-frame + (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-vterm--cleanup name)))) + (test-ai-term--cleanup name)))) -(provide 'test-ai-vterm--display-rule) -;;; test-ai-vterm--display-rule.el ends here +(provide 'test-ai-term--display-rule) +;;; test-ai-term--display-rule.el ends here diff --git a/tests/test-ai-vterm--display-saved.el b/tests/test-ai-term--display-saved.el index 0cf59a29..8b689aa6 100644 --- a/tests/test-ai-vterm--display-saved.el +++ b/tests/test-ai-term--display-saved.el @@ -1,10 +1,10 @@ -;;; test-ai-vterm--display-saved.el --- Tests for the display-saved action -*- lexical-binding: t; -*- +;;; test-ai-term--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 +;; `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-vterm-last-direction' + `cj/--ai-vterm-last-size' +;; 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'. @@ -13,7 +13,7 @@ ;; 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 +;; 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. @@ -25,79 +25,79 @@ (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) +(require 'ai-term) +(require 'testutil-ghostel-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. +(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-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil) - (cj/ai-vterm-desktop-width 0.5)) + (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-vterm-display-saved 'fake-buf '((inhibit-same-window . t)))) + (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-vterm--display-saved-uses-laptop-defaults-when-state-nil () - "Normal: nil state on a laptop -> bottom, size=cj/ai-vterm-laptop-height. +(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-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil) - (cj/ai-vterm-laptop-height 0.75)) + (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-vterm-display-saved 'fake-buf '((inhibit-same-window . t)))) + (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-vterm--display-saved-uses-saved-direction-and-size-below () +(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-vterm-last-direction 'below) - (cj/--ai-vterm-last-size 0.4)) + (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-vterm-display-saved 'fake-buf nil)) + (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-vterm--display-saved-uses-saved-direction-and-size-right () +(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-vterm-last-direction 'right) - (cj/--ai-vterm-last-size 0.7)) + (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-vterm-display-saved 'fake-buf nil)) + (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-vterm--display-saved-strips-conflicting-alist-entries () +(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-vterm-last-direction 'right) - (cj/--ai-vterm-last-size 0.7)) + (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-vterm-display-saved + (cj/--ai-term-display-saved 'fake-buf '((direction . below) (window-width . 0.2) @@ -113,17 +113,17 @@ stubbed t to pin the laptop branch." received-alist))) (should (null wh-cells))))) -(ert-deftest test-ai-vterm--display-saved-passes-buffer-through () +(ert-deftest test-ai-term--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)) + (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-vterm-display-saved 'sentinel-buffer nil)) + (cj/--ai-term-display-saved 'sentinel-buffer nil)) (should (eq received-buf 'sentinel-buffer)))) -(ert-deftest test-ai-vterm--toggle-after-buffer-move-no-extra-window () +(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. @@ -152,11 +152,11 @@ once and no spurious extra window leaks." ;; 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)) + (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-vterm) ; off - (cj/test--call-as-gui #'cj/ai-vterm) ; on + (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 @@ -169,5 +169,5 @@ once and no spurious extra window leaks." (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 +(provide 'test-ai-term--display-saved) +;;; test-ai-term--display-saved.el ends here diff --git a/tests/test-ai-vterm--displayed-agent-window.el b/tests/test-ai-term--displayed-agent-window.el index f36ca9f5..eeb40ed3 100644 --- a/tests/test-ai-vterm--displayed-agent-window.el +++ b/tests/test-ai-term--displayed-agent-window.el @@ -1,8 +1,8 @@ -;;; test-ai-vterm--displayed-agent-window.el --- Tests for the displayed-window helper -*- lexical-binding: t; -*- +;;; 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-vterm-buffer-p', or nil when no such window +;; satisfies `cj/--ai-term-buffer-p', or nil when no such window ;; exists. Used by F9 dispatch and M-F9 in-place replacement. ;;; Code: @@ -11,27 +11,27 @@ (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) +(require 'ai-term) +(require 'testutil-ghostel-buffers) -(ert-deftest test-ai-vterm--displayed-agent-window-no-buffers-returns-nil () +(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-vterm-displayed-agent-window)))) + (should-not (cj/--ai-term-displayed-agent-window)))) -(ert-deftest test-ai-vterm--displayed-agent-window-not-displayed-returns-nil () +(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-vterm-displayed-agent-window))) + (should-not (cj/--ai-term-displayed-agent-window))) (kill-buffer b1)))) -(ert-deftest test-ai-vterm--displayed-agent-window-returns-window-when-displayed () +(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]"))) @@ -40,12 +40,12 @@ (delete-other-windows) (let ((win (split-window-right))) (set-window-buffer win b1) - (let ((result (cj/--ai-vterm-displayed-agent-window))) + (let ((result (cj/--ai-term-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 () +(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"))) @@ -53,8 +53,8 @@ (save-window-excursion (delete-other-windows) (set-window-buffer (selected-window) other) - (should-not (cj/--ai-vterm-displayed-agent-window))) + (should-not (cj/--ai-term-displayed-agent-window))) (kill-buffer other)))) -(provide 'test-ai-vterm--displayed-agent-window) -;;; test-ai-vterm--displayed-agent-window.el ends here +(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 <f9> 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: <f9> in `ghostel-mode-map' runs the agent toggle." + (should (eq (keymap-lookup ghostel-mode-map "<f9>") #'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-<f9>' and `C-S-<f9>' both close an agent via `cj/ai-term-close'." + (should (eq (keymap-lookup ghostel-mode-map "C-<f9>") #'cj/ai-term-pick-project)) + (should (eq (keymap-lookup ghostel-mode-map "M-<f9>") #'cj/ai-term-close)) + (should (eq (keymap-lookup ghostel-mode-map "C-S-<f9>") #'cj/ai-term-close))) + +(ert-deftest test-ai-term-f9-still-bound-globally () + "Normal: the global F9 family bindings are intact. +`<f9>' toggles the ai-term agent window; `C-<f9>' picks a project +agent; `M-<f9>' and `C-S-<f9>' close an agent via `cj/ai-term-close'." + (should (eq (lookup-key (current-global-map) (kbd "<f9>")) #'cj/ai-term)) + (should (eq (lookup-key (current-global-map) (kbd "C-<f9>")) #'cj/ai-term-pick-project)) + (should (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/ai-term-close)) + (should (eq (lookup-key (current-global-map) (kbd "C-S-<f9>")) #'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 <window-name>' 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 <dir>' 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-vterm--live-tmux-sessions.el b/tests/test-ai-term--live-tmux-sessions.el index e00b0018..1952caed 100644 --- a/tests/test-ai-vterm--live-tmux-sessions.el +++ b/tests/test-ai-term--live-tmux-sessions.el @@ -1,7 +1,7 @@ -;;; test-ai-vterm--live-tmux-sessions.el --- Tests for cj/--ai-vterm-live-tmux-sessions -*- lexical-binding: t; -*- +;;; 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-vterm prefix so the +;; 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. @@ -12,9 +12,9 @@ (require 'cl-lib) (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) +(require 'ai-term) -(defmacro test-ai-vterm--with-tmux-list (exit-code output &rest body) +(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 @@ -35,37 +35,37 @@ make it signal). OUTPUT is written to the stdout destination buffer." ,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) +(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-vterm--live-tmux-sessions-honors-custom-prefix () +(ert-deftest test-ai-term--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) + (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-vterm--live-tmux-sessions-empty-output-yields-nil () +(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-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)))))) + (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-vterm--live-tmux-sessions-no-server-yields-nil () +(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-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)))))) + (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-vterm--live-tmux-sessions-tmux-missing-yields-nil () +(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-vterm-tmux-session-prefix "aiv-")) - (test-ai-vterm--with-tmux-list 'error "" - (should (null (cj/--ai-vterm-live-tmux-sessions)))))) + (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-vterm--live-tmux-sessions) -;;; test-ai-vterm--live-tmux-sessions.el ends here +(provide 'test-ai-term--live-tmux-sessions) +;;; test-ai-term--live-tmux-sessions.el ends here diff --git a/tests/test-ai-vterm--pick-project.el b/tests/test-ai-term--pick-project.el index f332589a..e6d2f25b 100644 --- a/tests/test-ai-vterm--pick-project.el +++ b/tests/test-ai-term--pick-project.el @@ -1,4 +1,4 @@ -;;; test-ai-vterm--pick-project.el --- Tests for cj/--ai-vterm-pick-project -*- lexical-binding: t; -*- +;;; 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 @@ -14,104 +14,104 @@ (require 'cl-lib) (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) +(require 'ai-term) -(defun test-ai-vterm--collection-strings (collection) +(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-vterm--pick-project-returns-absolute-path-of-choice () +(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-vterm-candidates) + (cl-letf (((symbol-function 'cj/--ai-term-candidates) (lambda () '("/home/u/code/foo" "/home/u/code/bar"))) - ((symbol-function 'cj/--ai-vterm-live-tmux-sessions) + ((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-vterm--collection-strings collection))))) - (should (equal (cj/--ai-vterm-pick-project) "/home/u/code/bar")))) + (test-ai-term--collection-strings collection))))) + (should (equal (cj/--ai-term-pick-project) "/home/u/code/bar")))) -(ert-deftest test-ai-vterm--pick-project-empty-candidates-raises-user-error () +(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-vterm-candidates) (lambda () nil))) - (should-error (cj/--ai-vterm-pick-project) :type 'user-error))) + (cl-letf (((symbol-function 'cj/--ai-term-candidates) (lambda () nil))) + (should-error (cj/--ai-term-pick-project) :type 'user-error))) -(ert-deftest test-ai-vterm--pick-project-presents-abbreviated-paths () +(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-vterm-candidates) + (cl-letf (((symbol-function 'cj/--ai-term-candidates) (lambda () (list (expand-file-name "~/code/foo")))) - ((symbol-function 'cj/--ai-vterm-live-tmux-sessions) + ((symbol-function 'cj/--ai-term-live-tmux-sessions) (lambda () nil)) ((symbol-function 'completing-read) (lambda (_p collection &rest _) - (setq received-strings (test-ai-vterm--collection-strings collection)) + (setq received-strings (test-ai-term--collection-strings collection)) (car received-strings)))) - (cj/--ai-vterm-pick-project) + (cj/--ai-term-pick-project) (should (equal (car received-strings) "~/code/foo"))))) -(ert-deftest test-ai-vterm--pick-project-active-sessions-sort-first () +(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-vterm-tmux-session-prefix "aiv-") + (let ((cj/ai-term-tmux-session-prefix "aiv-") received-strings) - (cl-letf (((symbol-function 'cj/--ai-vterm-candidates) + (cl-letf (((symbol-function 'cj/--ai-term-candidates) (lambda () '("/c/foo" "/c/bar" "/c/baz"))) - ((symbol-function 'cj/--ai-vterm-live-tmux-sessions) + ((symbol-function 'cj/--ai-term-live-tmux-sessions) (lambda () '("aiv-baz"))) ((symbol-function 'completing-read) (lambda (_p collection &rest _) - (setq received-strings (test-ai-vterm--collection-strings collection)) + (setq received-strings (test-ai-term--collection-strings collection)) (car received-strings)))) - (cj/--ai-vterm-pick-project) + (cj/--ai-term-pick-project) (should (equal received-strings '("/c/baz [detached]" "/c/bar" "/c/foo")))))) -(ert-deftest test-ai-vterm--format-candidate-flags-running-project () +(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-vterm-buffer-name path)) + (buffer-name (cj/--ai-term-buffer-name path)) (buf (get-buffer-create buffer-name))) (unwind-protect - (cl-letf (((symbol-function 'cj/--ai-vterm-process-live-p) + (cl-letf (((symbol-function 'cj/--ai-term-process-live-p) (lambda (b) (eq b buf)))) - (should (equal (cj/--ai-vterm-format-candidate path) + (should (equal (cj/--ai-term-format-candidate path) (format "%s [running]" (abbreviate-file-name path))))) (kill-buffer buf)))) -(ert-deftest test-ai-vterm--format-candidate-flags-detached-session () +(ert-deftest test-ai-term--format-candidate-flags-detached-session () "Normal: no buffer but a matching tmux session -> [detached] suffix." - (let* ((cj/ai-vterm-tmux-session-prefix "aiv-") + (let* ((cj/ai-term-tmux-session-prefix "aiv-") (path (expand-file-name "~/code/has-session")) - (bn (cj/--ai-vterm-buffer-name path))) + (bn (cj/--ai-term-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))) + (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-vterm--format-candidate-running-beats-detached () +(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-vterm-tmux-session-prefix "aiv-") + (let* ((cj/ai-term-tmux-session-prefix "aiv-") (path (expand-file-name "~/code/both")) - (bn (cj/--ai-vterm-buffer-name path)) + (bn (cj/--ai-term-buffer-name path)) (buf (get-buffer-create bn))) (unwind-protect - (cl-letf (((symbol-function 'cj/--ai-vterm-process-live-p) + (cl-letf (((symbol-function 'cj/--ai-term-process-live-p) (lambda (b) (eq b buf)))) - (should (equal (cj/--ai-vterm-format-candidate - path (list (cj/--ai-vterm-tmux-session-name path))) + (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-vterm--format-candidate-omits-flag-when-not-running () +(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-vterm-buffer-name path))) + (let ((bn (cj/--ai-term-buffer-name path))) (when (get-buffer bn) (kill-buffer bn))) - (should (equal (cj/--ai-vterm-format-candidate path) + (should (equal (cj/--ai-term-format-candidate path) (abbreviate-file-name path))))) -(provide 'test-ai-vterm--pick-project) -;;; test-ai-vterm--pick-project.el ends here +(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-vterm--reuse-edge-window.el b/tests/test-ai-term--reuse-edge-window.el index eb1b1d75..c41aab73 100644 --- a/tests/test-ai-vterm--reuse-edge-window.el +++ b/tests/test-ai-term--reuse-edge-window.el @@ -1,11 +1,11 @@ -;;; test-ai-vterm--reuse-edge-window.el --- Tests for edge-window reuse -*- lexical-binding: t; -*- +;;; test-ai-term--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 +;; `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-vterm-reuse-existing-agent' and `cj/--ai-vterm-display-saved' +;; `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 @@ -14,8 +14,8 @@ ;; 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. +;; through the actual `cj/--ai-term-display-rule-list', the same pattern +;; as test-ai-term--display-saved.el. ;;; Code: @@ -24,15 +24,15 @@ (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) +(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-vterm--reuse-edge-window-2col-desktop-no-third-window () +(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]." @@ -40,8 +40,8 @@ right half: the frame stays at two windows [left | agent]." (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)) + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) (unwind-protect (save-window-excursion (delete-other-windows) @@ -52,7 +52,7 @@ right half: the frame stays at two windows [left | agent]." (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))) + (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))) @@ -64,7 +64,7 @@ right half: the frame stays at two windows [left | agent]." (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 () +(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." @@ -72,8 +72,8 @@ bottom half: the frame stays at two windows." (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)) + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) (unwind-protect (save-window-excursion (delete-other-windows) @@ -84,7 +84,7 @@ bottom half: the frame stays at two windows." (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))) + (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))) @@ -95,15 +95,15 @@ bottom half: the frame stays at two windows." (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 () +(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-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil)) + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) (unwind-protect (save-window-excursion (delete-other-windows) @@ -111,14 +111,14 @@ the frame goes from one window to two with the agent present." (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))) + (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-vterm--reuse-edge-window-axis-mismatch-falls-through () +(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 @@ -128,8 +128,8 @@ ends up displayed." (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)) + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) (unwind-protect (save-window-excursion (delete-other-windows) @@ -140,7 +140,7 @@ ends up displayed." (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))) + (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))) @@ -150,7 +150,7 @@ ends up displayed." (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 () +(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 @@ -160,8 +160,8 @@ window rather than restoring the displaced buffer into a kept slot." (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)) + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) (unwind-protect (save-window-excursion (delete-other-windows) @@ -172,13 +172,13 @@ window rather than restoring the displaced buffer into a kept slot." (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))) + (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-vterm) + (cj/test--call-as-gui #'cj/ai-term) (should (= (count-windows) 1)) (let ((bufs (cj/test--displayed-buffer-names))) (should (member left-name bufs)) @@ -187,7 +187,7 @@ window rather than restoring the displaced buffer into a kept slot." (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 () +(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 @@ -197,8 +197,8 @@ preserved across the toggle (respect-split-width)." (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)) + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) (unwind-protect (save-window-excursion (delete-other-windows) @@ -210,20 +210,20 @@ preserved across the toggle (respect-split-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))) + (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-vterm-displayed-agent-window))) + (window-body-width (cj/--ai-term-displayed-agent-window))) ;; off -- the split collapses to a single window - (cj/test--call-as-gui #'cj/ai-vterm) + (cj/test--call-as-gui #'cj/ai-term) (should (= (count-windows) 1)) - (should-not (cj/--ai-vterm-displayed-agent-window)) + (should-not (cj/--ai-term-displayed-agent-window)) ;; on again -- re-split at the captured width - (cj/test--call-as-gui #'cj/ai-vterm) + (cj/test--call-as-gui #'cj/ai-term) (should (= (count-windows) 2)) - (let ((win (cj/--ai-vterm-displayed-agent-window))) + (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))))))) @@ -231,18 +231,18 @@ preserved across the toggle (respect-split-width)." (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 () +(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-vterm-dispatch' re-shows the +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-vterm-last-direction nil) - (cj/--ai-vterm-last-size nil)) + (cj/--ai-term-last-direction nil) + (cj/--ai-term-last-size nil)) (unwind-protect (save-window-excursion (delete-other-windows) @@ -256,18 +256,18 @@ most-recent agent, which would now be the other one." (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))) + (let ((display-buffer-alist (cj/--ai-term-display-rule-list))) (display-buffer a2) ; | left | A2 | - (should (eq (window-buffer (cj/--ai-vterm-displayed-agent-window)) + (should (eq (window-buffer (cj/--ai-term-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)) + (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-vterm--reuse-edge-window) -;;; test-ai-vterm--reuse-edge-window.el ends here +(provide 'test-ai-term--reuse-edge-window) +;;; test-ai-term--reuse-edge-window.el ends here diff --git a/tests/test-ai-vterm--reuse-existing-agent.el b/tests/test-ai-term--reuse-existing-agent.el index e6848014..3f0c6449 100644 --- a/tests/test-ai-vterm--reuse-existing-agent.el +++ b/tests/test-ai-term--reuse-existing-agent.el @@ -1,8 +1,8 @@ -;;; test-ai-vterm--reuse-existing-agent.el --- Tests for reuse-existing-agent action -*- lexical-binding: t; -*- +;;; 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-vterm-buffer-p'. When found, swaps that +;; 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. @@ -16,10 +16,10 @@ (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) +(require 'ai-term) +(require 'testutil-ghostel-buffers) -(ert-deftest test-ai-vterm--reuse-existing-agent-swaps-buffer-when-window-exists () +(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 @@ -30,23 +30,23 @@ (unwind-protect (progn (set-window-buffer split existing) - (let ((result (cj/--ai-vterm-reuse-existing-agent new-buf nil))) + (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-vterm--reuse-existing-agent-returns-nil-when-no-agent-window () +(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-vterm-reuse-existing-agent new-buf nil))) + (should (null (cj/--ai-term-reuse-existing-agent new-buf nil))) (kill-buffer new-buf))))) -(ert-deftest test-ai-vterm--reuse-existing-agent-leaves-non-agent-windows-alone () +(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 @@ -58,7 +58,7 @@ (progn (set-window-buffer (selected-window) code-buf) (set-window-buffer other-win code-buf) - (let ((result (cj/--ai-vterm-reuse-existing-agent + (let ((result (cj/--ai-term-reuse-existing-agent new-agent nil))) (should (null result)) (should (eq (window-buffer (selected-window)) code-buf)) @@ -66,7 +66,7 @@ (kill-buffer code-buf) (kill-buffer new-agent))))) -(ert-deftest test-ai-vterm--reuse-existing-agent-preserves-non-agent-window-when-swapping () +(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 @@ -86,7 +86,7 @@ buffer, not the top window's." (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 + (let ((result (cj/--ai-term-reuse-existing-agent agent-b nil))) (should (eq result bottom-win)) (should (eq (window-buffer bottom-win) agent-b)) @@ -95,5 +95,5 @@ buffer, not the top window's." (kill-buffer agent-a) (kill-buffer agent-b))))) -(provide 'test-ai-vterm--reuse-existing-agent) -;;; test-ai-vterm--reuse-existing-agent.el ends here +(provide 'test-ai-term--reuse-existing-agent) +;;; test-ai-term--reuse-existing-agent.el ends here diff --git a/tests/test-ai-vterm--server-display.el b/tests/test-ai-term--server-display.el index 1d0d1001..b3d32dc8 100644 --- a/tests/test-ai-vterm--server-display.el +++ b/tests/test-ai-term--server-display.el @@ -1,12 +1,12 @@ -;;; test-ai-vterm--server-display.el --- Tests for emacsclient window routing -*- lexical-binding: t; -*- +;;; test-ai-term--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 +;; `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 vterm. When the selected +;; 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-vterm-non-agent-window' picks that window. +;; `cj/--ai-term-non-agent-window' picks that window. ;;; Code: @@ -14,11 +14,11 @@ (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 'ai-term) (require 'server) -(require 'testutil-vterm-buffers) +(require 'testutil-ghostel-buffers) -(ert-deftest test-ai-vterm--non-agent-window-finds-code-window () +(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]")) @@ -29,13 +29,13 @@ (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))) + (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-vterm--non-agent-window-none-when-only-agent () +(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]"))) @@ -43,10 +43,10 @@ (save-window-excursion (delete-other-windows) (set-window-buffer (selected-window) agent) - (should-not (cj/--ai-vterm-non-agent-window (selected-window)))) + (should-not (cj/--ai-term-non-agent-window (selected-window)))) (kill-buffer agent)))) -(ert-deftest test-ai-vterm--non-agent-window-skips-dedicated () +(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]")) @@ -59,12 +59,12 @@ (set-window-buffer w side) (set-window-dedicated-p w t) (unwind-protect - (should-not (cj/--ai-vterm-non-agent-window (selected-window))) + (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-vterm--server-display-routes-around-agent () +(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) @@ -78,7 +78,7 @@ window and the agent window keeps the agent buffer." (let ((agent-win (split-window-right))) (set-window-buffer agent-win agent) (select-window agent-win) - (cj/--ai-vterm-server-display file) + (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)))) @@ -86,7 +86,7 @@ window and the agent window keeps the agent buffer." (kill-buffer code) (kill-buffer file)))) -(ert-deftest test-ai-vterm--server-display-splits-when-agent-is-only-window () +(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) @@ -97,14 +97,14 @@ file; the agent window keeps the agent buffer." (delete-other-windows) (set-window-buffer (selected-window) agent) (let ((agent-win (selected-window))) - (cj/--ai-vterm-server-display file) + (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-vterm--server-display-passthrough-when-not-agent () +(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) @@ -114,14 +114,14 @@ normally and nothing special happens (no agent window to protect)." (save-window-excursion (delete-other-windows) (set-window-buffer (selected-window) code) - (cj/--ai-vterm-server-display file) + (cj/--ai-term-server-display file) (should (get-buffer-window file))) (kill-buffer code) (kill-buffer file)))) -(ert-deftest test-ai-vterm--server-window-wired-to-helper () +(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-vterm-server-display))) + (should (eq server-window #'cj/--ai-term-server-display))) -(provide 'test-ai-vterm--server-display) -;;; test-ai-vterm--server-display.el ends here +(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-vterm--single-window-toggle.el b/tests/test-ai-term--single-window-toggle.el index 928656f2..aa507f03 100644 --- a/tests/test-ai-vterm--single-window-toggle.el +++ b/tests/test-ai-term--single-window-toggle.el @@ -1,11 +1,11 @@ -;;; test-ai-vterm--single-window-toggle.el --- F9 toggle round-trip when agent is the only window -*- lexical-binding: t; -*- +;;; 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-vterm-last-was-bury' flag set at +;; 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 @@ -18,12 +18,12 @@ (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) +(require 'ai-term) +(require 'testutil-ghostel-buffers) ;;; Normal Cases -(ert-deftest test-ai-vterm--single-window-toggle-normal-roundtrip-preserves-fullscreen () +(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 @@ -36,33 +36,33 @@ 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)) + (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-vterm-display-rule-list))) + (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-vterm) + (cj/test--call-as-gui #'cj/ai-term) (should (one-window-p)) - (should-not (cj/--ai-vterm-displayed-agent-window)) - (should (eq cj/--ai-vterm-last-was-bury t)) + (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-vterm) + (cj/test--call-as-gui #'cj/ai-term) (should (one-window-p)) - (let ((win (cj/--ai-vterm-displayed-agent-window))) + (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-vterm-last-was-bury)))) + (should-not cj/--ai-term-last-was-bury)))) (cj/test--kill-agent-buffers)))) -(ert-deftest test-ai-vterm--single-window-toggle-off-swaps-window-buffer () +(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. @@ -72,35 +72,35 @@ 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)) + (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-vterm-display-rule-list))) - (cj/test--call-as-gui #'cj/ai-vterm)) + (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-vterm-buffer-p (window-buffer win))))) + (should-not (cj/--ai-term-buffer-p (window-buffer win))))) (cj/test--kill-agent-buffers)))) -(ert-deftest test-ai-vterm--single-window-toggle-normal-flag-set-on-bury () +(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-vterm-last-was-bury nil)) + (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-vterm-display-rule-list))) - (cj/test--call-as-gui #'cj/ai-vterm) - (should (eq cj/--ai-vterm-last-was-bury t))))) + (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-vterm--single-window-toggle-normal-flag-cleared-on-multi-window-off () +(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 @@ -108,7 +108,7 @@ 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 + (cj/--ai-term-last-was-bury t)) ; stale t from prior bury (unwind-protect (save-window-excursion (delete-other-windows) @@ -116,25 +116,25 @@ toggle-off." (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))) + (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-vterm) - (should-not cj/--ai-vterm-last-was-bury)))) + (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-vterm--single-window-toggle-boundary-flag-respected-only-when-still-one-window () +(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-vterm-last-was-bury t) ; flag pretends prior bury - (cj/--ai-vterm-last-direction 'right) - (cj/--ai-vterm-last-size 40)) + (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) @@ -146,26 +146,26 @@ genuine single-window case, not an override for every redisplay." (split-window-right) (should-not (one-window-p)) (let (received-buf - (display-buffer-alist (cj/--ai-vterm-display-rule-list))) + (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-vterm-display-saved agent-buf nil)) + (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-vterm-last-was-bury)))) + (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-vterm--single-window-toggle-boundary-flag-not-set-when-bury-not-used () +(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-vterm-last-was-bury nil)) + (cj/--ai-term-last-was-bury nil)) (unwind-protect (save-window-excursion (delete-other-windows) @@ -173,14 +173,14 @@ the flag nil (no spurious set)." (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))) + (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-vterm) - (should-not cj/--ai-vterm-last-was-bury)))) + (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-vterm--single-window-toggle) -;;; test-ai-vterm--single-window-toggle.el ends here +(provide 'test-ai-term--single-window-toggle) +;;; test-ai-term--single-window-toggle.el ends here diff --git a/tests/test-ai-vterm--sort-candidates.el b/tests/test-ai-term--sort-candidates.el index 26953604..f1f6155f 100644 --- a/tests/test-ai-vterm--sort-candidates.el +++ b/tests/test-ai-term--sort-candidates.el @@ -1,10 +1,10 @@ -;;; test-ai-vterm--sort-candidates.el --- Tests for cj/--ai-vterm-sort-candidates -*- lexical-binding: t; -*- +;;; 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-vterm-mru') lead, most-recent first; the rest of the +;; 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. @@ -13,62 +13,62 @@ (require 'ert) (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) +(require 'ai-term) -(ert-deftest test-ai-vterm--sort-candidates-active-first-then-alpha () +(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-vterm-tmux-session-prefix "aiv-")) - (should (equal (cj/--ai-vterm-sort-candidates + (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-vterm--sort-candidates-multiple-active-each-group-alpha () +(ert-deftest test-ai-term--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 + (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-vterm--sort-candidates-no-sessions-is-plain-alpha () +(ert-deftest test-ai-term--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 + (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-vterm--sort-candidates-empty-dirs-yields-nil () +(ert-deftest test-ai-term--sort-candidates-empty-dirs-yields-nil () "Boundary: no candidates -> nil." - (should (null (cj/--ai-vterm-sort-candidates nil '("aiv-foo"))))) + (should (null (cj/--ai-term-sort-candidates nil '("aiv-foo"))))) -(ert-deftest test-ai-vterm--sort-candidates-active-group-mru-first () +(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-vterm-tmux-session-prefix "aiv-") - (cj/--ai-vterm-mru '("/c/baz" "/c/foo"))) - (should (equal (cj/--ai-vterm-sort-candidates + (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-vterm--sort-candidates-mru-does-not-bump-inactive () +(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-vterm-tmux-session-prefix "aiv-") - (cj/--ai-vterm-mru '("/c/zed"))) - (should (equal (cj/--ai-vterm-sort-candidates + (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-vterm--session-active-p-matches-by-derived-name () +(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-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)))) + (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-vterm--sort-candidates) -;;; test-ai-vterm--sort-candidates.el ends here +(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--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--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--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 <f1>..<f12> to `vterm--self-insert', so a plain <f9> 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: <f9> in `vterm-mode-map' runs the agent toggle, not `vterm--self-insert'." - (should (eq (keymap-lookup vterm-mode-map "<f9>") #'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-<f9>' and `C-S-<f9>' both close an agent via `cj/ai-vterm-close'." - (should (eq (keymap-lookup vterm-mode-map "C-<f9>") #'cj/ai-vterm-pick-project)) - (should (eq (keymap-lookup vterm-mode-map "M-<f9>") #'cj/ai-vterm-close)) - (should (eq (keymap-lookup vterm-mode-map "C-S-<f9>") #'cj/ai-vterm-close))) - -(ert-deftest test-ai-vterm-f9-not-self-insert-in-vterm () - "Boundary: vterm's default <f9> -> `vterm--self-insert' was overridden." - (should-not (eq (keymap-lookup vterm-mode-map "<f9>") 'vterm--self-insert))) - -(ert-deftest test-ai-vterm-f9-still-bound-globally () - "Normal: the global F9 family bindings are intact. -`<f9>' toggles the ai-vterm agent window; `C-<f9>' picks a project -agent; `M-<f9>' and `C-S-<f9>' close an agent via `cj/ai-vterm-close'." - (should (eq (lookup-key (current-global-map) (kbd "<f9>")) #'cj/ai-vterm)) - (should (eq (lookup-key (current-global-map) (kbd "C-<f9>")) #'cj/ai-vterm-pick-project)) - (should (eq (lookup-key (current-global-map) (kbd "M-<f9>")) #'cj/ai-vterm-close)) - (should (eq (lookup-key (current-global-map) (kbd "C-S-<f9>")) #'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 <window-name>' 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 <dir>' 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--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--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--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 / <escape> / 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, <escape>, +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 "<escape>") + #'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-vterm-toggle--display.el b/tests/test-term-toggle--display.el index 69bf2360..0943a488 100644 --- a/tests/test-vterm-toggle--display.el +++ b/tests/test-term-toggle--display.el @@ -1,7 +1,7 @@ -;;; test-vterm-toggle--display.el --- Tests for the F12 display-saved action -*- lexical-binding: t; -*- +;;; test-term-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: +;; 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 @@ -14,62 +14,62 @@ (require 'cl-lib) (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'vterm-config) +(require 'term-config) -(ert-deftest test-vterm-toggle--capture-state-records-direction-and-size () +(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/--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)))))) + (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-vterm-toggle--capture-state-noop-on-dead-window () +(ert-deftest test-term-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)))) + (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-vterm-toggle--display-saved-defaults-when-state-nil () - "Normal: nil state -> direction=bottom, size=cj/vterm-toggle-window-height." +(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/--vterm-toggle-last-direction nil) - (cj/--vterm-toggle-last-size nil) - (cj/vterm-toggle-window-height 0.7)) + (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/--vterm-toggle-display-saved 'fake-buf '((inhibit-same-window . t)))) + (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-vterm-toggle--display-saved-maps-cardinal-to-edge () +(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/--vterm-toggle-last-direction 'below) - (cj/--vterm-toggle-last-size 12)) + (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/--vterm-toggle-display-saved 'fake-buf nil)) + (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-vterm-toggle--display-saved-strips-conflicting-alist-entries () +(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/--vterm-toggle-last-direction 'right) - (cj/--vterm-toggle-last-size 30)) + (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/--vterm-toggle-display-saved + (cj/--term-toggle-display-saved 'fake-buf '((direction . above) (window-width . 0.2) @@ -83,5 +83,5 @@ received-alist))) (should (null wh-cells))))) -(provide 'test-vterm-toggle--display) -;;; test-vterm-toggle--display.el ends here +(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 / <escape> 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 / <escape> / 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, <escape>, -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 "<escape>") - #'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: <pause> is no longer wired as a vterm-copy-mode entry point. -The personal `C-; x c' binding is the canonical entry; <pause> is rare on -modern keyboards and was redundant." - (let ((binding (keymap-lookup vterm-mode-map "<pause>"))) - (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, <escape>, 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 "<escape>") - #'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 "<return>"))) - -(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 -`<escape>' → `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: `<escape>' in `vterm-mode-map' routes through `cj/vterm-send-escape'." - (should (eq (keymap-lookup vterm-mode-map "<escape>") - #'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 "<wheel-up>") - #'cj/vterm-mouse-wheel-up)) - (should (eq (keymap-lookup vterm-mode-map "<wheel-down>") - #'cj/vterm-mouse-wheel-down)) - (should (eq (keymap-lookup vterm-mode-map "<mouse-4>") - #'cj/vterm-mouse-wheel-up)) - (should (eq (keymap-lookup vterm-mode-map "<mouse-5>") - #'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/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 @@ -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-<project> +- 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. |
