diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-01 22:42:38 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-01 22:42:38 -0400 |
| commit | cf6dfae6ad311991ce0914370c04e60c284874b2 (patch) | |
| tree | a501fc585bde2a2e0f419d80f7d369281cf5a7e5 /modules/ai-term-display.el | |
| parent | e18cf02e22049ad3cc4ce96059edc37a5ecb6719 (diff) | |
| download | dotemacs-cf6dfae6ad311991ce0914370c04e60c284874b2.tar.gz dotemacs-cf6dfae6ad311991ce0914370c04e60c284874b2.zip | |
refactor(ai-term): split into sessions, display, and EAT-backend layers
ai-term.el had grown to ~1,215 lines mixing project/tmux session discovery, window display policy, the EAT terminal backend, and the public commands, so a change to any one risked coupling to the others.
I extracted three layers, following the calendar-sync split shape: ai-term-sessions.el (discovery, tmux naming and parsing, launch command, picker ordering), ai-term-display.el (display-buffer actions and rule, toggle state, server-window routing), and ai-term-backend-eat.el (terminal create/reattach, pty send, EAT keymap). The backend file is named for its backend so a future one lands as a sibling. ai-term.el stays the public face (options, commands, keymap, shutdown), every name unchanged, so existing (require 'ai-term) callers and all 30 test files work as before.
The extracted layers forward-declare the face's defcustoms rather than requiring it, keeping the graph acyclic. I dropped the unused cl-lib and host-environment requires and added the three modules to the header-contract list.
Diffstat (limited to 'modules/ai-term-display.el')
| -rw-r--r-- | modules/ai-term-display.el | 460 |
1 files changed, 460 insertions, 0 deletions
diff --git a/modules/ai-term-display.el b/modules/ai-term-display.el new file mode 100644 index 00000000..b78a2638 --- /dev/null +++ b/modules/ai-term-display.el @@ -0,0 +1,460 @@ +;;; ai-term-display.el --- AI-term window and display policy -*- lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; Layer: 3 (Domain Workflow). +;; Category: D. +;; Load shape: library. +;; Top-level side effects: installs the agent display-buffer-alist rule, adds +;; the geometry tracker to window-configuration-change-hook, and wires +;; `server-window' after server loads. +;; Runtime requires: seq, cj-window-geometry-lib, cj-window-toggle-lib, +;; ai-term-sessions. +;; Direct test load: yes. +;; +;; Display/window layer of ai-term: the display-buffer action chain and its +;; alist rule, the toggle capture/restore state and geometry tracking, the +;; toggle-off teardown, the working-buffer swap, and the server-window +;; routing that keeps emacsclient-opened files off the agent terminal. +;; No EAT and no tmux -- window behavior here is exercisable with plain +;; buffers and stubbed session/backend functions. +;; +;; The size defaults this layer reads (`cj/ai-term-desktop-width', +;; `cj/ai-term-laptop-height') are owned by ai-term.el, the public face, +;; which requires this module before defining them; they are +;; forward-declared here so this module compiles and reads them without a +;; cycle. + +;;; Code: + +(require 'seq) +(require 'cj-window-geometry-lib) +(require 'cj-window-toggle-lib) +(require 'ai-term-sessions) + +;; Owned by ai-term.el (the public face's defcustoms); forward-declared so +;; this module compiles and reads them without a cycle. +(defvar cj/ai-term-desktop-width) +(defvar cj/ai-term-laptop-height) + +(defun cj/--ai-term-displayed-agent-window (&optional frame) + "Return a window in FRAME currently displaying an AI-term buffer, or nil. + +FRAME defaults to the selected frame. When more than one window in +the frame shows an agent buffer, the first one in `window-list' order +is returned. The minibuffer is excluded from the search." + (seq-find (lambda (w) + (cj/--ai-term-buffer-p (window-buffer w))) + (window-list (or frame (selected-frame)) 'never))) + +(defun cj/--ai-term-most-recent-non-agent-buffer () + "Return the most-recently-selected live non-agent buffer, or nil. + +Walks `buffer-list' (most-recently-selected first) and returns the +first buffer that is not an AI-term agent buffer (per +`cj/--ai-term-buffer-p') and is not an internal buffer (name starting +with a space). Used by the single-window toggle-off so dismissing a +full-frame agent returns to the file the user was working in (e.g. +todo.org) rather than swapping in another agent." + (seq-find (lambda (b) + (and (buffer-live-p b) + (not (cj/--ai-term-buffer-p b)) + (not (string-prefix-p " " (buffer-name b))))) + (buffer-list))) + +(defun cj/--ai-term-default-direction (&optional frame) + "Return the default split direction for the agent window. + +Chosen at display time from FRAME's column width (FRAME defaults to the +selected frame): `right' when a side-by-side split would leave both the +agent and the main window at least `cj/window-dock-min-columns' wide, +`below' otherwise. The agent's share of the width is +`cj/ai-term-desktop-width'. See `cj/preferred-dock-direction'." + (let ((frame (or frame (selected-frame)))) + (cj/preferred-dock-direction (frame-width frame) + cj/ai-term-desktop-width))) + +(defun cj/--ai-term-default-size () + "Return the default size fraction paired with the chosen direction. + +`cj/ai-term-desktop-width' (a width fraction) when the default direction is +`right', `cj/ai-term-laptop-height' (a height fraction) when it is `below'." + (if (eq (cj/--ai-term-default-direction) 'right) + cj/ai-term-desktop-width + cj/ai-term-laptop-height)) + +(defvar cj/--ai-term-last-direction nil + "Last user-chosen direction for the AI-term display. + +Symbol: right, below, or left. `above' is never stored -- the agent +window must not be remembered at the top of the frame, so a top +placement falls back to the host default at capture time. nil means no +agent window has been toggled off yet this session, so the default +direction applies. Captured at toggle-off by +`cj/--ai-term-capture-state' and consumed by +`cj/--ai-term-display-saved'.") + +(defvar cj/--ai-term-last-was-bury nil + "Non-nil when the last toggle-off used `bury-buffer'. + +Set by `cj/ai-term' in its `toggle-off' branch: t when the agent +window was the only window in the frame (so toggle-off buried +without deleting), nil when the window was deleted. Consumed by +`cj/--ai-term-display-saved' to decide between restoring the +buried agent in the current window (the only one) or splitting per +the saved direction.") + +(defvar cj/--ai-term-last-toggle-deleted-split nil + "Non-nil when the last toggle-off deleted the agent's own split window. + +Set t by `cj/--ai-term-toggle-off' only when it actually `delete-window's +the agent (a multi-window layout where the agent had its own window); +nil for a bury or a degenerate swap. Consumed by +`cj/--ai-term-reuse-edge-window': when set, the next toggle-on re-splits a +fresh agent window instead of reusing a window at the edge. Without this, +toggling the agent off and on in a 3+ window layout would reuse the user's +working window at the edge, displacing its buffer and collapsing the layout +-- the toggle must be reversible (off then on returns the same windows).") + +(defvar cj/--ai-term-last-hidden-buffer nil + "The agent buffer hidden by the most recent toggle-off. + +Captured in `cj/ai-term' just before an agent window is torn down, and +consumed by `cj/--ai-term-dispatch' so the next toggle-on reopens the +SAME agent that was on screen rather than whichever agent happens to be +most-recent in `buffer-list'. Without this, hiding one agent and +reopening could surface a different one when several agents are alive -- +the \"the displayed buffer changes\" bug. Falls back to the buffer-list +MRU when nil or when the remembered buffer has been killed.") + +(defvar cj/--ai-term-last-size nil + "Last user-chosen size for the AI-term display. + +Positive integer: body-columns when `cj/--ai-term-last-direction' +is right or left, total-lines when below or above. nil means use +the host-aware default from `cj/--ai-term-default-size' (a float +fraction). See `cj/window-replay-size' for the per-axis capture. + +The axis choice is asymmetric. Width captures body-width, not +total-width: total-width includes the right-edge divider when the +window has a right sibling but excludes it at the frame edge, so +capturing total-width from a rightmost agent (no divider) and +replaying into a middle position (with divider) leaves the body 1 +column short. Body-width is divider-independent. + +Height captures total-height, not body-height: every window has +exactly one mode line regardless of position, so total-height has +no divider-position problem, and total-height is the same whether +the window is active or inactive. Body-height would subtract the +mode line's pixel height, which differs between an active and an +inactive (theme-shrunk) mode line -- capturing body-height active +and replaying it inactive then re-measuring active drifts the +window down by ~1 line per toggle (the F9 shrink bug, 2026-06-20). + +Absolute values rather than fractions because +`display-buffer-in-direction' interprets a float `window-width' / +`window-height' as a fraction of the new window's parent in the +window tree. In a 3+ window layout the parent may be a sub-tree, +and a fraction-of-frame produces the wrong size on replay +(squeezes the other windows). An integer is unambiguous, at the +cost of not auto-scaling if the frame itself resizes.") + +(defvar cj/--ai-term-last-fullscreen nil + "Non-nil when the agent window was last seen filling its frame. + +Maintained by `cj/--ai-term-track-geometry' on +`window-configuration-change-hook': set t whenever a live agent window is +the sole window in its frame, cleared when the agent is shown as a split +\(its dock direction and size are captured then instead). Consulted by +`cj/--ai-term-display-saved' so a summon into a single-window frame +restores the agent fullscreen rather than docking it -- the sole-window +state isn't a representable dock size, so this flag is how it round-trips. +Unlike `cj/--ai-term-last-was-bury' it does not depend on a toggle-off, so +it also covers leaving the agent by switching buffers or `C-x 1'.") + +(defun cj/--ai-term-capture-state (window) + "Capture WINDOW's direction and size into module-level state. + +Sets `cj/--ai-term-last-direction' and `cj/--ai-term-last-size' +so a subsequent display can restore the user's chosen orientation +and size. Called at toggle-off (just before the window is torn +down). The default direction is host-aware via +`cj/--ai-term-default-direction' (used only when WINDOW fills its +frame and no direction can be inferred). Does nothing when WINDOW +is not live." + (cj/window-toggle-capture-state + window (cj/--ai-term-default-direction) + 'cj/--ai-term-last-direction + 'cj/--ai-term-last-size + '(right below left))) + +(defun cj/--ai-term-window-sole-p (window) + "Return non-nil when WINDOW is the only live window in its frame. +A frame's sole window is its root window; once split, the root is an +internal window and no live window equals it." + (and (window-live-p window) + (eq window (frame-root-window (window-frame window))))) + +(defun cj/--ai-term-track-geometry (&rest _) + "Track whether the displayed agent window is fullscreen. + +Run from `window-configuration-change-hook'. Sets +`cj/--ai-term-last-fullscreen' to whether a live agent window is the sole +window in its frame, and leaves it untouched when no agent window is +displayed -- that retained value is the just-left state a later summon +replays. Dock direction and size stay owned by the toggle-off capture +\(`cj/--ai-term-capture-state'); this hook must not re-capture them, or the +repeated capture/replay drifts the dock height a couple rows per cycle." + (let ((win (cj/--ai-term-displayed-agent-window))) + (when (window-live-p win) + (setq cj/--ai-term-last-fullscreen (cj/--ai-term-window-sole-p win))))) + +(add-hook 'window-configuration-change-hook #'cj/--ai-term-track-geometry) + +(defun cj/--ai-term-reuse-existing-agent (buffer _alist) + "Display-buffer action: reuse any window in this frame already showing +an agent buffer. + +Looks up `cj/--ai-term-displayed-agent-window' on the selected +frame. When an agent window exists, replaces its buffer with BUFFER +and returns the window. When none exists, returns nil so the next +action in the chain runs. + +This is more specific than `display-buffer-use-some-window', which +would happily steal any non-selected window (e.g. a code window +above the agent split) when the user is focused in agent and +swaps projects via C-; a s. The selective lookup here keeps non-agent +windows undisturbed and preserves the user's split geometry across +project changes." + (let ((win (cj/--ai-term-displayed-agent-window))) + (when win + (set-window-buffer win buffer) + win))) + +(defun cj/--ai-term-reuse-edge-window (buffer _alist) + "Display-buffer action: reuse the existing window forming the target half. + +When the frame already holds a window forming the half the agent would +occupy -- the right column on a desktop, the bottom row on a laptop, per +the saved or default direction -- swap BUFFER into it with +`set-window-buffer' and return that window, rather than splitting a third +window in. The target half is found by `cj/window-at-edge'. + +Returns nil when there is no such half to reuse (a single-window frame, +or a layout split on the other axis), so the chain falls through to +`cj/--ai-term-display-saved', which splits a fresh half. Also returns +nil when the edge window is dedicated -- those are not ours to replace. + +Records the displaced buffer through `display-buffer-record-window' +\(type `reuse') before swapping, so the native `quit-restore-window' +called at toggle-off puts that buffer back into the slot instead of +deleting the window -- toggling swaps the slot's buffer between the +displaced buffer and the agent, never changing the window count. + +Runs after `cj/--ai-term-reuse-existing-agent', so an agent already on +screen has been handled already; the window reused here always holds a +non-agent buffer, which is replaced (it stays alive, just unshown). + +Skipped entirely when the prior toggle-off deleted the agent's own split +window (`cj/--ai-term-last-toggle-deleted-split'): re-showing then reuses a +working window at the edge and collapses the layout. Consume the flag and +return nil so `cj/--ai-term-display-saved' re-splits a fresh agent window, +keeping the toggle reversible." + (if cj/--ai-term-last-toggle-deleted-split + (progn (setq cj/--ai-term-last-toggle-deleted-split nil) nil) + (let* ((direction (or cj/--ai-term-last-direction + (cj/--ai-term-default-direction))) + (win (cj/window-at-edge direction))) + (when (and win (not (window-dedicated-p win))) + (display-buffer-record-window 'reuse win buffer) + (set-window-buffer win buffer) + win)))) + +(defun cj/--ai-term-display-saved (buffer alist) + "Display-buffer action: restore fullscreen in a single-window frame, +otherwise split per saved direction and size. + +When the frame is a single window and the agent was last fullscreen +\(`cj/--ai-term-last-fullscreen', tracked by `cj/--ai-term-track-geometry') +or the prior toggle-off was a single-window bury +\(`cj/--ai-term-last-was-bury'), restore the agent into the selected window +in place rather than splitting. This round-trips a fullscreen agent -- +left by toggle-off, `C-x 1', or switching buffers -- since the sole-window +state isn't a representable dock size. + +Otherwise delegates to `cj/window-toggle-display-saved' against the +toggle state vars, falling back to the host-aware defaults from +`cj/--ai-term-default-direction' and `cj/--ai-term-default-size'." + (cond + ;; NOMINI t: don't count an active minibuffer as a second window. A summon + ;; can run with a picker prompt up, and a bare `one-window-p' then returns + ;; nil on a structurally single-window frame, misfiring the fullscreen + ;; restore into a dock -- which clears the fullscreen flag and cascades. + ((and (or cj/--ai-term-last-fullscreen cj/--ai-term-last-was-bury) + (one-window-p t)) + (setq cj/--ai-term-last-was-bury nil) + (let ((win (selected-window))) + (set-window-buffer win buffer) + win)) + (t + (setq cj/--ai-term-last-was-bury nil) + (cj/window-toggle-display-saved + buffer alist + 'cj/--ai-term-last-direction (cj/--ai-term-default-direction) + 'cj/--ai-term-last-size (cj/--ai-term-default-size))))) + +(defun cj/--ai-term-display-rule-list () + "Return the `display-buffer-alist' entry list installed by this module. + +The single rule routes any buffer whose name starts with \"agent [\" +through four actions in order: + +1. `display-buffer-reuse-window' -- if the same buffer is already + visible in any window, focus that one. +2. `cj/--ai-term-reuse-existing-agent' -- otherwise, if any + window in this frame already shows an agent-prefixed buffer, + swap its buffer for the new one (preserves geometry across + project changes via C-; a s). +3. `cj/--ai-term-reuse-edge-window' -- otherwise, if the frame + already has a window forming the half the agent would occupy + (the right column on a desktop, the bottom row on a laptop), + reuse it instead of splitting a third window in. +4. `cj/--ai-term-display-saved' -- otherwise (single-window frame, + or a layout split on the other axis), split per the saved + direction + size from the last toggle-off (or defaults when no + capture has happened this session). + +`display-buffer-in-side-window' is avoided deliberately. Side +windows enforce dedication, which breaks `buffer-move' (C-M-arrows) +and `switch-to-buffer' replacement. The chain above keeps the +resulting window an ordinary window so all standard window commands +work. + +`display-buffer-use-some-window' is also avoided -- it would happily +steal any non-selected window (e.g. a code window above an agent +split) when the user is focused in agent and switches projects." + '(("\\`agent \\[" + (display-buffer-reuse-window + cj/--ai-term-reuse-existing-agent + cj/--ai-term-reuse-edge-window + cj/--ai-term-display-saved) + (inhibit-same-window . t)))) + +(dolist (entry (cj/--ai-term-display-rule-list)) + (add-to-list 'display-buffer-alist entry)) + +(defun cj/--ai-term-swap-to-working-buffer (win) + "In WIN, switch to the most-recent non-agent buffer (a working file). +Falls back to `other-buffer' (excluding WIN's current agent buffer) when no +non-agent buffer is on record. Used at toggle-off and close so dismissing an +agent surfaces the file the user was working on rather than another agent or +the agent itself." + (with-selected-window win + (switch-to-buffer + (or (cj/--ai-term-most-recent-non-agent-buffer) + (other-buffer (window-buffer win) t))))) + +(defun cj/--ai-term-toggle-off (win) + "Hide the agent shown in WIN for a toggle-off. Always returns nil. + +Two cases, by window count: + +- Lone fullscreen agent (e.g. after `C-x 1' inside it): there is no prior + layout for the native undo to restore and deleting would leave the frame + empty. Bury and flag, so the next toggle-on (`cj/--ai-term-display-saved') + restores the agent in place at full frame rather than splitting. Capture + geometry for that restore. `bury-buffer' can no-op when the window's + prev-buffer history holds only the agent (common right after `C-x 1'), so + force a swap to a non-agent buffer to keep the toggle observable. + +- Multi-window: collapse the agent split outright by deleting its window, so + the working buffer (e.g. todo.org) reclaims the space. The toggle is a pure + show/hide toggle of THE agent split -- it must never surface a different + agent. `quit-restore-window' can't guarantee that here: switching among + several agents reuses the one slot via `set-window-buffer' (see + `cj/--ai-term-reuse-existing-agent'), which leaves the window's + `quit-restore' parameter pointing at the FIRST agent shown. Once it's + stale, `quit-restore-window' falls back to `switch-to-prev-buffer' and + surfaces another agent instead of removing the window -- exactly the \"F9 + shows another agent\" bug. `delete-window' is unconditional and + slot-history-independent. Capture geometry first so the next toggle-on + splits at the same size (the user's chosen split width is preserved)." + ;; Remember which agent we're hiding so the next toggle-on reopens this + ;; same one, not whichever agent is most-recent in `buffer-list'. + (setq cj/--ai-term-last-hidden-buffer (window-buffer win)) + (cond + ((one-window-p) + (cj/--ai-term-capture-state win) + (setq cj/--ai-term-last-was-bury t) + (setq cj/--ai-term-last-toggle-deleted-split nil) + (bury-buffer (window-buffer win)) + (when (and (window-live-p win) + (cj/--ai-term-buffer-p (window-buffer win))) + (cj/--ai-term-swap-to-working-buffer win))) + (t + (cj/--ai-term-capture-state win) + (setq cj/--ai-term-last-was-bury nil) + (if (and (window-live-p win) + (> (length (window-list (window-frame win) 'never)) 1)) + (progn + (delete-window win) + ;; The agent had its own window in a multi-window layout, now gone: + ;; the next toggle-on must re-split it rather than reuse a working + ;; window at the edge (see `cj/--ai-term-reuse-edge-window'). + (setq cj/--ai-term-last-toggle-deleted-split t)) + ;; Degenerate fallback (window became sole between dispatch and + ;; here): swap to a non-agent buffer rather than leave the agent up. + (setq cj/--ai-term-last-toggle-deleted-split nil) + (when (window-live-p win) + (cj/--ai-term-swap-to-working-buffer win))))) + nil) + +;; ---------- emacsclient: keep opened files off the agent terminal ---------- +;; +;; `server-start' (in system-defaults.el) leaves `server-window' nil, so +;; `server-switch-buffer' opens an `emacsclient -n' file in the *selected* +;; window. When the user is typing in the agent terminal, that's the agent +;; window -- so "tell the agent to open X" would replace the agent buffer +;; with X. The function below, wired as `server-window', routes such files +;; into a non-agent window instead (splitting one off the agent when the +;; agent is the only window). emacsclient invocations from anywhere else +;; fall through to `pop-to-buffer' and behave as before. + +(defun cj/--ai-term-non-agent-window (&optional exclude) + "Return a window in the selected frame fit to show a non-agent buffer. + +Skips the minibuffer, the EXCLUDE window, dedicated windows, and any +window already showing an AI-term agent buffer. Returns nil when no +such window exists." + (seq-find (lambda (w) + (and (not (eq w exclude)) + (not (window-dedicated-p w)) + (not (cj/--ai-term-buffer-p (window-buffer w))))) + (window-list (selected-frame) 'never))) + +(defun cj/--ai-term-server-display (buffer) + "Display BUFFER for `server-window', keeping it off the agent terminal. + +When the selected window shows an AI-term agent buffer, put BUFFER in +a non-agent window (`cj/--ai-term-non-agent-window'), splitting a +left-side window off the agent when the agent is the only window, then +select that window. Otherwise hand off to `pop-to-buffer'. Returns +the window BUFFER ends up in -- the value `server-switch-buffer' +expects from a `server-window' function." + (if (cj/--ai-term-buffer-p (window-buffer (selected-window))) + (let* ((agent-win (selected-window)) + (target (or (cj/--ai-term-non-agent-window agent-win) + (split-window agent-win nil 'left)))) + (set-window-buffer target buffer) + (select-window target)) + (pop-to-buffer buffer) + (selected-window))) + +(defvar server-window) +(with-eval-after-load 'server + (setq server-window #'cj/--ai-term-server-display)) + +(provide 'ai-term-display) +;;; ai-term-display.el ends here |
