PR #228 review found a correctness gap this lane made reachable. The
GPU dropdown derives its height, its visible window and its
selection-highlight offset from `rows.len()` — ONE logical row per
candidate — while a detail carrying a line break shapes into more
physical lines than that. One such row misaligns every row below it
and the highlight with it. The grid TUI has the same exposure from the
other side: it writes the description into a single-row suffix on the
minibuffer band.
## Why not reject CR/LF at registration
That was the obvious fix. It was implemented, measured, and abandoned
on evidence.
MCP tool registration renders a whole schema block into
`Command.description` — tool text, blank line, `Arguments:`, then one
line per argument (`tests/fixtures/pmacs-mcp-tools/init.lua:272`, a
`table.concat(lines, "\n")`, used at `:496`). And
`tests/m9_6_acceptance.rs:583-598` ASSERTS four of those lines. A
one-line guard in `CommandRegistry::define` fails 36 tests across
`m9_6` (19/25), `m9_7` (16/19) and `m9_8` (1/17), in both feature
configurations, and could only be made green by deleting a shipped
acceptance criterion.
So the one-line constraint goes where the constraint actually is: the
surfaces that have one row. `Command.description` stays free-form,
which it legitimately is.
## The change
`Command::description_first_line` clips to the first CR **or** LF — a
lone CR ends a line too, and an LF-only clip would pass a bare `\r`
straight through to the same surface. Both single-row consumers call
it: the semantic producer filling `MinibufferRow.detail`
(`src/semantic_render.rs`) and the TUI suffix (`src/editor.rs`). A
first line that is empty ships as `None` rather than `Some("")`, which
would draw trailing padding.
No ellipsis or truncation marker, matching the in-tree precedent and
the minibuffer's own width rule.
`describe-command` and `help.list-commands` are untouched and still
report every line. That is what makes this a rendering decision rather
than data loss, and it is asserted, not assumed.
## Precedent, already in this tree
The same MCP fixture clips a tool RESULT to its first line because
"a multi-line set_status would corrupt the row layout"
(`init.lua:277-285`), leaving width clipping to the frontend. Same
hazard class, same resolution.
## Verification
`src/command.rs`: a schema block registers AND clips, in all three
break forms; a single-line description is byte-identical after the
clip; an empty first line clips to empty.
`tests/discovery_stage2_acceptance.rs`: an MCP-shaped description
reaches the TUI band and the GPU row as one line, through the real
prompt path — with the full text still reachable via
`describe-command` asserted alongside, so a clip that deleted the
schema block everywhere would fail rather than pass.
`pmacs-gpu`: one physical shaped line per logical candidate row — the
geometry invariant the dropdown depends on.
Mutation-checked: neutering `first_line` to the identity fails all
four new break-handling tests
(`a_multi_line_description_registers_and_clips_to_its_first_line`,
`a_description_whose_first_line_is_empty_clips_to_empty`,
`a_multi_line_description_reaches_the_tui_band_as_one_line`,
`a_multi_line_description_reaches_the_gpu_row_as_one_physical_line`)
and leaves the two "did not tighten past purpose" tests green.
`Command.description`'s doc comment claimed "one-line", which the MCP
path openly violates. It now states the real contract and records why
a registration guard must not be re-proposed.
`m9_6`/`m9_7`/`m9_8` pass COMPLETELY UNTOUCHED, and are now named
gate suites so that stays on the record.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
`Command.description` has always been required and has always been
rendered by `help.list-commands`. It was missing at the one moment it
would change a decision: the M-x row. This carries it there.
COHERENCE.md §5's clause "M-x rows are still bare names", per
docs/discovery-stage2-framing.md revision 3.
## The wire half is additive, and the old variant is FROZEN
postcard is not self-describing: enum variants encode by index and
fields by position. Widening `MinibufferPrompt.candidates` in place
would make every v12–v22 peer MIS-DECODE the bytes rather than ignore
them — and gating the widened form at `>= 23` would not rescue them
either, because with only one variant to gate they would receive no
minibuffer message at all. Compatibility requires the old shape to
still exist AND still be sent.
So `MinibufferPrompt` is retained unchanged for `12..=22`, and
`MinibufferPromptRows { prompt, input, cursor, rows, selected, total }`
is APPENDED as the final variant, carrying `MinibufferRow { label,
detail: Option<String> }`. A new row type, not `CompletionPopupRow`,
whose `kind` is an LSP `CompletionItemKind` code with no honest value
for a command (Q#D2-1).
Exactly one of the two reaches any peer, ever. The producer selects on
the session's negotiated version, so the CLOSE necessarily uses the
same family as the OPEN — a rows session closed by a legacy clear
leaves the dropdown on screen forever. The daemon's write loop gates
both directions again, with the legacy gate written as a RANGE
(`12..MINIBUFFER_ROWS_MIN_VERSION`) rather than a floor, so a v23 peer
cannot receive both and double-render.
`ADVERTISED_PROTOCOL_VERSION` stays 20, untouched.
## The TUI half involves no wire at all
`src/editor.rs` contains zero references to `MinibufferPrompt`:
`paint_minibuffer` reads `core.minibuffer` directly. So it reads
`Command.description` from the registry in-process, which is why this
half is independent of the bump.
Clipping is three ORDERED steps (§3.4), and the guarantee is "never a
PARTIAL name", not "the name always survives" — the prompt and typed
input consume the budget first, so the remainder can be too small even
for the bare name. If the whole name does not fit, the suffix is
omitted entirely; only once it fits is a description attempted; a
description that does not fit whole is dropped, leaving today's
`[name]`. No ellipsis stub, and no prefix of a name is ever emitted.
## Verification
`src/protocol.rs` gains this repo's FIRST literal postcard byte
fixtures: `minibuffer_prompt_v12_wire_bytes_are_frozen`, open and
cleared. A round-trip freezes nothing — it encodes and decodes with
the same types, so a field addition leaves it passing while every
shipped peer breaks. Bite-verified: reordering two fields of
`MinibufferPrompt` leaves `minibuffer_prompt_round_trips_through_postcard`
green and fails the fixture.
`line_wrap_facts_encoding_is_unchanged_by_the_v23_build` pins the
PREVIOUS final variant, per the handoff §4 rule that an appended
variant's own round-trip cannot detect a discriminant shift.
`tests/discovery_stage2_acceptance.rs` runs ONE daemon serving a v22
and a v23 session simultaneously, through the real M-x key path, and
asserts each receives its own variant AND ONLY its own — open and
close alike — by collecting every minibuffer message rather than
filtering for the expected one.
No cross-version cache test, deliberately (§3.2):
`SemanticRenderState::for_peer` bakes the negotiated version in at
attach and is dropped at detach, so a cache cannot span two versions.
A test for an impossible condition passes forever while teaching the
next reader that the hazard is real.
Five version assertions updated, each read before editing:
`src/protocol.rs` (the `PROTOCOL_VERSION` tripwire, renamed; and the
v6-floor ladder's accepted/rejected ranges),
`tests/statusline_segments_acceptance.rs`,
`tests/bottom_panel_stage2b_gpu_acceptance.rs`,
`tests/vterm_stage3_acceptance.rs`. No `ADVERTISED_PROTOCOL_VERSION`
assertion fired.
Gates: `scripts/gate --protocol --acceptance discovery_stage2_acceptance`
— all ten green, including the strengthened two-configuration sweep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: frame QoL Stage 5, GPU horizontal scroll
Stage 4 merged as #222, so the lane advances to its last stage. Rule 4
still does not apply — the arc closes when Stage 5 merges, not before.
THE FRAMING'S FIRST FINDING CORRECTS STAGE 4'S. §1.3 there said the GPU
"needs a mechanism that does not exist", named it the fact most likely
to invert the cost estimate, and I endorsed the Stage 4/5 split partly
on that basis.
Half of it holds: `Scroll::horizontal` really is discarded throughout,
because glyphon 0.11 never applies it when placing glyphs — three
doc sites and three asserting tests. But that is not the only
mechanism. The document `TextArea` already carries an explicit `left`
origin and a `TextBounds` clip whose `left` is `gutter_clip_left`, and
horizontal scroll is `left: text_left - offset_px` with the clip
unchanged. glyphon then drops what falls left of the gutter — the same
"paint from column 0, clip at the edge" shape the grid renderer uses,
expressed in pixels. It is machinery the file already depends on, not
new machinery.
The split stays right for the reason that survives: the three consumers
Stage 4 named — caret (`code_byte_px`), decoration geometry
(`push_glyph_extent_rects`), hit testing (`gutter_aware_rel_x`) — each
produce x relative to `text_left()` and each need the same offset,
applied ONCE or they disagree. Shipping that inside Stage 4 would have
made one reviewable change into two unreviewable ones. But it was
justified partly by an overstatement, and saying so is cheaper than
letting a future reader inherit it.
No wire, no version bump: the GPU owns its viewport locally, exactly as
it owns `scroll_top` and `code_scroll_residual`. The parallel with
`ui.line-wrap` is misleading and the doc says why — the MODE is buffer
state and needed v22, the OFFSET is viewport state and needs nothing.
Five questions, each with my vote. Q#G3 is the one I am least sure of:
the GPU can resolve a proportional family, where "column" has no fixed
pixel width, so column-for-column parity with the TUI is unachievable.
I lean to defining the behavior in pixels and accepting imprecise
correspondence rather than gating a navigation feature on a font
choice — but that is a product call.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: Stage 5 revision 2 — a clip, not just an offset
Two functional findings and two record repairs.
Q#G3 WAS BUILT ON A FALSE PREMISE, and the correction makes the lane
stricter rather than looser. Revision 1 said the GPU can resolve a
proportional family and proposed accepting a new TUI/GPU divergence to
accommodate it. It cannot: `family_is_monospace_everywhere` gates the
family across all four weight/style combinations,
`apply_font_facts` falls back when that fails, and
`unresolvable_and_proportional_families_fall_back` REQUIRES the
fallback. Answered as monospace-only by the font contract that already
exists — and the consequence is that the TUI-parity witness becomes
UNCONDITIONAL for every font the GPU supports. Revision 1 would have
introduced a font-dependent behavior difference to solve a problem the
codebase had already solved, in the lane whose purpose is removing
unchosen divergence.
"THREE CONSUMERS" WAS INCOMPLETE IN A WAY THAT WOULD HAVE SHIPPED A
DEFECT. Shifting the `TextArea` clips glyphon's text because glyphon
honors `TextBounds`. The manual quad and squiggle renderers have no
code-area scissor at all — nothing stops them painting into the gutter,
and today nothing needs to, because no code-relative x can be negative.
Scrolling makes that false.
So the framing now requires TWO shared things: one screen↔code
transform, and one code clip rectangle every code-relative painter
intersects with. The paths are tabulated with sites — caret rect
(`:9698`), caret-painted predicate (`:9734`), glyph extent rects
(`:9766`), inline math origins (`:9434`), completion anchor (`:7606`).
The two caret sites are the sharpest, and one of them falsifies a claim
revision 1 made: `:9734` has no left-edge test, so "the scroll
indicator inherits the fix" was false — `code_byte_painted` reuses it
and would call an off-left byte painted. And `:9698` does not merely
lack a check, it DOCUMENTS the absence as safe ("the caret x can't
precede `text_left`"). A comment asserting an invariant this lane
deletes is worse than silence.
Q#G2: "inert under wrap" was too weak. The offset must be RESET to zero
on the wrap transition, as the TUI already does — `horizontal_follow`
assigns `view_left = 0` on the wrap branch. Inertness hides a stale
value that reappears the moment the buffer toggles back to `truncate`,
before any cursor motion. G5 gains a witness that an inertness-only
implementation fails.
RECORDS. Rule 4's Stage-5 removal precondition was not actually met:
the handoff still described Stage 4 as upcoming work. Stage 4's durable
facts are now transferred — the unsnapped per-window column with a
per-line effective edge, the line-absolute walk, the three-way cell
designation, `Viewport::visible_cols` and its five adopters, the
wrap-branch reset, the `#[serde(default)]` persistence, and the absence
of any wire. The ledger's "Stage 4 ahead" / "Stage 4 plan" text is
corrected to Stage 5, and its Rule 4 note now says the removal is
legitimate BECAUSE those bullets exist.
And the journey-step claim is withdrawn. Revision 1 said this lane
completes journey step 4; step 4 is scored on welcome/help/tutorial
discoverability and COHERENCE.md:395 holds it Partial for reasons this
lane does not touch (`C-h` deletes a word, no tutorial). Restated as
preserving interface comprehension with no scorecard movement. §16 is
the direct target. Writing an unearned mark into a scorecard is how a
coherence document stops being ground truth.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: Stage 5 revision 3 — four corrections, one of them impossible
Q#G1 CONTRADICTED THE Q#G3 ANSWER IN THE SAME DOCUMENT. It still said
the GPU's font "need not be monospace" and that Q#G3 makes "column"
ill-defined — both falsified by the answer two sections below, in the
same revision that wrote it. The pixel-storage vote is unchanged, but
its reasons narrow to the ones that survive, and the conversion is now
stated as EXACT: columns × the supported monospace advance. That is
what makes the unconditional parity witness checkable at all.
Also removed `follow_cursor`, which I invented. The GPU's pass is
`ensure_caret_painted`, and it is now named rather than cited by line —
robust against the transposition that put these two sites at each
other's line numbers in review.
Q#G2 WAS MISSING THE BUFFER-SNAPSHOT RESET. The GPU zeroes `scroll_top`
and `code_scroll_residual` when a snapshot installs a new buffer; the
horizontal offset must reset there for the same reason. Without it a
buffer switch INHERITS the previous document's leftward viewport,
showing the new buffer scrolled sideways until a cursor motion repairs
it — a worse symptom than the wrap case, because nothing about the new
buffer explains it.
THE GUTTER ASSERTION WAS IMPOSSIBLE, not merely imprecise. Revision 2
proposed asserting that nothing paints left of `gutter_clip_left`. With
line numbers on, the gutter DELIBERATELY holds digit glyphs and
diagnostic-sign quads, so that assertion fails on a correct
implementation — a test that can only be satisfied by removing the
gutter. Replaced with the checkable form of the same intent: the gutter
rectangle is byte-identical before and after a horizontal scroll, and
the left-edge rule is checked against code-relative geometry only. It
still catches a code painter bleeding into the gutter, because that
changes those pixels.
THE COMPLETION ANCHOR HIDES, IT DOES NOT CLOSE. `completion_anchor_px`
already returns `None` when the anchor scrolls out, so nothing draws
while the daemon-owned completion state and its key handling are
retained; actual closure is `CompletionPopup { anchor: None }`, which
is the daemon's to send. Revision 2 said "closes", which would have had
a viewport-geometry lane quietly redefining when a completion ends.
Specified as: no completion paint while the anchor is off-left, popup
reappears when it scrolls back, session semantics unchanged.
Ledger drift fixed: it still called the framing revision 1 with five
questions open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: Stage 5 revision 4 — witnesses for the two rules that had none
Both additions cover requirements the framing had already stated and
then left untested, which is how a rule becomes a comment.
THE SNAPSHOT RESET (Q#G2). Revision 3 added the buffer-snapshot reset
and tested only the wrap one. The witness now scrolls buffer A to a
non-zero offset, installs a buffer B snapshot, and asserts the offset
is zero and B renders at its code origin BEFORE any `CursorByte`
arrives.
The pre-cursor scoping is the entire test. A later cursor motion
repairs the offset regardless, so a witness that waits for one cannot
distinguish "reset on snapshot" from "repaired on first motion" — and
the second is the defect. Same shape as the wrap witness, which is also
scoped to before any motion, and for the same reason.
THE MINIMAP (Q#G4). The vote is "no movement", and the implementation
already supports it: the minimap derives from the summary, the surface
dimensions and `scroll_top`, with no horizontal input. So the witness
pins an existing property rather than requesting work — which is
exactly why it is worth writing. An offset threaded one seam too far
would break it silently, and nothing else in G5 would notice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: Stage 5 approved, five questions resolved
G1 pixels with exact conversion via the supported monospace advance; G2
automatic cursor-follow only, zeroing on both the wrap transition and
BufferSnapshot; G3 monospace-only by the existing font contract; G4
minimap unchanged; G5 accepted whole, including the snapshot-reset and
minimap-stability witnesses.
The scope boundary is restated in both documents because it is what
keeps this lane small: local GPU viewport state, no wire message, no
protocol bump, no command surface, no minimap movement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* feat(gpu): horizontal scroll — the transform, the clip, and both resets
Stage 5, partial. The mechanism and lifecycle are in; two consumers and
the whole verification set are NOT yet done — see the tail of this
message, which is a status, not a summary.
WHAT IS IN.
The offset, `code_scroll_left`, in pixels (Q#G1). Column parity stays
exact because the code font is monospace by contract, so
`columns × advance` is a definition rather than an approximation.
Local viewport state: no wire, no version bump.
One screen↔code transform (`code_x_to_screen` / `screen_x_to_code`) and
one code clip (`code_clip_left` / `survives_code_clip_left`), which is
the pair framing §1.1 requires. Written before any consumer moved,
because five sites deriving the same offset independently is how the
caret and the glyphs it sits among come to disagree.
The glyph-side mechanism is one line: the document `TextArea`'s `left`
shifts while its `bounds.left` stays at the gutter, so glyphon clips
and the gutter keeps its own pixels.
BOTH LIFECYCLE RESETS (Q#G2), which were the two rules most likely to
be left as comments. The wrap transition zeroes the offset in
`apply_line_wrap` — inertness would park a stale value that reappears
the instant the buffer toggles back to `truncate`. The buffer snapshot
zeroes it beside `scroll_top` and `code_scroll_residual`, or a buffer
switch inherits the previous document's leftward viewport and shows the
new buffer scrolled sideways until a cursor motion repairs it.
`code_caret_rect_in_clip` gains its left-edge test, and its comment is
REWRITTEN rather than extended: it used to assert "the caret x can't
precede `text_left`", an invariant this stage deletes. A comment
asserting something a later stage falsifies is worse than silence. That
also repairs `code_byte_painted`, which reuses it — revision 1's claim
that the scroll indicator "inherits the fix" was false precisely here.
`gutter_aware_rel_x` is now the exact inverse of the transform, with
the gutter clamp applied in screen space first: a click in the gutter
band means "the first visible column", which after scrolling is the
offset, not column 0.
The completion anchor HIDES when scrolled off-left and does not close —
the daemon owns completion state and its key handling, and closure is
`CompletionPopup { anchor: None }`, which is the daemon's to send.
`horizontal_follow` mirrors the TUI's: automatic only, scroll just far
enough, so a caret already visible never moves the view. It runs after
`normalize_code_scroll` because it reads the caret's laid-out x, which
vertical normalization can change.
WHAT IS NOT IN, and must land before this is reviewable:
- `push_glyph_extent_rects` — washes, squiggles and selection extents
still paint at unshifted x and are not cropped at the gutter.
- Inline math origins (`:9434`) — same.
- Every Q#G5 witness. The 228 existing GPU tests pass, which says
only that nothing regressed at offset 0; not one of them exercises
a non-zero offset.
Gates so far: fmt; clippy --workspace --all-targets -D warnings;
PMACS_REQUIRE_GPU=1 -p pmacs-gpu 228/0; git diff --check. The full
two-configuration sweep is deliberately not claimed — the lane is not
finished.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* feat(gpu): the last two painters move, and twelve witnesses say so
Completes Stage 5. `62fb93e` landed the transform, the clip and both
resets but left two code-relative painters at unshifted x and the whole
Q#G5 witness set unwritten; its 228 green tests said only that nothing
regressed at offset 0.
The two painters:
- `push_glyph_extent_rects` — selection/search washes, peer presence
and diagnostic squiggles. Shifted through `code_x_to_screen`, then
CROPPED at the gutter rather than dropped: a selection running in
from off the left edge must paint the part that is visible. That is
the same boundary Stage 4's review caught the TUI painter getting
wrong, and it would have been easy to reproduce here.
- Inline math. The glyph mini-buffers only needed their origin moved —
their layer already carries the code area's `TextBounds`. The
fraction rules are quads in the background batch with no scissor of
their own, so those are cropped by hand.
`crop_to_code_clip_left` is the crop, and `survives_code_clip_left` now
delegates to it, so a caret the crop would discard is never painted.
One boundary rule, not two that agree today.
TWELVE WITNESSES, EACH MUTATION-TESTED. Eleven production mutations —
unshifted wash x, uncropped wash, unshifted math origin, uncropped math
rule, untested caret left edge, missing snapshot reset, missing wrap
reset, unhidden completion anchor, unscrolled glyphs, inverted hit-test
sign, pixel-instead-of-column snap — each fail the intended witness as
an ASSERTION failure, not a compile error. The minimap-stability
witness was mutation-tested separately by threading the offset into
`minimap_vertex_bytes`.
That battery earned its keep immediately. The gutter byte-identity
test's "the code area must actually have moved" assertion is satisfied
by a decoration wash and the caret alone, so it PASSED with
`TextArea.left` pinned to `text_left` — the entire glyph-side mechanism
was unwitnessed and nothing in review would have shown it. Its
replacement isolates the glyph layer: no decorations, and a source line
carrying no caret, whose band is blank at offset 0 and inked after.
ONE DELIBERATE STEP OUTSIDE THE APPROVED SCOPE, and it needs a ruling.
Q#G5 asks for frontend agreement that is "checkable rather than
asserted". Two tests in two crates asserting the same literal is not
that; it is the structural duplication `pmacs-protocol::scroll`'s own
module docs condemn, and that module exists because THIS ARC already
shipped that defect — the scroll indicator, fixed in one copy and left
wrong in the other. So the follow rule moved to
`pmacs_protocol:📜:follow_left`, beside `classify`, and both
frontends call it: `src/editor.rs::horizontal_follow` delegates, and the
GPU converts px <-> columns around it, exact by Q#G3.
The cost is that Stage 5 now touches `src/editor.rs`, which "local GPU
viewport state" does not cover. No wire message and no version bump —
the same argument `classify` already makes. If rejected, reverting is
small: restore the four-line conditional, drop `follow_left` and its
four protocol tests, rewrite the parity witness as a two-sided pin.
GATES, both configurations, five ambient roots isolated: fmt; clippy
`--workspace --all-targets -D warnings`; `--lib` 1920 and `--lib
--features crdt` 2105; horizontal_scroll 11, long_line_readable 3,
line_wrap 6, full_grid_resync 1; `PMACS_REQUIRE_GPU=1 -p pmacs-gpu`
239; `-p pmacs-protocol --lib` 29; both full workspace sweeps;
`git diff --check`.
TWO SWEEP FAILURES, NEITHER THIS LANE'S, both logged:
- R8, new row: `flat_listview_consumers_render_byte_identically...`
fails DETERMINISTICALLY, and the merge-base control is done — it
fails identically on `main`. The row renders with a leading
directory stripped; it is a prefix strip, not width truncation, and
the mechanism is NOT diagnosed. Deliberately not fixed here.
- U3: the R7 selector failed once and passed on rerun. Recorded as a
new incident, NOT an R7 match — different flavor, and its fragments
are unverified because I filtered the sweep output before reading
it. U2 records me making that exact mistake already. Sweeps go to a
file from now on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* fix(gpu): the completion anchor is a point, and the witness now says where
Review round 1. One defect, and a lesson about the witnesses that
missed it.
THE DEFECT. `completion_anchor_px` reused `survives_code_clip_left` and
passed `line_height` as the horizontal extent — a VERTICAL dimension
standing in for a horizontal one. The predicate is
`screen_x + w > code_clip_left()`, so an anchor up to a whole line
height left of the gutter "survived". `completion_dropdown_rect` bounds
`ax` against the right margin only, so that x reached the popup's left
edge and painted over the line numbers.
An anchor is a position between glyphs. It has no width, and the popup
it places is drawn to its right. So the predicate is a point:
`screen_x < code_clip_left()`.
The absent left clamp downstream stays absent, deliberately. This
predicate is what guarantees `ax >= code_clip_left()`; a second clamp
would be a duplicate of the same rule, which is the failure mode this
stage's shared-transform design exists to avoid. It is witnessed
instead.
THE LESSON, which is the more useful half. The existing test placed the
anchor 200px off-left — and 200px off-left fails a width-based
predicate too, so it stayed green straight through the defect. The
mutation battery agreed with it, because every mutation asked only
whether REMOVING a check was caught, never whether the check had the
right shape.
A boundary must be tested AT the boundary. The new witness straddles it
by ±0.05px — the same anchor either side of the edge, which no
width-based predicate can separate — and additionally asserts the
popup's own left edge stays out of the gutter, making "no left clamp
needed downstream" a checked claim rather than a comment. Verified both
ways: the new witness fails against the original predicate, the old one
passes against it.
THE AUDIT that finding prompted. Stage 5 has one other left-edge
predicate, the caret's. Its use of `survives_code_clip_left(rect.x,
rect.w)` is correct — a caret quad genuinely is `CARET_WIDTH` wide —
and it was also only tested far from the edge. It is now walked ACROSS
the boundary a column at a time, asserting painted carets are wholly
inside the code area and hidden ones wholly outside.
That pins an argument that was load-bearing and invisible: because
`horizontal_follow` snaps to whole columns, a caret is never partly
behind the gutter, since `CARET_WIDTH` (2px) is far below any code
advance. Substituting `rect.h` for `rect.w` — the exact error above —
fails it. An over-width smaller than one advance does not, and that is
the invariant rather than a gap.
SCOPE. `follow_left` recorded as the one approved exception to "local
GPU viewport state" in the framing doc, new §1.2a: what it is, why the
Q#G5 parity witness cannot be real without it, and what it does not do
— no viewport state moved, no wire message, no version bump.
GATES, both configurations, five ambient roots isolated, sweeps
redirected to files per U3's lesson: fmt; clippy `--workspace
--all-targets -D warnings`; `--lib` 1920 and crdt 2105;
`-p pmacs-protocol --lib` 29; `PMACS_REQUIRE_GPU=1 -p pmacs-gpu` 241;
horizontal_scroll 11, long_line_readable 3, line_wrap 6,
full_grid_resync 1; both full workspace sweeps; `git diff --check`.
The only sweep failure is R8, confirmed by its recorded fragments —
pre-existing, deterministic, merge-base controlled against `main`, and
not this lane's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: Stage 5 is PR #223, head 55faa45
The ledger said "no PR opened yet", which stopped being true the moment
it was. Records the PR, its head SHA, and the standing do-not-merge.
Rule 4 still applies at merge, not now: the long-lines lane stays until
#223 lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
* docs: the tip is the ref, not a SHA the commit itself invalidates
The previous commit wrote "head 55faa45" into the ledger and, by
existing, made it false — recording the PR moved the head to 4902048.
A SHA pinned in a document that the act of writing it stales is a trap,
not a record.
The ledger already states the correct convention two paragraphs down
("the authoritative tip — the ref, not a SHA"); this follows it, and
says to verify CI against the PR's live headRefOid.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The GPU's readout reckoned in source lines while its window holds
visual rows: `format_scroll_indicator` compares `visible` (rows that
fit) against `current_line_starts.len()` (source lines). The GPU has
always wrapped, so this is not new in this lane — but the lane is
where it became nameable, because `ui.line-wrap` is now what decides
which formula applies. A one-line file took the first branch,
`total_lines <= 1`, and reported "All" with most of itself below the
window.
Under wrap it now goes through `pmacs_protocol:📜:classify`,
the same rule the TUI took in eaf3df8, with only the string spelling
local (framing §5d.6).
The load-bearing part is how `first_visible` / `last_visible` are
decided. Two cheaper predicates are available and both are wrong:
- `view_range.0 == 0` / `view_range.1 == len` describe the SHAPED
span, which carries SCROLL_OVERSCAN source lines past the window.
A slice reaching EOF says nothing about EOF being on screen. This
is the guess that broke extreme_sizes_render_with_contained_popups
when it was tried earlier and got reverted rather than shipped.
- `scroll_top == 0` ignores `code_scroll_residual`, so scrolling
into the middle of a wrapped first line still claims "Top".
So `code_byte_painted` asks cosmic-text where the byte actually
landed and intersects it with the drawable clip — `caret_rect`'s
existing test, generalized off the own cursor. Wrapped continuation
runs below the band and overscan lines shaped past the bottom both
fail it, because layout is what decides, not arithmetic over it.
`compose_status_runs` takes `&mut self` for this. That is the point
rather than a wart: the alternative is a cached per-frame
(first_visible, last_visible) pair, which is a value maintained
beside the layout and free to disagree with it — the same shape as
the `code_wrap` shadow field this lane already removed once.
One bug the tests found rather than confirmed. The first version
rejected an empty `view_range`, a guard borrowed from the caret and
completion-anchor paths where it means "nothing shaped". A file
ending in a newline has a final empty line, and a viewport parked on
it is `(len, len)` with one real row — so reaching the bottom of any
such file reported a percentage instead of "Bot". `code_byte_px`
already returns `None` when nothing is shaped, which is what that
guard was reaching for.
Bite, per clause. Replacing the pixel clip with the range test alone
fails a_wrapped_single_line_is_not_all,
a_slice_that_reaches_eof_is_not_yet_bot,
a_sub_line_residual_moves_off_top — and independently
extreme_sizes_render_with_contained_popups, the pre-existing test
that rejected this same shortcut before. Restoring the empty-range
guard fails an_empty_final_line_still_counts_as_bot and
a_slice_that_reaches_eof_is_not_yet_bot.
Gates: fmt, workspace clippy -D warnings, git diff --check,
PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu 228/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
All three were review findings, and all three shared a shape: the code
that decided a mode and the thing that held it were allowed to differ.
The GPU compared against a shadow field. code_wrap started at
Wrap::None while the cosmic-text buffer had never had set_wrap called
at all, so it was still on the constructor default WordOrGlyph. Since
apply_line_wrap short-circuits when the request already matches, the
FIRST wrap: false was a no-op and the document kept word wrapping. The
existing test hid it by sending true first, which synced the buffer as
a side effect.
Fixed twice over. The document buffer now declares its wrap at
construction like every other buffer in the file --- Wrap::Glyph, not
None, because ui.line-wrap defaults to wrap, so a frontend told nothing
(or talking to a pre-v22 daemon that never will tell it) should already
be in the default mode. And the shadow field is GONE: apply_line_wrap
reads self.buffer.wrap() instead. A cached copy can disagree with the
authority; reading the authority cannot. Same principle as byte
anchoring and the fold-projection cache key.
The first attempt at that fix set Wrap::None at construction, which
made truncate the pre-message default and broke eleven tests --- the
GPU had always wrapped, and the setting's default is wrap. The failures
were right and the change was wrong.
A one-column viewport shoved every wide glyph down a row. The rule
moves a double-width glyph to the next row when it will not fit in the
cells left, but at one column it will not fit there either --- so a
single CJK character rendered on row 1 with row 0 left blank, and
clipped anyway. Now it only moves when the next row could actually hold
it (max_cols >= 2); below that it clips in place, which is what
Truncate does at the edge for exactly the same reason.
A zero-column content area panicked. Under Wrap the first
col >= max_cols test is true immediately, so the walk advanced a row
and then indexed column 0 of a zero-width grid. Reachable whenever the
gutter consumes the window's width. paint_line now returns before the
walk, and put() refuses out-of-range columns as a second line.
Four witnesses, all biting. The GPU one took three attempts to make
discriminating: wrap-versus-truncate could not see the bug (both modes
differ from each other either way), and a row-count comparison between
spaced and solid text did not discriminate at the width I chose ---
measured, not assumed. Asserting buffer.wrap() directly does, and fails
with left: WordOrGlyph, right: Glyph.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1914/0,
crdt 2099/0, protocol 25/0, pmacs-gpu 224/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The GPU is not a grid consumer --- it ignores the CellDelta family and
lays out locally --- so ui.line-wrap reaching the viewport reaches only
the TUI. Without a wire message, setting truncate would change one
frontend and leave the other wrapping: exactly the cross-frontend
disagreement this stage exists to remove.
LineWrapFacts is appended after PanelFrame, the final v21 variant, so
no postcard discriminant moves. PROTOCOL_VERSION 21 -> 22;
ADVERTISED_PROTOCOL_VERSION stays at 20, per its own doc --- moving the
advertised baseline is reserved for changes that cannot be expressed
additively, and this one can. A v21 frontend negotiates v21, never
receives the variant, and keeps its behavior.
It carries buffer_id because the mode is buffer-local. That is also why
the daemon must resend on BUFFER SWITCH, not only on attach and config
change: font size is global, wrap mode is not, so moving from a
truncate buffer to a wrap buffer changes the effective mode with no
config event at all. The GPU handler leans on that --- it ignores a
message for any buffer other than the one on screen, rather than
keeping a per-buffer cache.
On the GPU side the document buffer had never called set_wrap, so it
was running on cosmic-text's constructor default of WordOrGlyph: word
wrap nobody chose. code_wrap makes it explicit in both directions and
settles on Wrap::Glyph. Character wrap is what the grid can implement
identically without pulling UAX #14 into it, and what Emacs does by
default. GUI users lose word wrap --- a deliberate, documented trade
for the two frontends agreeing, and it belongs in the release notes.
Changing wrap reflows the document exactly like a font change, so the
retained scroll anchor is repaired through the existing
normalize_code_scroll rather than left pointing at a row that no longer
exists.
Three test updates that were NOT stale assertions. The version pin and
the resume ladder both had to widen, and the GPU's byte-exact bootstrap
test failed because SUPPORTED_PROTOCOL_VERSIONS still ended at 21 ---
the handshake was genuinely rejecting v22. That test earned its keep.
Two new GPU witnesses. the_gpu_honors_an_explicit_non_wrap_mode is the
discriminating case framing section 7 asked for: the existing
wrapped_caret test passes against a wrap nobody configured, so it
cannot tell "honors the setting" from "the default happened to match".
Comparing row counts across the two modes can.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1912/0,
crdt 2097/0, protocol 25/0, pmacs-gpu 223/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Review round 1: six findings, four sharing one shape — the panel layer was a
partial port of the document/terminal layer, and the tests asserted the
declaration side only, so each omission was invisible. Audited as a port
rather than patched as a list.
GEOMETRY AGREEMENT (P1). Three grids had drifted apart. The declaration
subtracted `TEXT_LEFT` from its width against the parent framing's explicit
contract ("`total.cols` describes the full-width panel grid beginning at
x=0; document `TEXT_LEFT`/gutter padding is unrelated"), while painting and
hit-testing used the document-dependent `mono_advance` and the declaration
used the stable probe. So daemon columns could overflow the surface and a
click could resolve to a different cell than the one painted — and the new
test separated the two advances and then asserted only the declaration, so
it saw none of it.
The fix is structural, not three edits: the advance is cached BEHIND the
declaration (`PanelBand::declared_advance`) and painting and hit-testing read
it. They cannot disagree, because there is one value. The band's rect is now
x = 0 across the full surface width, and the fractional right-edge remainder
is band background that maps to no cell — which is what the framing says and
what `hit_test_cell`'s column bound already enforced.
GESTURES (P1). Only `Move` was sent. Left press never armed, so `Drag(Left)`
was never emitted and panel selection could not work; releases outside the
band were dropped, leaving the daemon holding a button down; right-click and
wheel never consulted the band at all and were applied to the document
underneath.
The root cause is that four handlers each decided for themselves whether the
band owned a pixel, and three did not ask. There is now ONE authority —
`PointerSurface` / `classify_pointer_surface` — and all four route through
it, so a future handler cannot quietly forget the band. `PanelBackground` is
its own arm: the remainder is the band's pixel even though it emits no
`PanelPointer`, so it must not fall through either.
PASSIVE CARET (P1). The producer ships `cursor` for a passive panel too — it
is the window's real point and the daemon does not suppress it — so painting
it unconditionally put a second insertion caret on screen. Gated on
`frame.focused`, the presentation bit Q#BP14b reserves for exactly this.
UNDERLINES (P2). `build_grid` planned them and nobody consumed them. Straight
forms now ride the quad batch and curly rides the squiggle pipeline, the same
split the terminal path makes for the same reason.
VERSION MISMATCH (P2). The daemon reported the advertised baseline as the
server version while its own `PROTOCOL_VERSION` is 21, contradicting the wire
field's own documentation and inverting the upgrade advice. The field doc now
states what each side can know, and the acceptance is re-pinned — it had been
holding the wrong value in place.
Two gaps the audit found beyond the six, same shape:
* the headless probe never armed the panel wire at all, so no probe could
ever exercise a band;
* a disconnect left the band on screen — the frozen, live-looking surface
the terminal arm already refuses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Three float comparisons became explicit epsilon checks, one const-valued
assertion moved into a const block, and the crdt-only half of the new
acceptance suite is now gated import-by-import.
That last one is the interesting part: the negotiation-rule tests are pure
and run in BOTH configurations, while everything needing a real daemon
needs the crdt feature — a semantic session is necessarily a text replica,
so a non-CRDT build cannot host one at all. Splitting the imports along
that line is what keeps the default clippy configuration clean while
leaving the version-ladder assertions where CI can actually reach them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
23 falsifying mutations, each executed. Three of the first-pass assertions
were VACUOUS and the mutation runs are what found them:
* The contrast assertion compared status_band_top before and after
installing a panel — a FIXED POINT. The blanket rewrite the framing
exists to prevent (subtract the band from the status boundary too)
moved both readings together and passed. It is now anchored to an
independent formula: the physical window bottom minus the band height.
* The criterion-46 pixel test only checked that no pixel moved above the
band and none below it. Installing a panel reshapes the document to the
smaller height, and THAT produced the whole diff — so the test passed
with the band painting nothing at all. It now counts differing pixels
in the divider row and the band's cell rows directly: content produced,
not an invariant preserved.
* A2B-3's fixture compared two ASCII documents, which in a monospace
family have identical glyph advances — so it could not tell the stable
probe from the document-glyph fallback. It now separates the two
derivations explicitly and asserts they produce different column counts
in the fixture, so the claim about which one the declaration uses is
discriminating.
And one about the CODE, not the tests: 'a v20 semantic session receives no
panel frame' is defence in depth, not the placement gate. The producer's
peer flag and the write-loop filter both suppress PanelFrame below the
panel version independently of panel_capable, so that claim passed with
the capability gate removed entirely. The load-bearing claim is placement:
the adopter's buffer must land in the pre-panel session's own DOCUMENT
window, because a side window it cannot render is simply invisible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Placed by what each assertion needs: the band's pixel geometry, the epoch
latch, the probe-derived columns, and the three-boundary contrast
assertion live in pmacs-gpu's own tests because they need a real State and
a real surface; the handshake, the negotiation, and the capability flip
live in bottom_panel_stage2b_gpu_acceptance because they need a real
daemon.
Every acceptance runs both directions in one fixture. The activation test
uses ONE daemon for all three halves — a shipped v20 client reaching its
initial grid, a v21 counter-offer receiving a Present band, and a v20
semantic session that is never sent a panel frame and keeps a live
document window. Two daemons could each pass their own half while the same
build was incapable of serving both, which is the only property that
matters.
Two real defects the new tests caught in my own implementation:
* edge_scroll_direction has no upper bound, so moving its boundary was
necessary but not sufficient — a pixel inside the band still read as
'further down the document' and armed the document's auto-scroll. That
is the exact named symptom of leaving that consumer on the old bottom.
The falsification keeps both answers: the probe pixel is inside the
unmoved boundary's own edge strip, so the two genuinely differ, and a
third assertion proves the feature is not simply switched off.
* apply_panel_payload ignored the exhaustion latch, so a latched session
kept storing frames and reporting 'changed'. That left presented() as
the only thing between a disowned declaration and a painted band, and
spent a reshape on every arriving frame for the rest of the session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Bottom-panel Stage 2B-3, part 3 of 3: the band a user can see.
Declaration. `FrontendCellGeometry` ships on attach — deliberately without
a side window, because the daemon needs columns before it can paint a
first frame and gating on panel presence would deadlock the first open —
and again on resize and on every font/scale transaction. The two triggers
differ in exactly one way: `Surface` dedups an identical `CellSize`,
`Metrics` never does, because the cells can be identical while the pixels
behind them are not. That is the case daemon-side value dedup cannot see,
and it is why the epoch is frontend-owned.
Columns come from the stable normal-face probe, never `mono_advance`'s
document-glyph fallback: that fallback would make the panel's width
depend on the first glyph of whatever file is open, so two frontends with
identical metrics and different documents would derive different totals.
A probe that returns no width declares zero usable geometry rather than
reaching for a document sample.
Receipt. `Absent` is authoritative and clears; silence retains. A frame
is validated before any state is touched, so rejection is atomic and the
previous valid frame survives it. A duplicate does no work at all — no
plan rebuild, no reshape, no redraw. `Absent` does NOT discard the
geometry declaration: the frame capacity is unchanged by a panel closing.
Paint. The divider strip and the band's cells ride the existing quad and
glyph layers, in BOTH document and terminal modes — gating the band on
`terminal_mode` would make it vanish exactly when it is hosting the
output the user asked for. The strip's painted rect IS its hover/drag hit
rect, so the icon cannot advertise a target the press would miss.
Input. The band claims gestures before either document path: divider
press starts a drag, motion sends `PanelResizeRows` only when the
requested ROW count changes, bare motion inside the band is a `Move` that
neither focuses nor claims. A drag whose epochs no longer match the
panel on screen is dropped rather than applied to its successor — the
same rule the daemon enforces on receipt, checked on both sides because
neither may depend on the other having done it.
Outbox. Four more tail-only coalescing tags. Geometry is latest-wins
because epochs need only increase, not be consecutive. Resize coalesces
over the complete event including its epochs. Panel `Down`/`Up`/wheel/
context stay lossless and ordered: repeated left `Down`s are what the
daemon reads as a multi-click.
Exhaustion latches, and the latch is load-bearing beyond dropping the
frame: an old `Present` whose epoch still matched would otherwise
resurrect a band under geometry this frontend has disowned.
One pre-existing gap found and left alone: the terminal glyph layer
paints every run in one fixed color, dropping `TextRun::color`. The
panel layer resolves its runs properly rather than mirroring that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Bottom-panel Stage 2B-3, part 2 of 3: the pixel substrate for the band.
`text_area_bottom` was three boundaries wearing one name — its own doc
comment called it "the single source for every bottom-of-text
computation" — and once a band can be installed they must diverge:
status_band_top = max(0, height - status_band_height)
geometry_capacity_bottom = max(0, status_band_top - divider_height)
document_text_bottom = max(0, status_band_top - installed_band)
The census is 29 matches: 20 production call sites, 1 definition, 8 test
sites. All 20 were read in their enclosing function and classified
individually — 8 status-owned, 12 document-owned. A blanket rewrite that
subtracted the band from all of them would move the status chrome with
the document and pass an "everything moved" assertion, which is why the
classification is per site and the criterion asserts both directions.
The three easiest to get wrong keep their named symptoms: document
completion placement is document-owned (status-owned would overlap the
band), minibuffer candidate clipping is status-owned (the minibuffer is
global bufferless chrome anchored to the band, and clipping it at the
document boundary would cut it off), and edge scrolling is document-owned
(left on the old bottom it would auto-scroll from inside the panel).
`geometry_capacity_bottom` reserves the divider even while the panel is
absent. That asymmetry is what breaks the first-open cycle: the daemon
sizes a panel from the capacity it was told about, so a capacity that
ignored the divider would grant a first panel that does not fit once the
divider appears beside it. The document loses no pixels until a `Present`
frame is really on screen.
`PanelBandInset` is a newtype, not an `f32`, because three boundaries here
take a pixel height and only one takes this one.
Alongside it, the band's own machinery: `PanelBand` with ONE derivation of
"is a panel on screen" (`presented()` — retained valid frame, matching
geometry epoch, latch clear), the frontend-owned epoch state machine with
its fail-closed exhaustion latch, the `Absent`-is-authoritative receipt
path, `panel_cell_capacity` (no per-axis cap — a panel may legitimately be
wider than a PTY — plus the daemon's virtual status row), the stable
normal-face probe for column count, and the divider strip whose paint rect
IS its hit rect.
`TerminalPaintPlan::build_grid` factors the shared cell planner so a panel
and a terminal cannot disagree about a wide-continuation pair; terminal
selection spans stay outside it rather than being faked as empty inside.
`PANEL_MIN_VERSION` moves into `pmacs-protocol` so the GPU frontend aliases
one definition instead of restating 21.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Bottom-panel Stage 2B-3, part 1 of 3: the compatibility-preserving v21
activation mechanism and the negotiated `panel_capable` flip.
2B-1 reserved the v21 wire and 2B-2 built the daemon projection behind
it, both dark, because the handshake is server-first: the daemon writes
`Hello` before the frontend has said anything, and a frontend rejects a
`protocol_version` outside its supported range *before* it can send
`AttachRequest`. Advertising 21 there is therefore an incompatible act on
its own, independent of whether one new message is ever exchanged.
So the advertised version does not move. `ADVERTISED_PROTOCOL_VERSION`
becomes a permanent compatibility BASELINE, and the session's real
version is settled one message later, by the frontend:
1. the daemon advertises the baseline (20, unchanged);
2. the frontend answers `requested_protocol_version(baseline)` — its
own `PROTOCOL_VERSION` when the baseline is the current one, and a
verbatim echo of anything older;
3. the daemon records `negotiated_session_version(offer)`.
A shipped v20 frontend echoes 20 and gets a v20 session, byte-for-byte
as before — the real-daemon acceptance that emulates its rejection point
still passes untouched. A current frontend offers up and gets v21. The
`Hello` encoding and value are unchanged, which is why the old frontend
never sees a version it must reject.
`peer_declared_panel_support` gains the arm 2B-2 deliberately left off:
a semantic session is panel-capable exactly when it negotiated
`PANEL_MIN_VERSION` or later. The gate is on placement, not only
transport, so a v6-v20 semantic session keeps the Stage 1 fallback.
The GPU client's `server_protocol_version` splits into
`session_protocol_version` (what the session speaks — every wire gate
keys on this) and `baseline_protocol_version` (what `Hello` advertised).
They now differ in the normal case, and that difference IS the
compatibility property, so both headless probe reports emit both keys and
the two ratchets that read them assert both directions: session 21 AND
baseline 20. Asserting only the session version would pass if the
baseline had been bumped too — the exact incompatible change this
mechanism avoids.
Also fixes a pre-existing `unused_mut` in a `crdt`-gated daemon test,
dark to the standard clippy gate because that gate runs without the
feature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Let producer probes name the frame text they require while input probes
finish on their latched echo observation. Report and assert whether the
probe reached that evidence so the 20-second safety deadline cannot
masquerade as successful completion.
The CAT acceptance now finishes in 0.32 seconds instead of waiting out
the full deadline, while the VTERMROW producer still waits for its own
PTY breadcrumb.
Keep the real GPU/PTY acceptance probe running until the child output
that its report asserts has actually reached the terminal frame. This
closes the blank-last-frame race exposed by the v20-compatible handshake.
Integrate main through the Journey/GPU directory-target ratchet (#183).
The public managed-GPU probe additions compose with the v21 wire layer.
Resolve the volatile ledgers to the landed state: advance the canonical
anchor to 7fd646d, remove the completed ratchet lane, preserve the
approved Stage 2 revision-5 recovery facts, and mark the integrated
2B-1 tree as awaiting its from-start full gate rerun.
Drive pmacs --gpu . through the root broker and real managed GPU
connector, keep the session alive through the asynchronous dired
replacement, and assert its canonical listing before daemon reuse.
Expose snapshot count and materialized text through the private
display-less acceptance probe so the public path is observable.
Adds the four wire shapes Q#BP9 names, bumps the protocol to v21, and
factors the cell-grid validator so a panel frame shares the terminal's
rules without inheriting its PTY caps.
- `InstanceMessage::PanelFrame(PanelFramePayload)`, appended after
`InitialTargetResult`; `Absent` is an explicit authoritative state, not
silence, because the receiver retains its last valid frame.
- `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`,
appended after `TerminalPointer`. Geometry is valid without a side
window — gating it on panel presence would deadlock the first open,
since the daemon needs columns before it can paint a first frame.
- `pmacs-protocol/src/wire_grid.rs` holds the shared rules: checked area,
visible-cell bound, cell count, cursor bounds, glyph legality,
wide-continuation topology, the aggregate glyph budget, and the
attachment rejection. The 512 per-axis caps, metadata, selection spans,
and the at_bottom/scroll_offset coupling stay terminal-only.
- The attachment rejection is deliberately shared despite its
terminal-side wording: panels render no attachments either, so sharing
it fails closed for both.
Both byte pins were falsified by revert: moving `PanelFrame` ahead of
`InitialTargetResult` shifts it 27 -> 28 and fails; moving the three
events ahead of `TerminalPointer` shifts it 12 -> 15 and fails.
The factoring changed no terminal acceptance — all 17 terminal tests pass
unchanged. It did surface a pre-existing coverage gap: those tests pin
the row cap but never the column cap, so widening `max_cols` to u32::MAX
left them green. `a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not`
now covers that direction.
The daemon projection, the epoch state machine, and the GPU band are
later slices of this stage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR
The dispatcher loop applied BOTH terminal-layout syncs to EVERY attached
frontend. A semantic session satisfies both conditions, because it has a
term_sizes entry from AttachRequest and a semantic terminal declaration,
so its PTY was resized twice on every tick forever: the grid arm
installed the TUI placement size, the semantic arm installed the declared
content rectangle, and each arm's own idempotence guard only ever saw the
size the other had just written. The child took a SIGWINCH storm at tick
cadence and the screen reflowed continuously, which is what made typing
into a GPU terminal impossible while output kept flowing.
The grid arm is also the only per-tick controller-liveness release a
semantic frontend gets, so simply skipping it for those frontends trades
one defect for another: a GPU window that switches away from its terminal
would hold the controller forever, and no peer could resize that PTY
again. The semantic arm cannot take over that job, because the
buffer-follow snapshot clears the viewport declaration that would drive
it.
sync_terminal_layout is therefore split into a frontend-kind-neutral half
(panel reconciliation plus controller liveness, which read only views,
windows and the controller) and a grid-only geometry half (TUI placement
plus the resize). The dispatcher runs the neutral half for every attached
frontend once per tick, then exactly one geometry arm per frontend kind.
sync_terminal_layout survives as the composition of both halves, so the
in-process editor loop and LOCAL are unchanged.
The loop body is extracted into sync_terminal_layouts_for_tick, which
makes the grid/semantic exclusivity structural rather than two adjacent
ifs, and lets the tests drive the real loop body instead of a
re-implementation.
The release that fires when a window has no placement stays in the grid
half deliberately: a semantic frontend has no window_placements entry at
all, so moving it into the neutral half would release a GPU session's
controller on every tick.
Bite-verified against two pre-images, because one is not enough here --
the naive guard fixes the storm and introduces the controller leak, so a
single revert would score the fix complete when it is not:
pin main naive guard split
settle (acc 2+3) FAIL pass pass
controller release (acc 6) pass FAIL pass
grid still resizes (acc 5) pass pass pass
Real-path acceptance: a quiet child that counts SIGWINCH reports 144
frames in 4 s and WINCH 1..12 on screen against the pre-fix tree, versus
a settled screen with the fix. Acceptance 4 (input reaches the child and
returns) is a keep-working pin and passes on both sides -- key transport
was never the defect.
No protocol change; stays v20.
The lane was 28 commits behind. Merged rather than rebased, per the
#135/#137 precedent: the PR is awaiting review rounds and a rebase would
break every review anchor.
The only conflict was docs/active-work.md, where both sides add lanes.
Kept both: main's lanes verbatim, with this lane leading since it is the
one in flight. The conflict was pre-existing rather than introduced by
the dired or Lean 4 ledger commits -- it already conflicted against main
at e745068.
The integration surface, derived from git diff merge-base..main rather
than from another PR's file list, is pmacs-gpu/src/main.rs: main gained
72 lines there from e547a90, the minimap all-blank-slab divide-by-zero
fix, and this lane rewrites large parts of the same file. Git auto-merged
it textually. A clean auto-merge is not evidence the tree compiles, so
the full gate suite is what discharges it; the ledger records the
post-integration numbers separately from the pre-integration ones, which
described a tree 28 commits behind.
`dominant_line_shape` averages only the lines in a bucket that have
content, then guarded the result with `bool::then_some`. `then_some`
takes its argument by value, so the `MinimapLineShape` literal --- and
with it `indent_sum / count` --- is evaluated before the `count > 0`
guard is ever consulted. When a bucket holds no contentful lines the
division panics and takes the GPU frontend down.
This is reachable in ordinary use, not at an edge: the bucketing branch
runs whenever a file has more lines than the minimap has pixel rows, and
it is exactly then that a run of blank lines can fill a whole downsampled
row. A whitespace-only line counts as blank too --- `minimap_line_shape`
subtracts the indent from the total, so `content_cols` is zero.
Switch to `bool::then`, which defers the body into a closure so the zero
case short-circuits to `None`. The call site already treats `None` as
"draw no stroke for this row", so no other change is needed.
Three tests, two of which fail against the previous line:
* a 10,000-line all-blank file driven through `minimap_rects`, which
reproduces the original panic through the real downsampling path;
* `dominant_line_shape` on an empty bucket;
* a mixed bucket, asserting the average still ignores blank lines ---
a companion guard so the fix cannot regress into counting the whole
slice.
A comment records why this must not be "simplified" back: clippy's
`unnecessary_lazy_evaluations` pushes in precisely the wrong direction
here, and does not fire on a body that can panic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two halves that touch live rendering, landed together because the
acceptance criteria that make either honest need both.
Suppression (Q#MS3/MS4/MS5/MS11). Detection runs in the per-line chunk
builder — the chunk-build path, never the edit path — and substitutes
each suppressed span's source bytes with ONE spacer chunk BEFORE tab
expansion, so a literal tab inside a span vanishes with it while tabs
outside keep their SourceTab provenance. The gate reads the EFFECTIVE
caret (own_cursor, which optimistic edits predict forward — F4's
no-flap requirement holds by construction) plus both own-selection
endpoints. Three motion paths can flip a gate without a content
change, and each now re-runs the per-line chunk compare, gated on a
one-scan "does the visible slice hold a $" check: the CursorByte arm,
finish_optimistic_edit (the text re-chunks under the OLD caret there;
without the hook a typed char rendered one keystroke stale), and the
Decorations arm — whose "no decoration change needs a reshape" premise
acquires exactly one exception, the Selection endpoints Q#MS11 made
suppression inputs.
The line-reuse predicate (acceptance 11, the #120 edge). Per-line
math state is cached in lockstep with line_chunk_cache: every detected
span with the gate bit it was built under. The scroll-reuse path
refuses a retained line whose cached bits disagree with the CURRENT
caret/selection — content is unchanged on every reuse path, so the
cached span set is authoritative and the gate bits are the only
variable. The acceptance test drives the stale-gate case through
rebuild_lines_reusing_scroll directly and fails if the gate is removed
from the predicate.
The hit map (B1'). hit_test_source_byte rebuilds its runs from a
whole-slice chunk walk, so it now reads the substitutions BACK from
the per-line caches — never re-planned under a possibly-newer caret —
keeping the map and the shaped glyphs one source of truth.
The draw pass (Q#MS6/MS7). Every MathItem::Glyph draws from its own
mini-buffer with Attrs pinned to the bundled math family (F8b), placed
at layout's exact x and the shaped line's REAL baseline; the
mini-buffer itself is positioned by the line_y cosmic-text actually
produced for it, so no font-metric rederivation can drift. Fraction
rules ride the bg quad batch after the decoration washes and under the
glyphs. Wash geometry gains Q#MS11's intersection rule: a wash
touching a suppressed span widens to the box's whole reserved
rectangle (a match strictly inside the span produced a zero-width
interval before), while the round-3 exclusive-end fix keeps a
non-intersecting wash off the box.
Acceptance (framing §5). Criteria 5-11 and 14-16 run on real pixels
through render_to_view: drawn ink where a literal-spacer control
renders none, with the before-region pixel-identical; the fraction
rule as a full-width run with operand ink both sides; caret-inside
rendering EXACTLY as math-disabled (driven through the real
CursorByte arm, which owns the refresh — a direct helper call would
not have pinned the wiring); every failure mode (unbalanced, unknown
command, $$, uncoverable glyph) pixel-equal to disabled; box clicks
snapping to the span start with the trailing edge landing after the
span; the scroll-reuse stale-gate bite; reflow confined to the
affected line with the after-text shifted by exactly the quantized
projection difference; selection gating and the whole-rectangle wash;
and the licence provenance pair. Criterion 17 is discharged
differentially: cargo tree -e features output for ttf-parser is
byte-identical with and without this crate's dependency line.
Also folded in, per the round-3 close-out: the F6 documenting test
($a$$b$ is eaten by the $$-opaque rule; one separating character
restores both spans), the depth-search bound raised 6 -> 8 so a
metric shift cannot make the "floor is dead code" expect fire with a
misleading message, the MathBox { end, .. } pattern nit, and the
active-work.md lane entry.
Named v0 approximations, deliberate: the peer-caret half of
acceptance 14 is pinned at the mapping level (unit tests), not
pixels; a soft-wrapped spacer draws its box whole at the first run's
origin (the one-rectangle model); the fit budget reads the bundled
code face even under a custom set_font family — the draw anchors to
the real shaped baseline either way, so only the fit margin is
approximate.
Clippy is CLEAN across the workspace at -D warnings for the first
time on this branch: the draw pass consumed every formerly-dead item,
and the three lints it could not fix (a test-only accessor, one doc
string, one manual midpoint) are fixed here.
Gates: cargo fmt --check; cargo clippy --workspace --all-targets
-- -D warnings; 1,815 default + 1,992 CRDT library tests; M4 121
(basedpyright skipped); 199 pmacs-gpu tests under PMACS_REQUIRE_GPU=1;
workspace sweep 3,131 across 88 suites (isolated XDG_CONFIG_HOME);
git diff --check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F1 was a real bug pinned by my own committed test. `end` in
ChunkSource::MathBox is EXCLUSIVE, so source position `end` is the first byte
AFTER the span — but the arm claimed it for the box's left edge, and the test
asserted that wrong value while calling the byte "interior". Consequences it
would have caused once overlays land: a search match starting just after a
span washes the whole box it does not intersect, violating Q#MS11; a peer
caret after the span draws at the box's left edge; caret geometry jumps
backwards. The same class existed in projected_to_source for a line-FINAL box,
where `within` clamps to the run length and the arm returned `start`
unconditionally, so a click past end-of-line landed on the span start. Both
committed hit tests put a chunk after the box, so that edge was never
exercised; there is now a test with the box last.
F2: parse_scripts peeked for the next marker without skipping whitespace, so
`x^2 _i` built a NESTED script — drawing the subscript displaced right by the
superscript's width — and `x^2 ^3` parsed where TeX errors, contradicting the
module's own "whitespace is insignificant" rule.
F3: layout is now fallible. A character the math font cannot draw used to
yield zero metrics and still emit a Glyph item, rendering tofu at zero advance
over its neighbour. Q#MS8's rule is "failure is always show the source", and
the draw pass needs a refusal signal — changed now, before that pass consumes
the API.
F4: the fraction gap was a hardcoded `thickness * 2.0` while the MATH table's
FractionNumeratorGapMin / FractionDenominatorGapMin went unread. Reading them
moved the flagship \frac{a}{b} from 0.732 to 0.867 and the fallback boundary
from depth 3 to depth 5. The round-2 review's hand-arithmetic estimate of
~0.85 was right; my 0.732 was inflated by the guess. The depth-SEARCHING test
absorbed the change without edits, which is the property it was written for.
F5: TeX's \epsilon and \phi are the lunate/symbol forms (U+03F5, U+03D5), not
U+03B5/U+03C6. Their italic mappings had to land with the seed change, since
both sit outside math_italic's U+03B1..03C9 run and would otherwise render
upright beside italic neighbours.
F7: the line-box budget derivation moved out of the test into
`line_box_budget`, so the draw pass and the acceptance test cannot compute
different splits while both stay green.
F8: the live-code clippy items are cleared. The 25 that remain are all
dead-code awaiting the draw pass.
189 pmacs-gpu tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suppression mechanism F2 forced: a RichChunk's only width is its text, so
a suppressed span reserves room with SPACER SPACES the way SourceTab already
does, quantized up to whole advances. Quantizing up keeps the projection
grid-aligned with the surrounding monospace text and keeps hit runs integral,
at the cost of under one advance of slack on the right.
Adding the variant to an exhaustive enum made the compiler enumerate every
seam it must participate in, which is why it is wired through all five rather
than the two I had in mind: projected_to_source, source_to_projected, the tab
expander's source remap, and offset_chunk_source. Hits anywhere inside a box
snap to the span start — the Adornment rule, because Q#MS4 gives the box no
interior byte map — and source positions inside it collapse to the box's left
edge, so text after the span accounts for the whole reserved width.
Two details the tab expander needed: a math chunk's spacer text is generated
rather than source, so it holds no tab byte to expand, and its suppressed
range is already in slice coordinates and never split, so a within-chunk
offset does not move it.
spacer_for_width guards its inputs: a non-finite width, a non-positive
advance, or a pathological ratio reserves nothing or clamps, rather than
panicking or minting an enormous string from a cast.
184 pmacs-gpu tests pass, including the 155 that predate this branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per-line `$…$` scanning with the Pandoc guards F5 required: an opener must be
followed by a non-space, a closer must be preceded by a non-space and not
followed by a digit, and `\$` escapes. Without them "prices are $5 and $6
today" renders "5 and " as math, in exactly the grammar-less prose buffers
this scanner targets. Spans never cross a newline (Q#MS3), so callers scan one
line at a time.
`$$` is opaque, and the test that forced this is worth keeping in mind. My
first version simply refused to OPEN on `$$`, reasoning that display math
would then never match. It still did: in `$$x$$` the first `$` declines to
open, the second one opens, and the third closes it — matching the inner `$x$`,
whose interior parses perfectly well. So display math would have half-rendered
as math with a stray `$` on each side, which is precisely what acceptance 15
forbids. `$$` now neither opens nor closes and abandons any pending opener.
The test that caught it was itself nearly vacuous. It asserted "every span
found must fail to parse", which passes trivially when the matched interior is
`x`. Asserting that NO span is found is the form that actually holds the
contract; the weaker version would have shipped the bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MathBox/MathItem composition for the Q#MS2 subset: characters advance a pen,
scripts shift by the MATH table's superscript/subscript amounts at script
scale, and \frac stacks its operands around a rule at the math axis. Inline
\frac sets its operands one style down, which is TeX's rule and also what the
parent framing's Tier 3 specifies — and it is load-bearing for Q#MS10, since
full-size operands would not fit the line at all.
The height budget is now measured rather than assumed, and the round-2 review
was right to insist on that. Two things were wrong.
First, my own test derived the budget from the MATH font's metrics. Q#MS10
says the budget is the LINE BOX, whose baseline the CODE font places —
JetBrains Mono ascends 16.32 px and descends 4.80 px at 16 px inside the 22 px
line, against Latin Modern Math's 12.90/3.10. Using the wrong font made a
plain \frac{a}{b} score 0.485 and appear to fall below the floor, which would
have meant the flagship case never rendering.
Second, with the budget derived correctly, B6 holds — \frac{a}{b} scales to
0.732 — but rev 3's guessed fallback case does not. A doubly-nested fraction
scores 0.744 and still renders; the floor is not tripped until depth 3, at
0.580. Round 2 predicted precisely this surprise-pass. Worth keeping: depth 2
scores HIGHER than depth 1, because the binding constraint flips from descent
to ascent as nesting grows asymmetrically, so "deeper is always tighter" is
false.
The test therefore SEARCHES for the tripping depth instead of hardcoding it,
and fails if no depth trips the floor at all — which would mean the fallback
arm is unreachable and the floor is dead code. Acceptance 12 records the
measured table.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Font, licence, and the metrics half of Tier 3.
The bundled font is Latin Modern Math under the GUST Font License, added as
fonts/GUST-FONT-LICENSE.txt — deliberately a separate file from fonts/OFL.txt,
which covers JetBrains Mono only. GFL is LPPL-derived, not the SIL OFL; the
framing's F6 corrected that error and this is the discharge. At 733,736 bytes
the font is now the largest embedded asset in the repository.
ttf-parser is declared with default-features = false and only
"opentype-layout". Verified differentially: the ttf-parser feature set from
`cargo tree -e features` is byte-identical with and without this dependency
line, so the declaration widens nothing and forces no rebuild of the font
chain.
That check also corrected acceptance 17, which asserted `std` would be absent.
It is not — fontdb already enables it via `std = ["ttf-parser/std"]`, upstream
and independent of us. As written the criterion would have failed a correct
implementation, so it is now stated as the differential property that actually
matters.
MathConstants reads only what the Q#MS2 subset needs — axis height, script
scale percent, the two script shifts, and fraction rule thickness. Reading
more would be speculative: constants for deferred constructs have no consumer
to validate them, which is the Q#LX5 discipline applied to metrics. A font
with no MATH table is a typed error rather than plausible-looking zeros, so a
bundled-font regression cannot be silent (Q#MS7).
math_italic implements TeX's convention as the framing's table states it:
ASCII letters and lowercase Greek italic, uppercase Greek upright, digits and
operators unchanged, with U+210E for `h` because the 1D4xx run has a hole
there and arithmetic would land on a reserved codepoint.
Five tests, all against the real embedded bytes rather than fixtures, since B5
is the bet that would sink Tier 3 if false. One goes beyond the framing: every
italic mapping must resolve to a glyph the bundled font actually has, because
a mapping that produced tofu would be worse than the roman fallback it
replaces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pmacs-gpu/src/math_parse.rs — the Q#MS2 subset: characters, groups,
sub/superscripts in either order, and \frac, plus the Greek seed map.
Everything outside the subset is a typed error, which Q#MS8 turns into
"render the raw source".
The AST is semantic, not presentational: \alpha resolves to 'α' here, but the
math-italic mapping stays in layout, where a codepoint becomes a glyph.
Baking italics into the AST would make the tree disagree with the source and
would have to be unpicked by any later non-italic style context.
Two bugs the tests caught before they could reach layout, both from skipping
whitespace in the wrong place. `parse_atom` consumed it, but the ^/_ dispatch
happens in `parse_sequence` BEFORE atoms are read — so `x ^ 2` parsed the
caret as a literal character, and an all-whitespace span produced an empty
group instead of the Empty error. Whitespace is now skipped at the dispatch
point, which fixes both at one seam.
Interior `$` is rejected explicitly so `$$x$$` degrades through the error path
(acceptance 15) rather than half-rendering.
Clippy reports MathNode as dead code, which is correct and expected: the
parser has no consumer until Tier 3 layout lands. That is exactly the
condition Q#LX5 refused to ship, now enforced mechanically. It is not
suppressed; it clears when layout arrives in this same branch.
11 unit tests, no GPU or font required.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keep foreign BufferSnapshot publications out of existing semantic GPU
sessions while retaining grid-replica coherence. Treat dead peer writes as
peer-local failures, restore active-frontend cleanup, deterministic probe
readiness, GPU logging, shared tilde expansion, and accurate docs.
Add focused publication and cleanup coverage and record the two-window
Wayland/Vulkan smoke plus the complete post-review gate results.
Add protocol-v20 semantic bootstrap and readiness result framing so
`pmacs --gpu FILE` opens the requested path before the GPU window becomes
ready. Keep target identity scoped to the authenticated frontend, preserve
legacy/no-target attach behavior, and publish fresh buffers coherently to
existing replicas.
Carry Unix path bytes and launcher cwd through the root broker, resolve paths
lexically in the daemon, reuse or create buffers without ambient-view state,
and preserve the managed daemon lifecycle from #141. Add focused parser,
wire, lifecycle, hook, isolation, and real-connector acceptance coverage.
Throttle the managed probe after its event channel closes, reject option-like
path operands, and document the connector test seam. Strengthen non-CRDT and
Ctrl-C acceptance so socket side effects and a pre-signal surviving frontend
are exercised, while avoiding cleanup signals to already-reaped daemon PIDs.
Transfer every successfully spawned daemon child to the named reaper before
any connection or handshake step can fail. Read early exit status from shared
reaper facts, eliminating fallible child-handle paths that could return without
reaping.
Buffer attach events until winit state exists, keep spawned daemon ownership
until the reaper handoff, and detach daemon stderr from the launcher terminal.
Tighten direct GPU CLI guidance and sibling discovery. Strengthen managed
connector unit and process acceptance coverage for transient retries, timeout
reporting, hermetic paths, and deterministic loser reaping.
Add the root --gpu broker, strict GPU entry points, daemon connect-or-start orchestration, process-group isolation, bounded retry, named child reaping, and a deterministic headless lifecycle probe. Cover the complete launch matrix with real subprocess acceptance, make root Cargo runs unambiguous, and document the coherent build and one-command workflow.
Integrates canonical `main` @ 2625ec7 after PR #137 (tab-width parity)
merged. The agreed order was #137 first, this lane second: #137 was
approved and FROZEN at 5b23e11, and "frozen" is incompatible with
"rebase onto the resulting main" — landing it second would have broken
its freeze and voided its approval.
Integrated by MERGING main into the branch rather than rebasing, matching
repo precedent (Merge canonical main into vterm-tui, ... into modeline
detection). A rebase would have force-pushed away the review anchors on
the two completed review rounds of #135.
Main had also moved past this lane's base by #133/#134/#136, so the
integration surface was wider than the #135/#137 overlap: src/
semantic_render.rs was a fourth overlapping code file. It auto-merged, as
did pmacs-protocol/src/lib.rs. The single code conflict was the
pmacs_protocol import list in pmacs-gpu/src/main.rs — TAB_STOP_COLUMNS
against the terminal types — resolved as a union.
The feared semantic collision did not occur, and this is verified rather
than assumed: terminal cell geometry still uses the monospace advance and
never TAB_STOP_COLUMNS. pmacs-gpu/src/terminal.rs references neither the
constant nor display_width, and terminal_cell_viewport / terminal_run_rect
/ hit_test_cell derive from mono_advance() and code_line_height() alone.
That separation is correct by construction: a terminal's columns come
from the child, while tab expansion is a document projection concern.
Doc conflicts resolved toward landed state: the tab-width lane moves to
"Closed since the last snapshot", the #135/#137 coordination section is
kept as a resolved worked example, and the Arc 5 lines in the roadmap and
handoff now read "implemented and in review". While resolving, restored a
clause main had dropped from the handoff's injection-follow-ups list
("literals, doc-comment code);"), keeping main's strikethrough-and-SHIPPED
convention for the modeline entry.
Post-integration gates, from a clean tree: cargo fmt --check; strict
workspace clippy; pmacs-protocol 17; cargo test --lib 1,768; --features
crdt 1,944 (3 ignored each); vterm Stage 1 9/10, Stage 2 4/4, Stage 3
5/7, statusline 7/8, tab-width 2/2 (default/CRDT); M4 121 passed (3
ignored, 1 filtered); required GPU 139; workspace sweep 2,946 passed
across 84 suites (19 ignored), one invocation; git diff --check clean.
One real defect, three cleanups, and a named deferral.
A daemon disconnect in terminal mode hid the disconnect notice. The
Disconnected arm set the placeholder text but never left terminal mode,
where the document code layer is not prepared at all and the terminal glyph
layer keeps painting its last frame — so the user was left looking at a
frozen, live-looking terminal that silently ignored input. GPU auto-reconnect
is a named deferral, so that state persisted until relaunch. State::
on_daemon_disconnected now leaves terminal mode, forces a repaint even when
the notice text is byte-identical, and requests a redraw.
The fix and its test share a file, so scripts/bite's file granularity cannot
bite it; the equivalent was done by hand. Neutralizing only the
exit_terminal_mode() call makes the test fail on the "must leave terminal
mode" assertion; restoring it makes it pass.
sync_semantic_terminal_layout no longer clones the whole visible cell grid to
read one size. It ran every dispatcher tick for any semantic frontend with a
declared terminal; TerminalManager::screen_size reads the value from the
borrowed projection instead.
Inbound terminal events now require a negotiated v19 session. The outbound
TerminalFrame was gated twice while TerminalResize/TerminalPointer relied on
the frontend's send gate alone. A pre-v19 peer cannot construct those
variants, so this only refuses a hand-rolled client — and the a32 forgery
tests already prove such an event reaches nothing but the sender's own
authenticated active view — but the asymmetry was not deliberate.
A terminal-mode press that misses the grid no longer arms a drag, so a later
in-grid motion cannot send a Drag with no preceding Down. Daemon-side impact
was nil; the state is now honest. A release still always ends the drag.
The roadmap and handoff Arc 5 lines still said Stage 3 was framed and
awaiting approval, contradicting this PR's own ledger. Both corrected.
Named deferral: terminal wheel gestures discard scroll magnitude. One winit
wheel event becomes one gesture regardless of the lines it accumulated, while
the document path scrolls by lines. Closing it means either N gestures
(chattier) or a magnitude field on the pointer event — a protocol change.
Neither belongs in this stage.
Gates: fmt; strict workspace clippy; 1,758 default + 1,934 CRDT library
tests; Stage 1 9/10, Stage 2 4/4, Stage 3 5/7, statusline 7/8
(default/CRDT); M4 120; required GPU 129; workspace sweep 2,923 across 83
suites; diff check clean.
Share one fixed eight-column tab-stop contract across core and GPU renderers. Consolidate byte-to-display-column accounting, expand GPU code tabs with source provenance, align caret/hit/decoration geometry, and refresh minimap projection on edits.
Five findings, all addressed. One was a real defect; one prediction did not
reproduce and is documented as such rather than papered over.
Hover no longer claims durable terminal control (finding 2, the real one).
apply_terminal_gesture claimed the controller before dispatching, including
for Move, which does nothing. A semantic frontend reports motion at pixel
rate, so sweeping the mouse across a passive split's terminal took durable
control, and the next layout sync resized the shared PTY to that background
view's geometry — precisely the theft the controller rule exists to prevent.
Bare motion no longer claims; every deliberate gesture still does.
scripts/bite HEAD src/editor.rs on the new test is a clean behavioral bite.
The terminal-mode presence-sweep skip is removed (finding 1), but the
predicted failure did NOT reproduce. The review reasoned that skipping the
sweep freezes last_broadcast at the abandoned document position. It does
not: the buffer-follow clears the terminal declaration when it ships the
snapshot, so terminal_active is false on the tick a window first shows a
terminal, and the declaration cannot arrive until a later tick — the
frontend learns the buffer id from that very snapshot. One truthful sweep
always lands first. The real-daemon two-frontend test written to catch the
freeze passes against the pre-fix tree; the bite is vacuous and the test is
labelled a regression guard, not fix evidence. The skip goes anyway: it was
load-bearing on tick ordering and bought nothing, and removing it makes
"presence follows the frontend" structural.
Terminal motion is deduplicated by cell (finding 3). Sub-cell motion
resolved to the same coordinate and still crossed the wire, where every
event is a daemon-side gesture. Press and release re-arm the memo so the
first drag after a press still reports. Its unit test cannot bite — the
seam did not exist pre-fix — and says so.
Declarations record only once sent (finding 4).
terminal_declaration_if_changed is now a pure query;
note_terminal_declaration_sent records. A failed write is retried instead of
suppressed as already-declared. The existing a35 test caught the contract
change and now pins both halves.
Unchanged frames skip revalidation (finding 5). The complete-payload
comparison runs before validate; only validated frames are ever stored, so a
frame equal to the baseline has already passed. The chrome tail is factored
into terminal_chrome so both exits emit it identically.
Gates: fmt; strict workspace clippy; 1,757 default + 1,933 CRDT library
tests; Stage 1 9/10, Stage 2 4/4, Stage 3 5/7, statusline 7/8
(default/CRDT); M4 120; required GPU 128; workspace sweep 2,921 across 83
suites; diff check clean.
Vterm Stage 3 — the final vterm stage. A semantic frontend can now host a
terminal: the daemon ships complete validated cell grids, and pmacs-gpu
renders them with fixed-cell geometry, its own input path, and no document
projection at all.
Protocol v19 appends three variants after their enums' final v18 members:
InstanceMessage::TerminalFrame (daemon-gated), and FrontendEvent::
TerminalResize / TerminalPointer (frontend-gated). It is the first bump to
gate in both directions, so criterion 28 pins each filter independently and
byte pins on StatuslineSegments and MenuPointer guard the placements.
pmacs-protocol gains src/terminal.rs: the shared row/column/visible-cell/
grapheme/metadata bounds, TerminalProcessState, TerminalSelectionSpan, and
TerminalFrame::validate — the ONE structural policy the daemon runs before
emission and the frontend runs after decode. src/terminal/* re-exports them
so no duplicate type exists, and unicode-width becomes a workspace dependency
so the screen and the validator measure glyph columns with one table. A new
8 MiB aggregate glyph bound keeps the largest legal frame (measured:
13,437,863 bytes) under the unchanged 16 MiB transport cap rather than
widening every connection's allocation ceiling.
The semantic producer suppresses the whole document family for a terminal
buffer while keeping the status band, theme, font, statusline, menu, and
minibuffer, and compares the complete ordered payload rather than
screen_generation — scroll, selection, and process state all change without
advancing it.
Two things the framing did not spell out, both found by the real-daemon
acceptance:
The Viewport gate keys on the authenticated source's ACTIVE buffer, not the
buffer the message names. Viewport also aligns the window to what it
declares, so a stale document viewport in flight when a command opened a
terminal dragged the frontend straight back off it: the window oscillated,
every terminal declaration was refused, and no frame ever arrived, with
nothing logged anywhere.
The producer clears terminal mode on every exit path. The daemon uses that
flag to suppress CursorByte and the presence sweep, so an early return that
left it set kept both suppressed after the frontend returned to a document.
pmacs-gpu/src/terminal.rs is a pure cell-space paint planner, unit-testable
without a GPU. The renderer builds one shaped buffer per text run, so a wide
or cluster glyph's advance can never choose the next column's origin.
Criterion 37 needed a seam rather than a fixture: pmacs-gpu depends only on
pmacs-protocol, so attach::connect's reader sink was generalized and a
--headless-probe mode added. The acceptance drives a real daemon, a real
/bin/sh child, the real attach client, and real composited pixels in one
path — which is how both defects above were found.
Gates: fmt; strict workspace clippy; 1,757 default + 1,933 CRDT library
tests; vterm Stage 1 9/10, Stage 2 4/4, Stage 3 4/5 acceptance
(default/CRDT); statusline 7/8; M4 120; required GPU 127; workspace sweep
2,919 across 83 suites; diff check clean.
Document and pin the GPU built-in-only narrow-band clipping policy,
including the intentional ability of a wide right group to hide the
left identity. Guard the fixed UI face ordering used by binary search,
preserve flattened provider tracebacks in *errors*, and rename the
phase-one unavailable reason to cover missing layout contexts.
Refresh the implementation verification record after the full gate
suite.
Co-Authored-By: Claude <noreply@anthropic.com>
Add the strict pmacs.statusline provider registry, deterministic
borrow-released per-window evaluation, context-scoped failure latches,
and a pure built-in LSP provider.
Preserve the legacy TUI modeline while composing faced custom runs,
and append authoritative complete StatuslineSegments replacements for
semantic frontends. Expand dynamic ThemeFacts, reset producer/frontend
baselines symmetrically, and gate all provider work off protocol v18.
Teach the GPU to atomically validate, resolve, shape, clip, and cache
custom modeline runs without displacing the protected status suffix.
Document the public Lua lifecycle, wire ownership, snapshot semantics,
and the fully gated Arc 4 stage-3 delivery state.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Normalize source bytes to representable shaped-cluster cursors and
reflow the code buffer whenever gutter or minimap geometry changes.
Measure alternate monospace advances across complete shaped runs and
load hermetic fixture faces before FontSystem construction. Complete
the rendered geometry, popup, caret, snapshot, and fixture acceptance
coverage, and record the review fixes in framing revision 5.
Twenty-one new headless tests covering the GPU-side acceptance items of
docs/gpu-set-font-framing.md (9-14, 16-19):
- 9: size route re-derives metrics; (None, None) reset reproduces
the never-set frame byte-for-byte
- 10: 600/7200 bounds render; minibuffer dropdown windows its rows
above the band at the derived row height; context menu keeps
the clipped route with coherent hit geometry
- 11: wrapped caret survives 16->72->6 px; an optimistic insertion
that wraps a new bottom row follows immediately and its
confirming CursorByte needs no second repair; overscan caret
never snaps the viewport; narrowing resize re-follows via the
coarse + visual-run + fold pipeline; adornment projection
shifts the caret past injected text with left gravity at the
anchor; the CursorByte arm follows into a wrapped run as a
normalized sub-line residual; explicit scrolls clear the
residual (including wheel-up at the clamp edge); the
minibuffer suspends the follow decision
- 4 (GPU half): BufferSnapshot resets the residual while the font
preference/metrics survive -- the replacement buffer wraps
too, so the EOF clamp cannot mask a leaked residual
- 12: unknown + proportional families fall back; the four-style
gate rejects a proportional BOLD sibling the normal-only
query would accept; a valid second monospace family resolves,
changes the ink, and resets cleanly; the parameterized
sanitizer removes exactly the same-family proportional
collision
- 13: the "\0" sentinel defeats the status string-equality gates
- 14: a shrinking visible slice re-declares the scoped viewport
- 16: 0/599/7201/u32::MAX reject the whole message, state untouched
- 17: metrics + drawable dimensions atomic on all seven buffers,
resize symmetric via Buffer::size()
- 18: popup rows never wrap (Wrap::None + unique line_i per run)
- 19: with line numbers and a context menu open over an EMPTY
buffer, the measured probe advance drives gutter reservation
and menu hit width (fixture ratio exactly 1.2 vs the bundled
default); reset restores the exact geometry and frame
Family routing is hermetic: four generated fixture faces under
pmacs-gpu/fonts/test/ (second monospace at 720/1000, a proportional,
and a "Pmacs Test Family" whose NORMAL face is monospaced but whose
BOLD face is proportional), built by the committed generate.py --
provenance and license in LICENSE.txt beside them. fontdb requires a
PostScript name (nameID 6), which the generator sets explicitly.
assemble() now routes construction through sync_buffer_dimensions
too: shape_until_cursor and wrapping must use the painter's clip
from the first frame -- a v16 daemon never sends the FontFacts that
would sync them later (found by the CursorByte-follow test: the
caret parked in the status band at surface-height dims).
Every protection was bitten (temporarily reverted, its test observed
failing, then restored): wire validation, CursorByte visual-run
follow, painted-before gate, projection inversion, Wrap::None,
four-style gate, sanitizer, status sentinel, normalize fold,
scroll_by_lines residual clear, BufferSnapshot residual reset, the
all-seven dimension helper, and the optimistic-completion follow.
The snapshot-reset test was strengthened after its first bite did
NOT fail (a short replacement buffer let cosmic's EOF clamp zero the
residual anyway).
PMACS_REQUIRE_GPU suite 90/90; clippy -D warnings and fmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
The stage-2 GPU application half, framing revision 4:
- apply_font_facts transaction: fail-closed wire validation
(600..=7200 centi-px; deserialized protocol input, not the Lua
courtesy check), painted-before decision from the ACTUAL caret rect
intersected with the drawable code clip, four-style monospace
resolution with total fallback to the sanitized default, derived
metrics + measured advance, atomic re-metric/re-size, status
shaping-cache drop, reshape at the retained scroll, drawable-width
settle pass, conditional caret re-follow, minimap cache drop.
- Advance probe: fixed ASCII digits shaped in the resolved family at
the new code metrics; the NORMAL-face advance is authoritative for
gutter geometry (mono_advance no longer trusts an arbitrary code
glyph once measured), and the selected/default ratio scales the
const-based fallbacks -- the default family is ratio 1 by
construction, so never-set/reset stays byte-identical.
- sync_buffer_dimensions: metrics + REAL drawable dimensions change
atomically on all seven buffers via set_metrics_and_size; the code
buffer wraps at the painter's clip width; resize() routes through
the helper (closes the old four-of-seven skew) and applies the same
painted-before follow policy.
- Rows stay rows: Wrap::None on the menu/minibuffer/completion
buffers at assembly and (idempotently) in the transaction.
- Normalized code-buffer Scroll residual: slice-local line == 0
invariant with a vertical pixel residual; horizontal always
discarded (glyphon 0.11 never applies it). normalize_code_scroll
folds line advances into whole-file scroll_top (strictly advancing
origin, EOF clamp) after EVERY final code shape. BufferSnapshot
resets the residual (buffer-scoped view state); explicit
wheel/minimap jumps clear it -- including a wheel-up whose only
remaining motion IS the residual.
- Byte-to-layout projection: code_byte_to_projected inverts the
line_chunk_cache chunk projection (earliest projected boundary for
adornment anchors, the left-gravity caret), then code_byte_px uses
cosmic-text's layout_cursor so wrap boundaries select the same
visual run shape_until_cursor scrolls to. caret_rect and
completion_anchor_px are now visual-run aware instead of scanning
the source line's first run.
- ensure_caret_painted (coarse source-line follow, then
shape_until_cursor, discard Scroll.horizontal, normalize) shared by
the CursorByte arm (under its existing moved gate -- fixes the
pre-existing wrapped-line follow hole), the optimistic edit
completion, the font transaction, and resize.
- Gutter mirrors the code layout's visual runs: continuation blanks
for wrapped runs plus the same normalized vertical scroll, so line
numbers stay row-aligned when wrapping appears.
- assemble() shapes the initial text through reshape() so the
buffer.lines <-> line_chunk_cache invariant holds from
construction (the projection inversion depends on it).
PMACS_REQUIRE_GPU suite 69/69; clippy -D warnings and fmt clean.
Acceptance tests for items 9-11/13-14/16-19 land in the next commit
with the embedded test faces.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
The 13 compile-time metric consts become BASE_* values behind a
FontMetrics { scale, advance_ratio } carried on State: every
surface derives from one knob (size/16.0), menu_char_w and the
empty-gutter advance fallback additionally multiply the measured
selected/default advance ratio, and the Default (1.0, 1.0)
reproduces today's constants bit-for-bit -- all 69 GPU tests pass
unchanged. The compiler enumerated every use site via the BASE_*
rename; fm threads as a parameter through the free helpers
(text_area_bottom, estimated_visible_lines, minimap_height/
band_contains/y_to_line/rects, edge_scroll_direction,
mb_dropdown_window, menu_width_px). The stray Metrics::new(16.0,
22.0) literal in assemble() now derives from the same fields, and
all seven Family::Name("JetBrains Mono") literals route through
State.resolved_family (seeded from the sanitized default; the
borrow checker forced hoisted clones at set-text sites, so the
field itself is the single source rather than an accessor).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
assemble() replaces FontSystem::new() + post-hoc load_font_data
with an explicit fontdb::Database built in today's order -- system
fonts first, the bundled JetBrains Mono second (its fontdb::ID
retained) -- then the parameterized same-family collision filter
(sanitize_font_database removes every NON-monospace face
advertising the default family; the bundled face survives by
construction), cosmic-text's generic-family defaults, and only
then FontSystem::new_with_locale_and_db (sys-locale with the
"en-US" fallback, the same resolution cosmic-text's own
constructor performs; new pmacs-gpu dependency) -- so the internal
monospace-ID set is computed over the final database, bundle
included. FontDefaults { default_family, bundled_id } lands on
State as the total-fallback anchor for the resolution work in the
next commit; query_normal_face is the shared normal-style query
(the same fontdb::Query the base Attrs imply). Debug assertions
pin both anchors present and monospaced at assembly. All 69 GPU
tests pass unchanged -- assembly preserves today's pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Protocol v16->17: InstanceMessage::FontFacts { family,
size_centi_px } appended after ThemeFacts (integer hundredths of a
logical pixel -- the enum derives Eq, f32 cannot; range 600..=7200
documented on the wire). Pins updated: version 17, ladder accepts
6..=17 rejects 18, FontFacts round-trip (populated + all-None), and
a ThemeFacts byte pin ([23, 0]) guarding the appended placement.
Daemon half: FontPref { family, size_centi_px, epoch } behind a
shared handle on EditorState, installed with the new pmacs.gpu Lua
module BEFORE load_user_config so init.lua set_font lands in the
state the first attachment reads. set_font is strict plain data
(raw_get, unknown raw keys rejected by name, metatables never
consulted, parse/validate/quantize fully before locking -- range-
check the ORIGINAL value so 5.999 errors, then nearest-hundredth
round); pmacs.gpu.font() returns a fresh quantized table.
Producer: font_facts_msg (the theme_facts_msg discipline --
Option-seeded epoch + payload baselines, advance on computation,
one authoritative send per attachment incl (None, None),
bufferless so on_buffer_snapshot_sent never touches it); for_peer
gains peer_knows_font_facts (>= 17); daemon write-loop skip arm;
TUI silent-drop arm + regression test; first-frame count test now
expects 6 messages. GPU application follows in the next commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Four review findings + a cleanup bundle.
[P1] Wire flattener was O(spans²) and ran over the WHOLE buffer (the
file-style summary uses a whole-buffer viewport, not the visible one).
Replaced the per-interval full scan with an ordered active-set event
sweep (activate on start, expire on end, fold the active set) — linear
in practice. Added full_buffer_summary_scales_on_large_grammar_file
(1500-line rust) as the perf gate.
[P2] _parse_now used the empty alias map from make_request while
_dispatch snapshotted the registry map, so a `py` fence injected async
but not sync. Snapshot aliases on both paths; pinned by
sync_parse_now_resolves_alias.
[P2] The multi-range inline test used a one-line paragraph, whose block
inline node has no named children (link/emphasis are child-grammar
structures) — one range, so it couldn't falsify multi-range. Replaced
with a multi-line blockquote whose inline node carries a named
block_continuation: content_node_ranges now asserts >1 collected range
and emphasis parses on both lines.
[P2] The layer backstop dropped regions silently; the framing requires
a surfaced warning. run_parse now sets ParseTreeBundle::injection_capped;
syntax.lua's settle tick raises it once per buffer via pmacs.error
(_injection_capped). Added injection_layer_cap_surfaces_and_preserves_root
(drives >4096 fences, asserts the flag + bounded count + intact root).
Cleanup:
- The GPU acceptance test now drives the real StyleSpans full-frame
transform (spans_from_segments, extracted from replace_style_spans)
instead of a hand-rolled sort.
- content_node_ranges excludes NAMED children (documented as a round-1
refinement); framing mechanic #3 / Q#IJ5 updated to match.
- parse_duration doc now says root parse; the markdown entry no longer
describes inline as unhighlighted/future.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
Teach the syntax engine that one buffer can hold more than one
language. After the root parse, run the grammar's injections.scm, parse
each embedded region with the injected language, and merge every
layer's highlight spans. First consumer: markdown fenced code + inline
(zero new grammars — the block grammar already ships an injection query
and the injected langs already have grammars from #118).
Engine (src/syntax.rs):
- ParseTreeBundle now holds Vec<Layer> (root layer 0 + injected
children, depth-ascending); installed atomically so the existing
Arc::ptr_eq style gate and highlight cache keep working (Q#IJ1).
- run_parse builds layers on the worker: run injections.scm, resolve
the injected language, compute Vec<Range> (exclude NAMED children,
intersect the parent's ranges), set_included_ranges cold-parse,
recurse — bounded by depth (3), a layer backstop (4096), and a
(lang,ranges) visited guard; any child failure drops that child only
(Q#IJ3/IJ5). LanguageEntry gains injections_query; markdown_inline is
registered (retires the M9.7 block-only floor); markdown/rust carry
injection queries.
- Injected languages resolve off the static BUILTIN_LANGUAGES table
(Send loaders + query sources), preserving lazy loading. Dynamic
fence names go through a case-folded alias map seeded with defaults
and Lua-extensible via pmacs.parse.injection_aliases, snapshotted into
ParseRequest at dispatch so the worker never touches the Rc registry
or a Lua table (Q#IJ2/IJ4). Highlight queries are resolved at settle
(resolve_layer_queries), keeping query compilation main-thread/cached.
Producers:
- SyntaxHighlightView (grid) iterates layers shallow-to-deep so a
deeper layer's styling wins within its region (Q#IJ6/IJ7).
- scoped_style_spans (wire) flattens all layers into DISJOINT effective
spans via a boundary sweep, since the GPU re-sorts spans by start
(replace_style_spans / merge_style_spans) and would otherwise destroy
producer order. The GPU source_color_at consumer is fixed to fold all
covering spans (matching semantic_client's effective_style_at) rather
than returning the first.
Named-children exclusion: content ranges exclude only NAMED children
(matching tree-sitter-md's own inline splitter) — excluding a block
inline node's anonymous text tokens would shred the paragraph into
unparseable fragments.
13 acceptance gates (framing docs/multi-language-injections-framing.md):
layer structure, absolute child offsets, alias resolution (static +
case-folded dynamic + unknown-skip + Lua-async override), multi-range
inline, recursion bounds, wire + grid + GPU producers, incremental edit
/ new fence, many-paragraph settle budget with tail coverage, and the
single-layer regression guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan