diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-28 13:36:51 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-28 13:36:51 -0400 |
| commit | 91405050fcce6424e90b520df67256c59e357915 (patch) | |
| tree | ffc046b09cb59d7b618e24eda047f6b1c1bd59a3 | |
| parent | e4166e0d8bbc9ed75d7ed01e9e8447401ea771c1 (diff) | |
| download | dotemacs-91405050fcce6424e90b520df67256c59e357915.tar.gz dotemacs-91405050fcce6424e90b520df67256c59e357915.zip | |
fix(ai-term): summon restores the agent's last fullscreen state
Summoning the agent (M-SPC) into a single-window frame docked it at the default fraction even when it was last fullscreen, because the size-memory model only captured split geometry at toggle-off and a sole window has no dock size to record. A window-configuration-change-hook tracker now records whether the displayed agent fills its frame (cj/--ai-term-last-fullscreen), and display-saved restores it in place when that flag and a single-window frame both hold.
The tracker records only that flag, not dock geometry: re-capturing the dock size on every window change fed a capture/replay loop that drifted the dock height a couple rows per cycle. The restore guard uses (one-window-p t) so an active minibuffer (a picker prompt mid-summon) isn't counted as a second window, which otherwise misfired the restore into a dock and cascaded.
| -rw-r--r-- | modules/ai-term.el | 58 | ||||
| -rw-r--r-- | tests/test-ai-term--single-window-toggle.el | 114 | ||||
| -rw-r--r-- | todo.org | 3 |
3 files changed, 168 insertions, 7 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el index 4b2495715..ff240b9bf 100644 --- a/modules/ai-term.el +++ b/modules/ai-term.el @@ -464,6 +464,19 @@ 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. @@ -480,6 +493,29 @@ is not live." '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. @@ -540,19 +576,27 @@ keeping the toggle reversible." win)))) (defun cj/--ai-term-display-saved (buffer alist) - "Display-buffer action: split per saved direction and size. + "Display-buffer action: restore fullscreen in a single-window frame, +otherwise split per saved direction and size. -When the prior toggle-off was a bury (single-window state, flagged -via `cj/--ai-term-last-was-bury') and the frame is still single- -window, restore the agent into the selected window in place rather -than splitting -- preserves the user's lone-window layout across -toggles. +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 - ((and cj/--ai-term-last-was-bury (one-window-p)) + ;; 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) diff --git a/tests/test-ai-term--single-window-toggle.el b/tests/test-ai-term--single-window-toggle.el index bc6de89f8..dd5adbcc3 100644 --- a/tests/test-ai-term--single-window-toggle.el +++ b/tests/test-ai-term--single-window-toggle.el @@ -182,5 +182,119 @@ the flag nil (no spurious set)." (kill-buffer "*test-sw-untouched-left*")) (cj/test--kill-agent-buffers)))) +;;; Geometry tracking (Approach B: remember the agent's fullscreen state) + +(ert-deftest test-ai-term--track-geometry-sole-sets-fullscreen () + "Normal: an agent window that is the sole window in its frame sets +`cj/--ai-term-last-fullscreen'." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [track-sole]") + (cj/--ai-term-last-fullscreen 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)) + (cj/--ai-term-track-geometry) + (should (eq cj/--ai-term-last-fullscreen t)))) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--track-geometry-split-clears-fullscreen () + "Normal: an agent window shown as a split clears `cj/--ai-term-last-fullscreen'. +The tracker must NOT re-capture dock direction/size here -- doing so on every +window change drifts the dock height per cycle; toggle-off owns that capture." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [track-split]") + (left-name "*test-track-left*") + (cj/--ai-term-last-fullscreen t) ; pretend it was fullscreen + (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)) + (left-buf (get-buffer-create left-name))) + (set-window-buffer (selected-window) left-buf) + (let ((agent-win (split-window (selected-window) nil 'right))) + (set-window-buffer agent-win agent-buf) + (should-not (one-window-p)) + (cj/--ai-term-track-geometry) + (should-not cj/--ai-term-last-fullscreen) ; flag cleared + (should-not cj/--ai-term-last-size)))) ; dock size NOT re-captured here + (when (get-buffer left-name) (kill-buffer left-name)) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--track-geometry-no-agent-retains-state () + "Boundary: with no agent window displayed, the tracker leaves the last-seen +fullscreen flag untouched -- that is the just-left state to replay." + (cj/test--kill-agent-buffers) + (let ((cj/--ai-term-last-fullscreen t)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (set-window-buffer (selected-window) + (get-buffer-create "*test-track-none*")) + (should-not (cj/--ai-term-displayed-agent-window)) + (cj/--ai-term-track-geometry) + (should (eq cj/--ai-term-last-fullscreen t))) ; unchanged + (when (get-buffer "*test-track-none*") (kill-buffer "*test-track-none*")) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--display-saved-restores-fullscreen-when-last-fullscreen () + "Normal: when the agent was last fullscreen and the target frame is a single +window, display-saved restores it in place rather than docking -- Craig's case +of leaving a fullscreen agent, switching to another fullscreen buffer, then +M-SPC. A stale dock size is on record; the split path must NOT run." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [restore-fullscreen]") + (cj/--ai-term-last-fullscreen t) + (cj/--ai-term-last-was-bury nil) + (cj/--ai-term-last-direction 'right) + (cj/--ai-term-last-size 40)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let* ((other-buf (get-buffer-create "*test-rfs-other*")) + (agent-buf (get-buffer-create agent-name)) + (win (selected-window)) + (split-called nil)) + (set-window-buffer win other-buf) + (should (one-window-p)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (&rest _) (setq split-called t) (selected-window)))) + (cj/--ai-term-display-saved agent-buf nil)) + (should (one-window-p)) ; no split -- stayed full-frame + (should (eq (window-buffer win) agent-buf)) ; agent took the lone window + (should-not split-called))) ; dock path never ran + (when (get-buffer "*test-rfs-other*") (kill-buffer "*test-rfs-other*")) + (cj/test--kill-agent-buffers)))) + +(ert-deftest test-ai-term--display-saved-docks-when-not-fullscreen () + "Boundary: without the fullscreen flag (or a bury), a single-window summon +docks via the saved-direction split. The discriminator is the remembered +state, not merely `one-window-p', so first-open and ordinary summons still +dock rather than seizing the whole frame." + (cj/test--kill-agent-buffers) + (let ((agent-name "agent [dock-not-fullscreen]") + (cj/--ai-term-last-fullscreen nil) + (cj/--ai-term-last-was-bury nil) + (cj/--ai-term-last-direction 'right) + (cj/--ai-term-last-size 40)) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (let ((agent-buf (get-buffer-create agent-name)) + (split-called nil)) + (set-window-buffer (selected-window) + (get-buffer-create "*test-dock-other*")) + (should (one-window-p)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (&rest _) (setq split-called t) (selected-window)))) + (cj/--ai-term-display-saved agent-buf nil)) + (should split-called))) ; dock path ran despite one-window-p + (when (get-buffer "*test-dock-other*") (kill-buffer "*test-dock-other*")) + (cj/test--kill-agent-buffers)))) + (provide 'test-ai-term--single-window-toggle) ;;; test-ai-term--single-window-toggle.el ends here @@ -709,6 +709,9 @@ Next: (1) revise the spec to the new direction; (2) spike the risky assumptions Put weather on the dashboard with a good illustrative icon, sourced from wttrin. Build on the release/0.4.0 wttrin now loaded via =:load-path= in =weather-config.el= (it carries the mode-line weather string plus auto-fit). Decide format (a current temp/conditions line vs a small forecast), refresh cadence, and placement/icon. From the roam inbox. ** TODO [#C] nov: sepia reading view (dark bg, tan/sepia text) :feature: A sepia setting for =nov-mode=: keep a dark background, render the letters in a tan/sepia color. nov defines no faces of its own and leans on shr, so the path is buffer-local face-remapping (=face-remap-add-relative= on =default= / =shr-text= / =variable-pitch=) in a nov-mode hook, toggled per a sepia preference. Overlaps the "epub/nov reading color" note under "Route hardcoded theme colors through the theme" (the removed =#E8DCC0= sepia plus "needs a themeable reading face") — reconcile with that themeable-face direction. From the roam inbox. +** DONE [#C] ai-term: M-SPC summon ignores the agent's last fullscreen size :bug: +CLOSED: [2026-06-28 Sun] +Fixed via Approach B (geometry tracking). A =window-configuration-change-hook= tracker (=cj/--ai-term-track-geometry=) records whether a displayed agent window is the sole window of its frame into =cj/--ai-term-last-fullscreen=; =cj/--ai-term-display-saved= restores the agent in place (fullscreen) when that flag (or the existing bury flag) is set and the frame is a single window, otherwise docks as before. Two follow-on bugs surfaced and were fixed during live testing: (1) the tracker must NOT re-capture dock direction/size on every window change -- doing so fed a capture/replay loop that drifted the dock height ~2 rows per cycle (the F9 shrink-bug class), so the tracker tracks only the fullscreen flag and leaves dock geometry to the toggle-off capture; (2) the restore condition used a bare =one-window-p=, which counts an active minibuffer (a picker prompt mid-summon) as a second window and misfired the restore into a dock that then cascaded -- fixed with =(one-window-p t)= (NOMINI). 5 new ERT tests in =test-ai-term--single-window-toggle.el=; 162/162 ai-term tests green; verified live by Craig (window holds (0 0 141 43) across round-trips). From Craig 2026-06-28. ** PROJECT [#B] Architecture review follow-up from 2026-05-03 :refactor: High-level pass over =init.el=, =early-init.el=, and all 104 files in |
