aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-28 13:36:51 -0400
committerCraig Jennings <c@cjennings.net>2026-06-28 13:36:51 -0400
commit91405050fcce6424e90b520df67256c59e357915 (patch)
treeffc046b09cb59d7b618e24eda047f6b1c1bd59a3
parente4166e0d8bbc9ed75d7ed01e9e8447401ea771c1 (diff)
downloaddotemacs-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.el58
-rw-r--r--tests/test-ai-term--single-window-toggle.el114
-rw-r--r--todo.org3
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
diff --git a/todo.org b/todo.org
index a77683906..9921743fc 100644
--- a/todo.org
+++ b/todo.org
@@ -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