Merge pull request #187 from levineuwirth/bottom-panel-stage2b2

Bottom panel Stage 2B-2: the daemon panel projection and the epoch machine
This commit is contained in:
Levi Neuwirth 2026-07-29 09:01:21 -04:00 committed by GitHub
commit 300cbc4ef0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 3846 additions and 438 deletions

View File

@ -5,11 +5,26 @@ landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
entries when their PR merges; do not let this become a second permanent
backlog.
**No lane below is retained past its merge.** The PTY terminate
diagnostic (#176) was the last one — retained because rule 4 removes a
**No lane below is retained past its merge.** This snapshot removes the
resource-op delete guard (#186) and dired Stage 2 framing (#171) lanes
the moment their PRs merged, because the same commit put their
load-bearing decisions into `docs/agent-handoff.md` §1 — rule 4's
precondition, satisfied deliberately rather than deferred. The
bottom-panel lane is not removed: 2B-2 landing leaves 2B-3 and Stage 3
ahead of it, so the lane is rewritten to the remaining plan.
**One open PR has no lane here: #188**, the generated-buffer
immutability framing. Its lane lives on its own branch and arrives with
it, which is deliberate — with several PRs open, a lane written here for
work that lands elsewhere re-conflicts on every merge.
The PTY terminate
diagnostic (#176) was the last lane retained past its merge — retained
because rule 4 removes a
merged lane only *after* its durable facts reach
`docs/agent-handoff.md`, and that absorption was unowned. This snapshot
owns it: #176's facts are now in the handoff (§1's arc bullet and §5's
`docs/agent-handoff.md`, and that absorption was unowned. The
2026-07-28 snapshot
owned it: #176's facts are now in the handoff (§1's arc bullet and §5's
two ops lessons about ticking observers and proving child exit), so its
lane is gone. The Lean 4, GPU-terminal-input, inline-math (#172), dired
(#169), and terminal config + copy mode lanes were removed the same way
@ -20,14 +35,15 @@ would stop re-conflicting in this file.
number appears in `git log --first-parent githubsucks/main`, it has
landed regardless of what a lane says.
**Two open PRs had no lane here at all before this snapshot** — #174 and
#171. An open PR is exactly the volatile work this file exists to
record, so its absence is a ledger defect rather than a tidy omission:
#171 drifted **153 commits** while invisible here, and its still-green
old CI run describes a tree nobody has looked at since. **When a PR is
opened, give it a lane.** #174 has since merged, so per rule 4 its lane
is gone again and its durable lesson is in `docs/agent-handoff.md` §5;
#171's lane is below.
**Two open PRs had no lane here at all before the 2026-07-28
snapshot** — #174 and #171. An open PR is exactly the volatile work this
file exists to record, so its absence is a ledger defect rather than a
tidy omission: #171 drifted **153 commits** while invisible here, and
its still-green old CI run described a tree nobody had looked at since.
**When a PR is opened, give it a lane.** All three have since merged —
#174, #171 and #186 — so per rule 4 their lanes are gone again and
their durable facts are in `docs/agent-handoff.md` (§5 for #174's
lesson, §1 for the two framings).
## Repository authority
@ -38,16 +54,17 @@ is gone again and its durable lesson is in `docs/agent-handoff.md` §5;
machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot:
`githubsucks/main` @ `0442d78` (the M4 config-sink race fix #174, atop
bottom-panel Stage 2B-1 #184, the Journey/GPU directory-target ratchet
#183, Journey Stage 1a #182 and the previously recorded landed work).
`githubsucks/main` @ `7586905` (the docs-only coherence listview
correction #189, atop the docs-only landed-state refresh #185, the M4
config-sink race fix #174, bottom-panel Stage 2B-1 #184, the
Journey/GPU directory-target ratchet #183, Journey Stage 1a #182 and
the previously recorded landed work).
**Protocol schema support is
`v6..=v21`; the production server-first `Hello` still advertises
v20** — two different facts, and #184 landed only the first. The
previous snapshot named `7fd646d`, and **the
previous snapshot named `0442d78`, and **the
recovery floor advances with it**: the check below now requires
`0442d78` or newer, so a tree at `7fd646d` — or at `6bee09d` — no
longer passes. That is
`7586905` or newer, so a tree at `0442d78` no longer passes. That is
deliberate — the floor moves with the base, because a check that
accepts an older commit than the declared base passes on a tree the
rest of this file does not describe.
@ -86,7 +103,7 @@ git worktree list
git status --short --branch
```
The `git log` command must expose `0442d78` — the base named above — or a
The `git log` command must expose `7586905` — the base named above — or a
newer intentional main. Keep this threshold and the canonical-base line in
step: a recovery check that accepts an older commit than the base it
declares canonical will pass on a tree the rest of this file does not
@ -228,17 +245,16 @@ If it does not, stop and repair the remote/fetch configuration.
never been enforced. Any CI job that compiles the `crdt` targets has to
fix them first or it will be red on arrival.
## Bottom-panel lane (Arc 7) — 2B-1 MERGED; 2B-2 IN FLIGHT
## Bottom-panel lane (Arc 7) — 2B-2 MERGED; 2B-3 IS NEXT
Stage 1, the Stage 2 framing, Stage 2A, and **Stage 2B-1 are all on
`main`**. Framing revision 5's three-way split of 2B was explicitly
approved on 2026-07-27; revision 6 records PR #184's review correction.
**2B-2 — the daemon panel projection and epoch machine — is under way**
on branch `bottom-panel-stage2b2`, worktree `../pmacs-bp-stage2b2`,
branched fresh from `githubsucks/main` @ `6bee09d`. It is *not* stacked
on the 2B-1 branch, which is the rule for every slice in this arc. Note
that `main` has since advanced to `0442d78`; the only difference is the
test-only #174, so the slice's integration surface is unchanged.
Stage 1, the Stage 2 framing, Stage 2A, Stage 2B-1, and **Stage 2B-2 are
all on `main`**. Framing revision 5's three-way split of 2B was
explicitly approved on 2026-07-27; revision 6 records PR #184's review
correction. **2B-2 — the daemon panel projection and epoch machine —
landed as [PR #187](https://github.com/levineuwirth/pmacs/pull/187)**,
one review round of five findings on top of the implementation, 12/12
green, 22/22 mutations biting. Its durable lessons are in
`docs/agent-handoff.md` §1; what remains below is the 2B-3 plan.
**2B-2's boundaries, restated because they are easy to overrun:** the
production `Hello` stays at v20 and `panel_capable` stays `false`. The
@ -247,6 +263,52 @@ activation, the GPU band, and the negotiated capability flip are all
2B-3's, and 2B-3 may **not** simply change the unsolicited `Hello` to
21.
- **What PR #187 shipped, dark by construction:** the semantic daemon's
`FrontendCellGeometry` epoch machine; one reconciled panel grid
derivation; `PanelFrame::{Present, Absent}` projection on both document
and terminal semantic paths; stable presentation epochs; resize and
pointer validation against the live window/buffer/epochs; the panel's
own statusline context; and pre-drain semantic panel-terminal resize.
It does not add the GPU consumer or enable the capability.
- **Review round 1 closed five findings plus one sweep result at
`3ecb03d`.** The wire-area clamp became durable hide state; a stale
same-buffer reopen can no longer retain input authority; semantic panel
terminals resize before child drain; `NoMessage` retains a published
band baseline while `Invalidated` clears it; wheel activation follows
the terminal-only focus rule; and legally wide panels clamp their PTY
content without disappearing.
- **Review round 2 closed two findings at `bfaaf2b` plus this ledger
commit.** Side affinity can replace the buffer while preserving the
`WindowId`, so retained panel statusline segments are now keyed by the
full `(WindowId, BufferId)` presentation. Every authoritative `Absent`
also clears that baseline, including duplicate-suppressed `Absent`, so
a later `Present` under `NoMessage` cannot resurrect peer state that
was already cleared. Two acceptance tests bite those exact transitions.
This lane and `docs/agent-handoff.md` now name the open PR, current
landed base, checkpoint, and 2B-3 ordering instead of calling 2B-2
merely “next.”
- **Round-2 verification at code checkpoint `bfaaf2b`:** formatting and
strict workspace Clippy; library **1,863 passed + 3 ignored** default
and **2,048 passed + 4 ignored** CRDT; bottom-panel Stage 1 / 2A /
2B-1 / 2B-2 **46 / 17 / 16 / 28**; statusline **8 CRDT**; semantic
routing **2 CRDT**; M4 **121 passed + 3 ignored + 1 filtered**;
required GPU **202/202**; isolated-config full workspace sweep; and
`git diff --check`. The first workspace sweep had one GPU rendering
failure in `failures_and_display_math_render_as_source`; that test had
passed in the immediately preceding required-GPU run, passed alone,
and the complete workspace rerun passed. Real-daemon and managed-attach
cases were rerun outside the tool sandbox after its local-socket policy
produced `Operation not permitted`; the authoritative reruns passed.
- **Cross-machine recovery (fresh clone):**
```sh
git fetch githubsucks --prune
git switch --track -c bottom-panel-stage2b2 githubsucks/bottom-panel-stage2b2
git rev-parse HEAD
```
#187 has landed, so `githubsucks/main` already contains this work and
the branch is retained only for provenance. Start 2B-3 from `main`.
- **Stage 2B-1 MERGED as #184** (`main` @ `6bee09d`, 2026-07-28; all
twelve checks green on the reviewed head `5539b6e`; two review rounds
plus a gate-found follow-up). Branch
@ -459,341 +521,6 @@ has **no branch and no framing yet**.
`FrontendView.fold_projection` to `true` for semantic frontends, which
Stage 2 deliberately left `false` (Q#FD21).
## Resource-op delete guard lane — PR #186 OPEN, PROPOSED, DO NOT MERGE
- Portable branch: `githubsucks/resource-op-delete-guard`; worktree
`../pmacs-resource-op-delete`. **PR #186**, base `main`. Currently
framing only — `docs/resource-op-delete-guard-framing.md`, **revision
5** — plus this lane entry. No runtime code yet.
- **Measured 2026-07-28, `main` @ `7586905`:**
```
$ git rev-list --left-right --count HEAD...githubsucks/main
5 0
```
Five commits ahead, **0 behind** at the pushed revision-5 head. This
count includes the revision commit itself; revision 4 recorded the
pre-commit count and was therefore one short.
- **This PR becomes the implementation PR.** Revision 2 dropped rev 1's
framing-PR-then-implementation-PR plan as a one-feature/one-branch/
one-PR violation. The framing is revised in place; implementation
commits land on this same branch **only after explicit user
approval**.
- **Live data-loss bug, reproduced four ways against `ad41cf1`.**
`pmacs.buffer.apply_resource_op`'s delete arm removes the path from
disk and *then* drops any buffer bound to it, with no dirty check at
any link — not the arm, not `remove_buffer_and_fire`, and not
`BufferRegistry::remove`, whose only guard is `editing_in_progress`.
Reachable through any language server's `WorkspaceEdit`. The four
modes: (a) the plain case returns `Ok(())` with file and buffer both
gone; (b) `ignore_if_not_exists = true` does **zero** filesystem work
and still destroys the buffer; (c) `recursive = true` reconciles
**nothing**, so a whole tree leaves orphaned buffers — the most
destructive arm does the least reconciliation, and it bypasses any
exact-path guard; (d) removal is not `kill_buffer`, so windows are
left bound to a removed `BufferId` and the registry can be driven to
**empty**.
- **Approved in principle after review round 1; revision 5 closes round
4's two contract P1s and the ledger-ownership P1. Still PROPOSED,
still not approved for implementation.** Settled: refuse
unconditionally; take the delete side now. Withdrawn: rev 1's
buffer-first ordering. The design is `stat/no-op/refuse → enumerate
and validate → mutate filesystem → reconcile`, which keeps
`on_removed`'s "path already gone" invariant and makes a failed
deletion leave buffers intact automatically.
- **The stable cross-lane ownership split with #171:**
> #186 owns the urgent **pre-filesystem refusal** for synchronous
> `apply_resource_op`. #171 later owns **full post-delete lifecycle
> reconciliation**, including the **async race where a buffer becomes
> modified after dired dispatch**.
#186 additionally **owns the shared walk query** (scan every
path-bound buffer, normalize once, component-aware `Path::starts_with`)
under the boundary's "whichever lands first owns the query"; #171
adopts it and extends it to `reconcile_rename`. **Neither lane guards
`pmacs.fs.remove`** — zero production callers today, named out of
scope by both.
- **#171 owns its own lane entry.** Revision 4 rewrote that sibling
block and was stale before push when #171 revision 8 landed 67 seconds
earlier. Revision 5 restores the block to `main`'s tree, so #186's
diff no longer changes it. The one fact this lane depends on is the
policy split above, which is stable through #171's pushed revision 8
and independent of its commit count.
- **Standing rule this lane learned the expensive way.** A census is a
reading, not a constant. **Do not write an ahead/behind count, a line
count, or a call-site count into this file that you have not just
produced with a command whose output you can paste.** #186 shipped a
stale line count, then a stale commit count, then a stale ledger
citation, in three consecutive revisions — each time by carrying a
measurement across a base change instead of re-running it. The
specific trap: a count taken against `ad41cf1` was reported in
present tense after `main` had moved to `7586905`, which silently
converted "0 behind" into a falsehood.
- **Four facts a re-scout should not have to rediscover**, all verified
at `ad41cf1`:
- **No caller reliably surfaces a raise.** The server pump runs under
`pcall(handle_server_requests)` (`builtin/runtime/lsp.lua:1892`), so
a raise unwinds past the `send_response` and the server is never
answered; and the two user-initiated paths route uncaught coroutine
errors through `pmacs.error`, which is **undefined** (11 call sites
in `builtin/`, zero definitions). Refusals must travel as values.
- **A partial batch is already the status quo** — verified in-repo:
two delete ops, the second raises, the first stayed applied. Any
framing claiming batch atomicity here is wrong. **Do not justify
this from the LSP spec.** Revision 2 of #186 wrote that LSP 3.18
"assigns `FailureHandlingKind.Abort` to resource-op-bearing edits";
**it does not** — recovery is described by the client's advertised
`workspace.workspaceEdit.failureHandling`, `Abort` is one of four
strategies, only `TextOnlyTransactional` degrades to abort for
resource changes, and **pmacs advertises none of them**. The
justification is repository evidence plus the judgement that a
visible partial refactor beats unrecoverable unsaved work.
- **`find_by_path` is singular and duplicates are reachable.**
`BufferRegistry::find_by_path` returns the first match in insertion
order, `EditorCore::find_buffer_for_path` inherits that, and
`pmacs.buffer.from_file` creates path-bound buffers with **no
dedup** — so a clean first match can hide a modified second. The
guard needs a full scan with component-aware `Path::starts_with`.
- **pmacs advertises no `workspace.workspaceEdit` capability at all**
`"applyEdit": true` but no `documentChanges`, no
`resourceOperations`, no `failureHandling`; `grep -rn
failureHandling` returns 0. Parked, not fixed here.
- **Ownership claim, concretely.** For this lane's duration #186 owns:
the pre-filesystem refusal inside synchronous `apply_resource_op`; the
**shared walk query**; and `builtin/runtime/lsp.lua`'s
`apply_workspace_edit` plus the `workspace/applyEdit` server-request
boundary. It does **not** own: full post-delete lifecycle
reconciliation, the dired async race between dispatch and
`remove_blocking`, the rename side of the walk, or `pmacs.fs.remove`.
Do not run the two lanes concurrently over `builtin/runtime/lsp.lua`
without re-splitting that claim.
- **Two residues #186 deliberately leaves for #171**, both named rather
than silent: after a successful *clean* recursive delete, descendant
buffers stay orphaned-and-clean (widening removal would promote the
dangling-window/empty-registry defect from exact-path to tree-wide);
and after a successful delete with several clean duplicates on one
path, only the first is reconciled. #186 validates **every** match but
reconciles **one**, which is today's behaviour preserved on purpose.
- Files the implementation will touch: `src/lua_bindings/mod.rs`,
`builtin/runtime/lsp.lua`, `tests/m4_acceptance.rs`,
`tests/lsp_dispatch_seams_acceptance.rs`,
`src/bin/pmacs_fake_lsp.rs`. **Not** `src/daemon.rs`,
`pmacs-protocol/`, `builtin/runtime/dired.lua`,
`docs/agent-handoff.md` or `COHERENCE.md`. No protocol change.
- Recovery from a clean checkout:
`git fetch githubsucks && git worktree add ../pmacs-resource-op-delete
-b resource-op-delete-guard githubsucks/resource-op-delete-guard`.
## dired Stage 2 framing lane — PR #171 AT REVISION 9, AWAITING APPROVAL
- Portable branch: `githubsucks/dired-stage2-framing`;
worktree `../pmacs-dired-stage1`. **PR #171**, base `main`, integrated
up to canonical `main` @ `7586905`. Framing only —
`docs/dired-stage2-framing.md`, **3,844 lines measured at this
revision**, no runtime code. This lane rides that PR — it has since
rev 6, when #185 merged; do not open a standalone ledger PR.
- **Status: PROPOSED, never approved.** Revision 5 was reviewed and NOT
approved (six findings, four P1); revision 6 answered them; **revision
7 resolved a cross-lane conflict with PR #186**; revision 8 was
reviewed and NOT approved (six findings, four P1); revision 9 answers
them. The framing is at revision 9 after seven review rounds; **that
is not the same as approval**, and it must not be read as one.
- **Round 8 (rev 9) made every rev-8 correction singular and
executable:**
- Q#DR26 no longer retains the withdrawn execution-order claim; it
owns only the homogeneous enum/vector shape and delegates sequence
semantics to Q#DR29: **bus arrival, not filesystem execution**.
- 2a owns acceptance **2338 and 5055, including every suffixed
item**, so rev 8's new tests cannot fall between implementation PRs.
- The LSP gate is one manager-owned, generation-scoped
`HashSet<(LspServerId, String)>`. Both
`absorb_publish_diagnostics` and the now-server-aware
`mark_document_stale` check the exact pair; `did_open` clears one
pair, and server teardown clears all and only that server's pairs.
It is reclaimed, not falsely called size-bounded. Acceptance covers
both writers and both reclamation paths.
- Buffer-name following now uses explicit
`BufferNameOrigin::{Explicit, PathDerived}` state. The acceptance
collision is a file `${cwd}/notes` explicitly named `notes`, which
defeats rev 8's path-equivalence heuristic.
- **Round 7 (rev 8) found five contract defects of ONE family, and the
family is the transferable lesson: a guarantee assumed from a
mechanism whose implementation was never read.** Reply order assumed
to be execution order; a refusal assumed to be inert; `forget_uri`
assumed to cover every writer. Three of the five were literally that.
The decisions:
- **Reply order is NOT execution order.** `AsyncRuntime::tick`
(`src/async_runtime.rs:1003`) is `while let Ok(env) =
self.main.try_recv()` — a bus drain with **no execution token**, so
a worker can finish, be descheduled before sending, and have a later
reply arrive first. The ordering guarantee was **withdrawn, not
engineered**: reconciliation is order-independent, and `fs.lua:155-165`
already recommends serialization. Rev 9 makes the public contract
exact: overlapping mutations dispatch serially, awaiting one before
the next. A lock was rejected — it would serialize every fs mutation
for a hazard absent from shipped in-tree callers.
- **`EditorCore::kill_buffer` damages editor state BEFORE it can
refuse.** It clears `round_trip_buffers`, closes side windows and
redirects ordinary windows, and only then can
`BufferRegistry::remove` return `ConcurrentEdit`. So "refused" is
not "nothing happened". Fixed by preflighting `editing_in_progress`
— sound because phase 1 is pure `EditorCore` and holds no Lua
handle, so nothing can re-enter between check and removal.
- **Buffer names are set from `path.display()` AS GIVEN** while only
`file_path` is normalized (`src/editor_core.rs:917`,
`src/lua_bindings/mod.rs:3112`), so a relative open leaves a short
name that a string-equality rule mistakes for user-chosen. Rev 8
moved to path-equivalence; rev 9 replaces that inference with
explicit provenance because a custom name can equal the path.
- **`diag_store` has ZERO correlated writers**, and
`DiagnosticStore.by_uri` is keyed by **URI alone** with no server
component (`src/diag.rs:198`) — so the store most needing protection
is the one a `pending_routes` purge cannot help. Rev 8 incorrectly
inferred that the tombstone must share the store's URI-only key;
rev 9 gates in `LspManager`, where `sid` is still available.
- **A first pass of rev 8 checked only `handle_notification` and
concluded `publishDiagnostics` was the only uncorrelated writer.
Wrong lens boundary** — `pub fn mark_document_stale`
(`src/lsp.rs:3108`) takes **no `LspServerId`** and creates URI keys
across three stores for every server. Rev 9 changes that surface and
its private Lua binding to `(sid, uri)` and pins it independently.
- **Both new hooks are `all-must-succeed`, not short-circuit** — a gap
the review did not raise and the sweep found. `src/hook.rs` defines
three `HookKind`s; registered short-circuit, one `resource.renamed`
subscriber returning falsey would silently stop every later
subscriber reconciling.
- **CROSS-LANE SPLIT WITH PR #186 — settled, recorded verbatim so the
two lanes cannot diverge again:**
> #186 owns the urgent **pre-filesystem refusal** for synchronous
> `apply_resource_op`. #171 later owns **full post-delete lifecycle
> reconciliation**, including the **async race where a buffer becomes
> modified after dired dispatch**. #171's revision 7 adopts the
> refusal and stops saying LSP intentionally deletes modified files.
**PR #186 (`resource-op-delete-guard`) owns the pre-filesystem
refusal.** Do not implement a competing dirty check from #171. Rev 7
adopts #186's Q#RD1 (refuse, do not prompt, do not save, do not back
up) and its Q#RD6 shared query (scan every path-bound buffer, not
first-match; normalize once; component-aware `Path::starts_with`);
whichever lane lands first owns that query and the other adopts it.
The two fit deliberately: #186's Q#RD5 keeps reconciliation
**exact-path** so it does not promote the dangling-window and
last-buffer defects tree-wide, and #171's Q#DR27 is the lane that
makes widening safe.
- **What rev 7 removed.** Rev 6 said an LSP-authored delete removes the
file and orphans the modified buffer as accepted residue. That is
withdrawn — **20 matched lines across 13 passages** were censused and
reclassified: 4 ownership claims reassigned to #186, 4 policy claims
deleted, 3 ground-truth statements kept but attributed, 2 async-race
claims kept and narrowed.
- **What rev 7 kept, and it is now one path not two.** The async race —
a buffer modified between dired's pre-dispatch check and
`remove_blocking`'s syscall — survives #186's refusal, because **dired
never goes through `apply_resource_op`**: it calls `pmacs.fs.remove`,
which dispatches a worker no in-applier guard can see. Rev 6 carried
this as "two paths, LSP and dired"; after #186 the LSP path has no
residue at all.
- **A gap neither lane closes, named in rev 7 §11:** `pmacs.fs.remove`
is public Lua API with **no dirty check of its own**. After both lanes
land the guards sit one layer above it on each side — at
`apply_resource_op` (#186) and in dired's policy layer (#171) — so a
third caller inherits neither. Latent, not live: it has zero
production callers today.
- **LSP failure-handling, verified against the 3.18 spec** because #186
got it wrong and this lane was checked for the same error. **This lane
never made the claim** — it asserts nothing about `WorkspaceEdit`
recovery. For the record: only `textOnlyTransactional` degrades to
abort for resource operations; `transactional` covers them; and the
specification states **no default** for a client that does not
advertise `failureHandling` (pmacs advertises none). So "Abort applies
by omission" does not follow.
- **Round 5's single theme, worth carrying because it will recur:** rev
5 changed the slice split and the ownership of a decision, and the
prose did not follow. Four of the six findings were that same defect
in different places — stale slice labels, an acceptance allocation
that contradicted the code split, and a PR body still describing
revision *1*'s two-slice plan. **When a cut changes, sweep for the
label, do not patch the instance.** Rev 6's own sweep found four more
the review had not cited, one of which was wrong in both halves
(`x`'s report tagged 2a, `remove_dir_all` tagged 2b).
- **Rev 4 was scouted at `c8ec8f3`, which is dired Stage 1's own merge
commit (#165)** — so dired Stage 1 and find-file (#162) were always
its base, not new arrivals. Eighteen PRs landed on top before the
re-scout. Rev 5 is that re-scout, against `6bee09d` and then
integrated forward.
- **Seven of rev 4's own claims about pmacs were wrong**, which is the
lane's most transferable lesson: a framing can verify all its external
facts and still be wrong about the codebase it is for. The load-bearing
one: §5 named `drain_external_cancelled` but cited `lsp.rs:1596`.
**Those are two different functions 35 lines apart**
`drain_external_cancelled` (`:1561`) is the unconditional
server-scoped drain and is the right precedent;
`drain_cancelled_externals` (`:1596`) is a per-tick token/timeout
sweep. A rename flips no token, so following the line number yields a
`forget_uri` whose drain half is a silent no-op and the awaiting
coroutine hangs forever — exactly the failure that step exists to
prevent.
- **The rename path-owner census is SIX, not five.** The sixth is
`lean.lua`'s `M.file_progress`, a URI-keyed Lua module table living in
no Rust store, so `forget_uri` structurally cannot reach it. That is
the first case outside dired proving the **hook**, not the Rust
method, is the mechanism that scales. The other five: buffer path,
buffer name, the URI-keyed LSP stores plus `DiagnosticView` (URI set
once at construction), dired's pathless handles, and a captured Lua
local no transaction can reach.
- **The journey ratchet is split across two files**, and both are gates:
`tests/journey_acceptance.rs` (24 tests, "stages add rows, none
removes them", seven rows asserting on dired) and
`gpu_invocation_acceptance.rs`, where #183 actually put the GPU row.
A scout that checks only the first will think #183 added nothing.
- **`open_directory` now commits under `pmacs.window.commit_to`, whose
scope REFUSES an `await`** (#182). That directly constrains the
serialize-and-await batch contract the framing proposes.
- **The typed-edit chain does not reach dired** — verified rather than
assumed. Its lessons bind the framing's two *new* hooks instead.
- **Scope MOVED OUT of this lane, and rev 6 records the handoff.**
Rev 5 added Q#DR25 to adopt `set_generated_contents` at the head of
2b, because dired's listing is a generated buffer whose paint bypasses
an intercept over a still writable rope. That turned out to be a
**class bug, not dired's** — the same idiom is in listview, compile
and search/grep, and **no Lua caller anywhere sets `read_only`**
because there is no Lua `set_read_only` to call — so it is owned by
the generated-buffer immutability lane. **Done: rev 6 withdraws
Q#DR25**, and §3.1 hands that lane what this one's re-scout found,
including the trap that
`dired_buffer_is_read_only_and_round_trips_input` passes **either
way** and is therefore not coverage of the adoption. Do not implement
Q#DR25 from this document.
- **Two substrate facts rev 6 added that outlive this lane.** (1) Buffer
removal is **two phases** and **no existing Rust path composes them**:
`EditorCore::kill_buffer` (`src/editor_core.rs:4590`) does windows and
registry, `after_buffer_removed` (`src/lua_bindings/mod.rs:1602`) does
keymaps/config/folds/`on_removed`, and only the Lua binding
`pmacs.buffer.kill` (`mod.rs:5476-5491`) runs both — while
`apply_resource_op`'s delete uses `remove_buffer_and_fire`, which
skips phase 1, **so a window displaying the deleted buffer keeps a
removed id**. That is a third defect on that arm beside the missing
dirty check and the first-match lookup. (2) The drain outcome must
carry deletions as well as renames in one `Vec<ResourceOp>` so every
kind is expressible, but its sequence is **bus-arrival order, not
filesystem execution order**; Q#DR29 requires callers with
interdependent mutations to serialize.
- Intended serial implementation once approved: **2a** rename/delete
reconciliation substrate with no dired UI, **2b** marks and
operations, **2c** mkdir/copy/recursive-delete primitives, then Stage
3 wdired. The re-scout re-examined this cut and it holds unchanged.
- **Ownership warning:** dired 2a overlaps `src/editor_core.rs`,
`builtin/runtime/lsp.lua`, and the URI-keyed LSP state with other
coherence work. Do not run it concurrently with Journey Stage 1b
without assigning those files to one lane first.
- **Two live bugs on `main` this lane confirmed but does NOT fix**, both
now owned elsewhere: `apply_resource_op`'s delete arm destroys unsaved
work (no dirty check anywhere on the path), and `View` still lacks
`rename_resource`.
## Parked lane: kill-ring browser + persistence
- Portable branch: `githubsucks/kill-ring-browser`

View File

@ -1,9 +1,15 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-28, after the M4 config-sink race fix (#174) and
bottom-panel Stage 2B-1 (#184) merged; the canonical landed base is
`0442d78`. #174 is test-only. #184 is the substantive one — the reserved
protocol-v21
**Last updated: 2026-07-28, as bottom-panel Stage 2B-2 (#187) — the
daemon panel projection and the epoch machine — lands on `6c9e765`,
which is the dired Stage 2 framing (#171) atop the resource-op delete
guard framing (#186). Those two are framing-only: both are approved
documents with no runtime code, and neither has begun implementation.
Beneath them, the docs-only coherence listview
correction (#189) and landed-state refresh (#185); the canonical
landed base beneath those is `7586905`. The runtime anchor beneath them is the M4
config-sink race fix (#174) and bottom-panel Stage 2B-1 (#184). #174 is
test-only. #184 is the substantive one — the reserved protocol-v21
bottom-panel wire family, dark by construction, with the production
handshake deliberately still advertising v20 — following the Journey/GPU
directory-target ratchet (#183), following Journey Stage 1a (#182),
@ -51,9 +57,14 @@ commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-28)
- `main` @ `0442d78` (the M4 config-sink race fix #174 — test-only —
atop bottom-panel Stage 2B-1 #184, the Journey/GPU
directory-target ratchet #183, Journey Stage 1a #182, incorporating
- `main` @ `6c9e765` (the dired Stage 2 framing #171 and the resource-op
delete guard framing #186 — both framing-only, no runtime code, no
implementation started — atop
the docs-only coherence listview correction #189,
atop the docs-only landed-state refresh #185, the M4 config-sink race
fix #174 — test-only — atop bottom-panel Stage 2B-1 #184, the
Journey/GPU directory-target ratchet #183, Journey Stage 1a #182,
incorporating
terminal configuration + copy
mode landed docs #180, Lean 4 Stage 4b #181, the dired Stage 1 landed
docs #169 and the PTY-terminate diagnostic #176, terminal copy mode
@ -518,20 +529,23 @@ commands, read `docs/active-work.md` immediately after this file.
`bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121;
required GPU 152; initial-target 14 CRDT; all three vterm suites; folding
Stage 2 48. All 12 CI checks green at merge.
- **Stage 2 (the GPU panel band) is FRAMED, and its first two slices
have LANDED** — `docs/bottom-panel-stage2-framing.md` rev 6, four
- **Stage 2 (the GPU panel band) is FRAMED; its first two slices have
LANDED and its third is open as PR #187** —
`docs/bottom-panel-stage2-framing.md` rev 6, four
framing review rounds, no open framing items; the rev-5
implementation split was explicitly approved 2026-07-27 and rev 6
records PR #184's server-first compatibility and gate correction.
It reserves protocol **v21** and ships as four serial
implementation slices: **2A** classified census routing +
per-window painter extraction (no wire change, #177), **2B-1** the
wire (#184), **2B-2** the daemon projection and epoch machine,
then **2B-3** the GPU band, compatible v21 activation, and the
wire (#184), **2B-2** the daemon projection and epoch machine
(implemented but not landed in PR #187), then **2B-3** the GPU band,
compatible v21 activation, and the
negotiated `panel_capable` flip. Production attachment remains v20
through 2B-2. Parent acceptance 3755 remains authoritative.
Stage 3 is the adopter default flip. **2B-2 is the next slice, and
it branches from `0442d78` or newer.**
Stage 3 is the adopter default flip. **Do not start 2B-3 until PR
#187 lands.** Its branch, checkpoints, two review rounds, verification,
and exact recovery commands live in `docs/active-work.md`.
- **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the
Projection class (#1#12, #21#22) routes through
`primary_document_window`; focus/input (#13#15, #23), focus chrome
@ -597,6 +611,97 @@ commands, read `docs/active-work.md` immediately after this file.
statusline and Vterm Stage 3 ladders still pinned v20 and rejected
v21, in both structural and real-headless-probe form. Grep for the
outgoing version across `tests/` before calling a bump complete.
- **Bottom panel Stage 2B-2 (daemon projection + epoch machine) LANDED
#187** (one review round of five findings on top of the
implementation; 12/12 green; 22/22 mutations bite). What it
establishes, beyond the feature:
- **A durable transition implemented as a per-frame effect is a bug
shape, not a style.** Four of the five review findings were one
defect: a renderer-side conditional with a durable twin that
disagreed with it. Wire-area exhaustion hid the rendered frame but
left panel state live; a stale `panel_epoch` could address a
reopened same-buffer panel; `NoMessage` cleared statusline segments
it should have retained. The fix that generalizes is
`presentable_panel_grid`**one** private derivation behind both
the renderer and `reconcile_panel_layout_core`, because two
derivations of one predicate is *how* they drift. When touching
panel rendering, ask of every conditional: is there a durable twin,
and does it agree?
- **Ask what the other frontend kind's equivalent does.** The
semantic panel terminal missed the pre-child-drain resize its
document sibling had; the repro is a 4×20 panel reporting 3×120.
The new `sync_semantic_panel_terminal_layout` is disjoint from that
sibling *by construction* — one resolves through
`primary_document_window`, the other through `side_window_for` — so
the SIGWINCH storm the original extraction prevented cannot recur
through the new path.
- **A panel may legitimately be wider than a PTY.** `>512` columns
are legal on the wire (2B-1's `WireGridLimits` parameter), but
`snapshot_for_view` refused the band's content rect and the band
went `Absent` with `panel_hidden` false. Clamp the *terminal
projection*, not the band: the child takes the columns a PTY can
have and the remainder is band background.
- **Mutation testing cannot reach behaviour never modelled.** All 16
original mutations passed while five real defects stood; one of
them even fired, removing an `Absent` the test asserted. Mutations
prove assertions bite the code that exists. They say nothing about
the assertion never written.
- **One durability hole is accepted deliberately:** epoch exhaustion
stays a per-frame `Absent`, flagged rather than hidden, because
making it durable needs a new "presentation permanently
unavailable" reason in `FrontendView` for a state requiring 2^64
presentations in one session.
- **dired Stage 2 framing LANDED (document only) — #171**
(`docs/dired-stage2-framing.md`, revision 9; seven review rounds).
**Approved as a framing; no runtime code and no implementation
started.** Load-bearing decisions a reader must not re-derive:
- **Reconciliation is order-independent, deliberately.** Reply order
is *not* execution order — `AsyncRuntime::tick` drains the bus with
`try_recv`, so a worker can finish first and be descheduled before
sending. An ordering token was rejected rather than built: no
static rule rescues the hazard anyway (rename `dir`→`newdir` racing
delete `dir/child.txt` needs opposite orders depending on which ran
first), and no production path can produce it. The trigger to
revisit is named: the first production caller that fire-and-forgets
two interdependent mutations.
- **A refused mid-edit kill is already destructive.**
`kill_buffer` clears round-trip state and moves windows *before*
`BufferRegistry::remove` can return `ConcurrentEdit`, so
`editing_in_progress` must be preflighted rather than relied on to
refuse cleanly.
- **Path-backed buffer names are not full paths.**
`get_or_load_buffer` takes the name from the path *as given* and
normalizes only the stored path, so a relative open is named
`foo.rs`. Rename provenance is path-*equivalence*, not equality.
- **URI-keyed stores have uncorrelated writers.**
`textDocument/publishDiagnostics` is absorbed unconditionally, and
`mark_document_stale` takes no `LspServerId` at all while creating
URI keys across three stores. A route purge keyed on request
responses cannot cover either.
- **Resource-op delete guard framing LANDED (document only) — #186**
(`docs/resource-op-delete-guard-framing.md`, revision 5; five review
rounds). **Approved as a framing; no runtime code.** It owns the
urgent **pre-filesystem refusal** for synchronous `apply_resource_op`
— which on `main` today destroys unsaved work — while #171 owns full
post-delete lifecycle reconciliation. Carried facts:
- The sequence is `stat/no-op -> enumerate and validate -> mutate
filesystem -> reconcile`. Validation inspects without removing, so
a failed deletion leaves buffers intact and `on_removed` still
observes the path already gone.
- **`EditorCore::find_buffer_for_path` is the wrong lookup** — it
delegates to first-match-only `find_by_path`, and
`pmacs.buffer.from_file` creates path-bound buffers with no dedup,
so a clean first match can hide a modified second.
- **LSP does not promise batch atomicity, and pmacs advertises no
`workspace.workspaceEdit` capability at all** — no
`documentChanges`, no `resourceOperations`, no `failureHandling`.
Recovery is the client's declared choice, and there is no spec
default for a client that declares none. Say only that observed
pmacs behaviour *resembles* abort-style application.
- **`pmacs.fs.remove` has no dirty check of its own** and neither
lane adds one. After both land the guards sit one layer above it.
Latent — zero production callers — but "both lanes guard deletion"
reads as the primitive being guarded, and it is not.
- **GPU initial target LANDED — #148**
(`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review
rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,7 @@ use unicode_width::UnicodeWidthStr;
use crate::async_runtime::SharedAsyncRuntime;
use crate::cell::{CellCoord, CellSize};
use crate::editor_core::EditorCore;
use crate::editor_core::{EditorCore, GeometryUpdate};
use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook};
use crate::key::{Chord, display_sequence};
use crate::keymap_stack::{Action, KeyDispatcher};
@ -1196,13 +1196,49 @@ impl EditorState {
/// (Q#BP2b / Q#BP15a).
///
/// The single seam for grid and `LOCAL` views, whose real attach and
/// resize sizes ARE the declaration. A semantic view never calls this
/// in Stage 1; its geometry stays **unknown**.
pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) {
self.core
/// resize sizes ARE the declaration. A semantic view never calls this;
/// its geometry arrives through
/// [`Self::accept_semantic_frame_geometry`].
///
/// Reconciliation runs on every call, not only on
/// [`GeometryUpdate::Advanced`]: panel presentability depends on the
/// layout as well as on the geometry, and this is also the defensive
/// pre-paint reconciliation point. The exhaustion arm is exactly why
/// it must still run after a `Rejected` — `declare_frame_geometry`
/// cleared the declaration to unknown, and the panel has to hide.
pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) -> GeometryUpdate {
let update = self
.core
.borrow_mut()
.declare_frame_geometry(frontend_id, total);
self.reconcile_panel_layout(frontend_id);
update
}
/// Accept an authenticated semantic frontend's
/// `FrontendEvent::FrontendCellGeometry` declaration (Q#BP15a).
///
/// The three outcomes are acted on differently, and that is the whole
/// point of the three-valued result: `Advanced` reconciles panel
/// layout, `Duplicate` returns without touching panel state, and
/// `Rejected` drops the event before any reconciliation. A
/// `Duplicate` that reconciled would do redundant work on every
/// repeated declaration; a `Rejected` that reconciled would let a
/// stale or conflicting declaration move the panel.
pub fn accept_semantic_frame_geometry(
&self,
frontend_id: FrontendId,
geometry_epoch: u64,
total: CellSize,
) -> GeometryUpdate {
let update =
self.core
.borrow_mut()
.accept_frame_geometry(frontend_id, geometry_epoch, total);
if update == GeometryUpdate::Advanced {
self.reconcile_panel_layout(frontend_id);
}
update
}
/// Local-frontend compatibility wrapper.
@ -1834,6 +1870,91 @@ impl EditorState {
}
}
/// Sync a semantic frontend's **panel** terminal to the
/// daemon-derived content grid (Q#BP7 / Q#BP15a).
///
/// The sibling of [`Self::sync_semantic_terminal_layout`], and the
/// case review round 1 (R1-3) found missing. The two arms are
/// disjoint by construction rather than by discipline:
/// `sync_semantic_terminal_layout` resolves its window through
/// `primary_document_window`, so it can never reach a side window,
/// and this one resolves through `side_window_for`, so it can never
/// reach the document. Nothing is ever resized twice per tick — the
/// failure mode the extraction of `sync_terminal_layouts_for_tick`
/// exists to prevent.
///
/// A panel terminal has **no** `FrontendEvent::TerminalResize`
/// declaration to consult: the daemon derives its geometry, the
/// frontend never asserts it (Q#BP15a). So the size comes from
/// `panel_grid_size` minus the panel's one mode line, and it must be
/// applied at the tick's layout step — before `tick_processes`
/// drains the child — or the program formats its output against a
/// geometry the band is not showing.
///
/// Recording the view size is unconditional for a resolvable panel
/// (that is what gives a passive view its own clipped projection);
/// only the durable controller resizes the shared PTY.
///
/// Returns whether the shared screen geometry actually changed.
pub fn sync_semantic_panel_terminal_layout(&mut self, frontend_id: FrontendId) -> bool {
let Some((window_id, buffer_id, content)) = ({
let core = self.core.borrow();
core.panel_grid_size(frontend_id).and_then(|size| {
let window_id = core.side_window_for(frontend_id)?;
let buffer_id = core.windows.get(&window_id)?.buffer_id;
Some((
window_id,
buffer_id,
CellSize::new(size.rows.saturating_sub(1), size.cols),
))
})
}) else {
return false;
};
if content.rows == 0 || content.cols == 0 {
return false;
}
if !self.terminal_manager.borrow().is_terminal(buffer_id) {
return false;
}
let key = TerminalViewKey::new(frontend_id, window_id, buffer_id);
let content = terminal_projection_size(content);
if !self
.terminal_manager
.borrow_mut()
.record_view_size(key, content)
{
return false;
}
let controls = self
.terminal_manager
.borrow()
.controller(buffer_id)
.is_some_and(|controller| controller.matches(key));
if !controls {
return false;
}
if self.terminal_manager.borrow().screen_size(buffer_id) == Some(content) {
return false;
}
let (Ok(rows), Ok(cols)) = (u16::try_from(content.rows), u16::try_from(content.cols))
else {
return false;
};
let result = self.terminal_manager.borrow_mut().resize(
buffer_id,
rows,
cols,
&mut self.process_supervisor.borrow_mut(),
);
if let Err(error) = result {
self.core.borrow_mut().status = error.to_string();
false
} else {
true
}
}
/// Apply a semantic frontend's terminal-cell pointer gesture.
///
/// The gesture must name the authenticated frontend's active
@ -1875,6 +1996,257 @@ impl EditorState {
true
}
/// Paint one semantic frontend's side window into a panel-sized grid
/// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17).
///
/// Returns `None` for every non-presentable state — no side window,
/// a hidden panel, unknown geometry, a zero-column frame, or a grid
/// too small for the structural floor. The caller turns that into an
/// **authoritative**
/// [`pmacs_protocol::panel::PanelFramePayload::Absent`]: silence
/// would leave the receiver's retained band on screen forever.
///
/// `statusline` is the side window's evaluated segments, supplied by
/// the caller from the *same* provider invocation that produced the
/// document's wire segments (parent acceptance 45). Evaluating again
/// here would run every provider twice per frame.
///
/// **Folds are gated on the OWNING frontend (Q#BP17).** The panel is
/// painted for `frontend_id`, which is not necessarily the acting
/// frontend, so `EditorCore::fold_map_for_window` — which gates on
/// the *active* frontend — is the wrong source and is deliberately
/// not called.
#[must_use]
#[allow(
clippy::too_many_lines,
reason = "one panel paint transaction: derive the grid, paint the window, resolve the caret"
)]
pub fn prepare_panel_projection(
&self,
frontend_id: FrontendId,
statusline: Option<&crate::statusline::StatuslineWindowSegments>,
) -> Option<PanelProjection> {
let (size, window_id, buffer_id, focused, fold_projection) = {
let core = self.core.borrow();
let size = core.panel_grid_size(frontend_id)?;
let window_id = core.side_window_for(frontend_id)?;
let buffer_id = core.windows.get(&window_id)?.buffer_id;
let view = core.views.get(&frontend_id)?;
(
size,
window_id,
buffer_id,
view.active == window_id,
view.fold_projection,
)
};
let outer = Rect::new(0, 0, size.rows, size.cols);
let content = Rect::new(0, 0, size.rows.saturating_sub(1), size.cols);
let placement = WindowPlacement { outer, content };
let theme = {
let handle = self.syntax_registry.theme();
let t = handle.lock().expect("theme mutex poisoned");
t.clone()
};
let mut cells = vec![crate::cell::Cell::default(); (size.rows * size.cols) as usize];
let mut grid = crate::cell::CellGrid {
cells: &mut cells,
stride: size.cols,
size,
};
// Q#BP7 / Q#BP15a: a terminal panel's grid excludes its one mode
// line, and its geometry reaches the shared screen through the
// same view-size path the grid frontends use — never the 24×80
// attach placeholder and never the full-window declaration.
let terminal = self.terminal_manager.borrow().is_terminal(buffer_id);
let cursor = if terminal {
let key = TerminalViewKey::new(frontend_id, window_id, buffer_id);
let snapshot = self
.terminal_manager
.borrow_mut()
.snapshot_for_view(key, terminal_projection_size(content.size))?;
paint_terminal_snapshot(&mut grid, content, &snapshot, &theme);
let registry = self.core.borrow().registry.clone();
let reg = registry.borrow();
if let Ok(buf) = reg.get(buffer_id) {
let coord = snapshot.cursor.unwrap_or_default();
let scroll = if snapshot.scroll_offset == 0 {
String::new()
} else {
format!("{}", snapshot.scroll_offset)
};
paint_mode_line(
&mut grid,
&outer,
buf.name(),
false,
focused,
coord.row,
coord.col,
&scroll,
"",
mode_line_style(&theme),
statusline.map_or(&[], |segments| segments.left.as_slice()),
statusline.map_or(&[], |segments| segments.right.as_slice()),
&theme,
);
}
snapshot
.cursor
.filter(|coord| coord.row < content.size.rows && coord.col < content.size.cols)
} else {
let registry = self.core.borrow().registry.clone();
let reg = registry.borrow();
let diag_store = self.lsp_manager.borrow().diag_store();
let mut core = self.core.borrow_mut();
let window = core.windows.get_mut(&window_id)?;
let buf = reg.get(buffer_id).ok()?;
let folds = if fold_projection {
crate::fold_view::map_for_window(&self.fold_registry, window)
} else {
None
};
window.last_visible_rows = content.size.rows;
// A2A-3 / parent 48: the auto-scroll clamp belongs to the
// FOCUSED window only. Running it for a passive panel would
// move a `view_top` the user is not driving.
if focused {
prepare_window_cursor_visible(window, buf, content.size.rows, folds.as_ref());
}
paint_window_content(
&mut grid,
window,
buf,
placement,
folds.as_ref(),
focused,
&theme,
statusline,
&diag_store,
);
window_cursor_cell(window, buf, folds.as_ref(), outer)
};
Some(PanelProjection {
window_id,
buffer_id,
size,
cells,
cursor,
focused,
})
}
/// Apply an accepted `FrontendEvent::PanelResizeRows` (Q#BP15a).
///
/// The request is expressed as a boundary move rather than a direct
/// `fixed_rows` write, so it lands on the same Q#BP5b clamp path a
/// TUI divider drag takes — including the interactive
/// `window.min-height` preference resolved per leaf. A request the
/// clamp cannot satisfy is a no-op, not an error.
///
/// Returns whether the effective allocation actually moved.
pub fn apply_panel_resize_rows(&self, frontend_id: FrontendId, rows: u32) -> bool {
let (side, area_rows, current) = {
let core = self.core.borrow();
match (
core.side_window_for(frontend_id),
core.frontend_area_rows(frontend_id),
) {
(Some(side), Some(area_rows)) => (
side,
area_rows,
core.panel_allocation(frontend_id, area_rows),
),
_ => return false,
}
};
let Some(current) = current else {
return false;
};
let Ok(rows) = EditorCore::clamp_panel_rows(rows) else {
return false;
};
let Ok(delta) = i32::try_from(i64::from(rows) - i64::from(current)) else {
return false;
};
if delta == 0 {
return false;
}
let _ = self.resize_window_boundary(frontend_id, side, delta, area_rows);
self.reconcile_panel_layout(frontend_id);
self.core
.borrow()
.panel_allocation(frontend_id, area_rows)
.is_some_and(|now| now != current)
}
/// Apply an accepted `FrontendEvent::PanelPointer` gesture (Q#BP16).
///
/// Steps 2, 5, and 6 of Q#BP16's ladder are re-derived here from the
/// daemon's own state — a live, non-hidden side window whose current
/// buffer matches the payload, and a coordinate inside the grid the
/// daemon derived. Steps 1, 3, and 4 (source authentication and both
/// epochs) belong to the caller, because only the session holds the
/// declaration the frontend was actually looking at.
///
/// **Activation is not uniform, and Q#BP16 says so explicitly.** A
/// **press** focuses any panel — that is click-to-focus, and
/// `Down(Right)` is the context-menu gesture, so both buttons count.
/// Everything else depends on what the panel holds:
///
/// * a **terminal** panel activates on *every* non-`Move` gesture,
/// because the shared terminal adapter claims the controller for
/// wheel, press, drag, and release alike — leaving a wheel step
/// unactivated would hand the child to a window that does not own
/// focus;
/// * a **document** panel keeps today's **scroll-without-focus**
/// behaviour, matching `dispatch_mouse`, where a wheel notch moves
/// a viewport without selecting the window (and preserves a kill
/// chain for the same reason).
///
/// Review round 1 (R2-5) found the terminal clause applied to both.
/// Bare hover neither focuses nor claims, on either kind.
///
/// **Replay is out of scope in Stage 2B-2.** Driving selection,
/// listview rows, or child SGR reporting is parent acceptance 48,
/// which needs the GPU band and lands in Stage 2B-3.
///
/// Returns whether the gesture was accepted.
pub fn dispatch_semantic_panel_pointer(
&self,
frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId,
coord: CellCoord,
kind: pmacs_protocol::MouseKind,
) -> bool {
let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else {
return false;
};
if coord.row >= size.rows || coord.col >= size.cols {
return false;
}
let is_terminal = self.terminal_manager.borrow().is_terminal(buffer_id);
let mut core = self.core.borrow_mut();
let Some(side) = core.side_window_for(frontend_id) else {
return false;
};
if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) {
return false;
}
let activates = if is_terminal {
!matches!(kind, pmacs_protocol::MouseKind::Move)
} else {
matches!(kind, pmacs_protocol::MouseKind::Down(_))
};
if activates {
core.focus_window(frontend_id, side);
core.active_frontend = frontend_id;
}
true
}
/// Precompute owned terminal view snapshots before entering paint borrows.
pub fn prepare_terminal_views(
&mut self,
@ -3085,6 +3457,59 @@ const SCROLL_LINES: i32 = 3;
/// therefore only appears when a line-number mode reserves a gutter.
const FOLD_GUTTER_GLYPH: char = '▸';
/// The largest viewport the terminal subsystem will actually project,
/// for a window content rect that may legitimately be larger.
///
/// A panel deliberately does **not** inherit the terminal's per-axis PTY
/// caps (Bet B5'): a 4K surface at a small font is legitimately wider
/// than 512 columns, and `PanelFrame` answers only to the shared area
/// bound. The terminal *screen* keeps its own policy, so without this
/// clamp `snapshot_for_view` refused the panel's content rect, the whole
/// projection collapsed to `None`, and the band went per-frame `Absent`
/// while `panel_hidden` still said "visible" — review round 1's R1-1
/// shape, found again by its own sweep.
///
/// Clamping rather than hiding is the right answer because the band is
/// legitimately that wide: the child occupies the columns a PTY can
/// have, and the remainder paints as band background exactly as a
/// snapshot narrower than its window already does. Rows are shed for the
/// area bound rather than columns, so a wide band keeps its full width.
fn terminal_projection_size(content: CellSize) -> CellSize {
let cols = content
.cols
.min(u32::from(crate::terminal::MAX_TERMINAL_COLS));
let rows = content
.rows
.min(u32::from(crate::terminal::MAX_TERMINAL_ROWS));
let rows_within_area =
u32::try_from(crate::terminal::MAX_TERMINAL_VISIBLE_CELLS / (cols as usize).max(1))
.unwrap_or(u32::MAX);
CellSize::new(rows.min(rows_within_area), cols)
}
/// One painted side window, ready to become a
/// [`pmacs_protocol::panel::PanelFrame`] (bottom-panel Stage 2B-2).
///
/// The producer carries the identity fields as well as the cells because
/// the presentation epoch is allocated from them: `window_id` changes on
/// a new side window and `buffer_id` on a replacement, and either one
/// moving is what makes a stale `PanelPointer` unaddressable (Q#BP16).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PanelProjection {
/// The side window this frame projects.
pub window_id: WindowId,
/// Buffer that window is currently showing.
pub buffer_id: crate::buffer::BufferId,
/// Panel grid dimensions, mode line included.
pub size: CellSize,
/// Row-major cells; exactly `size.area()` entries.
pub cells: Vec<crate::cell::Cell>,
/// Panel caret, or `None` when it is scrolled out of the band.
pub cursor: Option<CellCoord>,
/// Whether the panel currently owns this frontend's focus.
pub focused: bool,
}
/// Shared outer/content geometry consumed by terminal paint and PTY resize.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct WindowPlacement {
@ -3943,44 +4368,65 @@ pub fn paint_frame(
let registry = core.registry.clone();
let reg = registry.borrow();
let aw = &core.windows[&active];
let inner_rows = inner_rows(&active_rect);
let buf = reg.get(aw.buffer_id).ok()?;
// Arc 6 Stage 2 (Q#FD16, round-2 F3): a logical cursor on a hidden
// line renders at its hidden component's head POSITION — the visible
// head row *and* that head's end-of-content column, i.e. exactly
// where Stage 1 moves point on a fold-at-cursor. Row-only clamping
// would leave the column unspecified; resolving through the merged
// component (rather than the innermost containing fold) also keeps a
// crossing overlap from landing on another hidden position.
let folds = crate::fold_view::map_for_window(&state.fold_registry, aw);
let cursor = match folds.as_ref() {
Some(map) => map.visible_position(aw.text_view.line_at_offset(aw.cursor), aw.cursor),
None => aw.cursor,
window_cursor_cell(aw, buf, folds.as_ref(), active_rect)
}
/// Where one window's caret lands in the cell grid, or `None` when it is
/// scrolled out of that window's text area.
///
/// Extracted from `paint_frame`'s tail for bottom-panel Stage 2B-2: the
/// panel band ships its own caret in
/// [`pmacs_protocol::panel::PanelFrame::cursor`], and a second derivation
/// would be the exact shape of Stage 1's `Layout::compute` two-caller
/// defect — one consumer silently reckoning against different geometry.
///
/// Arc 6 Stage 2 (Q#FD16, round-2 F3): a logical cursor on a hidden line
/// renders at its hidden component's head POSITION — the visible head row
/// *and* that head's end-of-content column, i.e. exactly where Stage 1
/// moves point on a fold-at-cursor. Row-only clamping would leave the
/// column unspecified; resolving through the merged component (rather
/// than the innermost containing fold) also keeps a crossing overlap from
/// landing on another hidden position.
fn window_cursor_cell(
window: &crate::window::Window,
buf: &crate::buffer::Buffer,
folds: Option<&crate::fold_view::VisibleLineMap>,
rect: Rect,
) -> Option<CellCoord> {
let inner_rows = inner_rows(&rect);
let cursor = match folds {
Some(map) => map.visible_position(
window.text_view.line_at_offset(window.cursor),
window.cursor,
),
None => window.cursor,
};
let disp = aw.text_view.pos_to_display(buf, cursor)?;
let row_offset = match folds.as_ref() {
let disp = window.text_view.pos_to_display(buf, cursor)?;
let row_offset = match folds {
Some(map) => {
let top = map.clamp_view_top(aw.view_top);
let top = map.clamp_view_top(window.view_top);
let row = disp.row as usize;
if row < top {
return None;
}
map.visible_rows_between(top, row)
}
None => (disp.row as usize).checked_sub(aw.view_top)?,
None => (disp.row as usize).checked_sub(window.view_top)?,
};
if row_offset >= inner_rows as usize {
return None;
}
// UX gutter: the terminal caret sits in the text area, past the
// reserved gutter strip (mirrors the viewport shift above).
// UX gutter: the caret sits in the text area, past the reserved
// gutter strip (mirrors the viewport shift in `paint_window_content`).
let gutter_w = {
let w = aw.gutter_width();
if w >= active_rect.size.cols { 0 } else { w }
let w = window.gutter_width();
if w >= rect.size.cols { 0 } else { w }
};
let grid_row = active_rect.origin.row + u32::try_from(row_offset).ok()?;
let max_col = active_rect.origin.col + active_rect.size.cols.saturating_sub(1);
let grid_col = (active_rect.origin.col + gutter_w + disp.col).min(max_col);
let grid_row = rect.origin.row + u32::try_from(row_offset).ok()?;
let max_col = rect.origin.col + rect.size.cols.saturating_sub(1);
let grid_col = (rect.origin.col + gutter_w + disp.col).min(max_col);
Some(CellCoord::new(grid_row, grid_col))
}

View File

@ -236,6 +236,28 @@ pub struct PanelReconciliation {
pub released_terminal: Option<WindowId>,
}
/// What a frame-geometry declaration did (Q#BP2S1, Stage 2 §3.1).
///
/// Three-valued rather than a boolean because the caller must act
/// differently on each, and collapsing the middle arm is a defect in one
/// direction or the other: folded into `Advanced` it reconciles panel
/// layout on every repeated declaration; folded into `Rejected` it
/// reports a stale-event condition that never happened. A `Duplicate`
/// **is** accepted — which is why a narrower internal boolean would have
/// to be named `advanced`, never `accepted`.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum GeometryUpdate {
/// The epoch advanced and the declaration was stored verbatim. Run
/// panel reconciliation.
Advanced,
/// Same epoch, same total: already current. Do no work.
Duplicate,
/// Same epoch with a different total, a lower epoch, the reserved
/// epoch `0`, an unknown frontend, or allocator exhaustion. Drop the
/// event before any reconciliation.
Rejected,
}
/// Row extent of an arbitrary subtree, derived from its leaves' computed
/// rects: leaves tile their parent, so the union's height is the node's.
fn node_row_extent(node: &LayoutNode, placements: &HashMap<WindowId, crate::window::Rect>) -> u32 {
@ -3240,29 +3262,182 @@ impl EditorCore {
(geometry.total.rows >= 2 && geometry.total.cols > 0).then(|| geometry.total.rows - 1)
}
/// Cache a frontend's authoritative frame capacity (Q#BP2b).
/// A frontend's current authoritative frame-geometry declaration.
///
/// The panel producer echoes `geometry_epoch` into every
/// [`pmacs_protocol::panel::PanelFrame`] it ships, and the daemon
/// compares an inbound panel event's epoch against it (Q#BP16 step
/// 3), so the epoch has to be readable, not only the size.
#[must_use]
pub fn frame_geometry_for(
&self,
fid: FrontendId,
) -> Option<crate::window::DeclaredFrameGeometry> {
self.views.get(&fid)?.frame_geometry
}
/// Cache a frontend's authoritative frame capacity — the **grid /
/// `LOCAL`** allocator (Q#BP2b, Stage 2 §3.1).
///
/// Grid / `LOCAL` views call this from their real attach and resize
/// sizes with an internally minted epoch; a semantic view stays
/// `None` until Stage 2's authenticated declaration. A repeated
/// identical size is not a new declaration.
pub fn declare_frame_geometry(&mut self, fid: FrontendId, total: crate::cell::CellSize) {
/// sizes with an internally minted epoch; a semantic view never takes
/// this path at all — it goes through
/// [`Self::accept_frame_geometry`], which applies the frontend-owned
/// epoch verbatim and does **no** value dedup.
///
/// Value dedup is correct *here* and only here: a grid frontend's
/// cells are the unit it declares, so an unchanged grid under
/// unchanged metrics leaves any existing frame valid. It is wrong on
/// the semantic path, where a font or scale change can invalidate a
/// panel frame while [`crate::cell::CellSize`] is identical — the
/// case daemon-side dedup cannot see (Q#BP2S1).
///
/// **Exhaustion fails closed.** Allocation is checked rather than
/// saturating: `saturating_add` pins at `u64::MAX`, after which two
/// different geometries share one declaration id. On exhaustion the
/// authoritative declaration is *cleared* to `None` (unknown), which
/// is already non-presentable under Q#BP2b, so the caller's
/// reconciliation hides the panel. Retaining the last valid geometry
/// would keep painting a panel sized to a frame that no longer
/// exists.
pub fn declare_frame_geometry(
&mut self,
fid: FrontendId,
total: crate::cell::CellSize,
) -> GeometryUpdate {
let Some(view) = self.views.get_mut(&fid) else {
return;
return GeometryUpdate::Rejected;
};
if view
.frame_geometry
.is_some_and(|geometry| geometry.total == total)
{
return;
return GeometryUpdate::Duplicate;
}
let next = view
.frame_geometry
.map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1));
let next = match view.frame_geometry {
None => Some(1),
Some(geometry) => geometry.geometry_epoch.checked_add(1),
};
let Some(next) = next else {
view.frame_geometry = None;
return GeometryUpdate::Rejected;
};
view.frame_geometry = Some(crate::window::DeclaredFrameGeometry {
geometry_epoch: next,
total,
});
GeometryUpdate::Advanced
}
/// Accept a **semantic** frontend's authoritative geometry
/// declaration (Q#BP15a, Stage 2 §3.1).
///
/// Deliberately a second method rather than
/// [`Self::declare_frame_geometry`] with an optional epoch: the two
/// regimes differ in whether value dedup applies, and one ambiguous
/// entry point would let a future caller silently take the wrong one.
///
/// | Incoming declaration | Result |
/// | --- | --- |
/// | epoch **greater** than stored | [`GeometryUpdate::Advanced`], stored **verbatim**, even when `total` is unchanged |
/// | same epoch, same `total` | [`GeometryUpdate::Duplicate`] |
/// | same epoch, **different** `total` | [`GeometryUpdate::Rejected`] |
/// | **lower** epoch, any `total` | [`GeometryUpdate::Rejected`] |
///
/// The last row is not an optimization: a lower epoch carrying
/// *identical* data is still stale, and accepting it would let a
/// reordered declaration resurrect geometry the frontend has moved
/// past.
///
/// Epoch `0` is reserved for "never declared" and is rejected on the
/// wire.
pub fn accept_frame_geometry(
&mut self,
fid: FrontendId,
geometry_epoch: u64,
total: crate::cell::CellSize,
) -> GeometryUpdate {
if geometry_epoch == 0 {
return GeometryUpdate::Rejected;
}
let Some(view) = self.views.get_mut(&fid) else {
return GeometryUpdate::Rejected;
};
match view.frame_geometry {
Some(stored) if geometry_epoch < stored.geometry_epoch => GeometryUpdate::Rejected,
Some(stored) if geometry_epoch == stored.geometry_epoch => {
if stored.total == total {
GeometryUpdate::Duplicate
} else {
GeometryUpdate::Rejected
}
}
_ => {
view.frame_geometry = Some(crate::window::DeclaredFrameGeometry {
geometry_epoch,
total,
});
GeometryUpdate::Advanced
}
}
}
/// The **third** geometry of Q#BP15a: the panel grid the daemon
/// derives and paints, or `None` when no panel is presentable.
///
/// Columns are the frontend's full declared width. Rows are the
/// stored `fixed_rows` request clamped by Q#BP2's recursive document
/// minimum ([`Self::panel_allocation`]) and then by the shared wire
/// area budget, so a very wide frame cannot produce a frame the
/// protocol would reject. **The stored request is never rewritten**
/// — a later narrower geometry restores it.
///
/// Returns `None` — the Q#BP2b hidden arm — when geometry is unknown,
/// when the panel is hidden or absent, when the frame declares zero
/// columns, or when even [`MIN_WINDOW_OUTER_ROWS`] rows would exceed
/// the area budget.
#[must_use]
pub fn panel_grid_size(&self, fid: FrontendId) -> Option<crate::cell::CellSize> {
if self.views.get(&fid)?.panel_hidden {
return None;
}
self.presentable_panel_grid(fid)
}
/// The panel grid this frontend's layout and geometry **could**
/// present, ignoring the cached `panel_hidden` bit.
///
/// This is the single derivation behind both [`Self::panel_grid_size`]
/// and [`Self::reconcile_panel_layout_core`]'s satisfiability test,
/// and it is one function on purpose (review round 1, R1-1). When the
/// wire-area clamp lived only in the renderer, the daemon shipped an
/// authoritative `Absent` while `panel_hidden` stayed `false` — so
/// keys still reached the invisible window and a panel terminal kept
/// its controller. Q#BP2b is explicit that hiding is a **durable
/// state transition**, never a per-frame effect, and two derivations
/// of "can this panel be shown" is exactly how it became one.
///
/// The area bound is a transport-safety limit rather than a frontend
/// policy, so it is applied uniformly rather than only on the
/// semantic path. It cannot bind for a grid frontend at any physically
/// reachable width — two rows fit until roughly 131,000 columns — so
/// one shared rule costs nothing and removes the drift.
#[must_use]
fn presentable_panel_grid(&self, fid: FrontendId) -> Option<crate::cell::CellSize> {
let view = self.views.get(&fid)?;
self.side_window_for(fid)?;
let geometry = view.frame_geometry?;
let cols = geometry.total.cols;
if cols == 0 {
return None;
}
let area_rows = self.frontend_area_rows(fid)?;
let rows = self.panel_allocation(fid, area_rows)?;
let budget_rows =
u32::try_from(pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS / (cols as usize).max(1))
.unwrap_or(u32::MAX);
let rows = rows.min(budget_rows);
(rows >= MIN_WINDOW_OUTER_ROWS).then(|| crate::cell::CellSize::new(rows, cols))
}
/// Core half of the idempotent panel-reconciliation transaction
@ -3285,13 +3460,14 @@ impl EditorCore {
return result;
};
let was_hidden = self.views.get(&fid).is_some_and(|view| view.panel_hidden);
// Unknown geometry (a semantic view before Stage 2's declaration)
// and a zero-column frame are both non-presentable, and follow the
// hidden arm rather than being sized against a placeholder.
let satisfiable = self
.frontend_area_rows(fid)
.and_then(|rows| self.panel_allocation(fid, rows))
.is_some();
// Unknown geometry (a semantic view before Stage 2's declaration),
// a zero-column frame, a layout that cannot spare the rows, and a
// grid the shared wire budget cannot carry are ALL non-presentable
// and all follow the hidden arm. One derivation, shared with the
// renderer (R1-1): a condition that only the renderer knew about
// produced a blank band with the durable state still saying
// "visible".
let satisfiable = self.presentable_panel_grid(fid).is_some();
let Some(view) = self.views.get_mut(&fid) else {
return result;
};

View File

@ -60,6 +60,21 @@ use std::path::PathBuf;
// directly.
pub use pmacs_protocol::*;
/// Lowest negotiated protocol version that carries the bottom-panel wire
/// family (Q#BP9): [`InstanceMessage::PanelFrame`] daemon→frontend, and
/// `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`
/// frontend→daemon.
///
/// One constant rather than a literal at each gate, because the panel
/// bump gates in **both** directions: the send filter, the producer's
/// peer flag, and the three inbound event gates must move together or one
/// side starts trusting a wire the other never negotiated.
///
/// Distinct from [`ADVERTISED_PROTOCOL_VERSION`], which the production
/// daemon still holds at 20: the v21 schema is reserved, and the
/// compatibility-preserving activation is bottom-panel Stage 2B-3's.
pub const PANEL_MIN_VERSION: u32 = 21;
// ---------------------------------------------------------------------------
// Attachment
// ---------------------------------------------------------------------------

View File

@ -38,14 +38,16 @@ use crate::cell::{CellSize, Style};
use crate::editor::EditorState;
use crate::protocol::{
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, StatuslineSegment, StyleSegment,
StyleSpan,
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, PANEL_MIN_VERSION,
StatuslineSegment, StyleSegment, StyleSpan,
};
use crate::statusline::{
StatuslineEvaluation, StatuslineEvaluationOutcome, StatuslineEvaluationTarget,
evaluate_statusline,
StatuslineWindowSegments, evaluate_statusline,
};
use crate::terminal::TerminalFrame;
use crate::window::WindowId;
use pmacs_protocol::panel::{PanelFrame, PanelFramePayload};
/// The viewport a `semantic_render` frontend last declared.
#[derive(Clone, Debug, Eq, PartialEq)]
@ -317,6 +319,64 @@ pub struct SemanticRenderState {
terminal_error_latched: bool,
/// Whether the most recent render pass projected a terminal.
terminal_active: bool,
/// Whether the peer negotiated protocol v21, which is where
/// [`InstanceMessage::PanelFrame`] was appended (Q#BP9). A v20 peer
/// receives no band at all — and, per Q#BP13, is never *placed* in a
/// side window either, because denying only the message would leave
/// its window invisible.
peer_knows_panel_frames: bool,
/// The panel payload this peer last received, compared in FULL.
///
/// Seeded to `Absent` rather than `None`: a fresh session starts with
/// no band, so the opening state is a fact rather than an absence,
/// and seeding it keeps every session from paying one redundant
/// `Absent` before it ever shows a panel.
///
/// `Absent` is authoritative and duplicate-suppressed like any other
/// payload — hide and close must both send it, because the receiver
/// retains its last valid frame and silence would leave a stale band
/// on screen indefinitely (Q#BP15).
last_panel_payload: Option<PanelFramePayload>,
/// Highest presentation epoch allocated for this session; `0` means
/// none has been. Advanced only when a frame is actually shipped, so
/// a frame that fails validation does not burn an identity the peer
/// never saw.
panel_epoch_used: u64,
/// Identity behind the `Present` in `last_panel_payload`, or `None`
/// when the last payload was `Absent`.
///
/// Cleared by every `Absent`, which is what makes hide/reopen and
/// close/reopen of the **same** persistent buffer allocate a fresh
/// epoch — the hole a `buffer_id` alone cannot close (Q#BP16).
panel_presentation: Option<PanelPresentation>,
/// Whether an invalid panel frame was already reported since the last
/// valid one. Bounds the log exactly like `terminal_error_latched`.
panel_error_latched: bool,
/// The band's last PUBLISHED statusline segments and the side-window
/// presentation they belong to (review rounds 1 and 2, R2-4).
///
/// The band repaints its whole mode line every frame, so
/// "publish nothing" has to be expressed as "paint what was published
/// last" — there is no wire-level suppression to fall back on the way
/// `StatuslineSegments` has. Side affinity can replace a buffer in the
/// same window, so both identities are required to prevent one panel
/// presentation from inheriting its predecessor's provider text.
last_panel_statusline: Option<((WindowId, BufferId), StatuslineWindowSegments)>,
}
/// The presentation identity a shipped [`PanelFrame`] carries.
///
/// `window_id` moves when a new side window is created and `buffer_id`
/// when the panel's buffer is replaced; either one changing allocates a
/// new `panel_epoch`, and that is what stops a stale `PanelPointer` from
/// addressing a reopened panel as if it were the old one (Q#BP16).
/// `WindowId` deliberately stays off the wire — the epoch is the opaque
/// stand-in for it.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
struct PanelPresentation {
window_id: WindowId,
buffer_id: BufferId,
panel_epoch: u64,
}
/// One [`SemanticRenderState::diag_line_cache`] entry: the line-start
@ -413,6 +473,7 @@ impl SemanticRenderState {
s.peer_knows_font_facts = negotiated_protocol_version >= 17;
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION;
s
}
@ -464,9 +525,69 @@ impl SemanticRenderState {
last_terminal_frame: None,
terminal_error_latched: false,
terminal_active: false,
peer_knows_panel_frames: true,
// Q#BP15: a fresh session has no band, and that is a fact the
// peer already holds. Seeding the baseline keeps the first
// frame from shipping a redundant authoritative `Absent`.
last_panel_payload: Some(PanelFramePayload::Absent),
panel_epoch_used: 0,
panel_presentation: None,
panel_error_latched: false,
last_panel_statusline: None,
}
}
/// The `Present` panel declaration this session last shipped.
///
/// The daemon reads it to run steps 3 and 4 of Q#BP16's validation
/// ladder: an inbound `PanelPointer` or `PanelResizeRows` must name
/// the geometry and presentation epochs of the frame the frontend was
/// actually looking at. `None` after an `Absent` — which is precisely
/// how `Absent` "clears input authority".
#[must_use]
pub fn panel_declaration(&self) -> Option<&PanelFrame> {
match self.last_panel_payload.as_ref()? {
PanelFramePayload::Present(frame) => Some(frame),
PanelFramePayload::Absent => None,
}
}
/// Whether the last shipped declaration is a `Present` whose epochs
/// both match an inbound panel event **and** which still describes
/// the side window that is live now (Q#BP16 steps 24).
///
/// Rolled into one predicate so no caller can check the geometry
/// epoch and forget the presentation epoch: they close different
/// holes and neither subsumes the other.
///
/// `live_presentation` is the frontend's **current** side window and
/// its buffer. Comparing it is what closes review round 1's R1-2:
/// closing and reopening the same *persistent* buffer inside one
/// dispatcher burst leaves the shipped declaration intact while the
/// window it describes is already dead, and same-buffer/same-size
/// makes the successor indistinguishable by every other field. A
/// presentation epoch only identifies a presentation if something
/// checks that the presentation it names is still the one on screen.
///
/// `None` means "no side window right now", which never matches — an
/// event cannot address a panel that does not exist.
#[must_use]
pub fn panel_declaration_matches(
&self,
geometry_epoch: u64,
panel_epoch: u64,
live_presentation: Option<(WindowId, BufferId)>,
) -> bool {
let Some((window_id, buffer_id)) = live_presentation else {
return false;
};
self.panel_presentation.is_some_and(|presentation| {
presentation.window_id == window_id && presentation.buffer_id == buffer_id
}) && self.panel_declaration().is_some_and(|frame| {
frame.geometry_epoch == geometry_epoch && frame.panel_epoch == panel_epoch
})
}
/// Record the frontend's declared on-screen byte range. Called by
/// the dispatcher when it receives
/// [`crate::protocol::FrontendEvent::Viewport`]. Replaces any
@ -616,8 +737,15 @@ impl SemanticRenderState {
return messages;
}
let Some(vp) = self.viewport.clone() else {
// Emit nothing before the frontend declares a viewport.
return Vec::new();
// Emit nothing document-scoped before the frontend declares a
// viewport — but the band is a SEPARATE surface (Q#BP15a),
// and gating it on the document declaration would make the
// first panel unpaintable on a frontend that has not declared
// one yet. Its statusline is `None` here for the same reason:
// the semantic fan-out is keyed on the declared buffer.
let mut out = Vec::new();
self.emit_panel_frame(state, None, &mut out);
return out;
};
// Evaluate callbacks before any long-lived core borrow and before
@ -825,9 +953,17 @@ impl SemanticRenderState {
out.extend(self.theme_facts_msg(state));
out.extend(self.font_facts_msg(state));
// Q#SL6/Q#SL8: face inventory must precede segment text.
// Parent acceptance 45: ONE provider invocation supplies both the
// primary-document wire segments and the panel mode line, so the
// side half is taken from this same evaluation before it is
// consumed.
let panel_presentation = self.panel_statusline_presentation(state);
let panel_statusline =
self.panel_statusline(statusline_evaluation.as_ref(), panel_presentation);
if let Some(evaluation) = statusline_evaluation {
self.emit_statusline_segments(evaluation, statusline_document_window, &mut out);
}
self.emit_panel_frame(state, panel_statusline.as_ref(), &mut out);
out
}
@ -965,9 +1101,17 @@ impl SemanticRenderState {
out.extend(self.theme_facts_msg(state));
out.extend(self.font_facts_msg(state));
// Q#SL6/Q#SL8: face inventory must precede segment text.
// The band rides the terminal path too: a frontend whose DOCUMENT
// surface is a full-window terminal can still hold a side window,
// and suppressing the panel here would leave the peer's retained
// band on screen with no way to clear it.
let panel_presentation = self.panel_statusline_presentation(state);
let panel_statusline =
self.panel_statusline(statusline_evaluation.as_ref(), panel_presentation);
if let Some(evaluation) = statusline_evaluation {
self.emit_statusline_segments(evaluation, statusline_document_window, &mut out);
}
self.emit_panel_frame(state, panel_statusline.as_ref(), &mut out);
out
}
@ -1033,6 +1177,231 @@ impl SemanticRenderState {
}
}
/// The side window's evaluated segments from **this frame's** single
/// provider invocation (parent acceptance 45).
///
/// Selected by window identity, exactly like the document half: the
/// fan-out yields the primary document *and* the visible side window,
/// and taking "some context for my frontend" would depend on capture
/// order and could paint the document's status text into the panel's
/// mode line.
///
/// The three outcomes are **not** interchangeable, and review round 1
/// (R2-4) found two of them collapsed:
///
/// * `Ready` is authoritative — including an empty result. It
/// replaces the retained baseline.
/// * `Invalidated` discards all evaluated text: a callback mutated
/// registry, layout, or focus mid-evaluation, so the band clears to
/// its plain mode line and the baseline dies with it.
/// * `NoMessage` means **publish nothing**. Phase 1 was already stale
/// — most reachably a buffer-follow mismatch, where the primary
/// document window has moved off the buffer the frontend declared.
/// The band therefore keeps what it last published. Treating this
/// like `Invalidated` *removes* provider text on a transient
/// condition that said nothing about it.
///
/// The baseline is keyed by both window and buffer identity. Side
/// affinity deliberately replaces the buffer in an existing side
/// window, and that replacement is a new presentation even though the
/// `WindowId` is stable.
fn panel_statusline_presentation(&self, state: &EditorState) -> Option<(WindowId, BufferId)> {
let core = state.core.borrow();
let window_id = core.side_window_for(self.frontend_id)?;
let buffer_id = core.windows.get(&window_id)?.buffer_id;
Some((window_id, buffer_id))
}
fn panel_statusline(
&mut self,
evaluation: Option<&StatuslineEvaluation>,
panel_presentation: Option<(WindowId, BufferId)>,
) -> Option<StatuslineWindowSegments> {
let Some((side_window, side_buffer)) = panel_presentation else {
// No band to publish for; drop any baseline so a later panel
// cannot inherit a dead window's text.
self.last_panel_statusline = None;
return None;
};
let retained = |state: &Self| {
state
.last_panel_statusline
.as_ref()
.filter(|(presentation, _)| *presentation == (side_window, side_buffer))
.map(|(_, segments)| segments.clone())
};
let Some(evaluation) = evaluation else {
// No evaluation ran at all (an unsupported peer, or a frame
// before the document viewport exists). Nothing was
// published, so nothing is retracted.
return retained(self);
};
match &evaluation.outcome {
StatuslineEvaluationOutcome::Ready(windows) => {
let found = windows
.iter()
.find(|window| {
window.context.frontend_id == self.frontend_id
&& window.context.window_id == side_window
&& window.context.buffer_id == side_buffer
})
.cloned();
self.last_panel_statusline = found
.clone()
.map(|segments| ((side_window, side_buffer), segments));
found
}
StatuslineEvaluationOutcome::Invalidated { .. } => {
self.last_panel_statusline = None;
None
}
StatuslineEvaluationOutcome::NoMessage(_) => retained(self),
}
}
/// Project this frontend's side window as an
/// [`InstanceMessage::PanelFrame`] (Q#BP15).
///
/// Runs on every frame of a panel-capable v21 semantic session,
/// independently of the document byte viewport: the band is a
/// separate surface, and gating it on a declared viewport would leave
/// the first panel unpaintable on a frontend that has not yet
/// declared one.
///
/// Not reset by [`Self::on_buffer_snapshot_sent`]: a `BufferSnapshot`
/// resets *document* mirror state, and the band is neither
/// buffer-scoped to the document nor rebuilt from it.
fn emit_panel_frame(
&mut self,
state: &EditorState,
statusline: Option<&StatuslineWindowSegments>,
out: &mut Vec<InstanceMessage>,
) {
// Q#BP13: capability, not merely wire version. A session that
// cannot render a band must not be shipped one — and, on the
// production path, is never placed in a side window either.
if !self.peer_knows_panel_frames || !state.core.borrow().panel_capable_for(self.frontend_id)
{
return;
}
// Q#BP15a: `Present` echoes the daemon's latest ACCEPTED geometry
// declaration. Read before painting so the frame cannot answer a
// declaration that arrived mid-projection.
let geometry = state.core.borrow().frame_geometry_for(self.frontend_id);
let projection =
geometry.and_then(|_| state.prepare_panel_projection(self.frontend_id, statusline));
let (Some(geometry), Some(projection)) = (geometry, projection) else {
self.publish_absent_panel(out);
return;
};
let identity = (projection.window_id, projection.buffer_id);
let panel_epoch = match self.panel_presentation {
Some(presentation) if (presentation.window_id, presentation.buffer_id) == identity => {
Some(presentation.panel_epoch)
}
// A new side window, a replaced buffer, or any `Absent` →
// `Present` transition (which cleared `panel_presentation`)
// takes a fresh identity.
_ => self.panel_epoch_used.checked_add(1),
};
let Some(panel_epoch) = panel_epoch else {
// Q#BP15: allocation is checked and exhaustion fails closed
// to `Absent`. Wrapping would let a new panel inherit a live
// identity and accept gestures aimed at its predecessor.
//
// **The one knowingly per-frame `Absent` left in this file**,
// and it is recorded rather than fixed. Review round 1's R1-1
// established that a band cleared on the wire must also move
// the durable `panel_hidden` state, or keys keep reaching an
// invisible window; this arm cannot, because the producer
// holds session state and `panel_hidden` is recomputed by
// core reconciliation from geometry alone. Making it durable
// needs a new "presentation permanently unavailable" reason
// in `FrontendView`, which is machinery for a state that
// takes 2^64 shipped presentation changes in ONE session to
// reach — unlike the wire-area exhaustion in
// `presentable_panel_grid`, which any frontend can trigger
// with one declaration. If the epoch ever becomes
// frontend-supplied, this stops being unreachable and needs
// the durable arm.
self.publish_absent_panel(out);
return;
};
let payload = PanelFramePayload::Present(PanelFrame {
buffer_id: projection.buffer_id,
panel_epoch,
geometry_epoch: geometry.geometry_epoch,
size: projection.size,
cells: projection.cells,
cursor: projection.cursor,
focused: projection.focused,
});
// Complete-payload comparison FIRST, like the terminal pass: only
// validated payloads are ever stored, so a payload equal to the
// baseline has already passed and re-running the per-cell width
// and topology checks would recompute a verdict we hold.
if self.last_panel_payload.as_ref() == Some(&payload) {
self.panel_error_latched = false;
return;
}
let PanelFramePayload::Present(frame) = &payload else {
unreachable!("the Present payload was constructed immediately above");
};
match frame.validate() {
Ok(()) => {
self.panel_error_latched = false;
self.panel_epoch_used = self.panel_epoch_used.max(panel_epoch);
self.panel_presentation = Some(PanelPresentation {
window_id: projection.window_id,
buffer_id: projection.buffer_id,
panel_epoch,
});
self.last_panel_payload = Some(payload.clone());
out.push(InstanceMessage::PanelFrame(payload));
}
Err(error) => {
// Atomic rejection: the peer keeps its last valid frame,
// this session keeps the presentation identity behind it,
// and one bounded log line marks the condition. Advancing
// `panel_epoch_used` here would burn an identity the peer
// never saw.
if !self.panel_error_latched {
self.panel_error_latched = true;
eprintln!(
"pmacs: panel frame for {:?} on {:?} failed validation, \
retaining the last valid frame: {error}",
projection.buffer_id, self.frontend_id
);
}
}
}
}
/// Publish the authoritative `Absent` for every non-presentable state
/// (Q#BP15, Q#BP2b).
///
/// Clears the declared presentation on this side before any later
/// event can validate against it — that is what "`Absent` clears
/// input authority" means. The whole-frame geometry declaration
/// deliberately survives: it is answered by the frontend, not by the
/// panel's presence.
fn publish_absent_panel(&mut self, out: &mut Vec<InstanceMessage>) {
self.panel_presentation = None;
// `Absent` also clears the peer's retained mode line. A later
// `Present` under `NoMessage` therefore has nothing it can
// legitimately retain, even if the same window and buffer reopen.
self.last_panel_statusline = None;
if self.last_panel_payload.as_ref() == Some(&PanelFramePayload::Absent) {
// A duplicate `Absent` does no wire work — but both clears
// above still run, so the state stays idempotent rather than
// depending on which duplicate arrived first.
return;
}
self.panel_error_latched = false;
self.last_panel_payload = Some(PanelFramePayload::Absent);
out.push(InstanceMessage::PanelFrame(PanelFramePayload::Absent));
}
fn emit_statusline_payload(
&mut self,
buffer_id: BufferId,

File diff suppressed because it is too large Load Diff