diff options
| -rw-r--r-- | docs/specs/2026-07-17-org-agenda-dock-mode-spec.org | 198 | ||||
| -rw-r--r-- | docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org | 403 | ||||
| -rw-r--r-- | init.el | 1 | ||||
| -rw-r--r-- | modules/org-agenda-config.el | 9 | ||||
| -rw-r--r-- | modules/org-agenda-frame.el | 634 | ||||
| -rw-r--r-- | tests/test-org-agenda-frame.el | 736 |
6 files changed, 1780 insertions, 201 deletions
diff --git a/docs/specs/2026-07-17-org-agenda-dock-mode-spec.org b/docs/specs/2026-07-17-org-agenda-dock-mode-spec.org deleted file mode 100644 index 1fcb9968..00000000 --- a/docs/specs/2026-07-17-org-agenda-dock-mode-spec.org +++ /dev/null @@ -1,198 +0,0 @@ -#+TITLE: org-agenda fullscreen frame — Spec -#+AUTHOR: Craig Jennings -#+DATE: 2026-07-17 -#+TODO: TODO | DONE -#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED - -* READY org-agenda fullscreen frame -:PROPERTIES: -:ID: 7705c94b-9bb7-47d8-9828-e9584172c54f -:END: -- 2026-07-20 Mon @ 09:53 -0500 — spec-review: READY. All 8 decisions resolved; code read confirmed the F8 bindings and display rule. One non-blocking finding recorded (the frame view needs its own custom-command entry). Ready to decompose the 3 phases. -- 2026-07-20 Mon @ 09:45 -0500 — redesigned: a dedicated fullscreen frame replaces the right-side side-window dock. All eight design decisions resolved (see Decisions). The filename slug still reads "dock-mode" for link stability; the design is a fullscreen agenda frame, not a side dock. -- 2026-07-17 Fri @ 19:34:07 -0500 — drafted. - -* Metadata -| Status | ready | -|----------+------------------------------------------------| -| Owner | Craig Jennings | -|----------+------------------------------------------------| -| Reviewer | Craig Jennings | -|----------+------------------------------------------------| -| Related | [[file:../../todo.org][dock-mode task (Emacs Open Work)]] | -|----------+------------------------------------------------| - -* Summary - -A dedicated fullscreen Emacs frame that shows the agenda. One key spawns (or raises) a frame of the running daemon, fullscreened, displaying the next seven days of schedule and tasks, refreshing itself every few minutes. Inside that frame focus stays on the agenda. The point is a standing, always-current agenda surface you can throw on its own workspace or monitor, one keystroke away, without the agenda ever stealing the frame you work in. - -* Problem / Context - -The normal agenda (=<f8>=, =cj/main-agenda-display=) opens below-selected at 75% of the frame (=cj/org-agenda-window-height=, applied through =cj/--org-agenda-display-rule=). That's a modal, take-over-the-frame view: you summon it, read it, dismiss it, and go back to work. There's no way to keep the schedule and task list glanceable while working, and no live refresh, so an agenda left open drifts stale (the now-line and freshly-synced calendar events don't move). - -Craig wants a second, non-modal surface: the agenda living in its own fullscreen frame, current and ready to engage, so it can sit on a separate workspace or monitor while the working frames stay untouched. This is a distinct mode from the existing full-view agenda, not a replacement for it. - -* Goals and Non-Goals - -** Goals -- A dedicated fullscreen frame of the running daemon showing the seven-day schedule and task list, wholly separate from the working frames. -- Live: the frame refreshes on a few-minute cadence so the now-line and synced events stay current. It shares the daemon's state, so calendar-sync results and buffer edits are already reflected. -- One key to spawn, raise, and close it. -- Focus stays on the agenda inside that frame; engaging a task opens the file in the working frame, not over the agenda. - -** Non-Goals -- Not a replacement for the existing =<f8>= full-view agenda — that stays as is. -- Not a separate OS process. It's a frame of the running daemon (chosen for live shared state over an isolated second Emacs). It therefore dies with the daemon, which is acceptable. -- Not a capture/scratch surface in v1. Whether an agenda surface should double as a scratchpad is a separate exploration (see the [#D] task); v1 is read + engage only. -- Not a startup auto-open. The frame is spawned on demand by its key; there is no auto-open defcustom. -- Not a redesign of agenda content or faces — it reuses org-agenda's rendering. - -** Scope tiers -- v1: a spawn/raise/close command bound to =S-<f8>=; a fullscreen frame; a seven-day agenda view filling it; focus held on the agenda; jump-to-task routed to the working frame; a frame-scoped refresh timer; the force-refresh keybinding move. -- Out of scope: the scratchpad surface; a separate-process agenda; persisting frame state across daemon restarts. -- vNext: scratchpad exploration (its own task); richer view tuning once the mode is in daily use. - -* Design - -The agenda lives in its own frame of the running daemon, created with =make-frame= carrying =(fullscreen . fullboth)= and a marker parameter (=(cj/agenda-frame . t)=) so the toggle can find, raise, or delete it. Sharing the daemon means the frame sees the same live state as every working frame: calendar-sync writes, unsaved buffer edits, and the current now-line are all already there, so "live" needs only a periodic redo, not a reload. - -For the user, the interface is one key. =S-<f8>= toggles the frame: spawn it fullscreen if none exists, raise and select it if it exists but isn't focused, delete it if it's the selected frame. The current occupant of =S-<f8>= (=cj/org-agenda-refresh-files=, the manual force-rescan shipped in 17ae3e2a) moves to =C-M-<f8>=, keeping the whole force-refresh idea in the F8 family. - -Inside the agenda frame, focus stays on the agenda. The frame holds a single window showing the seven-day agenda buffer, so point rests there by construction. Engaging a task (=RET=/=TAB= on an agenda line) opens the target file in the working frame and raises it; the agenda frame is never chosen as the display target, so it keeps showing the agenda. In the working frame (the one that launched it, and every other frame) there are no such restrictions — normal behavior throughout. - -For the implementer, four pieces compose: - -1. A toggle command that spawns, raises, or deletes the marked frame. Spawn creates the fullscreen frame, runs the agenda view into its sole window, and selects the agenda. Delete removes the frame and stops the timer. The frame carries the =cj/agenda-frame= marker so the command can locate it among the daemon's frames. - -2. A seven-day agenda view, defined as an =org-agenda-custom-commands= entry (=org-agenda-span= 7), run with =org-agenda-sticky= on so the buffer persists and redo is instant and doesn't re-prompt. No separate TODO block — the seven-day span already surfaces scheduled work; unscheduled-priority surfacing is a vNext tuning question, not v1. - -3. Jump-to-task routing. =RET=/=TAB= must open the file in the working frame, never in the agenda frame. Pin =org-agenda-window-setup= behavior for the agenda buffer and route the file's =display-buffer= to a non-agenda frame (the most-recently-selected other frame), so the side effect of engaging a task lands where the work happens. - -4. A frame-scoped refresh timer: an =org-agenda-redo= on the agenda buffer every 5 minutes, aligned to the wall-clock 5-minute mark, started when the frame spawns and cancelled when it's deleted (or killed out from under the timer). =redo= re-reads the agenda files' contents (calendar sync, task edits) but not the file list — a newly-created project's todo.org still needs the manual force-rescan, consistent with the 24h file-list cache TTL. - -** Not a prototype-pipeline UI - -=ui-prototyping.md='s research → five-prototype → iterate process governs bespoke visual surfaces (panels, multi-control widgets, SVG faceplates). This frame is org-agenda's existing text rendering shown fullscreen; the layout question ("where does it go?") is answered in a sentence (its own fullscreen frame). So the prototype pipeline is intentionally skipped. The open questions here were behavioral (frame vs process, view span, focus, jump routing, auto-open), not visual-layout, and they're settled in the Decisions below. - -* Alternatives Considered - -** Dedicated fullscreen frame of the daemon (chosen) -- Good, because it shares the daemon's live state: calendar-sync, open buffers, and edits are already reflected, so "live" costs only a periodic redo. -- Good, because it's genuinely separate from the working frames — it can sit on its own workspace or monitor and never steals working space. -- Neutral, because it dies with the daemon (no cross-restart persistence). Acceptable for v1. - -** A separate Emacs process -- Good, because it's fully isolated and survives a daemon restart. -- Bad, because it doesn't share the daemon's in-memory state — it reads the org files from disk and only reflects what's been saved. The "always current, shares live edits" goal argues against it, so it was rejected in favor of a frame. - -** A right-side side-window dock (the prior draft's design) -- Good, because it lives in the working frame and is protected from =delete-other-windows=. -- Bad, because it isn't what Craig wants: it shares the working frame rather than standing alone, it's bounded to a dock width that crowds the working area, and it can't move to its own workspace or monitor. Superseded by the fullscreen frame. - -** A normal split window -- Good, because it's the least new machinery. -- Bad, because it isn't protected, has no clean "this is the agenda surface" identity, and takes over the working frame. This is what the current 0.75 rule already gives and what the frame needs to be different from. - -** Global "refresh any visible agenda" timer -- Good, because it would also refresh the =<f8>= full view when left open. -- Bad, because it has no clean lifecycle — it has to poll for visible agenda buffers and decide when to stop. The frame-scoped timer starts and stops with the frame, which is simpler and matches the feature's boundary. - -* Decisions [8/8] - -** DONE Dedicated fullscreen frame as the display mechanism -- Context: the surface must stand wholly apart from the working frames, be placeable on its own workspace or monitor, and stay live. -- Decision: We display the agenda in a dedicated frame of the running daemon, created with =make-frame= + =(fullscreen . fullboth)= and a =cj/agenda-frame= marker parameter. A frame (not a separate process) so it shares the daemon's live state. -- Consequences: easier — live state, no reload, full isolation from working frames. Harder — it dies with the daemon; frame lookup and lifecycle must be explicit. - -** DONE Launch/toggle on S-<f8>; force-refresh moves to C-M-<f8> -- Context: =S-<f8>= currently runs =cj/org-agenda-refresh-files= (shipped 17ae3e2a); Craig wants =S-<f8>= to spawn/raise/close the agenda frame. -- Decision: =S-<f8>= toggles the agenda frame (spawn if none, raise+select if unfocused, delete if selected). =cj/org-agenda-refresh-files= rebinds to =C-M-<f8>=, keeping both in the F8 family. -- Consequences: easier — one gesture, next to the other agenda keys. Harder — one existing binding moves; the docstring/family comment needs updating. - -** DONE View: the seven-day agenda span alone -- Context: "the day's work visible" is a composite; how many days, and whether to append a TODO block. -- Decision: the frame shows a seven-day agenda span (=org-agenda-span= 7) as one =org-agenda-custom-commands= entry, with no separate prioritized TODO block. The span already surfaces scheduled work. -- Consequences: easier — one custom-command entry, no block composition to tune. Harder — unscheduled priorities aren't surfaced; that's a vNext tuning question if it turns out to matter. - -** DONE Focus stays on the agenda inside the frame -- Context: opening or working the agenda frame should keep point on the agenda, not scatter into other buffers; the launching frame stays unrestricted. -- Decision: the agenda frame holds a single window on the agenda buffer and point rests there; no focus restrictions apply to any other frame. -- Consequences: easier — the frame is unambiguous to use. Harder — anything that would open a buffer in the frame must be routed elsewhere (see jump-to-task). - -** DONE Jump-to-task opens the file in the working frame -- Context: engaging a task from a fullscreen agenda must not replace the agenda with the target file. -- Decision: =RET=/=TAB= on an agenda line opens the target file in the working frame (the most-recently-selected non-agenda frame) and raises it; the agenda frame keeps showing the agenda. -- Consequences: easier — the agenda frame is genuinely "ready to engage" and stays pure. Harder — needs deliberate =org-agenda-window-setup= / =display-buffer= handling so the agenda frame is never the jump target (the likeliest rabbit hole). - -** DONE Frame-scoped refresh via org-agenda-redo, wall-clock aligned -- Context: the frame must stay current (now-line, synced events) without re-scanning the file list or churning a timer when it's closed. -- Decision: run =org-agenda-redo= on the agenda buffer every 5 minutes aligned to the :00/:05/:10 mark, started on spawn and cancelled on delete/kill. Not a file-list rebuild — that's the manual force-rescan. -- Consequences: easier — cheap, self-scoped, picks up content changes. Harder — a brand-new project's todo.org won't appear until a manual force-rescan or the 24h cache TTL lapses. - -** DONE No startup auto-open -- Context: the frame could spawn automatically on daemon start. -- Decision: no auto-open. The frame is spawned on demand by =S-<f8>=; there is no auto-open defcustom. -- Consequences: easier — no startup coupling, no small-frame-at-startup edge case, one fewer knob. Harder — none; a standing frame is one keystroke away. - -** DONE Not a separate process — a frame of the daemon -- Context: "its own Emacs" could mean an isolated process or a frame of the running daemon. -- Decision: a frame of the daemon, for live shared state (calendar-sync, edits, now-line). A separate process would only reflect saved-to-disk state. -- Consequences: easier — always current, zero duplicated config load. Harder — no cross-restart persistence; the frame is gone after a daemon restart and re-spawned by its key. - -* Review findings [0/1] - -** TODO Frame view needs its own custom-command entry (avoid the span-8 collision) -=modules/org-agenda-config.el:344-348= already sets =org-agenda-custom-commands= with a =(org-agenda-span 8)= entry. Phase 2 must add the frame's seven-day view as its own custom-command key rather than reusing or editing the existing span-8 entry, so the fullscreen frame and the normal =<f8>= view stay independent. Non-blocking — an implementation detail, not a product decision. - -* Implementation phases - -** Phase 1 — Spawn/raise/close command + fullscreen frame -The =S-<f8>= toggle: spawn the fullscreen agenda frame (=make-frame= + =fullboth= + the =cj/agenda-frame= marker), raise+select it if it exists, delete it if it's the selected frame. Move =cj/org-agenda-refresh-files= to =C-M-<f8>=. Leaves a working frame showing the plain daily agenda; the seven-day view, jump routing, and refresh come in later phases. - -** Phase 2 — Seven-day view + jump-to-task routing -Define the =org-agenda-custom-commands= seven-day entry (=org-agenda-span= 7, =org-agenda-sticky= on) and point the spawn at it. Route =RET=/=TAB= so the target file opens in the working frame and the agenda frame stays on the agenda. - -** Phase 3 — Frame-scoped refresh timer -A wall-clock-aligned 5-minute =org-agenda-redo= on the agenda buffer, started on spawn, cancelled on delete/kill, preserving point and any active filters across the redo. A live-buffer / live-frame guard so a killed frame doesn't error on the next tick. - -* Acceptance criteria -- [ ] =S-<f8>= spawns a fullscreen agenda frame; pressing it from that frame closes it; pressing it from a working frame raises it. -- [ ] =C-M-<f8>= runs the force-rescan (=cj/org-agenda-refresh-files=); =S-<f8>= no longer does. -- [ ] The agenda frame shows a seven-day span and keeps focus on the agenda. -- [ ] Opening a file from an agenda line (=RET=) shows the file in the working frame and raises it; the agenda frame remains on the agenda. -- [ ] The frame refreshes on the wall-clock 5-minute mark while open, and no timer runs after it's closed. -- [ ] Deleting the frame (or killing its buffer) cancels the timer without erroring. -- [ ] The normal =<f8>= full-view agenda is unchanged. - -* Readiness dimensions -- Data model & ownership: N/A — the frame reads existing agenda files; it authors nothing. Refresh is display-only. -- Errors, empty states & failure: an empty agenda renders as org's normal empty agenda in the frame. A frame/buffer killed out from under the timer must cancel the timer rather than error on the next tick — a live-frame/live-buffer guard in the callback. -- Security & privacy: N/A — no credentials, no new data surface. -- Observability: the frame is its own visible state; the timer is silent by design. -- Performance & scale: =redo= is cheap and already the manual-refresh path; a 5-minute cadence on one buffer is negligible. The file-list scan (the expensive path) is deliberately not on the tick. -- Reuse & lost opportunities: reuses =make-frame=, =org-agenda-custom-commands=, =org-agenda-sticky=, =org-agenda-redo=, and the F8 family. No new rendering. -- Architecture fit & weak points: integrates at =make-frame= + a frame parameter, the agenda display call, and the F8 keymap. Weak point: jump-to-task display routing across frames; timer lifecycle tied to frame deletion is the other watch point. -- Config surface: the seven-day custom-command view is the one implicit knob. No width, no auto-open flag (both removed by the redesign). -- Documentation plan: an entry in the keybinding/agenda notes; docstrings on the toggle command. No user-facing README beyond that. -- Dev tooling: existing =make test= / byte-compile / live-reload; frame and timer behavior get live-daemon verification (frame management and timers aren't cleanly unit-testable), with pure helpers (the frame-lookup predicate, the view definition) unit-tested where extractable. -- Rollout, compatibility & rollback: additive. The one compatibility touch is the =S-<f8>= rebind (force-refresh moves to =C-M-<f8>=). Rollback is removing the command and the binding; nothing persisted changes. -- External APIs & deps: N/A — all built-in Emacs/org. - -* Risks, Rabbit Holes, and Drawbacks -- Jump-to-task display routing across frames is the likeliest rabbit hole: getting =RET= to reliably open in the working frame (not the agenda frame) across =org-agenda-window-setup= values and single-frame states can take fiddling. Dodge: pin the agenda buffer's window-setup and target the file's =display-buffer= at a non-agenda frame explicitly. -- Frame lifecycle: closing or killing the frame must cancel the timer; a killed frame must not error on tick. Dodge: guard the callback and hang cancellation off =delete-frame-functions= / buffer-kill. -- Daemon-only lifetime: the frame is gone after a daemon restart. Accepted (re-spawn by key); noted so it isn't mistaken for a bug. - -* Review and iteration history -** 2026-07-20 Mon @ 09:53:02 -0500 — Claude Code (emacs-d) — reviewer -- What: spec-review of the redesigned spec. Verdict READY. Read =modules/org-agenda-config.el= first — confirmed =cj/org-agenda-refresh-files= is bound to =S-<f8>= (:232), the =cj/--org-agenda-display-rule= / =cj/org-agenda-window-height= 0.75 rule (:27-37), and =<f8>= = =cj/main-agenda-display= (:392), so the rebind and override premises hold. All 8 decisions resolved (cookie complete); phases decompose cleanly into 3 sessions; no blocking finding. One non-blocking finding recorded: the frame's seven-day view needs its own custom-command entry (the code already has a span-8 entry). -- Why: gate the DRAFT → READY transition before decomposing the build. The reworked design has no unverified API assumptions (all built-in Emacs/org) and no data/security surface. -- Artifacts: this spec's Review findings [0/1]; flipped status heading DRAFT → READY and the Metadata mirror. -** 2026-07-20 Mon @ 09:45:00 -0500 — Craig Jennings — redesign -- What: replaced the right-side side-window dock with a dedicated fullscreen frame of the daemon. Resolved all eight decisions: fullscreen frame (not side window, not separate process), =S-<f8>= toggle, seven-day span, focus-on-agenda, jump-to-task opens in the working frame, frame-scoped 5-min redo, no startup auto-open. -- Why: Craig wants a standing, fullscreen agenda surface placeable on its own workspace/monitor, sharing the daemon's live state, rather than a dock bounded to the working frame. -- Artifacts: this spec; the dock-mode task in todo.org (Emacs Open Work). -** 2026-07-17 Fri @ 19:34:07 -0500 — Craig Jennings — author -- What: initial draft (right-side side-window dock). -- Why: dock mode had four-plus open design questions and real trade-offs; settling them on paper before code. -- Artifacts: dock-mode task in todo.org (Emacs Open Work); supersedes the folded "auto-refresh every 5 min" roam item. diff --git a/docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org b/docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org new file mode 100644 index 00000000..d6a8d295 --- /dev/null +++ b/docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org @@ -0,0 +1,403 @@ +#+TITLE: org-agenda fullscreen frame — Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-17 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED org-agenda fullscreen frame +:PROPERTIES: +:ID: 7705c94b-9bb7-47d8-9828-e9584172c54f +:END: +- 2026-07-20 Mon @ 14:10 -0500 — IMPLEMENTED. Built both phases in =modules/org-agenda-frame.el= (58 ERT tests, full suite green, byte-compile clean, full init loads clean, live-reloaded into the daemon with all wiring confirmed). Phase 1: frame lookup/predicate/working-frame routing, the =F= today-anchored 7-day view + registration, the default-deny =cj/agenda-frame-mode= (allowlist + two message classes + menu removal + finalize re-enable), transactional spawn/raise/delete/toggle, engage routing, sticky/close lifecycle. Phase 2: the 5-min wall-clock =org-agenda-redo= timer with the window/focus contract, duplicate-timer prevention, deterministic point restoration, the frame-owned last-good snapshot with cloned markers + failure overlay + report-once latch, and the public =cj/agenda-frame-toggle= on =S-<f8>= with the force-rescan moved to =C-M-<f8>=. The compositor fullscreen/focus and real-redo behaviors are a residual manual check (VERIFY filed under Manual testing). +- 2026-07-20 Mon @ 13:30 -0500 — DOING. Decomposed into build tasks (Phase 1, Phase 2, flip-to-IMPLEMENTED) under the fullscreen-frame PROJECT in todo.org; =:SPEC_ID:= stamped. Building Phase 1. +- 2026-07-20 Mon @ 13:26 -0500 — READY (Craig accepted for build). Folded Codex's marker-clone finding (all 28 findings, 13 decisions resolved). Craig's call: build v1 now; the Hyprland-window variant is vNext once the in-Emacs frame proves out. Decomposing into build tasks next. +- 2026-07-20 Mon @ 13:07 -0500 — spec-review (Codex): still DRAFT. The two prior responses close their stated behavior gaps, but Org invalidates the old agenda's marker objects during redo; the specified shallow property-bearing snapshot therefore restores dead =org-marker=/=org-hd-marker= links after a failed rebuild. One blocking marker-cloning/ownership finding added. +- 2026-07-20 Mon @ 12:59 -0500 — spec-response: both fifth-pass findings dispositioned. Enumerated the full default-deny allowlist + honest keys/mouse enforcement boundary (menu removed, M-x out of contract); made failed-redo a complete retryable state (frame-owned snapshot with Org properties, explicit policy re-enable on the error path, overlay header, rebuild from =org-redo-cmd=). Findings [27/27]; Decisions [13/13]. Stays DRAFT pending Codex re-review. +- 2026-07-20 Mon @ 12:47 -0500 — spec-review (Codex): still DRAFT. The structural response closes the prior eight findings, but two blockers remain: the default-deny minor-mode map neither defines its actual allowlist nor covers menu/M-x invocation, and failed-redo rollback does not restore a complete policy-enabled, retryable agenda state. +- 2026-07-20 Mon @ 12:29 -0500 — renamed the file =…-dock-mode-spec.org= → =…-org-agenda-fullscreen-frame-spec.org= to match the design; inbound links in todo.org and .ai/notes.org updated. The ":ID:" is unchanged. +- 2026-07-20 Mon @ 12:26 -0500 — spec-response: all 8 fourth-pass findings dispositioned. Root fix: replaced keymap enumeration with a default-deny =cj/agenda-frame-mode= policy (re-enabled by a finalize hook so redo can't strip it), frame-owned timer/failure state, sticky-kill-on-close, failed-redo snapshot restore, non-interactive Phase 1, synced ERT. Decisions now 13; findings [25/25]. Stays DRAFT pending Codex re-review. +- 2026-07-20 Mon @ 12:11 -0500 — spec-review (Codex): still DRAFT. Detail-complete re-review found eight blocking gaps beneath the resolved values: Phase 1 remains callable through M-x; mutation, display-opening, view-change, and manual-redo command classes are incomplete; Org redo discards dedicated buffer-local policy/state; sticky reopen can show stale content; failed-redo display state is undefined; and the ERT surface does not cover the expanded contracts. +- 2026-07-20 Mon @ 12:02 -0500 — spec-response: all 7 third-pass findings dispositioned (accepted; 4 with a chosen value). Key =F=, current-window, cached build on spawn, read-only command policy, source-opening routing, retry-report-once refresh, unbound-Phase-1 then binding-in-Phase-2, deterministic point tie-break. Decisions now 12; findings [17/17]. Stays DRAFT pending Codex re-review. +- 2026-07-20 Mon @ 11:29 -0500 — spec-review (Codex): still DRAFT. A stricter second implementation-readiness pass found seven blocking choices still deferred to implementation: agenda command identity/window setup, initial source-list preparation, the permitted command surface, non-RET/TAB display routing, timer-failure recovery, phase exposure, and refresh point tie-breaking. +- 2026-07-20 Mon @ 10:22 -0500 — spec-response: all 10 findings dispositioned (accepted; 2 with a chosen behavior). Design, Decisions (now 10), phases (now 2), acceptance, and the test surface updated. Stays DRAFT pending a re-review to confirm the blockers are closed. +- 2026-07-20 Mon @ 10:12 -0500 — spec-review (Codex): demoted READY → DRAFT. Independent code-grounded re-review found six blocking gaps in date anchoring, exit/buffer lifecycle, working-frame fallback, phase safety, refresh context, and automated test coverage; three non-blocking implementation details also need definition. +- 2026-07-20 Mon @ 09:53 -0500 — spec-review: READY. All 8 decisions resolved; code read confirmed the F8 bindings and display rule. One non-blocking finding recorded (the frame view needs its own custom-command entry). Ready to decompose the 3 phases. +- 2026-07-20 Mon @ 09:45 -0500 — redesigned: a dedicated fullscreen frame replaces the right-side side-window dock. All eight design decisions resolved (see Decisions). The design is a fullscreen agenda frame, not a side dock; the file was later renamed to match. +- 2026-07-17 Fri @ 19:34:07 -0500 — drafted. + +* Metadata +| Status | implemented | +|----------+------------------------------------------------| +| Owner | Craig Jennings | +|----------+------------------------------------------------| +| Reviewer | Codex | +|----------+------------------------------------------------| +| Related | [[file:../../todo.org][fullscreen-frame task (Emacs Open Work)]] | +|----------+------------------------------------------------| + +* Summary + +A dedicated fullscreen Emacs frame that shows the agenda. One key spawns (or raises) a frame of the running daemon, fullscreened, displaying the next seven days of schedule and tasks, refreshing itself every few minutes. Inside that frame focus stays on the agenda. The point is a standing, always-current agenda surface you can throw on its own workspace or monitor, one keystroke away, without the agenda ever stealing the frame you work in. + +* Problem / Context + +The normal agenda (=<f8>=, =cj/main-agenda-display=) opens below-selected at 75% of the frame (=cj/org-agenda-window-height=, applied through =cj/--org-agenda-display-rule=). That's a modal, take-over-the-frame view: you summon it, read it, dismiss it, and go back to work. There's no way to keep the schedule and task list glanceable while working, and no live refresh, so an agenda left open drifts stale (the now-line and freshly-synced calendar events don't move). + +Craig wants a second, non-modal surface: the agenda living in its own fullscreen frame, current and ready to engage, so it can sit on a separate workspace or monitor while the working frames stay untouched. This is a distinct mode from the existing full-view agenda, not a replacement for it. + +* Goals and Non-Goals + +** Goals +- A dedicated fullscreen frame of the running daemon showing the seven-day schedule and task list, wholly separate from the working frames. +- Live: the frame refreshes on a few-minute cadence so the now-line and synced events stay current. It shares the daemon's state, so calendar-sync results and buffer edits are already reflected. +- One key to spawn, raise, and close it. +- Focus stays on the agenda inside that frame; engaging a task opens the file in the working frame, not over the agenda. + +** Non-Goals +- Not a replacement for the existing =<f8>= full-view agenda — that stays as is. +- Not a separate OS process. It's a frame of the running daemon (chosen for live shared state over an isolated second Emacs). It therefore dies with the daemon, which is acceptable. +- Not a capture/scratch surface in v1. Whether an agenda surface should double as a scratchpad is a separate exploration (see the [#D] task); v1 is read + engage only. +- Not a startup auto-open. The frame is spawned on demand by its key; there is no auto-open defcustom. +- Not a redesign of agenda content or faces — it reuses org-agenda's rendering. + +** Scope tiers +- v1: a spawn/raise/close command bound to =S-<f8>=; a fullscreen frame; a seven-day agenda view filling it; focus held on the agenda; jump-to-task routed to the working frame; a frame-scoped refresh timer; the force-refresh keybinding move. +- Out of scope: the scratchpad surface; a separate-process agenda; persisting frame state across daemon restarts. +- vNext: scratchpad exploration (its own task); richer view tuning once the mode is in daily use; if the fullscreen frame proves out, reimplement as a Hyprland-managed window with its own keybinding — an external launcher replacing the in-Emacs =S-<f8>= spawn (Craig, 2026-07-20). v1 stays the in-Emacs frame. + +* Design + +The agenda lives in its own frame of the running daemon, created with =make-frame= carrying =(fullscreen . fullboth)= and a marker parameter (=(cj/agenda-frame . t)=) so the toggle can find, raise, or delete it. Sharing the daemon means the frame sees the same live state as every working frame: calendar-sync writes, unsaved buffer edits, and the current now-line are all already there, so "live" needs only a periodic redo, not a reload. + +For the user, the interface is one key. =S-<f8>= toggles the frame: spawn it fullscreen if none exists, raise and select it if it exists but isn't focused, delete it if it's the selected frame. The current occupant of =S-<f8>= (=cj/org-agenda-refresh-files=, the manual force-rescan shipped in 17ae3e2a) moves to =C-M-<f8>=, keeping the whole force-refresh idea in the F8 family. + +Inside the agenda frame, focus stays on the agenda. The frame holds a single window showing the seven-day agenda buffer, so point rests there by construction. Engaging a task (=RET=/=TAB= on an agenda line) opens the target file in the working frame and raises it; the agenda frame is never chosen as the display target, so it keeps showing the agenda. In the working frame (the one that launched it, and every other frame) there are no such restrictions — normal behavior throughout. + +For the implementer, these pieces compose: + +1. A toggle command that spawns, raises, or deletes the marked frame. Spawn is transactional: create the fullscreen frame, run the cached non-forced =(cj/build-org-agenda-list)= (the same prep =cj/main-agenda-display= does at :380-391, so a frame spawned early after daemon startup isn't the base-files-only agenda), build and display the dedicated agenda buffer, install the frame-local routing, and start the timer. If any step fails after =make-frame= succeeds — including a build failure — cancel the timer, delete the partial frame and buffer, leave the original working frame selected, and report =Agenda frame: <operation> failed: <cause>=. Delete removes the frame and stops the timer. The frame carries the =cj/agenda-frame= marker so the command locates it among the daemon's frames. + +2. A dedicated seven-day agenda view, defined as its own =org-agenda-custom-commands= key =F= (the existing top-level key at =org-agenda-config.el:344= is =d=, so =F= is collision-free), with =org-agenda-sticky= and =(org-agenda-window-setup 'current-window)= bound in that command's *local* settings. =org-agenda-sticky= local gives Org a distinct =*Org Agenda(F)*= buffer without touching the ordinary =<f8>= behavior; =current-window= keeps the view in the frame's sole window (Org 9.7.11 defaults =org-agenda-window-setup= to =reorganize-frame=, which would split the new frame). The span is anchored to today, not the week: =(org-agenda-span 7)= plus =(org-agenda-start-day "0d")= and =(org-agenda-start-on-weekday nil)= (=org-agenda-list= otherwise anchors any seven-day span to Monday in Org 9.7.11; the daily command uses the same pair at :347-349). No separate TODO block — the span surfaces scheduled work; unscheduled-priority surfacing is a vNext question. + +3. A read-only command policy by *default-deny, not enumeration*. The dedicated buffer runs a minor mode =cj/agenda-frame-mode= whose keymap binds =[t]= (the catch-all) to a denial handler, so every *key or mouse* command not on an explicit allowlist is intercepted by one fallthrough — the whole keymap is covered, and a future Org binding is denied by default. The enforcement boundary is keys and mouse, stated honestly: a minor-mode map can't intercept an Agenda-menu item or a direct =M-x=, so the mode also *removes the Agenda menu-bar* in the dedicated buffer (no menu path to a mutation), and direct =M-x org-agenda-…= is explicitly *out of contract* — typing the command name is a deliberate bypass of a read-only surface, like editing a read-only buffer under =inhibit-read-only=. The complete allowlist is: (a) navigation — =org-agenda-next-line=/=-previous-line=, =org-agenda-next-item=/=-previous-item=, the arrow keys, =C-n=/=C-p=, =C-v=/=M-v= scroll, =M-<=/=M->=, and =C-g=; (b) engage/open — =RET=/=TAB= (=org-agenda-switch-to=/=org-agenda-goto=), mouse-2 (=org-agenda-goto-mouse=), =C-c C-o= (=org-agenda-open-link=), each routed to the working frame — the MRU live non-agenda frame at engagement time, or a new normal (non-fullscreen) frame when none exists — never into the agenda frame; and (c) the frame's own controls — =S-<f8>= toggle, =C-M-<f8>= force-rescan, =q=/=Q=/=x= close, and =r= (remapped to the safe-redo wrapper, below). Everything else is denied with a one-line message: source mutations (TODO-state, tags, priority, effort, schedule/deadline, refile, archive, kill, bulk, and any other) and buffer-opening/preview/follow commands that would split or replace the frame (=SPC=/=DEL=, mouse-3, =C-c C-x b=, clock-goto, follow mode) show =Agenda frame is read-only — press RET to edit in your working frame=; and view-changing commands that would break the today-anchored span (=d=/=w=/=y=, =f=/=b=, =j=, =g=) show =Agenda frame is fixed to the 7-day view=. The one exception is =r= (manual redo): it's allowlisted but remapped to the frame's own safe-redo wrapper (the manual version of the timer tick), so a manual refresh takes the same failure-latched, focus-safe path. The dedicated view also binds =org-agenda-start-with-follow-mode= nil locally, so a non-nil global default can't turn follow mode on at buffer creation. Because =org-agenda-redo= rebuilds through =org-agenda-mode=, which runs =kill-all-local-variables=, the mode is re-enabled after every build by =org-agenda-finalize-hook= gated on the frame's =cj/agenda-frame= marker (a frame parameter, which survives the buffer reset) — so the refresh never strips the policy. =q=/=Q=/=x= delete the marked frame and cancel its timer, and closing the frame (by any path) also kills the =*Org Agenda(F)*= sticky buffer, so the next spawn regenerates fresh rather than reusing stale sticky content; killing the dedicated buffer deletes the frame too, so no orphan fullscreen frame survives. + +4. A frame-scoped refresh timer: =org-agenda-redo= on the dedicated buffer every 5 minutes, wall-clock aligned, started on spawn and cancelled on delete/kill, with a live-frame/live-buffer guard and duplicate-timer prevention. The callback runs with the dedicated agenda window selected for the redo's dynamic extent, restores the prior selected frame and window afterward, and never calls an input-focus function — so a tick while Craig works in another frame neither errors on an out-of-range =window-start= nor steals focus. Point restoration is deterministic: restore the same-=org-marker= occurrence closest to the old agenda line (one marker can appear twice, e.g. a scheduled and a deadline line); if the marker is gone, clamp the old line number into the rebuilt buffer's range; on a header line move to the first agenda item; an empty view leaves point at buffer start. On a failed redo the timer keeps running and retries on the next scheduled tick — a transient failure shouldn't kill live refresh — and the failure is reported once per consecutive-failure run (naming the next action: =C-M-<f8>= force-rescan or re-spawn), with the next successful tick clearing the failure state. Because =org-agenda-redo= erases the buffer before it rebuilds and =org-agenda-mode= would strip the policy, the callback keeps a *frame-owned last-good snapshot* — the buffer text with its =org-redo-cmd=/=org-lprops= properties, point, window-start, and any active filter/identity — captured from the last successful build, undecorated. Markers get special handling: =org-agenda-reset-markers= nulls the old =org-marker=/=org-hd-marker= objects during a rebuild, so the snapshot must not keep them by reference — it *clones* each agenda marker into a snapshot-owned live marker (=copy-marker= into the unchanged source buffer) and reinstalls the clones on restore, so =RET=/=TAB= still resolve to the right source line; the clones are released when a successful build replaces the snapshot or the frame closes, so repeated failures don't leak markers. On a redo error it restores that snapshot verbatim, *explicitly re-enables =cj/agenda-frame-mode=* (the finalize hook runs only on success, so the error path must reinstate the policy or the frame would be left unrestricted), and shows the "refresh failed (=C-M-<f8>= to force-rescan)" line as an *overlay*, not inserted text — so the Org properties aren't corrupted and consecutive failures don't accumulate headers. The next tick then redoes from the preserved =org-redo-cmd= (a real rebuild, not a no-op off an error header); a success removes the overlay and replaces the snapshot. So the frame is never blank, never unrestricted, and always retryable — =RET= and navigation keep working on the restored snapshot. A thin wrapper suppresses =org-agenda-redo='s routine "Rebuilding..." chatter but lets that one failure message through. =redo= re-reads file contents, not the file list — a new project's todo.org still needs the manual force-rescan. + +** Not a prototype-pipeline UI + +=ui-prototyping.md='s research → five-prototype → iterate process governs bespoke visual surfaces (panels, multi-control widgets, SVG faceplates). This frame is org-agenda's existing text rendering shown fullscreen; the layout question ("where does it go?") is answered in a sentence (its own fullscreen frame). So the prototype pipeline is intentionally skipped. The open questions here were behavioral (frame vs process, view span, focus, jump routing, auto-open), not visual-layout, and they're settled in the Decisions below. + +* Alternatives Considered + +** Dedicated fullscreen frame of the daemon (chosen) +- Good, because it shares the daemon's live state: calendar-sync, open buffers, and edits are already reflected, so "live" costs only a periodic redo. +- Good, because it's genuinely separate from the working frames — it can sit on its own workspace or monitor and never steals working space. +- Neutral, because it dies with the daemon (no cross-restart persistence). Acceptable for v1. + +** A separate Emacs process +- Good, because it's fully isolated and survives a daemon restart. +- Bad, because it doesn't share the daemon's in-memory state — it reads the org files from disk and only reflects what's been saved. The "always current, shares live edits" goal argues against it, so it was rejected in favor of a frame. + +** A right-side side-window dock (the prior draft's design) +- Good, because it lives in the working frame and is protected from =delete-other-windows=. +- Bad, because it isn't what Craig wants: it shares the working frame rather than standing alone, it's bounded to a dock width that crowds the working area, and it can't move to its own workspace or monitor. Superseded by the fullscreen frame. + +** A normal split window +- Good, because it's the least new machinery. +- Bad, because it isn't protected, has no clean "this is the agenda surface" identity, and takes over the working frame. This is what the current 0.75 rule already gives and what the frame needs to be different from. + +** Global "refresh any visible agenda" timer +- Good, because it would also refresh the =<f8>= full view when left open. +- Bad, because it has no clean lifecycle — it has to poll for visible agenda buffers and decide when to stop. The frame-scoped timer starts and stops with the frame, which is simpler and matches the feature's boundary. + +* Decisions [13/13] + +** DONE Dedicated fullscreen frame as the display mechanism +- Context: the surface must stand wholly apart from the working frames, be placeable on its own workspace or monitor, and stay live. +- Decision: We display the agenda in a dedicated frame of the running daemon, created with =make-frame= + =(fullscreen . fullboth)= and a =cj/agenda-frame= marker parameter. A frame (not a separate process) so it shares the daemon's live state. +- Consequences: easier — live state, no reload, full isolation from working frames. Harder — it dies with the daemon; frame lookup and lifecycle must be explicit. + +** DONE Launch/toggle on S-<f8>; force-refresh moves to C-M-<f8> +- Context: =S-<f8>= currently runs =cj/org-agenda-refresh-files= (shipped 17ae3e2a); Craig wants =S-<f8>= to spawn/raise/close the agenda frame. +- Decision: =S-<f8>= toggles the agenda frame (spawn if none, raise+select if unfocused, delete if selected). =cj/org-agenda-refresh-files= rebinds to =C-M-<f8>=, keeping both in the F8 family. +- Consequences: easier — one gesture, next to the other agenda keys. Harder — one existing binding moves; the docstring/family comment needs updating. + +** DONE View: the seven-day agenda span alone +- Context: "the day's work visible" is a composite; how many days, and whether to append a TODO block. +- Decision: the frame shows a seven-day agenda span (=org-agenda-span= 7) as one =org-agenda-custom-commands= entry, with no separate prioritized TODO block. The span already surfaces scheduled work. +- Consequences: easier — one custom-command entry, no block composition to tune. Harder — unscheduled priorities aren't surfaced; that's a vNext tuning question if it turns out to matter. + +** DONE Focus stays on the agenda inside the frame +- Context: opening or working the agenda frame should keep point on the agenda, not scatter into other buffers; the launching frame stays unrestricted. +- Decision: the agenda frame holds a single window on the agenda buffer and point rests there; no focus restrictions apply to any other frame. +- Consequences: easier — the frame is unambiguous to use. Harder — anything that would open a buffer in the frame must be routed elsewhere (see jump-to-task). + +** DONE Jump-to-task opens the file in the working frame +- Context: engaging a task from a fullscreen agenda must not replace the agenda with the target file. +- Decision: =RET=/=TAB= on an agenda line opens the target file in the working frame (the most-recently-selected non-agenda frame) and raises it; the agenda frame keeps showing the agenda. +- Consequences: easier — the agenda frame is genuinely "ready to engage" and stays pure. Harder — needs deliberate =org-agenda-window-setup= / =display-buffer= handling so the agenda frame is never the jump target (the likeliest rabbit hole). + +** DONE Frame-scoped refresh via org-agenda-redo, wall-clock aligned +- Context: the frame must stay current (now-line, synced events) without re-scanning the file list or churning a timer when it's closed. +- Decision: run =org-agenda-redo= on the agenda buffer every 5 minutes aligned to the :00/:05/:10 mark, started on spawn and cancelled on delete/kill. Not a file-list rebuild — that's the manual force-rescan. +- Consequences: easier — cheap, self-scoped, picks up content changes. Harder — a brand-new project's todo.org won't appear until a manual force-rescan or the 24h cache TTL lapses. + +** DONE No startup auto-open +- Context: the frame could spawn automatically on daemon start. +- Decision: no auto-open. The frame is spawned on demand by =S-<f8>=; there is no auto-open defcustom. +- Consequences: easier — no startup coupling, no small-frame-at-startup edge case, one fewer knob. Harder — none; a standing frame is one keystroke away. + +** DONE Not a separate process — a frame of the daemon +- Context: "its own Emacs" could mean an isolated process or a frame of the running daemon. +- Decision: a frame of the daemon, for live shared state (calendar-sync, edits, now-line). A separate process would only reflect saved-to-disk state. +- Consequences: easier — always current, zero duplicated config load. Harder — no cross-restart persistence; the frame is gone after a daemon restart and re-spawned by its key. + +** DONE Frame-local exit and buffer-kill semantics +- Context: =S-<f8>= is the intended close, but Org's =q=/=Q=/=x= and a buffer-kill can otherwise leave the sole-window fullscreen frame alive on an unrelated buffer. +- Decision: =q=/=Q=/=x= in the agenda frame delete the marked frame and cancel its timer; killing the dedicated agenda buffer deletes the frame too. Ordinary agenda buffers in other frames are unaffected. +- Consequences: easier — the frame can never strand on a non-agenda buffer. Harder — needs frame-local key remaps and a buffer-kill hook scoped to the dedicated buffer. + +** DONE Working-frame target and no-frame fallback +- Context: "the most-recently-selected non-agenda frame" is the intent; the launch frame may be gone, several may exist, or the agenda frame may be the only live frame. +- Decision: engage targets the MRU live non-agenda frame at engagement time; when none exists, create a normal (non-fullscreen) frame and open there. The engage action never falls back into the agenda frame. +- Consequences: easier — a deterministic target in every frame state. Harder — the deleted-launch, multiple-frame, and no-frame cases each need explicit handling and tests. + +** DONE Read-only policy by default-deny, not enumeration +- Context: enumerating =org-agenda-mode-map= command-by-command can't converge (a review always finds a missed key), and =org-agenda-redo='s =kill-all-local-variables= would strip a buffer-local policy on every refresh. +- Decision: a minor mode =cj/agenda-frame-mode= with a =[t]= catch-all denial handler shadows =org-agenda-mode-map= — only an enumerated allowlist (navigation, the engage/open keys routed to the working frame, and the frame's own controls =S-<f8>=/=C-M-<f8>=/=q=/=Q=/=x=/=r=) is permitted; every other key/mouse command shows a read-only or fixed-view message. The enforcement boundary is keys and mouse: the mode removes the Agenda menu-bar in the buffer, and direct =M-x= is out of contract. The mode is re-enabled after each build by =org-agenda-finalize-hook= gated on the frame's =cj/agenda-frame= marker (and by the failed-redo error path), so redo can't strip it. +- Consequences: easier — one rule covers the whole keymap and survives refresh, with nothing to enumerate or keep in sync. Harder — the allowlist and the two message classes (read-only vs fixed-view) need explicit definition and tests. + +** DONE Sticky buffer and failed-redo display lifecycle +- Context: closing the frame left the command-local sticky =*Org Agenda(F)*= buffer alive (a reopen would reuse stale content), and =org-agenda-redo= erases the buffer before rebuilding (a failed redo would show blank/partial). +- Decision: closing the frame by any path also kills the =*Org Agenda(F)*= buffer, so the next spawn regenerates fresh. The refresh keeps a frame-owned last-good snapshot (buffer text with =org-redo-cmd=/=org-lprops=, point, window-start, filter/identity — undecorated; agenda markers *cloned* via =copy-marker= since =org-agenda-reset-markers= nulls the originals on rebuild); a failed redo restores it verbatim, re-enables the policy explicitly, and shows the failure as an overlay; the next tick rebuilds from the preserved =org-redo-cmd=. The clones are released on the next success or on close. The frame is never blank, unrestricted, or non-retryable. +- Consequences: easier — reopen is always current and a failed refresh degrades to stale-but-readable. Harder — the close paths must kill the sticky buffer, and the callback carries a pre-redo snapshot. + +** DONE Refresh-failure recovery: retry, report once +- Context: a failed =org-agenda-redo= tick needs a defined recovery, not "cancel or retry". +- Decision: keep the timer running and retry on the next scheduled tick; report the failure once per consecutive-failure run, naming the next action (=C-M-<f8>= force-rescan or re-spawn); the next successful tick clears the failure state. +- Consequences: easier — a transient failure doesn't kill live refresh, and the user isn't spammed. Harder — needs a small failure-state latch across ticks. + +* Review findings [28/28] + +** DONE Frame view needs its own custom-command entry (avoid the span-8 collision) +=modules/org-agenda-config.el:344-348= already sets =org-agenda-custom-commands= with a =(org-agenda-span 8)= entry. The frame view must be its own custom-command key rather than reusing or editing the existing span-8 entry, and =org-agenda-sticky= is bound in that command's local settings, not globally, so Org derives the dedicated =*Org Agenda(KEY)*= buffer without changing the ordinary =<f8>= behavior. +Response (accept): folded into Design piece 2. + +** DONE Seven-day view is week-anchored, not next-seven-days +=org-agenda-span= 7 alone anchors to Monday in Org 9.7.11 (=org-agenda-start-on-weekday= defaults to 1). The frame command adds =(org-agenda-start-day "0d")= and =(org-agenda-start-on-weekday nil)= (the same pair the daily command uses at :347-349) so the range is today..today+6. +Response (accept): folded into Design piece 2; acceptance criterion added (midweek starts today, ends six days later). + +** DONE Agenda exit paths can abandon the dedicated frame +=q=/=Q=/=x= and a buffer-kill could otherwise leave the sole-window fullscreen frame alive on an unrelated buffer. +Response (accept): added a Decision (frame-local exit + buffer-kill semantics), folded into Design piece 3, and added two acceptance criteria. + +** DONE Working-frame target and fallback are undefined +The launch frame may be gone, several working frames may exist, or the agenda frame may be the only live frame. +Response (accept, with a chosen behavior): target the MRU live non-agenda frame at engagement time; when none exists, create a normal (non-fullscreen) frame and open there (chose Codex's create-a-frame option over a user-facing refusal, so engage always succeeds). Added a Decision, folded into Design piece 3, and added the three frame-state acceptance cases. + +** DONE Phase 1 exposed a frame that violates v1 invariants +The former Phase 1 exposed a fullscreen frame before the sticky buffer and jump routing existed, which is a broken intermediate state. +Response (accept, with a chosen restructure): merged the former Phase 1+2 into a single Phase 1 that ships the dedicated sticky view plus full frame-local navigation/exit routing, so the first user-reachable state is fully isolated. Phases are now 2, not 3. + +** DONE Refresh callback lacks a window-and-focus contract +=org-agenda-redo= reads selected-window state (=window-start=), so a bare =with-current-buffer= tick from another frame can miscalculate or error. +Response (accept): folded into Design piece 4 (select the agenda window for the redo's extent, restore the prior frame/window, no input-focus calls) with an acceptance criterion. + +** DONE Frame and timer test surface is understated +The repo already tests these boundaries (=tests/test-dirvish-config-popup.el= mocks frame lookup/focus/delete; =tests/test-ai-term--project-color.el= drives timer callbacks + a dead-buffer case), so "not cleanly unit-testable" was wrong. +Response (accept): rewrote the Dev-tooling readiness dimension to require ERT coverage across frame lookup, spawn/raise/close, keybindings, sticky-buffer identity, the today-anchored settings, RET/TAB/exit routing, the MRU + no-frame fallback, timer alignment, duplicate-timer prevention, every cancellation path, dead frame/buffer guards, and silent-success/visible-failure behavior, with a live-daemon checklist only for compositor fullscreen/focus. + +** DONE Refresh point-preservation semantics are ambiguous +=org-agenda-redo= preserves a line number, not the same item when lines shift. +Response (accept): folded into Design piece 4 (restore the same =org-marker= when it exists, else clamp to the nearest valid line); Phase 2 carries it. + +** DONE Silent timer conflicts with org-agenda-redo messages +=org-agenda-redo= emits "Rebuilding..." chatter on every run. +Response (accept): folded into Design piece 4 (a wrapper suppresses routine success chatter, surfaces a failure once, and cancels/retries the stale timer); Observability updated; acceptance criterion added. + +** DONE Spawn failure cleanup and error message are undefined +A failure after =make-frame= could leave an orphan frame and partial state. +Response (accept): made spawn transactional in Design piece 1 (on failure: cancel the timer, delete the partial frame/buffer, restore the working frame, report =Agenda frame: <operation> failed: <cause>=); Errors dimension and an acceptance criterion added. + +** DONE Dedicated view identity and window setup remain placeholders +The custom-command key and =org-agenda-window-setup= value are observable behavior, and the Org 9.7.11 default (=reorganize-frame=) would split the frame. +Response (accept): chose key =F= (existing top-level key is =d=) and =(org-agenda-window-setup 'current-window)=, both in the command's local settings; folded into Design piece 2 with an acceptance criterion. + +** DONE Initial agenda-file preparation is undefined +Spawning early after startup could show the base-files-only agenda, since the project-file list is built by an idle timer and =cj/main-agenda-display= calls =cj/build-org-agenda-list= first (:380-391). +Response (accept): spawn runs the cached non-forced =(cj/build-org-agenda-list)= before rendering, and a build failure is part of the transactional spawn (Design piece 1); early-start acceptance criterion added. + +** DONE Read-and-engage command surface conflicts with Org's mutation keys +=org-agenda-mode-map= exposes source-mutating keys the "read + engage only" non-goal doesn't want. +Response (accept, resolved from the existing non-goal): source-mutating keys (TODO-state, schedule/deadline, refile, archive, kill, bulk) are remapped to a read-only message; engage (=RET=) reaches the file to edit it. Added a Decision (read-only command policy), folded into Design piece 3, acceptance criterion added. + +** DONE Source-opening routes beyond RET and TAB are unaccounted for +=SPC=/=DEL= preview, mouse-2, =C-c C-o=, and follow mode can also show source or split the frame. +Response (accept): defined the full class — =RET=/=TAB=/mouse-2/=C-c C-o= route to the working frame; =SPC=/=DEL= and follow mode are disabled in the dedicated buffer. Folded into Design piece 3 (and the read-only-policy Decision); invariant acceptance criterion added. + +** DONE Refresh failure recovery still contains an unresolved branch +"Cancels or safely retries" is two different user-visible outcomes. +Response (accept, chose retry): the timer keeps running and retries next tick; the failure is reported once per consecutive-failure run (naming =C-M-<f8>= / re-spawn), and the next success clears the state. Added a Decision (refresh-failure recovery), folded into Design piece 4, acceptance criterion added. + +** DONE Phase 1 exposes a non-live version of a live feature +The former Phase 1 bound =S-<f8>= before the timer (the "live" contract) existed, shipping a static intermediate. +Response (accept, chose defer-the-binding): Phase 1 now builds the command unbound (reachable only from ERT/=M-x=); Phase 2 adds the timer and only then binds =S-<f8>= and moves the force-rescan. No user-reachable intermediate is non-live. Phases and an acceptance criterion updated. + +** DONE Refresh point restoration lacks duplicate and fallback rules +A source marker can occur twice, and "nearest" was undefined. +Response (accept): restore the same-marker occurrence closest to the old agenda line; if the marker is gone, clamp the old line number into range; header line → first item; empty view → buffer start. Folded into Design piece 4, acceptance criterion added. + +** DONE Phase 1 is still user-reachable through M-x +Phase 1's body made the toggle an interactive command, so =M-x= could still open the non-live intermediate. +Response (accept): Phase 1 now builds only non-interactive helpers (=cj/--agenda-frame-*=); no =interactive= command exists until Phase 2, so it's reachable only from ERT. Phases + acceptance criterion updated. + +** DONE Read-only mutation policy is not exhaustive +Enumerating blocked mutations misses many keys (tags, priority, effort, clock, capture, =C-c C-c=, the menu). +Response (accept, resolved structurally): replaced the denylist with a *default-deny* policy — the =cj/agenda-frame-mode= keymap shadows =org-agenda-mode-map= and permits only a small allowlist; every unlisted command (all mutations, present and future) is denied with the read-only message. Nothing to enumerate. Design piece 3 + the Decision rewritten. + +** DONE Frame-isolation policy omits buffer-opening commands and initial follow state +mouse-3, =C-c C-x b=, clock-goto, calendar, and non-nil =org-agenda-start-with-follow-mode= could still split/replace the frame. +Response (accept): the same default-deny allowlist covers every buffer-opening command (denied unless allowlisted); the dedicated view binds =org-agenda-start-with-follow-mode= nil locally. Folded into Design piece 3. + +** DONE View-changing and manual-redo commands have no dedicated-frame policy +=d=/=w=/=y=, =f=/=b=, =j=, =r=, =g= had no policy against the fixed today-anchored view. +Response (accept, chose fixed view): the seven-day view is fixed — view-changers are denied with a =fixed to the 7-day view= message; =r= is remapped to the frame's safe-redo wrapper (manual version of the timer tick) so a manual refresh uses the failure-latched path; =g= is denied (it targets other buffers). Design piece 3 + acceptance criterion. + +** DONE Org redo discards dedicated buffer-local policy and state +=org-agenda-redo= → =org-agenda-mode= runs =kill-all-local-variables=, stripping a buffer-local map and any buffer-local timer/failure state. +Response (accept): =cj/agenda-frame-mode= is re-enabled after every build by =org-agenda-finalize-hook= gated on the frame's =cj/agenda-frame= marker (a frame parameter, which survives the reset), and the timer/failure state is frame-owned, not buffer-local — so redo can't strip either. Design piece 3 (policy) + Decision; acceptance criterion for policy-survives-redo. + +** DONE Sticky close and reopen freshness is undefined +Closing left the command-local sticky =*Org Agenda(F)*= buffer alive, so a reopen reused stale content. +Response (accept, chose kill-on-close): every close path also kills the sticky buffer, so the next spawn regenerates fresh. Added to Design piece 3 and the buffer-lifecycle Decision; close→change→reopen acceptance criterion. + +** DONE Failed-redo display state is undefined +=org-agenda-redo= erases before rebuilding, so a failure could leave the frame blank/partial. +Response (accept, chose last-known-good): the callback snapshots the last-good buffer before redoing and, on error, restores it with a "refresh failed" header; the frame is never blank/half-built, and the next success replaces it. Design piece 4 + the buffer-lifecycle Decision; acceptance criterion. + +** DONE Required ERT surface was not synchronized with the new contracts +The Dev-tooling dimension still listed the older surface. +Response (accept): expanded it to require ERT for the cached-build early-start, the default-deny policy (denied + allowed keys, both message classes), the policy surviving a redo, sticky kill-on-close / fresh reopen, the failed-redo snapshot restore, the deterministic marker cases, and the Phase-1 no-interactive-entry boundary; only compositor fullscreen/focus stays on the live-daemon checklist. + +** DONE Default-deny does not yet define or enforce the actual interactive surface +The allowlist wasn't concrete (lifecycle keys, =C-g=, scrolling absent), and a minor-mode map can't intercept the Agenda menu or a direct =M-x=, so "every command is intercepted" overclaimed. +Response (accept): enumerated the complete allowlist in Design piece 3 (navigation with concrete commands + =C-g=, engage/open routed to the working frame, and the frame's own controls =S-<f8>=/=C-M-<f8>=/=q=/=Q=/=x=/=r=), and chose the honest enforcement boundary — keys/mouse via the =[t]= catch-all, the Agenda menu-bar removed in the buffer, and direct =M-x= explicitly out of contract. Decision + acceptance updated. + +** DONE Failed-redo rollback is not yet a complete retryable agenda state +The error path didn't re-enable the policy (finalize runs only on success), and the snapshot didn't promise Org properties / point/window/filter / an undecorated copy — so =RET= and the next rebuild weren't guaranteed. +Response (accept): defined a frame-owned last-good snapshot (text + =org-redo-cmd=/=org-lprops=/markers + point/window/filter, undecorated); the error path restores it verbatim, explicitly re-enables =cj/agenda-frame-mode=, and shows the failure as an *overlay* (no header accumulation, no property corruption); the next tick rebuilds from the preserved =org-redo-cmd=. Design piece 4 + the buffer-lifecycle Decision; acceptance for failure-before-finalize, failure→failure, and failure→retry updated. + +** DONE Failed-redo snapshots retain markers only by shallow reference +=org-agenda-reset-markers= nulls the old =org-marker=/=org-hd-marker= objects during rebuild, so a text-property copy holds dead markers and =RET=/=TAB= break after a failed restore. +Response (accept): the snapshot *clones* each agenda marker into a snapshot-owned live marker (=copy-marker= into the unchanged source buffer) and reinstalls the clones on restore, so engage/navigation resolve to the right source line; the clones are released on the next successful build or on frame close, so repeated failures don't leak markers. Design piece 4 + the buffer-lifecycle Decision; ERT covers fail-after-reset → restored-marker targets → repeat → success → clones released. + +* Implementation phases + +** Phase 1 — Frame, view, and routing (private helpers, not user-reachable) +Build the mechanism as *non-interactive* helpers (=cj/--agenda-frame-spawn/-raise/-delete/-toggle=, =cj/agenda-frame-mode=): transactional spawn (cached =(cj/build-org-agenda-list)=, fullscreen =make-frame= + =fullboth= + the =cj/agenda-frame= marker, the dedicated =F= today-anchored seven-day view with =(org-agenda-window-setup 'current-window)= + local sticky), the default-deny read-only policy + source-opening routing to the working frame (MRU live non-agenda frame, or a new normal frame when none exists), and =q=/=Q=/=x=/buffer-kill close that also kills the sticky buffer. No =interactive= command exists yet, so the mechanism is reachable only from ERT — not =M-x=, not a key. Do NOT bind =S-<f8>= and do NOT move the force-rescan. Clean stop: the frame, view, and policy are correct and tested; nothing user-visible changed. + +** Phase 2 — Refresh timer + public command +Add the frame-scoped 5-minute wall-clock =org-agenda-redo= timer (window/focus contract, duplicate-timer prevention, dead frame/buffer guard, deterministic point restoration, retry-and-report-once failure recovery with the pre-redo snapshot restore, message-suppressing wrapper). Then wrap =cj/--agenda-frame-toggle= in the public interactive =cj/agenda-frame-toggle=, bind it to =S-<f8>=, and move =cj/org-agenda-refresh-files= to =C-M-<f8>=. The public gesture appears only when the feature is complete and live — the first user-reachable state is the finished feature. + +* Acceptance criteria +- [ ] =S-<f8>= spawns a fullscreen agenda frame; pressing it from that frame closes it; pressing it from a working frame raises it. +- [ ] =C-M-<f8>= runs the force-rescan (=cj/org-agenda-refresh-files=); =S-<f8>= no longer does. +- [ ] The agenda frame shows a seven-day span and keeps focus on the agenda. +- [ ] Opening a file from an agenda line (=RET=) shows the file in the working frame and raises it; the agenda frame remains on the agenda. +- [ ] A midweek invocation starts today and ends six days later (not Monday-anchored). +- [ ] =q=, =Q=, and =x= in the agenda frame close the frame and cancel its timer; ordinary =<f8>= agenda buffers are unaffected. +- [ ] Killing the dedicated agenda buffer deletes the frame (no orphan fullscreen frame). +- [ ] =RET= with the launch frame deleted, with multiple working frames, and with no working frame each opens the file outside the agenda frame (creating a normal frame in the last case). +- [ ] The frame refreshes on the wall-clock 5-minute mark while open, and no timer runs after it's closed. +- [ ] A refresh tick while another frame is active neither errors nor changes focus. +- [ ] The refresh timer is silent on success and surfaces a failure once with an actionable message. +- [ ] A spawn failure after =make-frame= leaves no orphan frame or timer and reports the failure. +- [ ] Deleting the frame (or killing its buffer) cancels the timer without erroring. +- [ ] The view uses key =F= with =current-window= — the frame stays a single window (no split). +- [ ] A frame spawned right after daemon startup shows the full project agenda (the cached build ran), not the base-files-only view. +- [ ] A source-mutating key (TODO-state, schedule, refile, archive, kill) in the frame shows the read-only message and edits nothing; =SPC=/=DEL= and follow mode don't split the frame; mouse-2 and =C-c C-o= open in the working frame. +- [ ] A failed refresh keeps the timer, reports once per consecutive-failure run, and the next successful tick clears the failure state. +- [ ] Point restoration: a duplicate source marker restores the occurrence nearest the old line; a missing marker clamps the old line into range; header-line and empty-view cases are handled. +- [ ] A denied command in the frame (a mutation, a view-changer like =w=/=d=, a splitting preview) shows the right read-only/fixed-view message and does not act; an allowlisted command (navigation, =RET= engage) works. +- [ ] After a refresh tick (=org-agenda-redo=), a denied command is still blocked — the policy survives =kill-all-local-variables=. +- [ ] Closing the frame kills the =*Org Agenda(F)*= sticky buffer; the next spawn shows fresh content, not the stale sticky buffer. +- [ ] A redo error restores the last-good buffer with a "refresh failed" header; the frame is never blank or half-built. +- [ ] The Agenda menu-bar is absent in the frame; every allowlisted key (=C-g=, scroll, navigation, the lifecycle keys) works and every other key shows the message. +- [ ] After a failed redo the policy is still active (a denied key is still blocked) and =RET=/navigation still work on the restored snapshot with point/filter preserved. +- [ ] Failure → failure shows one overlay and one message (no header/overlay accumulation); failure → success removes the overlay and rebuilds from =org-redo-cmd=. +- [ ] After a failed redo the restored snapshot's cloned markers point at the right source lines (=RET=/=TAB= resolve correctly); on the next success or on close the clones are released (no marker leak across repeated failures). +- [ ] Phase 1 exposes no interactive command — the mechanism is reachable only from ERT (no =M-x=, no key); =S-<f8>= and the =C-M-<f8>= move appear only in Phase 2. +- [ ] The normal =<f8>= full-view agenda is unchanged. + +* Readiness dimensions +- Data model & ownership: N/A — the frame reads existing agenda files; it authors nothing. Refresh is display-only. +- Errors, empty states & failure: an empty agenda renders as org's normal empty agenda in the frame. A frame/buffer killed out from under the timer must cancel the timer rather than error on the next tick — a live-frame/live-buffer guard in the callback. A spawn that fails after =make-frame= is transactional: cancel the timer, delete the partial frame/buffer, restore the working frame, and report =Agenda frame: <operation> failed: <cause>=. +- Security & privacy: N/A — no credentials, no new data surface. +- Observability: the frame is its own visible state; the refresh wrapper is quiet on success and surfaces a failure once with an actionable message. +- Performance & scale: =redo= is cheap and already the manual-refresh path; a 5-minute cadence on one buffer is negligible. The file-list scan (the expensive path) is deliberately not on the tick. +- Reuse & lost opportunities: reuses =make-frame=, =org-agenda-custom-commands=, =org-agenda-sticky=, =org-agenda-redo=, and the F8 family. No new rendering. +- Architecture fit & weak points: integrates at =make-frame= + a frame parameter, the agenda display call, and the F8 keymap. Weak point: jump-to-task display routing across frames; timer lifecycle tied to frame deletion is the other watch point. +- Config surface: the seven-day custom-command view is the one implicit knob. No width, no auto-open flag (both removed by the redesign). +- Documentation plan: an entry in the keybinding/agenda notes; docstrings on the toggle command. No user-facing README beyond that. +- Dev tooling: existing =make test= / byte-compile / live-reload. ERT covers frame lookup, spawn/raise/close, the =S-<f8>=/=C-M-<f8>= rebind, distinct sticky-buffer identity, the today-anchored seven-day settings, the cached-build early-start, the =RET=/=TAB=/mouse/=C-c C-o= engage routing and the MRU + no-frame fallback, the default-deny policy (the full allowlist works including =C-g=/scroll/lifecycle keys, representative denied keys show the right message, the Agenda menu-bar is absent), the policy surviving an =org-agenda-redo= (re-enabled by the finalize hook) and being re-enabled on the failed-redo error path, sticky-buffer kill on close and a fresh reopen, the failed-redo snapshot restore (policy active, =RET=/nav work, point/filter preserved, one overlay across consecutive failures, rebuild-from-=org-redo-cmd= on retry), timer alignment, duplicate-timer prevention, every cancellation path, dead frame/buffer guards, and silent-success/visible-failure behavior — the repo already tests these boundaries (=tests/test-dirvish-config-popup.el= mocks frame lookup/focus/delete; =tests/test-ai-term--project-color.el= drives timer callbacks + a dead-buffer case). One live-daemon checklist covers compositor fullscreen/focus; manual verification supplements, not replaces, the ERT surface. +- Rollout, compatibility & rollback: additive. The one compatibility touch is the =S-<f8>= rebind (force-refresh moves to =C-M-<f8>=). Rollback is removing the command and the binding; nothing persisted changes. +- External APIs & deps: N/A — all built-in Emacs/org. + +* Risks, Rabbit Holes, and Drawbacks +- Jump-to-task display routing across frames is the likeliest rabbit hole: getting =RET= to reliably open in the working frame (not the agenda frame) across =org-agenda-window-setup= values and single-frame states can take fiddling. Dodge: pin the agenda buffer's window-setup and target the file's =display-buffer= at a non-agenda frame explicitly. +- Frame lifecycle: closing or killing the frame must cancel the timer; a killed frame must not error on tick. Dodge: guard the callback and hang cancellation off =delete-frame-functions= / buffer-kill. +- Daemon-only lifetime: the frame is gone after a daemon restart. Accepted (re-spawn by key); noted so it isn't mistaken for a bug. + +* Review and iteration history +** 2026-07-20 Mon @ 13:26:53 -0500 — Claude Code (emacs-d) — responder +- What: dispositioned Codex's sixth-pass marker finding — the snapshot now clones agenda markers (=copy-marker=) instead of holding them by reference, since =org-agenda-reset-markers= nulls the originals on rebuild; restore reinstalls the clones (so =RET=/=TAB= resolve), and they're released on the next success or on close. Added the vNext note (Hyprland-managed window with its own keybind, once the in-Emacs frame proves out). All 28 findings and 13 decisions resolved. Craig accepted the spec for build, so flipped =DRAFT= → =READY=. +- Why: the marker fix is a real correctness point (a text-property copy holds dead markers). With it folded, the spec is behavior-complete; Craig's call is to build v1 rather than run further review rounds. +- Artifacts: all Review findings DONE [28/28]; Decisions [13/13]; status =READY=, mirror =ready=. Six review/response rounds total; the default-deny structural pivot (12:26) was the turning point. +** 2026-07-20 Mon @ 13:07:58 -0500 — Codex (emacs-d) — reviewer +- What: re-ran the full readiness gate after all 27 prior findings were dispositioned. Verdict: Not ready. Added one blocking finding: a failed-redo snapshot must clone or reconstruct live agenda markers rather than retain shallow text-property references. +- Why: Org 9.7.11 calls =org-agenda-reset-markers= during regeneration, moving the old =org-marker=/=org-hd-marker= objects to nil. Emacs text-property snapshots retain those same objects, so the promised verbatim restore cannot support =RET=/=TAB= after a failed rebuild without an explicit marker-copy and cleanup contract. +- Artifacts: this spec's =Review findings [27/28]=. Source checks: =org-agenda-redo=, =org-agenda-prepare=, =org-agenda-reset-markers=, =org-agenda-new-marker=, and =org-agenda-goto= in installed Emacs 30.2 / Org 9.7.11; a batch check confirmed the snapshot property and source property hold the same marker object and both become dead after reset. +** 2026-07-20 Mon @ 12:59:00 -0500 — Claude Code (emacs-d) — responder +- What: dispositioned both of Codex's fifth-pass findings. (1) Enumerated the complete default-deny allowlist (navigation commands + =C-g= + scroll, engage/open routed out, and the frame's own controls =S-<f8>=/=C-M-<f8>=/=q=/=Q=/=x=/=r=) and set the honest enforcement boundary — keys/mouse via the =[t]= catch-all, the Agenda menu-bar removed in the buffer, direct =M-x= out of contract (dropped the overclaim that "every command" is intercepted). (2) Made the failed-redo rollback a complete retryable state: a frame-owned last-good snapshot carrying Org properties (=org-redo-cmd=/=org-lprops=/markers), point/window/filter, undecorated; the error path restores it and *explicitly re-enables the policy* (finalize only runs on success), shows the failure as an *overlay* (no accumulation, no property corruption), and the next tick rebuilds from the preserved =org-redo-cmd=. +- Why: Codex accepted the default-deny structure but flagged the two absolute claims it hadn't yet backed — the map can't cover the menu/M-x, and a text-only snapshot doesn't guarantee a re-enabled, retryable agenda. Both are now bounded by an explicit contract. +- Artifacts: all Review findings DONE [27/27]; Decisions [13/13] (the read-only and buffer-lifecycle Decisions were tightened, not added). Status stays DRAFT pending Codex's re-review. +** 2026-07-20 Mon @ 12:47:44 -0500 — Codex (emacs-d) — reviewer +- What: verified the renamed spec and all 25 resolved findings against installed Org's key dispatch, finalize, redo, and text-property behavior. Verdict: Not ready. Added two blocking findings: the default-deny map's exact/enforced interaction boundary and the failed-redo snapshot's policy-enabled, metadata-preserving retry contract. +- Why: the new structural design closes the earlier enumeration and sticky-lifecycle problems, but its remaining absolute claims exceed what a minor-mode map and a text-only rollback guarantee. Both gaps would make the implementer choose observable behavior and could leave the frame unrestricted or permanently stale after a failed refresh. +- Artifacts: this spec's =Review findings [25/27]=. Source checks: =org-agenda-mode-map=, =org-agenda-mode=, =org-agenda-finalize=, =org-agenda-redo=, and the =org-redo-cmd=/=org-lprops= text-property flow in installed Emacs 30.2 / Org 9.7.11. Filename/location precondition passes after the tracked rename. +** 2026-07-20 Mon @ 12:26:29 -0500 — Claude Code (emacs-d) — responder +- What: dispositioned all 8 of Codex's fourth-pass findings — accepted every one. The key change is structural: replaced the command-by-command enumeration (which the prior two rounds proved can't converge against a ~100-entry keymap) with a *default-deny* policy — a =cj/agenda-frame-mode= minor mode shadows =org-agenda-mode-map= and permits only a small allowlist, so all mutations / buffer-openers / view-changers are denied in one rule (closes findings 2, 3, 4). Re-enabled via =org-agenda-finalize-hook= keyed on the frame marker so =org-agenda-redo='s =kill-all-local-variables= can't strip it, with timer/failure state frame-owned (finding 5). Close kills the sticky buffer (finding 6); failed redo restores a pre-redo snapshot (finding 7); Phase 1 is non-interactive helpers only (finding 1); the ERT surface is synced (finding 8). Fixed the 7-day view, remapped =r= to the safe wrapper, bound follow-mode nil locally. Rewrote the read-only Decision to default-deny and added one (buffer/failed-redo lifecycle) — 13 Decisions. +- Why: the fourth review showed the earlier responses were enumerating a keymap instead of stating a policy — the root cause of the round-over-round regress. Default-deny covers the whole map in one rule and terminates. +- Artifacts: all Review findings DONE [25/25]; Decisions [13/13]. Status stays DRAFT pending Codex's re-review. +** 2026-07-20 Mon @ 12:11:31 -0500 — Codex (emacs-d) — reviewer +- What: verified all 17 prior findings against the current repo and installed Org, then traced every agenda command/lifecycle path through regeneration and sticky reuse. Verdict: Not ready. Added eight blocking findings covering the still-interactive Phase 1, incomplete mutation/display/view/manual-redo policies, loss of dedicated local state on redo, stale sticky reopen, failed-redo display state, and the unsynchronized ERT matrix. +- Why: the chosen values from the prior response are viable, but implementation would still require behavior and state-ownership decisions. In particular, Org's full keymap is broader than the enumerated commands, =org-agenda-mode= resets the local map during every redo, and command-local sticky agendas reuse a closed buffer without regenerating it. +- Artifacts: this spec's =Review findings [17/25]=. Source checks: =org-agenda-mode-map=, =org-agenda-mode=, =org-agenda-prepare=, =org-agenda-use-sticky-p=, =org-agenda-redo=, =org-agenda-goto-calendar=, =org-agenda-tree-to-indirect-buffer=, and =org-agenda-clock-goto= in installed Emacs 30.2 / Org 9.7.11; current hooks and display rules in =modules/org-agenda-config.el=. +** 2026-07-20 Mon @ 12:02:00 -0500 — Claude Code (emacs-d) — responder +- What: dispositioned all 7 of Codex's third-pass findings — accepted every one; four carried a chosen behavior. View identity: key =F= + =(org-agenda-window-setup 'current-window)=. Command policy: source-mutating keys read-only-remapped, all source-opening keys routed to the working frame, preview/follow disabled. Refresh failure: retry + report-once-per-run. Phase safety: Phase 1 builds the command unbound, Phase 2 adds the timer and the =S-<f8>= binding. Also pinned the cached-build-on-spawn and the point-restoration tie-break. Added three Decisions (read-only policy, refresh-failure recovery, plus the earlier two = now 12), and expanded acceptance criteria. +- Why: Craig's strict gate — no behavior decision deferred to implementation. Codex's third pass named seven still-open keymap/lifecycle choices against the installed Org 9.7.11; each is now pinned with a concrete value. +- Artifacts: all Review findings DONE [17/17]; Decisions [12/12]. The read-only mutation policy was resolved from the existing "read + engage only" non-goal, not a fresh product call — flip it if in-agenda mutation was actually wanted. Status stays DRAFT pending Codex's re-review. +** 2026-07-20 Mon @ 11:29:33 -0500 — Codex (emacs-d) — reviewer +- What: re-ran the authoritative code-grounded review after all 10 earlier findings were dispositioned. Verdict: Not ready. Added seven blocking findings covering the exact dedicated-command identity/window setup, initial agenda-file preparation, read-versus-mutate command policy, source-opening commands beyond =RET=/=TAB=, refresh-failure recovery, safe phase exposure, and deterministic point restoration. +- Why: Craig set the stricter gate that no behavior decision may be deferred to implementation. The revised spec closes the earlier findings, but still contains explicit alternatives and conflicts with the installed Org keymap and this repo's deferred agenda-file initialization. +- Artifacts: this spec's =Review findings [10/17]=. Source checks: =modules/org-agenda-config.el:186-220,344-391,431-433= and =org-agenda-mode-map=, =org-agenda-prepare-window=, =org-agenda-goto=, =org-agenda-open-link=, and =org-agenda-show-and-scroll-up= in installed Emacs 30.2 / Org 9.7.11. +** 2026-07-20 Mon @ 10:22:00 -0500 — Claude Code (emacs-d) — responder +- What: dispositioned all 10 of Codex's findings — accepted every one, two with a chosen behavior. Working-frame fallback: the MRU live non-agenda frame, creating a normal frame when none exists (Codex's create-a-frame option over a refusal). Phase safety: merged the former Phase 1+2 into one isolated Phase 1 so no broken intermediate state ships. Folded the resolutions into Design (transactional spawn, today-anchored sticky seven-day view, frame-local navigation/exit routing, refresh window/focus contract + message wrapper + org-marker restore), added two Decisions (exit semantics, working-frame fallback), cut to 2 phases, and expanded the acceptance criteria and the Dev-tooling test surface. +- Why: Codex's code-grounded re-review found six blocking gaps the first (my) review missed — week anchoring, exit lifecycle, working-frame fallback, phase safety, refresh window context, and the understated test surface. Each needed the spec to specify behavior it had left implicit. +- Artifacts: all Review findings DONE [10/10]; Decisions [10/10]. Status stays DRAFT pending a re-review to confirm the responses close the blockers. +** 2026-07-20 Mon @ 10:12:30 -0500 — Codex (emacs-d) — reviewer +- What: independently re-ran the full spec-review against =modules/org-agenda-config.el=, its ERT coverage, relevant frame/timer precedent, and the installed Emacs 30.2 / Org 9.7.11 source. Verdict: Not ready. Added six blocking and three non-blocking findings; retained the prior non-blocking custom-command finding. +- Why: the first review checked the local F8 premises but did not trace Org's seven-day anchoring, sticky exit behavior, cross-frame jump boundaries, selected-window dependency during redo, phase-by-phase safety, or the repository's existing frame/timer test patterns. Those gaps would force implementation decisions or ship broken intermediate behavior. +- Artifacts: this spec's =Review findings [0/10]=; lifecycle demoted =READY= → =DRAFT= and Metadata mirrored to =draft=. Source checks: =org-agenda-list=, =org-agenda-quit=, =org-agenda-redo=, =org-agenda-switch-to=, and =org-agenda-goto= in Org 9.7.11. +** 2026-07-20 Mon @ 09:53:02 -0500 — Claude Code (emacs-d) — reviewer +- What: spec-review of the redesigned spec. Verdict READY. Read =modules/org-agenda-config.el= first — confirmed =cj/org-agenda-refresh-files= is bound to =S-<f8>= (:232), the =cj/--org-agenda-display-rule= / =cj/org-agenda-window-height= 0.75 rule (:27-37), and =<f8>= = =cj/main-agenda-display= (:392), so the rebind and override premises hold. All 8 decisions resolved (cookie complete); phases decompose cleanly into 3 sessions; no blocking finding. One non-blocking finding recorded: the frame's seven-day view needs its own custom-command entry (the code already has a span-8 entry). +- Why: gate the DRAFT → READY transition before decomposing the build. The reworked design has no unverified API assumptions (all built-in Emacs/org) and no data/security surface. +- Artifacts: this spec's Review findings [0/1]; flipped status heading DRAFT → READY and the Metadata mirror. +** 2026-07-20 Mon @ 09:45:00 -0500 — Craig Jennings — redesign +- What: replaced the right-side side-window dock with a dedicated fullscreen frame of the daemon. Resolved all eight decisions: fullscreen frame (not side window, not separate process), =S-<f8>= toggle, seven-day span, focus-on-agenda, jump-to-task opens in the working frame, frame-scoped 5-min redo, no startup auto-open. +- Why: Craig wants a standing, fullscreen agenda surface placeable on its own workspace/monitor, sharing the daemon's live state, rather than a dock bounded to the working frame. +- Artifacts: this spec; the dock-mode task in todo.org (Emacs Open Work). +** 2026-07-17 Fri @ 19:34:07 -0500 — Craig Jennings — author +- What: initial draft (right-side side-window dock). +- Why: dock mode had four-plus open design questions and real trade-offs; settling them on paper before code. +- Artifacts: dock-mode task in todo.org (Emacs Open Work); supersedes the folded "auto-refresh every 5 min" roam item. @@ -129,6 +129,7 @@ (require 'org-config) ;; basic org-mode settings (require 'org-faces-config) ;; custom themeable faces for agenda keywords + priorities (require 'org-agenda-config) ;; agenda, task tracking, and notifications +(require 'org-agenda-frame) ;; S-<f8> dedicated fullscreen agenda frame (require 'org-babel-config) ;; org-mode prog blocks; literate programming (require 'org-capture-config) (require 'org-contacts-config) ;; fully integrated org-mode contacts management diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el index 3cff9d95..7bbb31ae 100644 --- a/modules/org-agenda-config.el +++ b/modules/org-agenda-config.el @@ -225,11 +225,14 @@ improves performance from several seconds to instant." Use this after adding new projects or todo.org files. Bypasses cache and scans directories from scratch. -Bound to S-<f8>, the force-rebuild sibling of the F8 agenda family -\(<f8> display, s-<f8> all files, C-<f8> single project, M-<f8> this buffer)." +Bound to C-M-<f8>, the force-rebuild sibling of the F8 agenda family +\(<f8> display, s-<f8> all files, C-<f8> single project, M-<f8> this buffer). +The binding lives in `org-agenda-frame.el', which took S-<f8> for the +agenda-frame toggle and moved the force-rescan here." (interactive) (cj/build-org-agenda-list 'force-rebuild)) -(global-set-key (kbd "S-<f8>") #'cj/org-agenda-refresh-files) +;; S-<f8> and C-M-<f8> are bound by `org-agenda-frame.el' (cj/--agenda-frame-install-keys): +;; S-<f8> toggles the fullscreen agenda frame; C-M-<f8> runs the force-rescan above. (defun cj/todo-list-all-agenda-files () "Displays an \\='org-agenda\\=' todo list. diff --git a/modules/org-agenda-frame.el b/modules/org-agenda-frame.el new file mode 100644 index 00000000..e4b60505 --- /dev/null +++ b/modules/org-agenda-frame.el @@ -0,0 +1,634 @@ +;;; org-agenda-frame.el --- Dedicated fullscreen agenda frame -*- lexical-binding: t; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; Layer: 4 (Optional). +;; Category: O/D. +;; Load shape: eager (binds keys in Phase 2; Phase 1 defines helpers only). +;; Top-level side effects: none yet (Phase 1 is private helpers). +;; Runtime requires: none. +;; Direct test load: yes. +;; +;; A dedicated fullscreen Emacs frame of the running daemon that shows a +;; today-anchored seven-day org-agenda, refreshing itself, kept read-only and +;; focus-locked. Spawned/raised/closed by one key. See the spec: +;; docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org. +;; +;; Phase 1 (this pass) builds non-interactive helpers only: frame lookup, +;; spawn/raise/delete, the dedicated view, the default-deny read-only policy, +;; and working-frame routing. No interactive command or key is bound until +;; Phase 2, so nothing user-visible changes yet. + +;;; Code: + +(require 'seq) + +;; Declared, not required: `org-agenda-config' pulls in vc packages that don't +;; load under `make test' (no package-initialize). The frame module references +;; org-agenda symbols through these declarations and does its real wiring inside +;; `with-eval-after-load' so a batch test-load runs no org-agenda side effects. +(defvar org-agenda-custom-commands) +(defvar org-agenda-finalize-hook) +(declare-function cj/build-org-agenda-list "org-agenda-config" (&optional force-rebuild)) +(declare-function cj/org-agenda-refresh-files "org-agenda-config" ()) +(declare-function org-agenda-redo "org-agenda" (&optional all)) +(declare-function org-agenda-next-line "org-agenda" ()) +(declare-function org-agenda-previous-line "org-agenda" ()) +(declare-function org-agenda-next-item "org-agenda" (n)) +(declare-function org-agenda-previous-item "org-agenda" (n)) +(declare-function org-agenda-open-link "org-agenda" (&optional arg)) +(declare-function org-get-at-bol "org" (property)) +(declare-function org-fold-show-context "org-fold" (&optional key)) +(declare-function org-agenda "org-agenda" (&optional arg org-keys restriction)) +;; Forward references: defined later in this file (Phase 2 for -safe-redo, +;; the lifecycle block for -delete). +(declare-function cj/--agenda-frame-safe-redo "org-agenda-frame" ()) +(declare-function cj/--agenda-frame-delete "org-agenda-frame" ()) + +(defconst cj/--agenda-frame-parameter 'cj/agenda-frame + "Frame parameter marking the dedicated agenda frame. +Its presence (non-nil) is how `cj/--agenda-frame' locates the frame among +all of the daemon's frames.") + +(defvar cj/--agenda-frame-launch-frame nil + "The frame selected when the agenda frame was last spawned. +Preferred routing target for source files opened from the agenda (see +`cj/--agenda-frame-working-frame'); ignored once it is dead or is itself +the agenda frame.") + +(defun cj/--agenda-frame-p (frame) + "Return non-nil when FRAME is a live agenda frame. +FRAME is an agenda frame when it is live and carries the +`cj/--agenda-frame-parameter' marker; a dead frame is never one." + (and (frame-live-p frame) + (frame-parameter frame cj/--agenda-frame-parameter))) + +(defun cj/--agenda-frame () + "Return the live agenda frame, or nil. +The frame is identified by the `cj/--agenda-frame-parameter' marker; a +dead frame is never returned even if it still carries the marker." + (seq-find #'cj/--agenda-frame-p (frame-list))) + +(defun cj/--agenda-frame-working-frame () + "Return a live non-agenda frame to route source files into, or nil. +Prefer `cj/--agenda-frame-launch-frame' when it is still live and not the +agenda frame; otherwise the first live non-agenda frame among all frames. +Return nil when the agenda frame is the only live frame -- the caller then +creates a normal frame." + (or (and (frame-live-p cj/--agenda-frame-launch-frame) + (not (cj/--agenda-frame-p cj/--agenda-frame-launch-frame)) + cj/--agenda-frame-launch-frame) + (seq-find (lambda (frame) + (and (frame-live-p frame) + (not (cj/--agenda-frame-p frame)))) + (frame-list)))) + +;;; The dedicated seven-day view (org-agenda-custom-commands key F) + +(defconst cj/--agenda-frame-command-key "F" + "The `org-agenda-custom-commands' key for the agenda-frame view. +The existing top-level key is `d' (org-agenda-config.el:344), so `F' is +collision-free. The sticky buffer derives its name from this key +\(*Org Agenda(F)*).") + +(defun cj/--agenda-frame-command () + "Return the `org-agenda-custom-commands' entry for the agenda frame. +A one-block agenda: a seven-day span anchored to today rather than +Monday (`org-agenda-list' otherwise anchors any seven-day span to the +week start in Org 9.7.11), rendered in the frame's sole window +\(`current-window', so Org's default `reorganize-frame' can't split it), +as its own sticky *Org Agenda(F)* buffer, with follow-mode forced off so +a non-nil global default can't open a second window at build time." + `(,cj/--agenda-frame-command-key "Agenda frame: 7-day today-anchored" + ((agenda "" + ((org-agenda-span 7) + (org-agenda-start-day "0d") + (org-agenda-start-on-weekday nil) + (org-agenda-start-with-follow-mode nil)))) + ((org-agenda-sticky t) + (org-agenda-window-setup 'current-window)))) + +(defun cj/--agenda-frame-register-command () + "Register the agenda-frame view in `org-agenda-custom-commands'. +Idempotent: any existing entry for `cj/--agenda-frame-command-key' is +replaced, so a module reload never accumulates duplicate keys." + (setq org-agenda-custom-commands + (cons (cj/--agenda-frame-command) + (assoc-delete-all cj/--agenda-frame-command-key + org-agenda-custom-commands)))) + +;;; Engage routing — open the item's source outside the agenda frame + +(defun cj/--agenda-frame-item-marker () + "Return the source marker for the agenda item at point, or nil. +Prefers the item's own marker, falling back to the heading marker. Reads +the text property directly (like `org-get-at-bol') so point restoration +is exercisable without loading org." + (or (get-text-property (line-beginning-position) 'org-marker) + (get-text-property (line-beginning-position) 'org-hd-marker))) + +(defun cj/--agenda-frame-target-frame () + "Return the frame to open agenda source in, creating one when needed. +The engage action never opens into the agenda frame: it targets the +working frame (`cj/--agenda-frame-working-frame'), and when the agenda +frame is the only live frame it creates a normal, non-fullscreen frame." + (or (cj/--agenda-frame-working-frame) + (make-frame))) + +(defun cj/--agenda-frame-engage-open () + "Open the source of the agenda item at point in the working frame. +Routes to the MRU non-agenda frame (or a new normal frame when the agenda +frame is the only one), so the agenda frame keeps showing the agenda. +Signals a `user-error' when point is not on an agenda item." + (interactive) + (let ((marker (cj/--agenda-frame-item-marker))) + (unless (and marker (marker-buffer marker)) + (user-error "No agenda item on this line")) + (let ((buffer (marker-buffer marker)) + (pos (marker-position marker)) + (frame (cj/--agenda-frame-target-frame))) + (select-frame-set-input-focus frame) + (pop-to-buffer-same-window buffer) + (widen) + (goto-char pos) + (when (derived-mode-p 'org-mode) + (org-fold-show-context 'agenda)) + (beginning-of-line)))) + +(defun cj/--agenda-frame-engage-mouse (event) + "Open the agenda item clicked by EVENT in the working frame." + (interactive "e") + (mouse-set-point event) + (cj/--agenda-frame-engage-open)) + +(defun cj/--agenda-frame-open-link () + "Follow the link in the agenda item at point, in the working frame." + (interactive) + (select-frame-set-input-focus (cj/--agenda-frame-target-frame)) + (org-agenda-open-link)) + +(defun cj/--agenda-frame-close () + "Close the agenda frame from within it. +Bound to q, Q, and x so Org's own quit keys delete the whole frame +\(and cancel its timer) rather than leaving a sole-window fullscreen +frame stranded on a non-agenda buffer." + (interactive) + (cj/--agenda-frame-delete)) + +;;; Default-deny read-only policy + +(defconst cj/--agenda-frame-readonly-message + "Agenda frame is read-only — press RET to edit in your working frame" + "Shown when a mutating or buffer-opening command is denied in the frame.") + +(defconst cj/--agenda-frame-fixed-view-message + "Agenda frame is fixed to the 7-day view" + "Shown when a view-changing command is denied in the frame.") + +(defun cj/--agenda-frame-denied-readonly () + "Deny a mutating or buffer-opening command in the agenda frame. +The default binding for every key not on the allowlist." + (interactive) + (message "%s" cj/--agenda-frame-readonly-message)) + +(defun cj/--agenda-frame-denied-fixed-view () + "Deny a view-changing command that would break the today-anchored span." + (interactive) + (message "%s" cj/--agenda-frame-fixed-view-message)) + +(defvar cj/agenda-frame-mode-map + (let ((map (make-sparse-keymap))) + ;; Default-deny: every key/mouse event not rebound below funnels through + ;; this one catch-all, so mutations (present and future) are read-only. + (define-key map [t] #'cj/--agenda-frame-denied-readonly) + ;; Hide the Org Agenda menu-bar entry so there is no menu path to a mutation. + (define-key map [menu-bar org-agenda] #'undefined) + ;; (a) Navigation — allowlisted to their org-agenda commands. + (define-key map (kbd "n") #'org-agenda-next-line) + (define-key map (kbd "p") #'org-agenda-previous-line) + (define-key map (kbd "<down>") #'org-agenda-next-line) + (define-key map (kbd "<up>") #'org-agenda-previous-line) + (define-key map (kbd "C-n") #'org-agenda-next-line) + (define-key map (kbd "C-p") #'org-agenda-previous-line) + (define-key map (kbd "N") #'org-agenda-next-item) + (define-key map (kbd "P") #'org-agenda-previous-item) + (define-key map (kbd "C-v") #'scroll-up-command) + (define-key map (kbd "M-v") #'scroll-down-command) + (define-key map (kbd "M-<") #'beginning-of-buffer) + (define-key map (kbd "M->") #'end-of-buffer) + (define-key map (kbd "C-g") #'keyboard-quit) + ;; (b) Engage / open — routed to the working frame, never the agenda frame. + ;; Bind the GUI function-key events ([return]/[tab]) as well as the ASCII + ;; forms: the [t] catch-all otherwise gives `return'/`tab' a binding, which + ;; suppresses their function-key translation to RET/TAB, so a bare RET would + ;; hit the deny handler instead of engaging in a graphical frame. + (define-key map (kbd "RET") #'cj/--agenda-frame-engage-open) + (define-key map (kbd "TAB") #'cj/--agenda-frame-engage-open) + (define-key map [return] #'cj/--agenda-frame-engage-open) + (define-key map [tab] #'cj/--agenda-frame-engage-open) + (define-key map (kbd "<mouse-2>") #'cj/--agenda-frame-engage-mouse) + (define-key map (kbd "C-c C-o") #'cj/--agenda-frame-open-link) + ;; (c) The frame's own controls. + (define-key map (kbd "q") #'cj/--agenda-frame-close) + (define-key map (kbd "Q") #'cj/--agenda-frame-close) + (define-key map (kbd "x") #'cj/--agenda-frame-close) + (define-key map (kbd "r") #'cj/--agenda-frame-safe-redo) + ;; View-changers get the distinct fixed-view message, not the read-only one. + (dolist (key '("w" "d" "y" "f" "b" "j" "g")) + (define-key map (kbd key) #'cj/--agenda-frame-denied-fixed-view)) + map) + "Keymap for `cj/agenda-frame-mode'. +Shadows `org-agenda-mode-map' by default-deny: the `[t]' catch-all denies +every key that is not explicitly allowlisted here, so a future Org binding +is denied by default and there is nothing to keep in sync.") + +(define-minor-mode cj/agenda-frame-mode + "Read-only, focus-locked policy for the dedicated agenda frame. +Only the allowlist in `cj/agenda-frame-mode-map' is permitted: navigation, +the engage/open keys (routed to the working frame), and the frame's own +controls. Every other key/mouse command is denied. The enforcement +boundary is keys and mouse; a direct \\[execute-extended-command] is out +of contract." + :init-value nil + :lighter " AgendaFrame" + :keymap cj/agenda-frame-mode-map) + +(defun cj/--agenda-frame-maybe-enable-mode () + "Re-enable `cj/agenda-frame-mode' after an agenda build in the agenda frame. +Added to `org-agenda-finalize-hook'. `org-agenda-redo' rebuilds through +`org-agenda-mode', whose `kill-all-local-variables' strips the buffer-local +minor mode; this reinstates it whenever the just-built buffer is displayed +in the frame carrying the `cj/agenda-frame' marker (a frame parameter, which +survives the buffer reset). Ordinary agenda builds in working frames are +left untouched." + (let ((frame (cj/--agenda-frame))) + (when (and frame (get-buffer-window (current-buffer) frame)) + (cj/agenda-frame-mode 1)))) + +;;; Frame lifecycle — spawn, raise, delete, toggle, cleanup + +(defconst cj/--agenda-frame-timer-parameter 'cj/agenda-frame-timer + "Frame parameter holding the agenda frame's refresh timer (set in Phase 2).") + +(defvar cj/--agenda-frame-tearing-down nil + "Non-nil while the agenda frame is being torn down. +Breaks the `delete-frame' / `kill-buffer-hook' re-entrancy loop: deleting +the frame kills its buffer and killing the buffer deletes the frame, so +whichever fires first sets this to skip the other.") + +(defun cj/--agenda-frame-sticky-buffer () + "Return the dedicated *Org Agenda(F)* sticky buffer, or nil if none." + (get-buffer (format "*Org Agenda(%s)*" cj/--agenda-frame-command-key))) + +(defun cj/--agenda-frame-cancel-timer (&optional frame) + "Cancel and clear the refresh timer on FRAME (default: the agenda frame). +Safe when no timer is set or FRAME is dead. Returns nil." + (let* ((frame (or frame (cj/--agenda-frame))) + (timer (and (frame-live-p frame) + (frame-parameter frame cj/--agenda-frame-timer-parameter)))) + (when (timerp timer) + (cancel-timer timer)) + (when (frame-live-p frame) + (set-frame-parameter frame cj/--agenda-frame-timer-parameter nil)) + nil)) + +(defun cj/--agenda-frame-on-delete-frame (frame) + "Clean up when the agenda FRAME dies by any path. +Registered on `delete-frame-functions': cancels the refresh timer and +kills the dedicated sticky buffer, so the next spawn regenerates fresh +rather than reusing stale sticky content. A non-agenda frame is ignored." + (when (cj/--agenda-frame-p frame) + (cj/--agenda-frame-cancel-timer frame) + (let ((buffer (cj/--agenda-frame-sticky-buffer)) + (cj/--agenda-frame-tearing-down t)) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(defun cj/--agenda-frame-on-kill-buffer () + "Delete the agenda frame when its dedicated buffer is killed. +A buffer-local `kill-buffer-hook' on the sticky buffer, so killing it from +anywhere takes the frame with it. Guarded against re-entry during a +frame-initiated teardown." + (unless cj/--agenda-frame-tearing-down + (let ((frame (cj/--agenda-frame))) + (when (frame-live-p frame) + (delete-frame frame))))) + +(defun cj/--agenda-frame-delete () + "Delete the agenda frame; a no-op when none exists. +`delete-frame' fires `cj/--agenda-frame-on-delete-frame', which cancels +the timer and kills the sticky buffer." + (let ((frame (cj/--agenda-frame))) + (when (frame-live-p frame) + (delete-frame frame)))) + +(defun cj/--agenda-frame-raise (frame) + "Raise FRAME and give it input focus. Returns FRAME." + (select-frame-set-input-focus frame) + frame) + +(defun cj/--agenda-frame-spawn () + "Create, display, and focus the dedicated fullscreen agenda frame. +Transactional: on any failure after `make-frame', delete the partial +frame (which cleans up its buffer and timer via the delete hook), restore +focus to the launching frame, and signal a `user-error' naming the cause. +Returns the new agenda frame on success." + (let ((launch (selected-frame)) + (frame nil)) + (condition-case err + (progn + (setq cj/--agenda-frame-launch-frame launch) + (setq frame (make-frame `((,cj/--agenda-frame-parameter . t) + (fullscreen . fullboth) + (name . "Org Agenda")))) + (select-frame-set-input-focus frame) + ;; Cached, non-forced: a frame spawned early after daemon startup + ;; still shows the full project agenda, not the base-files-only view. + (cj/build-org-agenda-list) + (org-agenda "a" cj/--agenda-frame-command-key) + (let ((buffer (cj/--agenda-frame-sticky-buffer))) + (when (buffer-live-p buffer) + (with-current-buffer buffer + (add-hook 'kill-buffer-hook + #'cj/--agenda-frame-on-kill-buffer nil t)))) + (cj/--agenda-frame-start-timer frame) + frame) + (error + (when (frame-live-p frame) + (delete-frame frame)) + (when (frame-live-p launch) + (select-frame-set-input-focus launch)) + (user-error "Agenda frame: spawn failed: %s" + (error-message-string err)))))) + +(defun cj/--agenda-frame-toggle () + "Spawn, raise, or delete the dedicated agenda frame. +Spawn when none exists, delete when the agenda frame is the selected +frame, raise and focus it otherwise. + +Non-interactive by design in Phase 1: reachable only from ERT, never from +\\[execute-extended-command] or a key. Phase 2 wraps this in the public +`cj/agenda-frame-toggle' and binds it to S-<f8>." + (let ((frame (cj/--agenda-frame))) + (cond + ((null frame) (cj/--agenda-frame-spawn)) + ((eq frame (selected-frame)) (cj/--agenda-frame-delete) nil) + (t (cj/--agenda-frame-raise frame))))) + +;;; Phase 2 — refresh timer, snapshot restore, and the public command + +(defconst cj/--agenda-frame-refresh-seconds 300 + "Refresh cadence for the agenda frame, in seconds (five minutes).") + +(defconst cj/--agenda-frame-fail-count-parameter 'cj/agenda-frame-fail-count + "Frame parameter holding the consecutive-failure count for the refresh timer.") + +(defvar-local cj/--agenda-frame-failure-overlay nil + "Overlay showing the refresh-failed notice in the dedicated buffer. +An overlay, not inserted text, so the org text properties (org-redo-cmd, +markers) stay intact and consecutive failures never accumulate a header.") + +(defun cj/--agenda-frame-seconds-to-next-mark (time period) + "Return seconds from TIME to the next wall-clock multiple of PERIOD. +TIME is any Emacs time value, PERIOD is seconds (300 gives the :00/:05 +marks). A TIME exactly on a mark returns a full PERIOD, so the timer +never fires twice back-to-back." + (let ((rem (mod (floor (float-time time)) period))) + (if (zerop rem) period (- period rem)))) + +;; -- Point restoration ------------------------------------------------------- + +(defun cj/--agenda-frame-goto-first-item () + "Move point to the first agenda item, or `point-min' when the view is empty." + (goto-char (point-min)) + (let ((found nil)) + (while (and (not found) (not (eobp))) + (if (get-text-property (line-beginning-position) 'org-marker) + (setq found t) + (forward-line 1))) + (unless found (goto-char (point-min))))) + +(defun cj/--agenda-frame-restore-point (old-marker old-line) + "Restore point in the rebuilt agenda buffer after a redo. +Prefer the line whose org-marker points at the same source location as +OLD-MARKER, choosing the occurrence nearest OLD-LINE when a source line +appears twice. When the marker is gone, clamp OLD-LINE into range; if +that lands on a header (no item), move to the first item; an item-less +view leaves point at buffer start." + (let ((max-line (line-number-at-pos (point-max))) + (targets '())) + (when (and (markerp old-marker) (marker-buffer old-marker)) + (let ((src-buf (marker-buffer old-marker)) + (src-pos (marker-position old-marker))) + (save-excursion + (goto-char (point-min)) + (while (not (eobp)) + (let ((m (get-text-property (line-beginning-position) 'org-marker))) + (when (and (markerp m) + (eq (marker-buffer m) src-buf) + (eql (marker-position m) src-pos)) + (push (line-number-at-pos) targets))) + (forward-line 1))))) + (cond + (targets + (let ((best (car (sort targets + (lambda (a b) + (< (abs (- a old-line)) (abs (- b old-line)))))))) + (goto-char (point-min)) + (forward-line (1- best)))) + (t + (let ((line (max 1 (min old-line max-line)))) + (goto-char (point-min)) + (forward-line (1- line)) + (unless (get-text-property (line-beginning-position) 'org-marker) + (cj/--agenda-frame-goto-first-item))))))) + +;; -- Snapshot with cloned markers -------------------------------------------- + +(defun cj/--agenda-frame-snapshot-markers (buffer) + "Return a list of (POSITION . CLONE) for every org-marker in BUFFER. +CLONE is an independent `copy-marker' into the same source location, so +it survives `org-agenda-reset-markers' nulling BUFFER's own markers on a +rebuild." + (with-current-buffer buffer + (let ((clones '()) + (pos (point-min))) + (while (< pos (point-max)) + (let ((m (get-text-property pos 'org-marker))) + (when (and (markerp m) (marker-buffer m)) + (push (cons pos (copy-marker m)) clones))) + (setq pos (or (next-single-property-change pos 'org-marker buffer) + (point-max)))) + (nreverse clones)))) + +(defun cj/--agenda-frame-reinstall-markers (buffer clones) + "Reapply CLONES (from `cj/--agenda-frame-snapshot-markers') to BUFFER. +Restores each cloned marker as the org-marker text property at its +recorded position, so RET/TAB resolve to the right source line after a +snapshot restore." + (with-current-buffer buffer + (dolist (entry clones) + (let ((pos (car entry))) + (when (and (>= pos (point-min)) (< pos (point-max))) + (put-text-property pos (1+ pos) 'org-marker (cdr entry))))))) + +(defun cj/--agenda-frame-snapshot (buffer window) + "Capture BUFFER's last-good state for restore after a failed redo. +Returns a plist of the propertized :text (carrying org-redo-cmd/org-lprops), +:point, :window-start, and :markers (cloned, source-owned)." + (with-current-buffer buffer + (list :text (buffer-substring (point-min) (point-max)) + :point (point) + :window-start (and (window-live-p window) (window-start window)) + :markers (cj/--agenda-frame-snapshot-markers buffer)))) + +(defun cj/--agenda-frame-restore-snapshot (buffer snapshot window) + "Restore SNAPSHOT verbatim into BUFFER, reinstating cloned markers. +Sets point and, when WINDOW is live, window-start from the snapshot." + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (plist-get snapshot :text)) + (cj/--agenda-frame-reinstall-markers buffer (plist-get snapshot :markers)) + (goto-char (min (plist-get snapshot :point) (point-max)))) + (when (and (window-live-p window) (plist-get snapshot :window-start)) + (set-window-start window (min (plist-get snapshot :window-start) + (point-max)))))) + +(defun cj/--agenda-frame-release-snapshot (snapshot) + "Release SNAPSHOT's cloned markers so repeated redoes don't leak markers. +Called on a successful redo (the snapshot is discarded); never on the +error path, where the clones become the buffer's live org-markers." + (dolist (entry (plist-get snapshot :markers)) + (when (markerp (cdr entry)) + (set-marker (cdr entry) nil)))) + +;; -- Failure latch and overlay ----------------------------------------------- + +(defun cj/--agenda-frame-record-failure (frame) + "Increment FRAME's consecutive-failure count; return non-nil to report. +Reports only on the first failure of a run (the 0 -> 1 transition)." + (let ((n (1+ (or (frame-parameter frame cj/--agenda-frame-fail-count-parameter) + 0)))) + (set-frame-parameter frame cj/--agenda-frame-fail-count-parameter n) + (= n 1))) + +(defun cj/--agenda-frame-clear-failure (frame) + "Reset FRAME's consecutive-failure count (the next tick reports again)." + (set-frame-parameter frame cj/--agenda-frame-fail-count-parameter 0)) + +(defun cj/--agenda-frame-show-failure-overlay (buffer) + "Show the refresh-failed notice as an overlay at the top of BUFFER." + (with-current-buffer buffer + (unless (overlayp cj/--agenda-frame-failure-overlay) + (setq cj/--agenda-frame-failure-overlay + (make-overlay (point-min) (point-min)))) + (overlay-put cj/--agenda-frame-failure-overlay 'before-string + (propertize "Agenda frame: refresh failed (C-M-<f8> to force-rescan)\n" + 'face 'warning)))) + +(defun cj/--agenda-frame-remove-overlay (buffer) + "Remove the refresh-failed overlay from BUFFER, if present." + (with-current-buffer buffer + (when (overlayp cj/--agenda-frame-failure-overlay) + (delete-overlay cj/--agenda-frame-failure-overlay) + (setq cj/--agenda-frame-failure-overlay nil)))) + +;; -- The refresh itself ------------------------------------------------------ + +(defun cj/--agenda-frame-do-redo (frame buffer window) + "Redo the agenda in BUFFER, degrading to the last-good snapshot on failure. +On success: drop the failure overlay, restore point, clear the failure +latch, and release the pre-redo snapshot. On error: restore the snapshot +verbatim, re-enable the policy (the finalize hook runs only on success), +show the failure overlay, and report once per consecutive-failure run. +Either way the frame is never blank, unrestricted, or non-retryable." + (with-current-buffer buffer + ;; Clone the point marker: `org-agenda-redo' calls `org-agenda-reset-markers' + ;; which nulls the buffer's own org-markers, so the raw marker would be dead + ;; by the time `cj/--agenda-frame-restore-point' runs -- collapsing the + ;; "follow the same source item" restoration to the line-number clamp on + ;; every normal tick. An independent clone survives the reset. + (let ((old-marker (let ((m (cj/--agenda-frame-item-marker))) + (and (markerp m) (marker-buffer m) (copy-marker m)))) + (old-line (line-number-at-pos)) + (snapshot (cj/--agenda-frame-snapshot buffer window))) + (unwind-protect + (condition-case nil + (let ((inhibit-message t)) + (org-agenda-redo) + (cj/--agenda-frame-remove-overlay buffer) + (cj/--agenda-frame-restore-point old-marker old-line) + (cj/--agenda-frame-clear-failure frame) + (cj/--agenda-frame-release-snapshot snapshot)) + (error + (cj/--agenda-frame-restore-snapshot buffer snapshot window) + (cj/agenda-frame-mode 1) + (cj/--agenda-frame-show-failure-overlay buffer) + (when (cj/--agenda-frame-record-failure frame) + (message "Agenda frame: refresh failed (C-M-<f8> to force-rescan)")))) + (when (markerp old-marker) + (set-marker old-marker nil)))))) + +(defun cj/--agenda-frame-safe-redo (&optional frame) + "Refresh the agenda buffer in FRAME safely (the timer tick and manual `r'). +Runs with the dedicated window selected for the redo's dynamic extent and +restores the prior window afterward, never calling an input-focus +function, so a tick while another frame is active neither errors on an +out-of-range window-start nor steals focus." + (interactive) + (let* ((frame (or frame (cj/--agenda-frame))) + (buffer (cj/--agenda-frame-sticky-buffer)) + (window (and (frame-live-p frame) (buffer-live-p buffer) + (get-buffer-window buffer frame)))) + (when (window-live-p window) + (let ((prev-window (selected-window))) + (unwind-protect + (progn + (select-window window t) + (cj/--agenda-frame-do-redo frame buffer window)) + (when (window-live-p prev-window) + (select-window prev-window t))))))) + +(defun cj/--agenda-frame-start-timer (frame) + "Start FRAME's five-minute wall-clock refresh timer, unless one exists. +Idempotent: a frame already carrying a live timer keeps it (no duplicate). +Returns the timer." + (unless (timerp (frame-parameter frame cj/--agenda-frame-timer-parameter)) + (let* ((period cj/--agenda-frame-refresh-seconds) + (delay (cj/--agenda-frame-seconds-to-next-mark (current-time) period)) + (timer (run-at-time delay period #'cj/--agenda-frame-safe-redo frame))) + (set-frame-parameter frame cj/--agenda-frame-timer-parameter timer) + timer))) + +;; -- Public command and key install ------------------------------------------ + +(defun cj/agenda-frame-toggle () + "Toggle the dedicated fullscreen agenda frame. +Spawn it fullscreen when none exists, raise and focus it when it exists +but is unfocused, and close it when it is the selected frame." + (interactive) + (cj/--agenda-frame-toggle)) + +(defun cj/--agenda-frame-install-keys (&optional map) + "Bind the F8-family keys for the agenda frame in MAP (default: the global map). +S-<f8> toggles the agenda frame; the force-rescan +\(`cj/org-agenda-refresh-files') moves to C-M-<f8>, keeping the whole +force-refresh idea in the F8 family." + (let ((map (or map (current-global-map)))) + (define-key map (kbd "S-<f8>") #'cj/agenda-frame-toggle) + (define-key map (kbd "C-M-<f8>") #'cj/org-agenda-refresh-files))) + +;;; Wiring — registered once org-agenda is loaded (no batch side effects) + +(with-eval-after-load 'org-agenda + (cj/--agenda-frame-register-command) + (add-hook 'org-agenda-finalize-hook #'cj/--agenda-frame-maybe-enable-mode) + (add-hook 'delete-frame-functions #'cj/--agenda-frame-on-delete-frame)) + +;; The public gesture appears only now that the feature is complete and live. +(cj/--agenda-frame-install-keys) + +(provide 'org-agenda-frame) +;;; org-agenda-frame.el ends here diff --git a/tests/test-org-agenda-frame.el b/tests/test-org-agenda-frame.el new file mode 100644 index 00000000..2b9dfcfc --- /dev/null +++ b/tests/test-org-agenda-frame.el @@ -0,0 +1,736 @@ +;;; test-org-agenda-frame.el --- Tests for the fullscreen agenda frame -*- lexical-binding: t; -*- + +;;; Commentary: +;; Phase 1 of the org-agenda fullscreen frame (spec: +;; docs/specs/2026-07-17-org-agenda-fullscreen-frame-spec.org). Frame lookup +;; is mocked (frame-list / frame-live-p / frame-parameter), the house pattern +;; from test-dirvish-config-popup.el, since --batch can't create real frames. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'org-agenda-frame) + +;; org-agenda isn't loaded in batch (no package-initialize), so declare the +;; command list special and bound for the registration tests to let-bind. +(defvar org-agenda-custom-commands nil) + +;;; cj/--agenda-frame — locate the marked frame + +(ert-deftest test-org-agenda-frame-find-returns-marked-live-frame () + "Normal: returns the live frame carrying the `cj/agenda-frame' marker." + (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb fc))) + ((symbol-function 'frame-live-p) (lambda (_f) t)) + ((symbol-function 'frame-parameter) + (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'fb))))) + (should (eq (cj/--agenda-frame) 'fb)))) + +(ert-deftest test-org-agenda-frame-find-nil-when-none-marked () + "Boundary: no frame carries the marker -> nil." + (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fc))) + ((symbol-function 'frame-live-p) (lambda (_f) t)) + ((symbol-function 'frame-parameter) (lambda (_f _p) nil))) + (should (null (cj/--agenda-frame))))) + +(ert-deftest test-org-agenda-frame-find-ignores-dead-marked-frame () + "Error: a marked but dead frame is not returned." + (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb))) + ((symbol-function 'frame-live-p) (lambda (f) (not (eq f 'fb)))) + ((symbol-function 'frame-parameter) + (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'fb))))) + (should (null (cj/--agenda-frame))))) + +;;; cj/--agenda-frame-p — is FRAME a live agenda frame + +(ert-deftest test-org-agenda-frame-p-true-for-live-marked () + "Normal: a live marked frame is an agenda frame." + (cl-letf (((symbol-function 'frame-live-p) (lambda (_f) t)) + ((symbol-function 'frame-parameter) + (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'fa))))) + (should (cj/--agenda-frame-p 'fa)))) + +(ert-deftest test-org-agenda-frame-p-nil-for-unmarked () + "Boundary: a live unmarked frame is not an agenda frame." + (cl-letf (((symbol-function 'frame-live-p) (lambda (_f) t)) + ((symbol-function 'frame-parameter) (lambda (_f _p) nil))) + (should (null (cj/--agenda-frame-p 'fa))))) + +(ert-deftest test-org-agenda-frame-p-nil-for-dead-marked () + "Error: a dead marked frame is not an agenda frame." + (cl-letf (((symbol-function 'frame-live-p) (lambda (_f) nil)) + ((symbol-function 'frame-parameter) + (lambda (_f p) (eq p 'cj/agenda-frame)))) + (should (null (cj/--agenda-frame-p 'fa))))) + +;;; cj/--agenda-frame-working-frame — route source files to a non-agenda frame + +(ert-deftest test-org-agenda-working-frame-returns-non-agenda-frame () + "Normal: with no recorded launch frame, returns the first live non-agenda frame." + (let ((cj/--agenda-frame-launch-frame nil)) + (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda work))) + ((symbol-function 'frame-live-p) (lambda (f) (memq f '(agenda work)))) + ((symbol-function 'frame-parameter) + (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda))))) + (should (eq (cj/--agenda-frame-working-frame) 'work))))) + +(ert-deftest test-org-agenda-working-frame-prefers-live-launch-frame () + "Normal: the recorded launch frame wins when live and non-agenda." + (let ((cj/--agenda-frame-launch-frame 'w2)) + (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda w1 w2))) + ((symbol-function 'frame-live-p) (lambda (f) (memq f '(agenda w1 w2)))) + ((symbol-function 'frame-parameter) + (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda))))) + (should (eq (cj/--agenda-frame-working-frame) 'w2))))) + +(ert-deftest test-org-agenda-working-frame-falls-back-when-launch-dead () + "Boundary: a dead recorded launch frame falls back to another non-agenda frame." + (let ((cj/--agenda-frame-launch-frame 'gone)) + (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda work))) + ((symbol-function 'frame-live-p) (lambda (f) (memq f '(agenda work)))) + ((symbol-function 'frame-parameter) + (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda))))) + (should (eq (cj/--agenda-frame-working-frame) 'work))))) + +(ert-deftest test-org-agenda-working-frame-nil-when-only-agenda () + "Error: the agenda frame is the only live frame -> nil (caller creates one)." + (let ((cj/--agenda-frame-launch-frame nil)) + (cl-letf (((symbol-function 'frame-list) (lambda () '(agenda))) + ((symbol-function 'frame-live-p) (lambda (f) (eq f 'agenda))) + ((symbol-function 'frame-parameter) + (lambda (f p) (and (eq p 'cj/agenda-frame) (eq f 'agenda))))) + (should (null (cj/--agenda-frame-working-frame)))))) + +;;; cj/--agenda-frame-command — the dedicated F seven-day view + +(defun test-org-agenda-frame--block-settings () + "Return the per-block settings alist of the agenda-frame command." + (let* ((cmd (cj/--agenda-frame-command)) + (blocks (nth 2 cmd)) + (agenda-block (car blocks))) + (nth 2 agenda-block))) + +(defun test-org-agenda-frame--general-settings () + "Return the general (view-wide) settings alist of the agenda-frame command." + (nth 3 (cj/--agenda-frame-command))) + +(ert-deftest test-org-agenda-frame-command-key-is-F () + "Normal: the command's key is F (collision-free with the existing d)." + (should (equal (nth 0 (cj/--agenda-frame-command)) "F"))) + +(ert-deftest test-org-agenda-frame-command-today-anchored-7-day () + "Normal: the span is seven days anchored to today, not Monday." + (let ((s (test-org-agenda-frame--block-settings))) + (should (equal (cadr (assq 'org-agenda-span s)) 7)) + (should (equal (cadr (assq 'org-agenda-start-day s)) "0d")) + ;; start-on-weekday nil is what un-anchors the span from Monday. + (should (assq 'org-agenda-start-on-weekday s)) + (should (null (cadr (assq 'org-agenda-start-on-weekday s)))))) + +(ert-deftest test-org-agenda-frame-command-follow-mode-off () + "Boundary: follow-mode is forced off locally so a global default can't split." + (let ((s (test-org-agenda-frame--block-settings))) + (should (assq 'org-agenda-start-with-follow-mode s)) + (should (null (cadr (assq 'org-agenda-start-with-follow-mode s)))))) + +(ert-deftest test-org-agenda-frame-command-sticky-and-current-window () + "Normal: sticky (distinct buffer) and current-window (no frame split)." + (let ((g (test-org-agenda-frame--general-settings))) + (should (eq (cadr (assq 'org-agenda-sticky g)) t)) + ;; org evaluates custom-command setting values via org-let, so the stored + ;; form is (quote current-window); eval it the way org would. + (should (eq (eval (cadr (assq 'org-agenda-window-setup g)) t) + 'current-window)))) + +;;; cj/--agenda-frame-register-command — idempotent registration + +(ert-deftest test-org-agenda-frame-register-adds-entry () + "Normal: registration inserts the F entry into org-agenda-custom-commands." + (let ((org-agenda-custom-commands '(("d" "Daily" nil)))) + (cj/--agenda-frame-register-command) + (should (assoc "F" org-agenda-custom-commands)) + (should (assoc "d" org-agenda-custom-commands)))) + +(ert-deftest test-org-agenda-frame-register-is-idempotent () + "Boundary: registering twice leaves exactly one F entry." + (let ((org-agenda-custom-commands nil)) + (cj/--agenda-frame-register-command) + (cj/--agenda-frame-register-command) + (should (= 1 (seq-count (lambda (e) (equal (car e) "F")) + org-agenda-custom-commands))))) + +;;; Default-deny policy — denial handlers + +(ert-deftest test-org-agenda-frame-denied-readonly-messages () + "Normal: the read-only denial shows the read-only message and acts on nothing." + (let (captured) + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) (setq captured (apply #'format fmt args))))) + (cj/--agenda-frame-denied-readonly)) + (should (string-match-p "read-only" captured)) + (should (string-match-p "working frame" captured)))) + +(ert-deftest test-org-agenda-frame-denied-fixed-view-messages () + "Normal: the view-change denial shows the fixed-view message." + (let (captured) + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) (setq captured (apply #'format fmt args))))) + (cj/--agenda-frame-denied-fixed-view)) + (should (string-match-p "7-day view" captured)))) + +;;; Default-deny policy — the keymap + +(ert-deftest test-org-agenda-frame-map-catch-all-is-readonly-deny () + "Normal: the [t] default binding denies with the read-only handler." + (should (eq (lookup-key cj/agenda-frame-mode-map [t]) + 'cj/--agenda-frame-denied-readonly))) + +(ert-deftest test-org-agenda-frame-map-navigation-allowed () + "Normal: navigation keys resolve to their org-agenda commands." + (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "n")) 'org-agenda-next-line)) + (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "p")) 'org-agenda-previous-line)) + (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "C-g")) 'keyboard-quit))) + +(ert-deftest test-org-agenda-frame-map-engage-routed () + "Normal: RET and TAB route to the working-frame engage command." + (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "RET")) + 'cj/--agenda-frame-engage-open)) + (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "TAB")) + 'cj/--agenda-frame-engage-open))) + +(ert-deftest test-org-agenda-frame-map-engage-gui-function-keys () + "Boundary: the GUI [return]/[tab] events engage too, not the [t] deny handler. +Without these, the catch-all suppresses their translation to RET/TAB in a +graphical frame and RET would be denied instead of opening the item." + (should (eq (lookup-key cj/agenda-frame-mode-map [return]) + 'cj/--agenda-frame-engage-open)) + (should (eq (lookup-key cj/agenda-frame-mode-map [tab]) + 'cj/--agenda-frame-engage-open))) + +(ert-deftest test-org-agenda-frame-map-lifecycle-keys () + "Normal: q/Q/x close the frame; r takes the safe-redo path." + (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "q")) 'cj/--agenda-frame-close)) + (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "Q")) 'cj/--agenda-frame-close)) + (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "x")) 'cj/--agenda-frame-close)) + (should (eq (lookup-key cj/agenda-frame-mode-map (kbd "r")) 'cj/--agenda-frame-safe-redo))) + +(ert-deftest test-org-agenda-frame-map-view-changers-fixed-view-deny () + "Boundary: view-changing keys are explicitly denied with the fixed-view message, +not caught by the read-only catch-all." + (dolist (key '("w" "d" "y" "f" "b" "j" "g")) + (should (eq (lookup-key cj/agenda-frame-mode-map (kbd key)) + 'cj/--agenda-frame-denied-fixed-view)))) + +(ert-deftest test-org-agenda-frame-map-mutation-keys-not-explicitly-bound () + "Boundary: a mutation key (t = org-agenda-todo) is not explicitly bound, so the +[t] catch-all denies it as read-only." + (should (null (lookup-key cj/agenda-frame-mode-map (kbd "t"))))) + +;;; Default-deny policy — the minor mode + finalize re-enable + +(ert-deftest test-org-agenda-frame-mode-toggles () + "Normal: the minor mode turns on and off in a buffer." + (with-temp-buffer + (cj/agenda-frame-mode 1) + (should cj/agenda-frame-mode) + (cj/agenda-frame-mode -1) + (should-not cj/agenda-frame-mode))) + +(ert-deftest test-org-agenda-frame-maybe-enable-in-agenda-frame () + "Normal: after a build in the agenda frame, the policy is re-enabled." + (with-temp-buffer + (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'agenda)) + ((symbol-function 'get-buffer-window) (lambda (_b _f) 'win))) + (cj/--agenda-frame-maybe-enable-mode) + (should cj/agenda-frame-mode)))) + +(ert-deftest test-org-agenda-frame-maybe-enable-skips-other-buffers () + "Boundary: a build not shown in the agenda frame leaves the policy off." + (with-temp-buffer + (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'agenda)) + ((symbol-function 'get-buffer-window) (lambda (_b _f) nil))) + (cj/--agenda-frame-maybe-enable-mode) + (should-not cj/agenda-frame-mode)))) + +(ert-deftest test-org-agenda-frame-maybe-enable-no-frame () + "Boundary: no agenda frame at all -> policy stays off, no error." + (with-temp-buffer + (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil))) + (cj/--agenda-frame-maybe-enable-mode) + (should-not cj/agenda-frame-mode)))) + +;;; Engage routing — frame target + open + +(ert-deftest test-org-agenda-frame-target-frame-uses-working-frame () + "Normal: the engage target is the working frame when one exists." + (cl-letf (((symbol-function 'cj/--agenda-frame-working-frame) (lambda () 'work)) + ((symbol-function 'make-frame) (lambda (&rest _) (error "should not create")))) + (should (eq (cj/--agenda-frame-target-frame) 'work)))) + +(ert-deftest test-org-agenda-frame-target-frame-creates-when-none () + "Boundary: no working frame -> a normal frame is created." + (cl-letf (((symbol-function 'cj/--agenda-frame-working-frame) (lambda () nil)) + ((symbol-function 'make-frame) (lambda (&rest _) 'new))) + (should (eq (cj/--agenda-frame-target-frame) 'new)))) + +(ert-deftest test-org-agenda-frame-engage-open-no-item-errors () + "Error: engaging on a line with no source item signals a user-error." + (cl-letf (((symbol-function 'cj/--agenda-frame-item-marker) (lambda () nil))) + (should-error (cj/--agenda-frame-engage-open) :type 'user-error))) + +(ert-deftest test-org-agenda-frame-engage-open-routes-to-source () + "Normal: engage opens the item's source buffer at the item's position." + (let ((source (generate-new-buffer " *frame-engage-source*"))) + (unwind-protect + (progn + (with-current-buffer source (insert "line one\nline two\nline three\n")) + (let ((marker (set-marker (make-marker) 10 source)) + focused opened) + (cl-letf (((symbol-function 'cj/--agenda-frame-item-marker) (lambda () marker)) + ((symbol-function 'cj/--agenda-frame-target-frame) (lambda () 'work)) + ((symbol-function 'select-frame-set-input-focus) + (lambda (f &rest _) (setq focused f))) + ((symbol-function 'pop-to-buffer-same-window) + (lambda (b &rest _) (setq opened b) (set-buffer b))) + ((symbol-function 'org-fold-show-context) (lambda (&rest _) nil))) + (cj/--agenda-frame-engage-open) + (should (eq focused 'work)) + (should (eq opened source)) + (should (eq (current-buffer) source)) + (should (= (point) (line-beginning-position)))))) + (kill-buffer source)))) + +;;; Frame lifecycle — sticky buffer, timer-cancel, teardown cleanup + +(ert-deftest test-org-agenda-frame-sticky-buffer-name () + "Normal: the sticky buffer is *Org Agenda(F)*; nil when absent." + (should (null (cj/--agenda-frame-sticky-buffer))) + (let ((buf (get-buffer-create "*Org Agenda(F)*"))) + (unwind-protect + (should (eq (cj/--agenda-frame-sticky-buffer) buf)) + (kill-buffer buf)))) + +(ert-deftest test-org-agenda-frame-cancel-timer-safe-when-none () + "Boundary: cancelling with no timer set does nothing and does not error." + (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil))) + (should-not (cj/--agenda-frame-cancel-timer)))) + +;;; Frame lifecycle — toggle dispatch + +(ert-deftest test-org-agenda-frame-toggle-spawns-when-none () + "Normal: with no agenda frame, toggle spawns one." + (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil)) + ((symbol-function 'cj/--agenda-frame-spawn) (lambda () 'spawned))) + (should (eq (cj/--agenda-frame-toggle) 'spawned)))) + +(ert-deftest test-org-agenda-frame-toggle-deletes-when-selected () + "Normal: toggle from within the agenda frame deletes it." + (let (deleted) + (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af)) + ((symbol-function 'selected-frame) (lambda () 'af)) + ((symbol-function 'cj/--agenda-frame-delete) + (lambda () (setq deleted t)))) + (cj/--agenda-frame-toggle) + (should deleted)))) + +(ert-deftest test-org-agenda-frame-toggle-raises-when-unfocused () + "Normal: toggle from a working frame raises the existing agenda frame." + (let (raised) + (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af)) + ((symbol-function 'selected-frame) (lambda () 'work)) + ((symbol-function 'cj/--agenda-frame-raise) + (lambda (f) (setq raised f)))) + (cj/--agenda-frame-toggle) + (should (eq raised 'af))))) + +;;; Frame lifecycle — delete + +(ert-deftest test-org-agenda-frame-delete-deletes-live-frame () + "Normal: delete removes the live agenda frame." + (let (deleted) + (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af)) + ((symbol-function 'frame-live-p) (lambda (f) (eq f 'af))) + ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f)))) + (cj/--agenda-frame-delete) + (should (eq deleted 'af))))) + +(ert-deftest test-org-agenda-frame-delete-noop-when-none () + "Boundary: delete with no agenda frame does nothing." + (let (called) + (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () nil)) + ((symbol-function 'delete-frame) (lambda (_f &rest _) (setq called t)))) + (cj/--agenda-frame-delete) + (should-not called)))) + +;;; Frame lifecycle — cleanup on frame death and buffer kill + +(ert-deftest test-org-agenda-frame-on-delete-cancels-and-kills-buffer () + "Normal: deleting the agenda frame cancels its timer and kills the sticky buffer." + (let ((buf (get-buffer-create "*Org Agenda(F)*")) + cancelled) + (unwind-protect + (cl-letf (((symbol-function 'cj/--agenda-frame-p) (lambda (_f) t)) + ((symbol-function 'cj/--agenda-frame-cancel-timer) + (lambda (&optional _f) (setq cancelled t)))) + (cj/--agenda-frame-on-delete-frame 'af) + (should cancelled) + (should-not (buffer-live-p buf))) + (when (buffer-live-p buf) (kill-buffer buf))))) + +(ert-deftest test-org-agenda-frame-on-delete-ignores-non-agenda-frame () + "Boundary: a non-agenda frame deletion triggers no cleanup." + (let (cancelled) + (cl-letf (((symbol-function 'cj/--agenda-frame-p) (lambda (_f) nil)) + ((symbol-function 'cj/--agenda-frame-cancel-timer) + (lambda (&optional _f) (setq cancelled t)))) + (cj/--agenda-frame-on-delete-frame 'work) + (should-not cancelled)))) + +(ert-deftest test-org-agenda-frame-on-kill-buffer-deletes-frame () + "Normal: killing the dedicated buffer deletes the frame." + (let (deleted) + (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af)) + ((symbol-function 'frame-live-p) (lambda (f) (eq f 'af))) + ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f)))) + (cj/--agenda-frame-on-kill-buffer) + (should (eq deleted 'af))))) + +(ert-deftest test-org-agenda-frame-on-kill-buffer-guarded-during-teardown () + "Boundary: during a teardown the buffer-kill hook does not re-delete the frame." + (let (deleted (cj/--agenda-frame-tearing-down t)) + (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af)) + ((symbol-function 'frame-live-p) (lambda (_f) t)) + ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f)))) + (cj/--agenda-frame-on-kill-buffer) + (should-not deleted)))) + +;;; Frame lifecycle — transactional spawn rollback + +(ert-deftest test-org-agenda-frame-spawn-rolls-back-on-failure () + "Error: a failure after make-frame deletes the partial frame, restores the +working frame, and signals a user-error." + (let (deleted focus) + (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch)) + ((symbol-function 'make-frame) (lambda (&rest _) 'pf)) + ((symbol-function 'select-frame-set-input-focus) + (lambda (f &rest _) (setq focus f))) + ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil)) + ((symbol-function 'org-agenda) (lambda (&rest _) (error "boom"))) + ((symbol-function 'frame-live-p) (lambda (f) (memq f '(pf launch)))) + ((symbol-function 'delete-frame) (lambda (f &rest _) (setq deleted f)))) + (should-error (cj/--agenda-frame-spawn) :type 'user-error) + (should (eq deleted 'pf)) + (should (eq focus 'launch))))) + +;;; Phase 2 — wall-clock alignment + +(ert-deftest test-org-agenda-frame-seconds-to-next-mark-aligned () + "Normal: a time exactly on a 5-minute boundary yields a full period." + ;; 1000000200 is divisible by 300 (a :00/:05 wall-clock mark). + (should (= (cj/--agenda-frame-seconds-to-next-mark 1000000200 300) 300))) + +(ert-deftest test-org-agenda-frame-seconds-to-next-mark-midway () + "Boundary: partway through a period returns the remainder to the next mark." + ;; 1000000200 + 120 -> 180 seconds remain to the next 300 mark. + (should (= (cj/--agenda-frame-seconds-to-next-mark 1000000320 300) 180))) + +;;; Phase 2 — deterministic point restoration + +(defun test-org-agenda-frame--make-agenda-buffer (lines source) + "Insert LINES into the current buffer; each is (TEXT . SRC-POS). +A non-nil SRC-POS puts an org-marker into SOURCE at that position on the line." + (dolist (spec lines) + (let ((start (point))) + (insert (car spec) "\n") + (when (cdr spec) + (put-text-property start (1+ start) 'org-marker + (set-marker (make-marker) (cdr spec) source)))))) + +(ert-deftest test-org-agenda-frame-restore-point-duplicate-nearest () + "Normal: a source marker occurring twice restores the occurrence nearest the old line." + (let ((src (generate-new-buffer " *rp-src*"))) + (unwind-protect + (with-temp-buffer + (with-current-buffer src (insert "aaaaaaaaaa\n")) + (test-org-agenda-frame--make-agenda-buffer + '(("header" . nil) ("item @2" . 3) ("filler" . nil) + ("filler" . nil) ("item @5" . 3)) + src) + (let ((old (set-marker (make-marker) 3 src))) + (cj/--agenda-frame-restore-point old 4)) + ;; lines 2 and 5 both point at src pos 3; nearest to old-line 4 is line 5. + (should (= (line-number-at-pos) 5))) + (kill-buffer src)))) + +(ert-deftest test-org-agenda-frame-restore-point-missing-clamps () + "Boundary: a gone marker clamps the old line into range and lands on an item." + (let ((src (generate-new-buffer " *rp-src*"))) + (unwind-protect + (with-temp-buffer + (with-current-buffer src (insert "aaaaaaaaaa\n")) + (test-org-agenda-frame--make-agenda-buffer + '(("header" . nil) ("item" . 3) ("item" . 5)) + src) + ;; old-line 99 is past the end; clamp to last line (an item). + (let ((gone (set-marker (make-marker) 99 src))) + (cj/--agenda-frame-restore-point gone 99)) + (should (get-text-property (line-beginning-position) 'org-marker))) + (kill-buffer src)))) + +(ert-deftest test-org-agenda-frame-restore-point-header-goes-to-first-item () + "Boundary: with no marker match, a header line moves to the first item." + (let ((src (generate-new-buffer " *rp-src*"))) + (unwind-protect + (with-temp-buffer + (with-current-buffer src (insert "aaaaaaaaaa\n")) + (test-org-agenda-frame--make-agenda-buffer + '(("header" . nil) ("item one" . 3) ("item two" . 5)) + src) + (cj/--agenda-frame-restore-point nil 1) ; line 1 is the header + (should (= (line-number-at-pos) 2)) + (should (get-text-property (line-beginning-position) 'org-marker))) + (kill-buffer src)))) + +(ert-deftest test-org-agenda-frame-restore-point-empty-buffer-start () + "Error: an empty (item-less) view leaves point at buffer start." + (with-temp-buffer + (test-org-agenda-frame--make-agenda-buffer + '(("only a header" . nil) ("no items here" . nil)) nil) + (cj/--agenda-frame-restore-point nil 2) + (should (= (point) (point-min))))) + +;;; Phase 2 — snapshot marker cloning + +(ert-deftest test-org-agenda-frame-clone-and-reinstall-markers () + "Normal: cloned markers survive nulling the originals and reinstall live." + (let ((src (generate-new-buffer " *clone-src*"))) + (unwind-protect + (let (clones) + (with-temp-buffer + (with-current-buffer src (insert "0123456789\n")) + (test-org-agenda-frame--make-agenda-buffer '(("item" . 4)) src) + (setq clones (cj/--agenda-frame-snapshot-markers (current-buffer))) + ;; Simulate org-agenda-reset-markers nulling the buffer's originals. + (let ((orig (get-text-property (point-min) 'org-marker))) + (set-marker orig nil))) + ;; Reinstall into a fresh buffer copy; the clone must still be live. + (with-temp-buffer + (test-org-agenda-frame--make-agenda-buffer '(("item" . nil)) nil) + (cj/--agenda-frame-reinstall-markers (current-buffer) clones) + (let ((m (get-text-property (point-min) 'org-marker))) + (should (markerp m)) + (should (eq (marker-buffer m) src)) + (should (= (marker-position m) 4))))) + (kill-buffer src)))) + +;;; Phase 2 — failure latch (report once per consecutive-failure run) + +(ert-deftest test-org-agenda-frame-failure-latch-reports-once () + "Normal: the first failure of a run reports; subsequent ones stay silent." + (let ((params '())) + (cl-letf (((symbol-function 'frame-parameter) + (lambda (_f p) (alist-get p params))) + ((symbol-function 'set-frame-parameter) + (lambda (_f p v) (setf (alist-get p params) v)))) + (should (cj/--agenda-frame-record-failure 'af)) ; 0 -> 1, report + (should-not (cj/--agenda-frame-record-failure 'af)) ; 1 -> 2, silent + (cj/--agenda-frame-clear-failure 'af) + (should (cj/--agenda-frame-record-failure 'af))))) ; reset -> report again + +;;; Phase 2 — timer start + duplicate prevention + +(ert-deftest test-org-agenda-frame-start-timer-sets-timer () + "Normal: start-timer schedules and stores a timer when none exists." + (let (stored) + (cl-letf (((symbol-function 'frame-parameter) (lambda (_f _p) nil)) + ((symbol-function 'run-at-time) (lambda (&rest _) 'the-timer)) + ((symbol-function 'set-frame-parameter) + (lambda (_f _p v) (setq stored v)))) + (should (eq (cj/--agenda-frame-start-timer 'af) 'the-timer)) + (should (eq stored 'the-timer))))) + +(ert-deftest test-org-agenda-frame-start-timer-no-duplicate () + "Boundary: a frame already carrying a live timer is not given a second one." + (let (called) + (cl-letf (((symbol-function 'frame-parameter) (lambda (_f _p) 'existing)) + ((symbol-function 'timerp) (lambda (x) (eq x 'existing))) + ((symbol-function 'run-at-time) (lambda (&rest _) (setq called t) 'new))) + (cj/--agenda-frame-start-timer 'af) + (should-not called)))) + +;;; Phase 2 — do-redo orchestration (success and failure branches) + +(ert-deftest test-org-agenda-frame-do-redo-success-clears-and-releases () + "Normal: a successful redo clears the failure latch and releases the snapshot." + (let ((params (list (cons 'cj/agenda-frame-fail-count 3))) + released) + (with-temp-buffer + (insert "agenda line\n") + (cl-letf (((symbol-function 'org-agenda-redo) (lambda (&rest _) nil)) + ((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params))) + ((symbol-function 'set-frame-parameter) + (lambda (_f p v) (setf (alist-get p params) v))) + ((symbol-function 'cj/--agenda-frame-release-snapshot) + (lambda (_s) (setq released t)))) + (cj/--agenda-frame-do-redo 'af (current-buffer) nil) + (should (equal (alist-get 'cj/agenda-frame-fail-count params) 0)) + (should released))))) + +(ert-deftest test-org-agenda-frame-do-redo-error-restores-reenables-reports () + "Error: a redo that fails mid-rebuild restores the last-good buffer verbatim, +re-enables the policy, shows one overlay, and reports once." + (let ((params '()) msgs) + (with-temp-buffer + (insert "good agenda content\n") + (unwind-protect + (cl-letf (((symbol-function 'org-agenda-redo) + (lambda (&rest _) (erase-buffer) (insert "PARTIAL") (error "boom"))) + ((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params))) + ((symbol-function 'set-frame-parameter) + (lambda (_f p v) (setf (alist-get p params) v))) + ((symbol-function 'message) + (lambda (fmt &rest a) (push (apply #'format fmt a) msgs)))) + (cj/--agenda-frame-do-redo 'af (current-buffer) nil) + (should cj/agenda-frame-mode) ; policy re-enabled + (should (overlayp cj/--agenda-frame-failure-overlay)) ; overlay shown + (should (string-match-p "good agenda content" (buffer-string))) ; restored + (should-not (string-match-p "PARTIAL" (buffer-string))) + (should (= 1 (seq-count (lambda (m) (string-match-p "refresh failed" m)) + msgs)))) + (cj/agenda-frame-mode -1) + (cj/--agenda-frame-remove-overlay (current-buffer)))))) + +(ert-deftest test-org-agenda-frame-do-redo-follows-item-across-shift () + "Normal: on a successful redo, point follows the same source item even when +lines shift and the buffer's own markers are nulled (the reset-markers case). +Guards against restoring by raw line number after the item moved." + (let ((src (generate-new-buffer " *shift-src*")) + (params '())) + (unwind-protect + (with-temp-buffer + (with-current-buffer src (insert "0123456789\n")) + ;; Before: item B (src pos 5) sits on line 3, and point is on it. + (test-org-agenda-frame--make-agenda-buffer + '(("header" . nil) ("item A" . 3) ("item B" . 5)) src) + (goto-char (point-min)) (forward-line 2) ; line 3, item B + (cl-letf (((symbol-function 'frame-parameter) (lambda (_f p) (alist-get p params))) + ((symbol-function 'set-frame-parameter) + (lambda (_f p v) (setf (alist-get p params) v))) + ((symbol-function 'org-agenda-redo) + (lambda (&rest _) + ;; Null the buffer's originals (as org-agenda-reset-markers + ;; does), then rebuild with item B shifted to line 4. + (save-excursion + (goto-char (point-min)) + (while (not (eobp)) + (let ((m (get-text-property (line-beginning-position) 'org-marker))) + (when (markerp m) (set-marker m nil))) + (forward-line 1))) + (erase-buffer) + (test-org-agenda-frame--make-agenda-buffer + '(("header" . nil) ("new item" . 1) ("item A" . 3) ("item B" . 5)) + src)))) + (cj/--agenda-frame-do-redo 'af (current-buffer) nil)) + ;; Point should be on the rebuilt item B (src pos 5), now line 4 -- + ;; not clamped to old line 3 (which is now item A, src pos 3). + (let ((m (get-text-property (line-beginning-position) 'org-marker))) + (should (markerp m)) + (should (= (marker-position m) 5)))) + (kill-buffer src)))) + +;;; Phase 2 — snapshot round-trip, release, safe-redo window contract, overlay + +(ert-deftest test-org-agenda-frame-restore-snapshot-round-trip () + "Normal: snapshot then restore reinstates text and a live cloned marker." + (let ((src (generate-new-buffer " *ss-src*"))) + (unwind-protect + (with-temp-buffer + (with-current-buffer src (insert "0123456789\n")) + (test-org-agenda-frame--make-agenda-buffer '(("item alpha" . 4)) src) + (let ((snap (cj/--agenda-frame-snapshot (current-buffer) nil))) + (erase-buffer) + (insert "CORRUPT") + (cj/--agenda-frame-restore-snapshot (current-buffer) snap nil) + (should (string-match-p "item alpha" (buffer-string))) + (let ((m (get-text-property (point-min) 'org-marker))) + (should (markerp m)) + (should (eq (marker-buffer m) src)) + (should (= (marker-position m) 4))) + (cj/--agenda-frame-release-snapshot snap) + (should-not (marker-buffer (cdr (car (plist-get snap :markers))))))) + (kill-buffer src)))) + +(ert-deftest test-org-agenda-frame-safe-redo-selects-and-restores-window () + "Normal: the active tick runs the redo and restores the prior window (no focus theft)." + (let (redone (prev (selected-window))) + (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af)) + ((symbol-function 'cj/--agenda-frame-sticky-buffer) + (lambda () (current-buffer))) + ((symbol-function 'frame-live-p) (lambda (_f) t)) + ((symbol-function 'get-buffer-window) (lambda (&rest _) prev)) + ((symbol-function 'cj/--agenda-frame-do-redo) + (lambda (&rest _) (setq redone t)))) + (cj/--agenda-frame-safe-redo) + (should redone) + (should (eq (selected-window) prev))))) + +(ert-deftest test-org-agenda-frame-overlay-idempotent () + "Boundary: showing the failure overlay twice keeps a single overlay." + (with-temp-buffer + (insert "x\n") + (unwind-protect + (progn + (cj/--agenda-frame-show-failure-overlay (current-buffer)) + (let ((first cj/--agenda-frame-failure-overlay)) + (cj/--agenda-frame-show-failure-overlay (current-buffer)) + (should (eq cj/--agenda-frame-failure-overlay first)) + (should (= 1 (seq-count (lambda (o) (overlay-get o 'before-string)) + (overlays-in (point-min) (point-max))))))) + (cj/--agenda-frame-remove-overlay (current-buffer))))) + +;;; Phase 2 — public command + F8-family rebind + +(ert-deftest test-org-agenda-frame-public-toggle-wraps-private () + "Normal: the interactive command delegates to the private toggle." + (let (called) + (cl-letf (((symbol-function 'cj/--agenda-frame-toggle) + (lambda () (setq called t)))) + (call-interactively 'cj/agenda-frame-toggle) + (should called)))) + +(ert-deftest test-org-agenda-frame-spawn-starts-timer () + "Normal: a successful spawn starts the refresh timer for the new frame." + (let (timed) + (cl-letf (((symbol-function 'selected-frame) (lambda () 'launch)) + ((symbol-function 'make-frame) (lambda (&rest _) 'af)) + ((symbol-function 'select-frame-set-input-focus) (lambda (_f &rest _) nil)) + ((symbol-function 'cj/build-org-agenda-list) (lambda (&rest _) nil)) + ((symbol-function 'org-agenda) (lambda (&rest _) nil)) + ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil)) + ((symbol-function 'cj/--agenda-frame-start-timer) + (lambda (f) (setq timed f)))) + (should (eq (cj/--agenda-frame-spawn) 'af)) + (should (eq timed 'af))))) + +(ert-deftest test-org-agenda-frame-safe-redo-noop-when-not-shown () + "Boundary: a tick with the buffer not shown in the frame does not redo or error." + (let (redone) + (cl-letf (((symbol-function 'cj/--agenda-frame) (lambda () 'af)) + ((symbol-function 'cj/--agenda-frame-sticky-buffer) (lambda () nil)) + ((symbol-function 'get-buffer-window) (lambda (&rest _) nil)) + ((symbol-function 'cj/--agenda-frame-do-redo) + (lambda (&rest _) (setq redone t)))) + (cj/--agenda-frame-safe-redo) + (should-not redone)))) + +(ert-deftest test-org-agenda-frame-install-keys-rebinds-f8-family () + "Normal: S-<f8> toggles the frame; the force-rescan moves to C-M-<f8>." + (let ((map (make-sparse-keymap))) + (cj/--agenda-frame-install-keys map) + (should (eq (lookup-key map (kbd "S-<f8>")) 'cj/agenda-frame-toggle)) + (should (eq (lookup-key map (kbd "C-M-<f8>")) 'cj/org-agenda-refresh-files)))) + +(provide 'test-org-agenda-frame) +;;; test-org-agenda-frame.el ends here |
