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>
Stage 4 of the QoL arc, framing revision 4 (approved). Under
`truncate`, text past the right edge was UNREACHABLE; moving the cursor
now brings it into view. Automatic only — no commands, no bindings, no
new interaction island (Q#HS2).
THE CONTRACT. `view_left` is an unsnapped per-window display column
(Q#HS7(a)), and each line derives its own effective edge during the
walk it already performs from column 0. Starting at 0 is not laziness:
tab expansion depends on the absolute column from the line start, so a
walk beginning at the edge would put tab stops in the wrong place. The
walk stays line-absolute and only the emit translates.
Where the edge bisects a wide glyph on a given line (Q#HS7(c′)), its
trailing cell paints a styled BLANK rather than a `Continuation` — that
glyph means "the cell before me is a wide glyph's head", and here that
cell is off-screen, so emitting it would name a cell nobody painted.
The mapping designates that cell to the glyph's START byte, which keeps
`byte_at_place` total over visible cells and makes the character the
user scrolled toward clickable. Tabs keep FORWARD rounding (Q#HS7(c″))
— preserved, not chosen.
DECORATIONS TRAVEL WITH THE TEXT. The first version of this commit
translated the base glyph walk and nothing else, which split the frame
in half: at `view_left = 10` a glyph from source column 10 painted at
screen column 0 while its syntax style, diagnostic underline, search
wash and `BufferStyleOverlay` span painted at screen column 10 — or
vanished. Decorations drifting off the characters they describe,
silently, and only once a window had been scrolled.
Every such site carried the same two lines (`start_col.min(max_cols)`,
`end_col.min(max_cols)`), correct only while the left edge was pinned
at zero. `Viewport::visible_cols` is now the one rule all FIVE adopters
share — syntax/LSP styling, diagnostic underlines, search washes,
`BufferStyleOverlay`, and the selection painter — so a future decorator
inherits the translation instead of re-deriving it. It also subsumes
the old `end_col <= start_col` guard rather than sitting beside it.
`StyleSpanOverlay` and `VirtualCellOverlay` are deliberately untouched:
they are documented as viewport-relative, so translating them would be
the mirror defect.
The selection painter was nearly a sixth site with its own copy of the
rule, which I justified by a width it supposedly needed and the
viewport lacked. That was FALSE — the render viewport's
`cell_size.cols` is already `rect.size.cols - gutter_w` and its origin
already sits past the gutter. It now takes that same viewport and drops
its `rect`/`gutter_w` parameters entirely. A canonical rule with one
honest exception is not canonical.
The selection painter had the same defect with a worse failure mode: it
asked `pos_to_display` through the LIVE context, which returns `None`
for a position left of the edge, so a selection beginning off-screen
and reaching into view took `continue` and painted NOTHING. That is the
common shape, not an edge case — select rightward from column 0 past
the window width and the view scrolls with the cursor.
TWO THINGS THE TESTS FOUND, both in `pos_to_display`. My framing note
said a caret sits between characters so never lands inside a glyph;
true for the caret, false for the DESIGNATION direction — the glyph's
start byte must map to its visible trailing cell, so `screen_col` needs
the straddle rule and not a bare subtraction. And the `take == 0` early
return short-circuited the translation entirely, so byte 0 looked
visible at every offset.
`view_left` is inert under `wrap` BY CONSTRUCTION —
`LayoutCtx::effective_left` and `Viewport::left_edge` return 0 while
wrapping — rather than by every caller remembering.
Persisted per leaf at DESKTOP_VERSION 1 (Q#HS5) with both approval
conditions: `#[serde(default)]` and a literal v1 JSON fixture omitting
the field, hand-written because a generated one would gain the field
and prove nothing.
Also: `view_left: window.view_left` in the render viewport, not a
literal 0. My mechanical fill put 0 there and it is EXACTLY the
`aa3cd4d` defect — coordinates and the indicator following the scroll
while the painter stays pinned at column 0.
BITE, per clause. Forcing `bisected = false` fails the multi-line
straddle witness; dropping the backward designation fails the
round-trip witness; removing `#[serde(default)]` fails the v1 fixture;
pinning `visible_cols` to an absolute clamp fails all three decorator
witnesses; restoring the selection painter's live-context lookup fails
the off-screen-start selection witness. Each alone. And with selection
now reading the shared helper, pinning `visible_cols` to an absolute
clamp fails the selection witnesses TOO — which is the check that the
duplication is really gone rather than merely reworded.
One unrelated red, logged as R7 in ci-red-signatures.md — the first
this session with a COMPLETE signature, so a matchable row rather than
a U note. `pmacs-gpu`'s managed-retry attach hit a BrokenPipe once
under full-sweep load and did not reproduce (6 isolated runs plus a
clean 113-target sweep). Per the rerun rule that is intermittence only,
and the row explicitly does not claim harmlessness. Not attributed to
this lane: Stage 4 touches no `pmacs-gpu` file and adds no wire
surface.
Gates: fmt; clippy --workspace --all-targets -D warnings, both
configurations; `cargo test --workspace --no-fail-fast -- --skip
basedpyright` 113 targets exit 0, and the same with --features crdt,
113 targets exit 0; git diff --check. No protocol change, so no version
bump and no protocol-bump matrix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The frame resolved ui.line-wrap, recorded it on the window, and fed it
to the coordinate mapping and the scroll indicator --- while the
viewport handed TextView::render a hard-coded Truncate. With the
default of wrap, that meant the cursor was placed for wrapped text and
the indicator reckoned against wrapped rows while the text was still
clipped at the edge. The worst possible split: every part that reports
where things are agreed, and the part that draws them did not.
The viewport now reads window.last_wrap, so it consumes the same single
resolution as everything else rather than resolving again.
How it survived: the edit was in a script that raised on a LATER
assertion, so nothing was written; a follow-up script's replace then
matched nothing and silently did nothing. Every test I had asked "is
the mode right?" and none asked "is the text wrapped?", so all of them
passed.
Hence the new witness reads the GRID. the_default_actually_wraps_the_
painted_text goes through RenderState and reconstructs rows from the
emitted CellDelta spans, for two reasons: the defect lived in the
DRIVER, between the resolved mode and the viewport it built, so a test
building its own viewport would have passed against it --- and the
spans are what a TUI actually consumes. truncate_clips_the_painted_text
is its control, so the pair is discriminating rather than merely true.
It bites: restoring the hard-coded Truncate fails the wrap witness
while all five other tests keep passing, which is exactly the shape
that let it through.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1917/0,
crdt 2102/0, line_wrap 6/6, tab_width 2/0, folding 21/0,
full_grid_resync 1/1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
format_scroll_indicator derives every branch from total_lines, and its
first branch is `if total_lines <= 1 { return "All" }`. A one-line
buffer wrapping to fifty rows still has one line, so the indicator
claimed the whole buffer was on screen while forty-nine rows sat below
the viewport.
Under wrap the TUI now classifies through pmacs_protocol:📜
All/Top/Bot from LOCAL predicates, the percentage from byte position.
Under truncate it calls the existing formatter with the same arguments
in the same units, so that output is byte-identical by construction and
every existing formatter test stays valid.
The local predicate needed a new fact, and getting it right took two
attempts. TextView::render now records whether the walk ran out of
BUFFER before it ran out of rows --- recorded by the walk rather than
recomputed, because under wrapping it cannot be derived from line
counts and a second derivation could disagree with what was painted.
The first version was `line >= line_count`, which is true whenever the
last line was STARTED. Under wrapping that is exactly the wrong moment:
a fifty-row line begun on the last visible row would report the buffer
end as on screen. It needs `row_offset <= max_rows` as well --- the
rows it wanted actually fit. The witness caught it; the reasoning did
not.
The GPU half is NOT in this commit, and that is deliberate. I wired it,
and extreme_sizes_render_with_contained_popups failed --- correctly.
That test asserts the frame diff between two renders is confined to the
completion popup, and my last_visible predicate read self.view_range,
which the popup's reshape moves, so the status text changed between the
two frames. The predicate was also simply wrong: view_range includes
overscan, so it can reach EOF while the last row is off-screen. A
stable, correct local predicate on that side needs an understanding of
the slice/overscan relationship I do not have yet, and a guess there
would ship the same class of defect this stage exists to remove.
Reverted; the GPU indicator remains open work.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1917/0,
crdt 2102/0, pmacs-gpu 224/0, line_wrap_acceptance 4/4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The setting exists now, and both frontends honor it. This is the commit
that turns the stage on: the grid renderer, the coordinate mapping, and
the GPU were all built and tested against a mode nothing could set.
ui.line-wrap is buffer-local, an enum of wrap and truncate, defaulting
to wrap. A closed set rather than a string, so an unknown value is
impossible rather than handled --- and so adding "word" later is
additive. It lives in ui., not editing.: editing.* is buffer-editing
behavior, this changes only how text is SHOWN, and the two existing
ui.* settings carry a gpu- prefix to mark frontend-specific ones, so
the absence of a prefix is what says "both frontends".
Resolved exactly once. The render loop reads it per window per frame
and records it on the window; the viewport is built FROM that, and
Window::layout_ctx hands the same answer to all twenty coordinate call
sites. Nothing re-resolves, so two callers cannot disagree about one
buffer. The earlier write-back from viewport to window is removed as
circular now that the resolution precedes the viewport.
Semantic frontends are told over LineWrapFacts, gated at v22 in the
producer and again in the daemon write loop. The dedup key is the
(buffer, wrap) PAIR, and that is the whole design rather than a
micro-optimisation: font size is global, so caching it by value is
right, but wrap mode is buffer-local, so a value-keyed cache stays
silent when the user switches from a truncating buffer to a wrapping
one --- a real mode change with no config event behind it.
a_buffer_switch_re_emits_the_wrap_mode pins that, and it bites: keying
the cache on the mode alone fails it while every other test still
passes, which is exactly how the bug would have shipped.
Two existing tests moved, and both moved for the right reason rather
than because they were stale. The semantic allowlist gains the variant,
and the first-frame count goes 7 to 8 --- the second is really an
assertion that the attach trigger works: a semantic frontend that is
not told on its first frame never learns the setting at all.
Also ships ui.toggle-line-wrap, and its status line says the quiet part
--- turning wrapping off makes text past the right edge unreachable
until horizontal scrolling lands in Stage 4. That belongs where a user
sees it, not only in the framing.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1916/0,
crdt 2101/0, pmacs-gpu 224/0, tab_width 2/0, folding 21/0, gui_zoom
15/15, full_grid_resync 1/1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
pos_to_display and display_to_pos now take a LayoutCtx and DisplayCoord
carries a sub_row. The asymmetry is the whole audit strategy, and it
played out as designed.
Breaking on the way in: adding a required parameter turned the audit
into 61 compiler errors instead of a grep. Additive on the way out:
sub_row defaults to 0, so overlay_paint's `row - view_top` and vertical
motion's bounds check stayed CORRECT rather than merely findable ---
neither needed touching. row is still the source line. Redefining it as
a visual row would have broken both silently.
Two structural gaps this surfaced, neither in the framing:
Window recorded last_visible_rows but no width, so the coordinate
callers --- vertical motion, paging, overlay placement --- had nothing
to build a context from. Added last_content_cols, taken from the
viewport the renderer actually used rather than recomputed: a second
derivation could disagree, and the disagreement would show only as a
cursor on the wrong row. Content width, not window width, because the
gutter grows at the line-count digit boundary.
The mode had the same problem one level up. It is buffer-local and the
registry has no ambient buffer, so only the driver can resolve it ---
but every consumer holds a window, not a registry. Window::last_wrap is
recorded beside the width and read through Window::layout_ctx(), so
there is ONE resolution consumed everywhere. When ui.line-wrap is
registered, only the driver changes and all twenty call sites become
wrap-aware together. The alternative, each caller resolving for itself,
is how two callers end up disagreeing about one buffer.
One real regression, caught by the render tests rather than reasoning:
generalising row_of_byte into place_of_byte lost the boundary rule. A
byte landing exactly on a row edge reported (row, max_cols) instead of
(row+1, 0), so a viewport anchored there painted the wrong row. The fix
is the rule framing section 7 already settled --- the wrap position is
owned by column 0 of the NEXT row, because that cell always exists and
(row, max_cols) does not.
Five coordinate witnesses. Identity on every cursor boundary of a line
containing a tab and a CJK glyph, across four widths; projection to the
codepoint start for interior bytes, unchanged by wrapping; the two
distinct adjacent codepoints across a break mapping distinctly; row
staying the source line; and a truncate control. They bite --- forcing
the wrap branch off fails three, including the round trip.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1912/0,
crdt 2092/0, tab_width 2/0, folding 21/0, gui_zoom 15/15.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Stage 2 (GUI zoom, #220) landed. Stage 3 shares no code with it, so this
is a currency merge rather than a dependency --- taken now so the
Stage 3 PR opens against a base it has already been tested on.
# Conflicts:
# docs/active-work.md
Inert by construction. Adds the type and the Viewport field, sets all
31 construction sites to Truncate, and changes no rendering: --lib is
1900/0 and crdt 2085/0, the same counts as the parent commit.
The field is required rather than defaulted on purpose. A default would
have let 31 sites stay silent about which behavior they meant; a
required field makes each one state it, so the pre-existing sites now
read as deliberately unwrapped rather than merely untouched. The
compiler enumerated them, including five integration tests --- Viewport
is public API, so this is a real break, and the break is the point.
The render driver is pinned to Truncate too. The wrap path does not
exist yet, and exposing a mode before the cursor mapping honors it
would ship a setting that renders one thing and navigates another ---
the shape of defect this lane exists to remove, not add.
Two notes on getting here, since both were nearly landed:
The first mechanical patch matched every `folds,` line and put a wrap
field into function call sites and a FoldStore literal. Scoping the
insertion to Viewport literals cut it from 40 sites to 31. The compiler
caught it, but only because a struct field cannot be mistaken for an
argument; a same-arity call would have compiled.
While rewriting the character walk I changed the wide-character edge
case --- a double-width glyph with one cell left now breaking instead
of painting a lone lead cell. That is arguably better behavior and it
is NOT this commit's to make: Truncate must be byte-identical, and an
"improvement" smuggled in beside a refactor is how identity cases stop
being identity cases. Reverted; the walk is untouched.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1900/0,
crdt 2085/0, tab_width 2/0, listview 26/0, compile_mode 73/0,
folding 21/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Ctrl +/- had no effect whatsoever in the GPU frontend. Stage 1 (#219)
fixed what zoom did to the TUI; this is the other half.
NO RENDERING WORK. FontMetrics::scale already derived every GUI
dimension — code size, line height, status band, divider, menu rows,
minibuffer dropdown, gutter advance — and apply_font_facts already
re-metriced all seven buffers in one transaction. This drives the
preference that existed: two settings, three commands, and a restore.
Q#Z1 = (c). Relative zoom needs an origin and the daemon is built never
to know one — font_pref.rs is explicit that it "never learns metrics,
advances, or what resolves". Hardcoding 16.0 would put a pixel constant
on the daemon side; always sending a size would destroy the `None`
state for everyone who never zooms. A configured base is the only
option where the daemon still infers nothing, and the untouched path
stays byte-identical.
THREE THINGS REVIEW CAUGHT THAT REVISION 1 HAD WRONG.
Q#Z3 was not implementable as framed. `keymap_stack::Scope` is
Buffer | Mode | Global and carries no frontend identity, so "bind on
GPU frontends only" does not exist; and FrontendEvent has no
command-invocation variant, so the GPU cannot ask for a command by name
either. A global binding would capture the chord in the TUI and take
away the terminal's own zoom — the very thing the user is pressing it
for. Commands ship; the binding waits on capability-aware keymap
resolution, which is now a named follow-on rather than something
smuggled in here.
The restore seam did not exist. Builtins and init.lua both run BEFORE
install_state_dirs, so a pmacs.state.read at module load returns
nothing, always. saveplace and recentf never meet this because both
read lazily inside functions; zoom must apply with no user action,
which makes it this project's first eager state consumer. Restore lives
at the end of install_state_dirs — by definition the moment state
becomes readable, so it cannot be ordered wrongly and a future third
startup path gets it without knowing it had to ask.
Every size write clobbered the family. set_font replaces both fields
unconditionally, so { size = n } alone silently cleared a configured
family until restart.
BITTEN, THREE WAYS. Dropping family preservation fails 3 tests.
Reverting to the framing's own first parser `^(%d+)$` fails 4 including
the seam restore — it anchors to end-of-subject and rejects the
newline-terminated file the writer emits, which is the contradiction
review caught in the framing before it reached code. Hardcoding the
16.0 origin fails the base test.
Also recorded: a loaded crdt run failed two m6_1 PTY tests with
`stty -a output was: ""`. That is R4/R6's empty-content readiness
family, and it means the readiness-helper audit's scope is wider than
three wait_for_file copies under tests/ — src/process.rs's own tests
carry the shape. Undiagnosed, load-sensitive, green isolated and on a
quiet full run; a scope note for that lane, not a registry row, since
the registry judges red CI runs and these were local.
Verified: fmt, clippy, diff-check, --lib 1900/0, crdt 2085/0,
gui_zoom 13/13, journey 47/0, m4 150/0, gpu 221/0, full_grid 1/1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements `docs/discovery-stage1-command-family-framing.md` (approved
at revision 6). `COHERENCE.md` §5 graded discoverability "substrate
without surface": the registries already carried descriptions, source
locations and reverse key lookup, and almost none of it was reachable.
Eleven commands under one `help.*` prefix, so typing `help` at M-x
surfaces the whole family. Nine are new; `editor.describe-command` and
`editor.describe-setting` are renamed in, with the old names retained
as forwarders so nothing documented breaks.
No Rust. Every command renders data `pmacs.describe.*`,
`pmacs.keymap.list()`, `pmacs.command.list()` and `pmacs.config.list()`
already return, and `describe-setting`'s completion source is a Lua
function via `CompletionSource::Custom`, which needed no binding work
either — correcting a comment in `default.lua` that claimed `source`
was a fixed Rust-side vocabulary.
`apropos` matches by substring, not fuzzy: `fuzzy_score` is
subsequence-based and descriptions are long sentences, so fuzzy would
match nearly every command.
Two disciplines the file keeps. Every command renders through the
public `pmacs.editor._show_help`, which buys one owner for the shared
`*help*` policy — reuse-by-name, wholesale replacement, `q`, and the
foreign-buffer hazard. It does NOT buy a one-site migration to
`src/help.rs`, which has no renderer for settings, lists or apropos; so
rendering is a named per-subject function, and the future Rust work is
enumerated per subject rather than discovered per call site.
The seam-counting pin earned its place immediately: the two renamed
commands were still calling the file-local `show_help_text`, so the
funnel was fiction for exactly the two commands that predate it. They
now call the public seam, with a comment saying why the local is not
used from the same file.
Moves `help` out of `welcome.lua` into the new `runtime/help.lua`,
which owns the family and loads after it so the index can read
`pmacs.welcome.entries`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
`EditorState::new` resolves two storage roots from the process
environment before it returns: the data root, which the bundled-package
materialization then WRITES into unconditionally (outside every `cfg`
guard), and the config root, from which `init.lua` is read.
The `#[cfg(not(test))]` guard on the second was written to stop the
crate's own unit tests picking up a developer's real `init.lua`. It does
exactly that and nothing more: `cfg(test)` is set only while compiling
the lib's own tests, so an integration test in `tests/` — compiled
without it — reads the real config and writes the real data root. On a
machine with a real `~/.config/pmacs/init.lua`, that is 11 deterministic
failures in `compile_mode_acceptance`, attributed to whatever branch is
checked out.
Tests cannot fix that themselves: `std::env::set_var` is `unsafe` and
this crate is `#![forbid(unsafe_code)]` — the same constraint that
produced `Installer::with_install_root_override`. So isolation arrives as
a parameter.
`BootstrapRoots` names the four storage roots (config, data, state,
cache). `ambient()` leaves every one `None` and every resolution goes to
the environment exactly as today, so production is unchanged.
`new_with_roots` and `open_with_roots` take it — both, because `open`
calls `Self::new()` internally and a constructor-only parameter would
leave every open-path test ambient. `install_state_dirs` consults it
too: it runs after construction, so resolving from the environment there
would reopen the hole the constructor closed.
The redirected branch changes WHICH directory is read, never WHETHER the
block runs. Config loading shares one conditional with
`set_init_complete()`, and `tests/m8_2_acceptance.rs:75` documents its
dependence on integration-test construction finishing init-complete.
`child_env()` translates the same value into the environment a spawned
`pmacs` needs. Five variables, not four: `PMACS_STATE_HOME` outranks
`XDG_STATE_HOME`, so a child given only the XDG four still resolves an
inherited state override — invisible on a machine that exports none.
The `src/editor.rs` comment claimed a protection it does not provide and
said nothing about the write above it; both are corrected in place. The
guard is deliberately NOT widened to cover integration tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Review round 1 on #205, two findings, both accepted.
The greeting was written straight into the registry without calling
`notify_buffer_edit`. The window's `TextView` had been indexed while
`*scratch*` was empty, and newlines are zero-width to a painter working
from a stale line index — so the first TUI frame collapsed the whole
three-line greeting onto row 0. Every buffer-text assertion passed
because the buffer content was correct; only the rendering was wrong.
The edit is now captured, the registry borrow released, and the core
notified.
The pin that would have caught it paints a real frame and asserts the
second line occupies its own row AND that row 0 does not contain it —
both directions, because a one-direction check passes when everything
collapses upward. Bitten by dropping the notify call: row 1 comes back
empty with row 0 holding the lot, and it is the only pin that fails.
Second: the project docs still described the arc as it was two PRs ago.
`COHERENCE.md` §20 called 1b-2 in flight and the welcome buffer
unstarted; its arc list said 1b-3 remained; and the ledger's journey
lane header still read "1b-2 PR OPEN" while the 1b-3 block carried a
mangled "Framing only; no code" line left by an earlier edit. All now
describe the PR-head state per §25.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Implements `docs/journey-stage1b3-welcome-framing.md` (approved at
revision 4, after three review rounds). The last of the 1b split.
`COHERENCE.md` §18 graded onboarding "missing entirely": no welcome, no
cheat sheet reachable from inside the editor, and `M-x` — the only door
in — discoverable only by already knowing about it. A fresh `pmacs` now
greets an untouched `*scratch*` with three lines naming `M-x` and four
real bindings, and `M-x help` renders a cheat sheet.
The startup seam is the substance. No constructor is the right hook:
`EditorState::open` calls `new` before resolving its target, the daemon
constructs one too, `init.lua` runs inside `new`, and desktop restore
happens later still. So `run()`'s terminal-free prefix is extracted into
`prepare_startup`, which `run` delegates to, and the greeting happens
there — after config, after attach dispatch resolves to local, and
after desktop restore. Extracting it is also what makes the wiring
testable: with the greeting called by hand from tests instead, deleting
the production call would leave every assertion green while shipping no
welcome.
Lua owns what is said, Rust owns when and where. `pmacs.welcome.entries`
is a structured list that both renders the text and drives the binding
checks — scraping the rendered prose would be ambiguous, since `C-c c`
is two chords and nothing in the text marks the boundary.
The greeting is deliberately NOT written through
`set_generated_contents`: that would lift read-only, discard history and
mark the buffer generated, all wrong for the buffer journey step 5
requires the user to type into immediately. It is left unmodified so it
does not look like unsaved work.
`M-x help` renders through `editor.describe-command`'s existing `*help*`
mechanism via a new `pmacs.editor._show_help` seam, rather than growing
a second help surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
All five review findings reproduced with a failing test before any fix,
and every fix falsified by reverting it.
R1-1 — the wire-area clamp lived only in `panel_grid_size`, so the daemon
shipped an authoritative `Absent` while `panel_hidden` stayed false:
keys kept reaching the invisible window and a panel terminal kept its
controller. Q#BP2b calls hiding a DURABLE state transition and the
exhaustion arm had made it a per-frame effect. Fixed structurally rather
than pointwise: `presentable_panel_grid` is now the one derivation behind
both the renderer and `reconcile_panel_layout_core`, so the two cannot
drift apart again.
R1-2 — closing and reopening the same PERSISTENT buffer inside one
dispatcher burst left the shipped declaration intact while the window it
described was already dead, and same-buffer/same-size made the successor
indistinguishable by every other field. A presentation epoch only
identifies a presentation if something checks that the presentation it
names is still on screen, so `panel_declaration_matches` now takes the
live side window and buffer.
R1-3 — the semantic terminal-layout twin consulted only the full-document
declaration, which a panel terminal deliberately lacks, so the child kept
its opening geometry through the drain. `sync_semantic_panel_terminal_
layout` is the missing case; it resolves through `side_window_for` while
its sibling resolves through `primary_document_window`, so the two are
disjoint by construction and nothing is resized twice per tick.
R2-4 — `NoMessage` means publish nothing, not publish empty. Treating it
like `Invalidated` removed the band's provider text on a transient
buffer-follow mismatch. The band repaints its whole mode line every
frame, so "publish nothing" has to be a retained baseline; it is keyed by
window id so a replaced panel inherits nothing.
R2-5 — non-`Move` activation is Q#BP16's TERMINAL clause, because the
shared adapter claims the controller for wheel steps too. A document
panel keeps scroll-without-focus, matching `dispatch_mouse`.
The sweep for R1-1's and R1-3's shape found one more, and it is the same
bug as R1-1: a panel wider than the terminal subsystem's per-axis cap is
legal on the wire (Bet B5') but its content rect was refused by
`snapshot_for_view`, collapsing the projection to `None` — a per-frame
`Absent` with the durable state still saying visible, reachable with one
`FrontendCellGeometry` declaration. The band is legitimately that wide,
so the child is clamped to the columns a PTY can have and the remainder
paints as band background, exactly as a narrower snapshot already does.
One knowingly per-frame `Absent` remains and is recorded in the code
rather than fixed: presentation-epoch exhaustion, which takes 2^64
shipped presentation changes in one session and cannot be reached by any
frontend.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Bottom-panel Stage 2B-2, second half: the producer, the presentation
epoch, and the three inbound event gates.
The producer lives beside the terminal pass in `semantic_render.rs` and
follows its shape: compare the complete payload first, validate only a
payload that differs, and store only what was actually shipped. The
presentation epoch is allocated from the side window and its buffer, so
a new side window, a replaced buffer, and every `Absent` -> `Present`
transition each take a fresh identity; `Absent` clears the identity,
which is what makes close/hide/reopen of the SAME persistent buffer
unaddressable by a stale `PanelPointer`. Allocation is checked and
exhaustion fails closed to `Absent` rather than wrapping into a live
identity. The `Absent` baseline is seeded rather than left empty: a
fresh session has no band, so the opening state is a fact the peer
already holds.
The band rides both render paths and does not wait for a declared byte
viewport: it is a separate surface, and gating it on the document
declaration would leave the first panel unpaintable. Its mode line takes
the side window's segments from the SAME provider invocation that serves
the document's wire segments.
Inbound, `peer_may_send_panel_events` checks four facts together — an
installed semantic projection, the negotiated version, the daemon's own
capability bit, and (via the transport source) that the payload's
claimed id is never consulted. `panel_event_epochs_are_current` then
runs Q#BP16 steps 2-4 as one predicate so no caller can check the
geometry epoch and forget the presentation epoch.
Two daemon gates moved from `panel_capable` to `!semantic_render`. Stage
1 could conflate them because panel capability implied grid; now that a
semantic view can be panel-capable, a capability-keyed gate would feed it
the permanent 24x80 attach placeholder that Q#BP15a forbids, and parent
acceptance 40 would fail through the attach line rather than through the
projection. Not a live defect — no production semantic session is
panel-capable yet — but it is the landmine Stage 2B-3 would have stepped
on.
`panel_capable` is unchanged for production negotiation and the
unsolicited `Hello` still advertises v20. Nothing here is reachable by a
user.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Bottom-panel Stage 2B-2, first half: the daemon-side primitives the
panel producer needs.
`GeometryUpdate` is three-valued rather than a boolean because the
caller must act differently on each arm. `declare_frame_geometry` stays
the grid/LOCAL allocator, keeps value dedup, and moves from
`saturating_add` to checked allocation with a fail-closed exhaustion
arm: it clears the declaration back to unknown, which is already
non-presentable, so reconciliation hides the panel rather than painting
one sized to a frame that no longer exists.
`accept_frame_geometry` is the separate semantic path. No value dedup —
a font or scale change can invalidate a panel frame while `CellSize` is
identical, which is exactly what daemon-side dedup cannot see (Q#BP2S1)
— and a lower epoch is rejected even when it carries identical data.
`panel_grid_size` derives Q#BP15a's third geometry: full declared
width, `fixed_rows` clamped by the recursive document minimum and then
by the shared wire area budget, with the stored request left alone.
`prepare_panel_projection` paints the side window through the Stage 2A
extracted painter, gating folds on the OWNING frontend rather than
`fold_map_for_window`'s active-frontend gate (Q#BP17), and takes the
side window's statusline segments as a parameter so one provider
invocation serves both surfaces. `window_cursor_cell` is `paint_frame`'s
caret derivation lifted out so the band does not become a second,
drifting copy of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Four review findings, all confirmed against the tree.
Q#JR3 was false. `replace_active_buffer` does not drop the startup
scratch buffer -- its body is one `switch_active_buffer` call, which
reassigns the window's buffer_id and removes nothing. The claim came
from that function's own doc comment, wrong for as long as it has
existed, and rev 5 propagated it into the framing and into new
documentation this branch added. Both comments are corrected here,
because this PR was adding further false references to a claim P4
depends on. Actually removing the stale scratch is buffer-lifetime work
and stays out.
The daemon bootstrap could report the wrong buffer. The directory arm
captured the destination id, ran the resolver chain synchronously, then
returned the captured id -- so a handler that opened something
synchronously through commit_to had already replaced the window's
buffer, and the reply paired one buffer's snapshot with another's
identity. It also returned early, skipping the post-hook revalidation
the framing said stayed active. The arm now re-reads the destination
after dispatch and rehomes through `non_side_target` as the file arm
does. Pinned by a test whose handler claims synchronously.
N11 tested neither RET nor self-insert: it called display_file and
buf:insert directly, so it stayed green with dired's RET binding, its
entry dispatch, and the editor's self-insert path all broken. Both
gestures now go through dispatch_key.
P7 is removed rather than weakened. Q#JR12 has nothing to pin --
`had_file = file.is_some()` and a directory is Some like any other, so
no directory-specific branch exists to break. The old test never armed
restore and hard-coded had_file, so it could not fail against any
implementation.
Also adds the daemon bootstrap pins (N2, N5) and fixes an insertion that
had orphaned a `#[cfg(feature = "crdt")]` from the test it guarded --
which would have made one new test dark and one existing test escape its
gate.
Framing: docs/journey-stage1a-framing.md rev 6.
Journey Stage 1a's core: `pmacs .` opens the directory instead of
exiting 1, and local startup stops being a second implementation of
path resolution.
`EditorState::open` now calls `EditorCore::resolve_target_buffer` --
the primitive whose own doc comment says it exists "so two
path-normalization, dedup, and hook transactions cannot drift apart",
and which local startup had never been a caller of.
`resolve_target_buffer` returns a typed `ResolvedTarget` rather than
`(BufferId, HookKind)`, with a `Directory` arm checked ahead of the
load. Without it the load runs and fails: `File::open` succeeds on a
directory and `read_to_end` returns EISDIR, which is not `NotFound`, so
the `[new file]` arm never fired.
A directory creates no buffer. It dispatches a resolver chain: the
short-circuit `path.open-directory` hook, which no builtin subscribes
to, and then `pmacs.path.directory_handler`, which dired defaults. The
split is forced rather than chosen -- hook callbacks only append and
builtins load before init.lua, so a subscribing builtin would always
claim before any user listener could run. A raising listener stops the
chain and suppresses the fallback.
The listing is async and the daemon bootstrap is not, so the whole
post-await commit runs inside a new `pmacs.window.commit_to`: it
validates the destination -- frontend live, window live, buffer
unchanged, window replaceable -- BEFORE invoking its callback, then
scopes the acting frontend for its extent. Validating at display time
would be four dired mutations too late.
That scope is deliberately not `InteractiveCommandOrigin`, which does
not reach the core-ambient APIs and is authenticated user-command
authority a background continuation must not acquire.
The dedication rule is extracted into one `window_accepts_buffer`
shared by exact display, the display probe, and the new preflight, with
`incoming: Option<BufferId>` -- `None` means "the replacement does not
exist yet" and refuses a dedicated window.
`display_file` keeps its directory-is-an-error contract and does not
enter the chain; find-file's accept arm depends on it.
Framing: docs/journey-stage1a-framing.md rev 5 (Q#JR1-JR15).
Typing `\alpha` in a Lean 4 buffer gives `α`; `\<>` gives `⟨⟩` with the
point between them. The abbreviation table is vendored from
vscode-lean4 and the expander is a typed-edit consumer registered on
the Stage 4a chain at priority 50, ahead of auto-pairing.
The ordering is load-bearing. 64 abbreviation keys contain a character
in the `lean4` pair set, so with pairing first, typing `\[` would
insert `[]` and corrupt the pending key to `\[]` before the second `[`
arrives — `\[[]]` becomes unreachable. The consumer therefore claims
every keystroke that EXTENDS a pending abbreviation, not only one that
completes an expansion; claiming only completions would hand each
intermediate `[` to pairing by a different route.
The vendored table is an ORDERED SEQUENCE, not a map. Upstream breaks
equal-length ties by source declaration order — 101 prefixes depend on
it, and `\f` resolves through `f<` rather than `f>` — which a
`pairs`-iterated Lua table cannot express. `scripts/regen-lean-abbrev`
takes a vscode-lean4 commit, emits the file with its provenance header,
and aborts on a duplicate key, invalid UTF-8, or a round-trip mismatch.
Undo is cross-peer-degraded on CRDT frontends and that is accepted and
named, not papered over (Q#LN21): `\alpha` arrives as six source-peer
optimistic inserts while the expansion is one daemon-peer replace.
`set_round_trip_input` would fix it and also makes `dispatch_idle`
report false, so RET would stop inserting a newline.
Round 9 corrects three approved acceptance criteria that the real table
contradicts, found by simulating the state machine over all 1,855
entries and re-reading upstream at the pinned commit rather than
re-reading the prose. `\to` is not eager — `top`, `to0` and `toa`
extend it. `\zzzz` expands to `ζzzz ` because `ze`, `zeta` and
`zsqrtd` exist; only `$ % , ; @ W` open no key at all. And `\alpha`'s
undo does not restore `\alpha ` because `alpha` IS eager, so the
terminator is a separate edit. Criteria 38, 41 and 42 now state both
paths, and the false halves are asserted too: they read as correct
until the table is consulted.
Three implementation traps worth the record. The generator's own
round-trip check was broken twice and failed closed both times:
`str.splitlines()` splits on U+2028, which 53 symbols contain, and
escaping through `chr(byte)` produced a latin-1-shaped string that the
UTF-8 write re-encoded. The first check compared in-memory strings and
agreed with itself; it now stages the file, re-reads the bytes from
disk, and renames into place only on a match. And the expansion SHRINKS
the buffer, so the point must be placed explicitly — pairing's
no-cursor-motion rule holds only for an insert AT the cursor, and
without this every self-insert after the first expansion is silently
rejected and the editor looks dead.
25 acceptance tests plus one `--lib` test for the optimistic CRDT
producer (45f), which is where the gate list's `--features crdt` run
reaches it; a crdt-gated integration test would be dark in CI and in
the gates both. Fifteen mutations bite, each failing its target. Three
of these tests were vacuous when first written and biting is what
found them: the abandonment test asserted text a surviving record
would also produce, the re-arm test used an example that never reaches
the re-arm branch, and both switch tests ran through
`find_or_open`'s fresh-load path rather than `buffer.after-switch`.
No protocol change (Q#LN14). Also reconciles the handoff and ledger
for Stage 4a (#179) and adds `lean.abbrev` to COHERENCE.md's
config-registry adoption census, now nine settings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B
`pmacs.editor.take_typed_edit()` is one-shot and per-frontend (Q#AP9):
the first `buffer.after-edit` callback to call it clears the slot, and
every later callback in the same fan-out sees nil. That was survivable
only because auto-pairing was the sole consumer — never a property
anyone chose. A second independent caller would get nil or steal the
record from pairing depending on hook registration order, and
registration order is not a contract.
This makes it one. `builtin/runtime/typed_edit.lua` owns the single
after-edit subscriber that reads the record, and offers that one read to
consumers registered through `pmacs.typed_edit.add_consumer{ name,
priority, fn }`: lowest priority first, ties by registration order, and
the first consumer to return truthy claims the edit and stops the chain.
`pair.lua` becomes that chain's only consumer, at priority 100.
No Lean content. Stage 4b's abbreviation expander is what needs the
ordering guarantee (64 of its 1,855 keys contain a `lean4` pair-set
character, so pairing running first corrupts them), but the chain is
substrate every language runs through, which is why it ships alone —
framing Q#LN10, and §4's rule that no PR in this arc mixes a
cross-cutting substrate change with Lean feature content.
Three design points worth review attention:
- Consumers are called even when the record is nil. "This fan-out
carried no typed edit" is information a consumer acts on: it is how
pairing's test seam observes a non-event, and how Stage 4b will
abandon a pending abbreviation an unrelated edit invalidated. Three
existing auto-pairing tests fail if the chain skips consumers on nil.
- The chain pcalls each consumer. `buffer.after-edit` is
all-must-succeed, so a throwing consumer would otherwise fail the
fan-out for every other subscriber, including lsp.lua's didChange
flush. Behavior-preserving for pairing, which already never throws.
- Ordered insertion, not `table.sort`, which is not stable in Lua —
"ties by registration order" is a stated contract, not a coincidence.
`tests/auto_pair_acceptance.rs` is UNCHANGED — zero lines — and its 45
tests pass. That is criterion 46 and the whole no-behavior-change claim;
a suite edited to accommodate the refactor would prove nothing.
`tests/typed_edit_chain_acceptance.rs` adds 9 tests for criteria
46a-46e. Every one is bite-verified by mutation: appending instead of
ordered insert (5 fail), `>=` for the tiebreak (1), re-taking per
consumer (4), ignoring the claim (1), dropping the pcall (1), skipping
nil fan-outs (1 here plus 3 in the untouched auto-pair suite), and
loading the chain after lsp.lua (the Q#AP7 flush test fails, alongside
the two existing pairing ones).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B
Integrates canonical `main` @ `cf54270` and closes every finding.
**P1-1 — a stale document `Pointer` stole focus from the panel.** Real
bug. `align_primary_document_window`'s unknown-buffer arm returned
`Some(window)` despite aligning nothing, so #8's activation focused the
document *before* `dispatch_pointer` rejected the mismatched buffer. It
now returns `None`: alignment did not happen, so no caller may treat it
as a document gesture.
Pinned through `handle_dispatcher_event` — the real dispatcher seam —
because the defect lived in the PAIR of alignment and activation, not
in either alone. **The first version of that test was vacuous**: an
unregistered session is dropped at `daemon.rs:1962` (#148's
membership check) before the aligner runs, so it passed with the bug
restored. It now registers a real semantic session and fails with
exactly the reported symptom, focus moving `WindowId(2)` →
`WindowId(3)`.
**P1-2 — the approved A2A-2 fan-out was missing.** The semantic target
returned one context. It now captures the primary document PLUS the
visible side window, each provider invoked once, with a
derived-hidden side omitted (Q#BP2b — no mode line to paint, so no
callback should run for it). The acceptance asserts `windows.len() == 2`.
This exposed a second defect the finding did not name: the consumer
selected segments with `.find(|w| w.context.frontend_id == frontend_id)`
— the FIRST context for the frontend. With two contexts that silently
depended on capture order and could have shipped the panel's mode-line
text as the document status band. `emit_statusline_segments` now takes
the document `WindowId` and selects on window identity.
**P1-3 — the census suite tested the authority, not the consumers.**
Confirmed: reverting a producer to `active_window_for` left all ten
tests green. Added consumer-level pins that drive the real producers
through `SemanticRenderState::render_frame` with a panel focused, plus
the terminal-declaration guard. Bite-verified: reverting the
`LineNumbers` routing now fails
`consumer_line_numbers_follow_the_document_not_the_focused_panel`.
**P1-4 — main integrated.** The textual conflict was `docs/active-work.md`
(both lanes rewrote the same region; the terminal-config lane is kept
whole and the bottom-panel heading updated). `src/editor.rs` auto-merged,
and the full gate suite was rerun on the merge result.
**P2-5 — the painter test was vacuous.** A fixed-point check that
survived deleting `window.text_view.render`. It now asserts each of the
four extracted outputs actually appears: buffer TEXT, the line-number
GUTTER (with line numbers explicitly enabled, rather than dropping the
assertion), the window MODE LINE, and a returned caret. Bite-verified
by deleting the render call.
**P2-6 — the stale fold-projection claim is corrected.**
`src/window.rs`'s `fold_projection` doc no longer asserts that a
semantic session never enters `paint_frame`; it records that the panel
band breaks that premise and that the extracted painters take the map
as a parameter.
Gates on the merge result: fmt clean; workspace clippy clean; 1,832
default + 2,010 CRDT library tests; Stage 2A acceptance 13; Stage 1 46;
statusline 8; m11_5 2; GPU initial target 14; terminal config 12;
folding Stage 2 48; vterm 1/2 10 / 6; M4 121; required GPU 202;
`git diff --check` clean. `vterm_stage3_acceptance::a37` remains the
pre-existing flake measured on the base commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bottom-panel Stage 2A, second half (Q#BP8, Q#BP17). Still no protocol
change and no behavior change: `paint_frame` builds the same fold map
it always did and passes it in, so grid rendering is unchanged.
Two extractions, both taking the fold map as a **parameter** rather
than building it:
- `prepare_window_cursor_visible` — the active-window auto-scroll
clamp. The panel band (2B) runs this for its own window when that
window owns focus, and leaves a passive panel's `view_top` alone.
- `paint_window_content` — the per-window document body: text, gutter,
overlays, selection, and the mode line. The panel paints into a
panel-sized grid at the same origin-agnostic `Viewport`, so this is
that body lifted out, not a second painter (Bet B2').
The parameter is the point (Q#BP17). Folding built its per-window map
ungated on the premise that "a semantic session never enters
`paint_frame`", which the panel band breaks. The panel path must pass
`None` for a frontend whose `fold_projection` is false, and must not
call `EditorCore::fold_map_for_window` — that gates on the **active**
frontend, which is right for command-time reckoning and wrong for
painting another frontend's panel.
`tests/bottom_panel_stage2a_acceptance.rs` — 10 tests. The negative
half is the load-bearing half, so Projection assertions are paired
with focus-class assertions taken in the SAME state:
- `focus_and_projection_disagree_in_the_same_state` is the key one:
with a panel focused, the focus authority must name the panel while
the projection authority names the document. Routing the focus class
through `primary_document_window` fails this even though every
Projection test still passes.
- The statusline pair pins the split: the LOOKUP resolves the document
window while `active` reports actual focus, with a non-vacuity twin
that flips `active` back to true when focus returns.
- The extraction pair pins cells, the returned cursor, the focused
window's `view_top`, AND a passive window's untouched scroll —
identical cells alone would not catch a clamp that moved to the
wrong window on a single-window frame.
- `the_panel_fixture_really_builds_a_side_window` pins the fixture's
own precondition, since every other test is worthless if
`focused_panel` silently produced an ordinary split.
One crdt-gated caller of the old `align_semantic_window_to_buffer` was
updated; it compiles only under `--features crdt`, which is the
config CI never runs.
1,832 default + 2,009 CRDT library tests, 10 new acceptance; fmt and
workspace clippy clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bottom-panel Stage 2A, first half. Every consumer the framing classifies
**Projection** now resolves the frontend's primary document window or
buffer instead of its focused one; every consumer classified focus,
focus-chrome, or focus/session is deliberately left alone.
No protocol change, no behavior change for any frontend today: with
`panel_capable = false` for semantic sessions, `primary_document_window`
returns `view.active` for every existing configuration, so this is a
seam adoption that becomes load-bearing in 2B.
Projection consumers routed:
- **#1** semantic buffer-follow / `BufferSnapshot` re-send
- **#2** the lazy CRDT upgrade — the sharpest case, since it BROADCASTS
to every replica, so keying it on focus would let focusing a fresh
generated panel buffer swap every peer's document mirror
- **#3** `CursorByte`
- **#4** `LineNumbers` mode
- **#5** selection decorations
- **#6/#10/#11** the full-window semantic terminal declaration, its
snapshot/sync, and terminal-frame suppression, via the shared
`semantic_terminal_key` resolver
- **#9** the `Viewport` terminal-context gate — a focused terminal panel
must not suppress the still-visible document's viewport
- **#12** the semantic statusline target LOOKUP
- **#21** the `BufferSnapshot` publication recipient filter
`align_semantic_window_to_buffer` splits per Q#BP14, which is the
distinction that makes rejecting panel-named events insufficient on its
own:
- `align_primary_document_window` (**#7**, `Viewport`) — aligns the
document window and **never touches `view.active`**.
- `align_and_activate_primary_document_window` (**#8**, `Pointer`) —
aligns and then activates, because a click in the document area means
"work here". This is the one place projection and focus legitimately
move together.
`dispatch_semantic_terminal_pointer` (**#11**) gains the same rule: an
accepted non-`Move` gesture activates the document window before the
gesture replays, while bare hover neither focuses nor claims.
The statusline change is deliberately a HALF change (parent acceptance
42): the window LOOKUP resolves the primary document window, but
`active` still reports **actual focus**, so a document provider can
truthfully observe `active = false` while a panel owns focus.
Untouched, and that is the load-bearing negative: #13 remote-op
validation, #14 `dispatch_idle_for`, #15 presence, #16-#19 search /
menu / minibuffer / completion chrome, #20 terminal bell drain, and #23
remote-op application all still resolve the actually focused window.
#16-#19's Q#BP14b routing table needs `PanelFrame` and lands in 2B.
1,832 library tests pass; fmt and workspace clippy clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stage 1 of the terminal config/copy-mode arc. The terminal had no
configuration surface at all: the command hardcoded $SHELL, scrollback
was a per-open argument only, and the escape chord was a literal in
Rust. No protocol change.
Profiles are a raw Lua table, pmacs.terminal.profiles, not a registry
setting: ConfigValue is four scalars with no table kind, so profiles
join pmacs.lsp.config and pmacs.pair.sets until table-valued settings
exist. The registry gains three scalars whose defaults reproduce the
previous behavior exactly.
Field resolution is explicit open argument, then profile field, then
scalar setting, then $SHELL. env MERGES, with explicit entries
overriding the profile's, because first-wins there would silently drop
half a user's environment. An explicitly named profile that does not
exist is an error even when terminal.default-profile is valid, so a typo
cannot silently fall back.
The two open-time settings resolve through the GLOBAL chain, because
they are read before the identity buffer exists and no caller could have
pinned a local override on a buffer that does not yet exist. Only
terminal.escape-key resolves per buffer, which makes a per-terminal
escape a supported feature.
The escape key is parsed at most once per (terminal, config epoch), and
the cache lives on TerminalSession so its lifetime is the terminal's,
with no purge hook to forget. The epoch alone is not a sufficient key:
it does not advance when focus moves between two terminals with
different buffer-local values, so an epoch-only cache serves one
terminal's chord to the other. An unparseable value falls back to C-c
and reports once per terminal per effective invalid value through the
status line, because a terminal with no escape chord cannot be escaped
to fix the setting that broke it.
Repeating the escape now sends THAT chord to the child through the
ordinary key encoder, rather than a hardcoded ETX. With an escape of
C-x, the previous code sent Ctrl-C and made literal Ctrl-X unreachable.
C-c t opens a terminal. COHERENCE Priority 1 names a terminal
keybinding, and section 2 step 8 grades the terminal works-but-
undiscoverable; C-c is already a live global prefix, so this is a new
leaf rather than a shadow. It is unreachable from inside a terminal,
where C-c is the escape.
Acceptance is tests/terminal_config_acceptance.rs, deliberately NOT
crdt-gated so CI actually runs it. Four bites, each against a different
plausible wrong implementation: a hardcoded ETX fails acc6/9; an
epoch-only cache key fails acc7; a single last-entry cache fails acc8's
parse count; removing the invalid-value fallback fails acc10.
Two test-instrument notes worth keeping. cat -v is the echo probe
because the screen rejects C0 controls before they reach cells, so a raw
echoed Ctrl-X would be invisible. And the probe counts occurrences
rather than testing presence, because a single-character probe collides
with the child's own banner text.
Framing Q#LN7, Q#LN8, Q#LN16; acceptance 22–28, 24a/24b, 35, 36, 36a, 37.
Stacked on Stage 3a (#167), whose notification/response seams and
`pmacs.fs.canonicalize` this consumes. No protocol change; the only Rust
outside the test helper is one `include_str!` line.
**The Lake-aware root (Q#LN8).** `pmacs.project.detect` cannot express
this rule — it is innermost-wins by construction, and a Lake package's
`lean-toolchain` sits at the outermost level, so a file under
`<pkg>/.lake/packages/dep/` belongs to `<pkg>`'s server rather than
`dep`'s. The resolver walks up collecting markers and returns the
outermost, stopping at `pmacs.project.search_boundary()` so a stray
marker above a fixture cannot leak in.
Two things about the marker test are easy to get wrong in opposite
directions, and both are pinned. `io.open` **succeeds on a directory**,
so a truthiness check accepts a `lean-toolchain` directory; but
requiring a non-nil read rejects an **empty** `lean-toolchain`, which is
a legitimate marker — `locate-dominating-file` semantics are existence,
not content. The discriminator is `read`'s second return: decline only
on a non-nil error. Acceptance 24a and 24b each fail against the
implementation that satisfies only the other; both bites are recorded.
The root is canonicalized once up front, because a configured root
reaches `file_uri_for` verbatim and that URI is the affinity key (#161).
Canonicalizing the starting directory suffices — every ancestor of a
canonical path is canonical, since the walk only strips components.
**`lake serve` with a lazy probe and a one-shot latch (Q#LN7).** Nothing
runs at init: `pmacs.lsp.config` is declarative, and spawning a process
at startup for every user, Lean-using or not, is the cost rev 1 refused.
Both the probe and the server spawn are gated on a real Lean attachment.
The probe cannot gate the first attach — there is no blocking process
run, so its verdict arrives after `ensure_server` has already decided.
Hence the optimistic spawn, with the probe and latch correcting it. A
non-zero probe exit is deliberately NOT a trigger: §2.9's elan-shim case
makes `lake --version` fail on machines where `lake serve` still works,
and the server-failure latch covers that better. The probe answers only
the question failure detection would answer slowly — an old-but-working
lake that starts a useless server.
The latch stops the failing server **before** spawning the fallback, and
that ordering is load-bearing rather than defensive: the spec default is
`OnCrash`, the termination handler never consults the exit code, and
`maybe_restart` has no attempt ceiling, so a broken `lake` respawns
forever underneath the latch. `pmacs.lsp.stop` sets `restart = Never`,
which is what disarms it. Bitten: removing the stop fails acceptance 36.
The swap rewrites `command` and `args` only, so a user's `env`,
`settings`, `init_options` and `root` survive — a wholesale table
replacement would discard their `init.lua` at the moment they are least
likely to notice.
**`waitForDiagnostics` (Q#LN16)** resolves through Stage 3a's response
seam, with `M-x lean.wait-for-diagnostics` on top. `$/lean/fileProgress`
subscribes on the notification seam and is pinned end-to-end through a
new `leanprogress` mode on the fake server rather than by calling the
handler directly — the wiring is the only part that can break.
**Attribution (COHERENCE §9/§1.2).** The probe spawns as
`lean:lake-version-probe`, so a user wondering why their editor touched
`lake` finds an owner in `pmacs.process.list`. The latch reports through
`pmacs.editor.set_status` — the channel that exists — and acceptance 36a
observes that channel, so a report made only through the undefined
`pmacs.error` would fail it.
**Stage 1's acceptance 12 is updated, half superseded.** It asserted
`pmacs.lsp.config.lean4 == nil` to guard against a Stage-3 front-run;
Stage 3b is that stage, so keeping it would pin the opposite of the
intended behavior. The half that survives is the one about restraint,
and it matters more now: constructing an editor spawns nothing even
though the config exists and names `lake`, and opening a Lean buffer
with no server configured spawns nothing either. That is what holds
Q#LN7's "not at init" promise.
Bites recorded, all against the committed tree: bare `io.open` -> 24a
fails, 24b passes; require-non-nil-read -> 24b fails, 24a passes; no
canonicalization -> the symlink case spawns two servers; no stop before
fallback -> acceptance 36 fails.
Main moved again while this lane was gating: the GPU terminal-input fix
merged as #166. One conflict, in COHERENCE.md's journey table, resolved
as the union -- this lane owns step 7's file half, #166 owns step 8's
GPU-terminal addendum.
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.
`describe_key_identifies_every_default_binding` iterated every binding
in the stack and asserted `pmacs.describe.key` resolves it context-free.
That held only because no builtin had ever bound a mode-scoped key:
dired is #129's first non-detection consumer, so its `n` / `p` / `g`
correctly resolved to nothing and the test went red on the feature
rather than on a defect.
It now sets the effective context per binding -- the mode for a
mode-scoped default, and explicitly NO mode for a global one, because a
leaked mode legitimately shadows a global chord of the same name
(dired's `RET` shadows `edit.newline-and-indent`, which is the point of
the mode) and would make the assertion compare the wrong pair. A floor
assertion keeps the new arm from going vacuous if the last mode-scoped
default is ever removed.
Also corrects a doc comment rather than leaving it to be believed:
acceptance 3c does not pin the descent ROUTING. Dired holds focus in its
own panel, so a raw `switch_buffer` lands in the same window and the
mutation is vacuous against that test; dedication is what distinguishes
the two paths, so the discriminating pin is the dedicated-panel test
next to it. Verified by mutation, not assumed.
Dired is the file surface, not a rider on one: before Stage 0 (#162)
pmacs had no way to open a file by path, and browsing is the half a
user reaches for when they do not already know the path. Stage 1 ships
the view.
builtin/runtime/dired.lua: one buffer per directory named by the
canonical path (Q#DR2) with an ownership check before any paint (F7);
read-only intercept plus round-trip input (Q#DR3); a `dired` major mode
carrying mode-scoped keys (Q#DR8) -- RET/f visit, ^ parent, n/p, g
revert, q quit, s sort; cursor re-seated by basename across every
wholesale repaint (Q#DR9); file visits through
`pmacs.window.display_file` and directory descent through dired's own
window (Q#DR10); `C-x d` / `C-x C-j`; and `dired.kill-when-opening`
through the config registry.
Two Rust changes, both narrow:
* `read_dir` grows per-entry tolerance behind an opt (Q#DR6). Five
per-entry conditions used to fail the entire listing, so a plain
refresh of a busy directory could just fail; the module doc's claim
that a tolerant wrapper was "the package's job" was false, because
the primitive hands Lua one structured error and no partial vec.
Per-entry readdir/lstat/readlink failures and non-UTF-8 symlink
targets now land in an `errors` channel; parent-level failures and
non-UTF-8 *names* stay fatal. The tolerance travels in the settled
payload, so the Lua boundary keeps the bare-array shape the frozen
M8.2 fixture consumes and never has to look the job back up. The read
ops' opts parsing now rejects unknown keys, so a typo'd `tolerant`
cannot silently degrade to the fatal contract.
* `normalize_buffer_path` is exposed as `pmacs.path.canonicalize`
rather than mirrored in Lua. Q#DR2 named exposure the preferred end
state; it needs no borrow plumbing, so dired's name-dedup and
`display_file`'s `find_buffer_for_path` dedup cannot fork, and the
mirror's Stage 2 removal is not owed.
tests/dired_acceptance.rs covers framing items 1-16 (22 tests), driven
through real key dispatch. Item 17 is the m8_1/m8_2/m8_3 gate.
One framing claim is corrected by the substrate: R2-3 expected a
dedicated dired panel to carry its dedication across a descent, but
`display_buffer` never replaces the buffer in a slot dedicated to
another one -- it discards every side-specific parameter and falls back
to the document window (Q#BP3 2.iii). Dired does not try to unpin the
user's panel; both arms are pinned.
PR #155 review round 2.
Finding 1 (must fix): Q#BP7 item 1 — "growth reaching the live tail
re-arms follow (top -> None), only when no selection is active" — was
never implemented. `at_bottom` is the instantaneous geometric readout
`scroll_offset == 0`, which a still-anchored view satisfies whenever it
happens to be tall enough to reach the tail, so the round-1 assertion
could not see the gap: the next rows the child printed pushed the
anchored view back into history.
`rearm_follow_on_growth` now clears `top` when a viewport-size
declaration makes the view cover the tail and no selection is frozen,
and every size-declaring path (`snapshot_for_view`, `record_view_size`,
`view_status_for_size`) routes through one `declare_view_size` helper so
grid and semantic declarations cannot disagree. `scroll_view` and
`begin_selection` deliberately stay out: they write `top` themselves,
and `scroll_view` already owns the scroll-driven arm.
New acc32b is the pin the review asked for: scroll into history, grow
past the tail, then release a SECOND burst of child output through a
filesystem gate and assert the view moved with it.
Finding 2: the PTY fixtures emitted LF-only output, which staircases
rightward until every row clips to blanks past the viewport width — so
the round-1 anchor assertions compared "" with "" and could not fail.
Both fixtures now emit CRLF, and each anchor comparison is guarded by
`assert!(!top_before.is_empty())`.
Finding 3: acc33's contrast case asserted nothing, and the behavior it
claimed was false as coded. With the re-arm in place it is true and now
asserted: clearing the selection at the same geometry re-arms follow and
leaves the frozen anchor.
Finding 4: `start_run` gated the panel branch on `display == "panel" or
already_in_panel(..)`, so an explicit `display = "current"` lost to the
inference — and that value is the documented user-facing opt-out from
the Stage 3 default flip. Now gated on OMISSION. acc19b gains the
explicit-"current" case.
Finding 5: `window_drag` is a `HashMap<FrontendId, WindowDragState>`, so
a peer's mode-line press can no longer steal or clear another
frontend's in-flight gesture, and concurrent drags are legal. Cleared on
detach. acc30c gains the mode-line-press case.
Minor: `pmacs.window.buffer()` resolves both arms through the acting
frontend using the shared `lookup_window` / `selected_window` validators
rather than re-implementing them beside an ambient `active_buffer_id()`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #155 review round 1.
Finding 1 (must fix): `try_split_active` had no production caller —
`pmacs.window.split_horizontal` / `split_vertical`, and therefore
`C-x 2` / `C-x 3`, still went through plain `split_active`. Splitting a
focused panel made the root wrapper's final child a split rather than
`Leaf(side)`, which both `Layout::compute`'s fixed pass and
`document_subtree` key on: the panel band reverted to 1:1 weight
division and an ordinary window ended up living inside it. Both bindings
now route through the guard, and acc26 asserts through the real Lua
path — a direct core call passes with the guard unwired, which is how it
survived the first round.
Finding 2: the armed-drag early return now checks the arming frontend,
so one frontend's in-flight gesture cannot cancel or swallow another's
mouse events. New acc30c.
Finding 3: `paint_mode_line_graphemes`'s doc block was left heading
`paint_divider_segment`; moved back.
Finding 4: a recompile carries no `display`, so it took the raw switch
and duplicated a panel-placed `*compilation*` into the document window.
`start_run` now detects that the buffer already owns the panel slot.
`pmacs.window.buffer` gained an optional window argument so an adopter
can ask without selecting the panel first. New acc19b.
Stage-2 hazard pins the review asked for, both in `src/daemon.rs`:
a fresh attach while LOCAL is focused in a panel inherits LOCAL's
document buffer, and an initial-target bootstrap whose `after-load`
hook creates and selects a panel still reasserts into a document window.
Minor: dropped listview's dead `p.side`; documented `focus_window`'s
caller-validates contract; `jump_back` restores through `focus_window`
so the "every focus change" contract holds; `params` / `resize` default
to the acting frontend's selected window rather than the ambient one;
widened the flexible-division math to u64 intermediates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- `listview.open`, `compile.run`, and `pmacs.terminal.open` all take the
same strict `display = "current" | "panel"`, validated before any
buffer, session, process, or wrapper exists. Omission keeps today's
behavior; Stage 3 flips the default.
- `listview.quit` / `compile.quit` delegate to `window.quit` only when
the buffer really is in a side window, so the presentation is deleted
or restored instead of leaving a source buffer stranded in the slot.
- LSP `visit_location`, LSP go-to-definition, and compile `visit_error`
route through `display_file`, so a visit from a panel lands in the
document target and fires its hook with that window active.
- `window.quit`'s Delete arm focuses the revalidated remembered origin.
- Capability fallback discards an accompanying `height` rather than
rejecting the call.
- `window.min-height` clamps a below-floor value on read instead of
refusing the write.
- `tests/bottom_panel_stage1_acceptance.rs`: 42 tests over the framing's
Stage 1 criteria, including the two production `Layout::compute`
callers, the recursive minima, hide/reappear, the final-focus matrix,
quit chains at the depth cap, per-frontend jump origins, the divider,
and a real-PTY pin of Bet B1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stage 1 substrate for the bottom-panel arc (docs/bottom-panel-framing.md).
- `WindowParams` (side / fixed_rows / dedicated + implementation-owned
quit action and remembered document origin), `Side`, `QuitAction` with
a bounded replacement history, and the `MIN_WINDOW_OUTER_ROWS` floor.
- `Layout::compute(area, fixed)` allocates fixed rows before dividing the
remainder by weight; both production callers feed the same shared map,
including the peer-presence overlay pass that derives its own rect.
- `subtree_min_rows` / `interactive_min_rows`: the recursive minima, and
`boundary_below` for the shared drag / keyboard resize boundary rule.
- `FrontendView` gains `panel_capable`, `frame_geometry`, and the derived
`panel_hidden`, each spelled explicitly at every construction site.
- `EditorCore`: `primary_document_window`, the non-side target rule,
`display_buffer` + placement policy, `quit_window`, side-window removal
on `kill_buffer`, per-frontend jump entries with origin windows, and the
shared resolve/load-without-switch seam the initial-target bootstrap now
uses too.
- `EditorState`: the panel reconciliation transaction, geometry
declaration, the side-window `dispatch_idle_for` gate, divider paint,
and divider drag.
- `pmacs.window.display / display_file / quit / panel / params /
set_params / resize / display_target`, plus `builtin/runtime/window.lua`
with `window.panel-height`, `window.min-height`, and the resize commands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements docs/folding-stage2-framing.md rev 4. The daemon grid
renderer now consults the fold store: hidden lines are omitted, rows
below shift up, and every consumer that assumed
`display_row = source_line - view_top` routes through one shared
projection. No wire schema change and no protocol bump (Bet B6) — the
collapse is entirely daemon-side; the GPU path is Stage 3.
The spine (Q#FD12) is `src/fold_view.rs`: a `VisibleLineMap` derived
from `FoldRegistry::folds` plus a window's line offsets and never
stored. Its unit is a merged **hidden component** — overlapping OR
adjacent hidden intervals unioned, each keeping the one visible
`head_line` and that line's exact `head_position`. Adjacent intervals
merge because the later fold's head is itself hidden, which is what
makes nesting, shared heads, and crossing overlap all resolve to a
head that can actually render (round-3 F2).
Instances are short-lived and built **per rendered window** and **per
command/event operation**, never once per frame: `paint_frame` renders
several windows that may show different buffers, so a singleton would
leak one pane's folds into another (round-2 F2). The render instance
rides on a lifetime-bearing `Viewport<'a>` as `Option<&'a
VisibleLineMap>` — a shared ref is `Copy`, so `Viewport` stays `Copy`
(Bet B7).
Rendering:
- `TextView::render` walks visible lines; the head line gets a
trailing content-area ellipsis (Q#FD13).
- The gutter walks visible lines too: Absolute keeps the raw `line+1`,
Relative/Hybrid measure VISIBLE distance anchored on the cursor's
visible head (Q#FD14). The fold glyph takes the col-0 sign cell only
when a gutter exists — line numbers default to Off, so with no gutter
the ellipsis is the sole marker (Q#FD20, round-1 F3). A diagnostic
clamped onto the head wins that cell by paint order.
- A diagnostic on a hidden line clamps its SIGN to the outermost
visible head (most-severe merge); the squiggle needs a real row, so
only the sign clamps (Q#FD15).
- Caret, local selection endpoints, and peer cursors project via
`visible_position_of` — the head row AND the head's end-of-content
column, never an arbitrary column (round-2 F3). Peer presence derives
the RECIPIENT window's map.
- Style/search/completion overlays route through
`Viewport::row_offset_of`; the mode-line indicator reckons in
visible-line space.
Command/event time is scoped per frontend (Q#FD21): a
`fold_projection` flag on `FrontendView`, set at attach from the
negotiated `semantic_render` bit (grid ⇒ true, semantic ⇒ false until
Stage 3, LOCAL ⇒ true) and never inferred from a `FrontendId` (Bet
B8). Without it, shared `EditorCore` motion would make a simultaneous
unfolded GPU session's cursor skip lines it still displays. The map's
two axes stay separate (round-3 F1): the acting frontend supplies the
policy, the operation's TARGET window supplies the buffer — a wheel
event names a pane without activating it.
Motion (Q#FD17, ruled: include), paging, wheel, the click inverse, and
the auto-scroll clamp all step by visible lines under that gate;
motion from a hidden logical cursor normalizes to the visible head
first. `view_top` stays a source-line index (Bet B5), set only via
`clamp_view_top` so it never rests hidden.
Unfold widening (Q#FD19): the pre-edit unfold moves to the top of
`apply_active_edit` — one funnel that subsumes the six primitives'
calls and covers yank + query-replace, both of which place point at
the edit site first. Interactive Lua mutators hook the common
`run_buffer_edit`, above the managed/bypass split, gated on
`InteractiveCommandOrigin` AND the edit targeting that frontend's
active-window buffer. The remote/optimistic-CRDT path stays excluded
(Stage 3); undo/redo unfold stays deferred.
Acceptance: `tests/folding_stage2_acceptance.rs`, 35 tests asserting
on the real `paint_frame` cell grid, covering framing items 1–14
including crossing folds, a nested deeply-hidden cursor, a split of
two different buffers with an inactive-pane wheel, and simultaneous
grid+semantic motion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
The fold engine behind `docs/folding-framing.md` (approved rev 5): a
per-buffer fold store, a structural tree-sitter fold source, the
state-aware Lua command + data-API surface with the Emacs hideshow
`C-c @` bindings, the dispatch-layer pre-edit unfold, and `FoldState`
production. No rendering — Stages 2 (grid) and 3 (GPU) consume the store.
- `src/fold.rs`: `FoldStore` (a buffer-attached `View` that translates
ranges on every edit and drops any whose head/tail the edit crosses,
provenance-blind — Q#FD6), the structural source (nearest block-like
node >= 2 source lines -> introducer<->body -> **derived head line**,
the line immediately above the first hidden line, so wrapped signatures
and `where` clauses stay visible per R3-1 -> **closer-aware tail**, a
closing-delimiter line stays visible per R2-5), injection-layer walk,
`(start, end]` containment, and the state-aware ops (close innermost
open / open outermost closed / org-TAB cycle). Stale/absent tree
refuses (Q#FD10).
- `src/lua_bindings/fold.rs`: `pmacs.fold.*` — explicit-buffer data API
(`fold`/`unfold`/`folds`/`toggle`) + interactive helpers, validation
(Q#FD11: document buffer, UTF-8 boundaries, >= 1 hidden line — Q#FD9
falls out of the last clause), point-moves-to-head (Q#FD3).
- `builtin/runtime/fold.lua`: `fold.toggle/close/open/close-all/open-all`
commands + the `C-c @` prefix set (Q#FD4).
- `src/editor_core.rs`: the six point-anchored edit primitives run the
pre-edit unfold keyed on the authenticated source's point (Q#FD5,
command path); `EditorCore` owns the shared `FoldRegistry`.
- `src/semantic_render.rs`: the `FoldState` producer —
authoritative-empty, diff-suppressed, baseline resets on
`BufferSnapshot` (Q#FD8); the "never emitted" pin split so
`BlockAdornments` stays unproduced.
- `tests/folding_acceptance.rs` (16) over real Rust/Python/markdown
grammars + `fold_state_producer_transitions` + 15 engine unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
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.
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.
Require the fixed C-c escape before editor-local terminal bindings, reject context-implicit Lua operations from document windows, and make controller replacement atomic per frontend. Borrow screen rows during view projection instead of deep-cloning retained history, preserve view anchors through zero-area layouts, and remove redundant detach paths.
Add focused child-input coverage for unescaped bound keys and C-c C-c, plus controller, zero-area, context-error, and clone-free projection assertions.
Co-authored-by: OpenAI Codex <codex@openai.com>
Give the daemon its normal five-second handshake window before switching
the mode-system acceptance client to short frame polling. Document reload
and session-persistence boundaries and correct stale describe-key guidance.
Resolve post-main integration drift in authenticated routing, terminal view projection, Lua installation, and inherited acceptance callers. Preserve the terminal statusline provider alongside the landed Themes provider and record the final Stage 2 gate evidence.
Co-authored-by: OpenAI Codex <codex@openai.com>