Commit Graph

333 Commits

Author SHA1 Message Date
Levi Neuwirth 3c9dc92962
docs(test): five adopters, four of them decorator families
The suite header still said "the four call sites", written before the
selection painter joined `Viewport::visible_cols`. Four is now the count
of decorator FAMILIES — syntax/LSP styling, diagnostic underlines,
search washes, `BufferStyleOverlay` — and five is the count of adopters,
selection being the fifth.

Also points at where selection's own witnesses live, since a reader of
this file would otherwise look for them here and find nothing:
`paint_local_selection` is private, so they are in `src/editor.rs`.

Comment only; no behavior and no assertion changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-07 22:49:59 +02:00
Levi Neuwirth bec8fc9aae
feat(view): horizontal scroll, text and decorations together
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
2026-08-07 22:43:17 +02:00
Levi Neuwirth 4d70ff6931
fix(tests): eight version assertions the v22 bump broke, five of them defects
CI red on #221: all five Test jobs, one identical test, every platform
— deterministic, not a flake. The production code was never wrong.

WHY MY GATES MISSED IT. The standing gate is "the touched acceptance
suites", selected from the diff. A PROTOCOL_VERSION bump breaks
version-assertion tests that appear nowhere in it. Worse, CI showed
only ONE of the eight, because cargo stops at the first failing
target; the rest surfaced only under `--tests --no-fail-fast`, and one
at a time would have cost four more red rounds.

Three of the eight were invisible even to that, because they are
crdt-gated real-daemon tests asserting on a live socket. Found by
`--tests --features crdt --no-fail-fast`. That is the handoff's
existing "a local sweep is blind to whichever configuration it does
not build" lesson, hit again by a different lane.

THREE TRIPWIRES, WORKING AS DESIGNED. `assert_eq!(PROTOCOL_VERSION,
21)` in statusline_segments, bottom_panel_stage2b_gpu, and
vterm_stage3 are meant to fire and take a deliberate edit; each says
so in its own comment. Updated to 22 with the reason recorded. Worth
noting the pin that must NEVER be edited —
ADVERTISED_PROTOCOL_VERSION == 20 — did not fire, which is the
mechanism behaving exactly as designed.

FIVE DEFECTS, ONE SHAPE: an absolute contract expressed as arithmetic
on, or equality with, a MOVING constant. Each was true when written
and silently false afterwards.

  - `PROTOCOL_VERSION - 1` meaning "below the panel version". Held
    only while PROTOCOL_VERSION == PANEL_MIN_VERSION; at v22 it
    equalled PANEL_MIN_VERSION exactly, so the fixture's "old" peer
    became panel-capable and the daemon correctly sent it a frame.
    Now `PANEL_MIN_VERSION - 1`.
  - `assert_eq!(PANEL_MIN_VERSION, PROTOCOL_VERSION)` — a coincidence
    true only while panels were the newest feature. Replaced by the
    two durable bounds: above the advertised floor, at or below this
    binary's wire.
  - `assert_eq!(PROTOCOL_VERSION, 21)` in a test named
    `the_panel_stage_takes_protocol_v21` — the current wire as a proxy
    for the panel stage's own version, in a test whose name says which
    one it means. Now PANEL_MIN_VERSION.
  - `session_protocol_version == "21"` in two real-daemon probes. What
    the counter-offer activates is THIS BINARY's wire, so the literal
    was only ever right by accident. Now PROTOCOL_VERSION, plus an
    explicit `>= PANEL_MIN_VERSION` for the panel capability the
    literal had been carrying implicitly.

The codebase already had the right idiom: src/daemon.rs and
pmacs-gpu/src/main.rs spell it `PANEL_MIN_VERSION - 1` in five places.
Every outlier was in tests/.

ALSO LOGGED, NOT FIXED: U2 in ci-red-signatures.md.
`process::tests::m6_1_pty_raw_mode_disables_kernel_echo` failed once
during a full corpus run and did not reproduce (108 targets exit 0,
plus 3 isolated --lib runs at 1917/0). It is in no registry row, so it
is a new incident, and leaked `pmacs --daemon` processes remain an
unexcluded rival explanation. Recorded with a selector this time —
unlike U1, whose name I destroyed by piping through `tail`.

Gates: fmt; clippy --workspace --all-targets -D warnings, both
configurations; --lib 1917/0; --lib --features crdt 2102/0; --tests
--no-fail-fast 108 targets exit 0; --tests --features crdt
--no-fail-fast 108 targets exit 0; PMACS_REQUIRE_GPU=1 -p pmacs-gpu
228/0; git diff --check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-07 20:15:33 +02:00
Levi Neuwirth 8cd1da41ac
test: the report itself, at a real PTY — and §1.1 was wrong
Closes Stage 3's remaining obligations.

THE PTY TEST. Every other test in this lane checks a mechanism.
tests/long_line_readable_acceptance.rs checks the complaint: the
shipped binary, a real 80x24 PTY, one source line 200 columns wide,
and an assertion that its tail marker reaches the host. It bites —
`scripts/bite HEAD~2 src/editor.rs --test long_line_readable_acceptance`
against the pre-aa3cd4d editor never paints TAILZQX in 20s.

Its truncate control (an isolated init.lua pinning the mode) is what
makes that marker discriminating, and it is also the honest statement
of what truncate costs today: those bytes are not off-screen, they are
unreachable until Stage 4.

What it does not prove: the workspace has no screen model and no
vt100/termwiz/vte, so this shows the tail was WRITTEN to the terminal,
not which row a human would point at. That is nonetheless the whole of
the report — under truncation the bytes are never emitted at all.

§1.1 WAS WRONG, FOR NINETEEN REVISIONS. `editing.fill-column` is not
an orphaned registry setting "of the exact shape Stage 1 just fixed".
Both cited occurrences are inside `#[cfg(test)] mod tests` — fixture
names in round-trip tests covering one setting per ConfigKind. Two of
those five names are real; three, including this one, are defined
nowhere else in the tree. There is no shipped setting, so the Q#LL4
deliverable "sharpen its description" had no object.

The mechanism is worth more than the correction. A grep hit at a src/
path, a genuine `r.define(...)` call that is real API usage rather
than a mock, and `#[cfg(test)]` about fifty lines above the citation.
Every later revision inherited the conclusion instead of the evidence,
and three review rounds reasoned about the consequences of an orphaned
setting rather than re-checking that it existed. A file:line citation
is not a substitute for reading the scope it sits in.

Had it gone unchecked into implementation, Stage 3 would have shipped
an edit to a unit-test fixture believing it was rewording a
user-visible setting — a no-op with a misleading commit message.

§1.1 is withdrawn in place, keeping the original text and the
reasoning that produced it; §6's answer is unchanged (a setting that
does not exist is a stronger reason not to adopt it) and its premise
corrected. Both fixture sites now say they are fixtures. The approval
is not reopened: nothing else in the document rested on §1.1, which
argued for a display setting separate from fill-column — which is what
shipped.

AND ONE UNCLASSIFIABLE RED, logged as U1 in ci-red-signatures.md. A
`-p pmacs-gpu` run went 227/1 once; every run since is 228/0. The
failing test name was NOT captured, because I piped that command
through `tail -3` and discarded the failure block above the summary.
36 later runs are clean, 6 under deliberate concurrent load — which
per the rerun rule establishes intermittence only, and without a
selector not even that. Deliberately NOT matched against A1 despite
A1 also being GPU-headless-under-load: matching requires an exact
selector and every required fragment, and calling a shapeless red
"probably the known one" is the reputation-by-adjacency that file
exists to deny.

Gates: fmt; clippy --workspace --all-targets -D warnings; --lib
1917/0; --lib --features crdt 2102/0; line_wrap 6/0;
long_line_readable 2/0; folding 21/0; folding_stage2 48/0;
full_grid_resync 1/0; config_registry 16/0; m4 150/0;
PMACS_REQUIRE_GPU=1 -p pmacs-gpu 228/0 (see U1); git diff --check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-07 19:37:46 +02:00
Levi Neuwirth aa3cd4da34
fix(editor): the renderer never got the mode everything else was reading
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
2026-08-07 18:48:11 +02:00
Levi Neuwirth 4a26f0005e
fix(linewrap): the toggle wrote the global layer, not the buffer
ui.line-wrap is buffer-local, and the toggle was built from
pmacs.config.get(name) and pmacs.config.set(name, value) --- both of
which address the GLOBAL layer. The registry's buffer-local surface is
get(name, buf) and set_local(buf, name, value), and the command used
neither.

The result was wrong in both directions at once, which is why it needed
two witnesses rather than one. In a buffer pinned to truncate, the
toggle would read "wrap" from the global layer, decide the next mode is
truncate, and leave that buffer exactly as it was --- while writing
truncate globally and flipping every buffer that had no override of its
own. The command that changes nothing here and everything elsewhere.

Now resolves the buffer ONCE and uses it for both calls. Once matters:
resolving twice would be a narrower version of the same bug, since the
active buffer can change between two calls.

Two witnesses in a new acceptance suite, and both bite against the code
review rejected --- restoring the global-layer toggle fails both while
the default and enum tests keep passing, which is exactly how it
shipped. the_toggle_moves_this_buffer_and_leaves_the_other_alone covers
the leak outward: a second buffer and the global layer must be
untouched. a_pinned_buffer_toggles_from_its_own_value covers the miss
inward: a buffer whose value differs from global must toggle from ITS
value.

Note on the second buffer: there is no Lua buffer-switch, so "the
other" is a buffer that exists but is not shown. That is the case that
matters anyway --- a global write reaches every buffer without an
override, shown or not.

Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1916/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
2026-08-07 18:22:40 +02:00
Levi Neuwirth 937544cd61
feat(view): wrap-aware coordinates, breaking in and additive out
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
2026-08-07 13:07:54 +02:00
Levi Neuwirth cad9393ef0
Merge main into long-lines
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
2026-08-07 10:27:04 +02:00
Levi Neuwirth 8b1eff10df
feat(view): WrapMode, threaded to every viewport and pinned to Truncate
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
2026-08-07 09:48:08 +02:00
Levi Neuwirth 1e054f7109
docs(zoom): the ties both round up, they do not oppose
Review caught the explanation of the 0.015 round-trip break, not the
fix. Three copies of it claimed 16.015 rounds up while 16.005 rounds
down --- "opposite directions". Both round UP.

Verified rather than reasoned about: at the point the quantizer sees
them, 16.015 * 100 is exactly 1601.5 and 16.005 * 100 is exactly
1600.5. Both are exact ties, and half-up sends both away from zero.

So the mechanism is not opposed rounding, it is that half-up is not
symmetric under negation. Rounding up on the way in adds half a
centi-pixel; rounding up on the way out adds another, so the two
errors ACCUMULATE instead of cancelling, and 16.00 -> 16.02 -> 16.01
ends one centi-pixel high. "Opposite directions" would have predicted
them cancelling, which is the reverse of what happens.

Corrected in all three places that carried it: the module comment, the
test's doc comment, and framing section 3.2.

Comments only --- no behavior change, and the witness values in the
tests were already right. Zoom suite still 15/15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-06 17:50:03 +02:00
Levi Neuwirth 828f57debb
fix(zoom): quantize the step, or the round-trip guarantee is false
THE BOUNDS WERE NOT SUFFICIENT AND THE TEST COULD NOT SEE IT.
`ConfigKind::Number` validates finiteness and bounds and nothing else,
and `on_change` listeners are notified after a value is stored — they
cannot veto. So 0.015 is a perfectly settable step and nothing in the
registry can refuse it.

Used raw it breaks the framed guarantee, because each operation rounds
independently and 16.015 and 16.005 round in OPPOSITE directions:

  step 0.015:  16.00 -> 16.02 -> 16.01     broken
  step 0.37 :  16.00 -> 16.37 -> 16.00     holds

The existing round-trip test used 0.37 — centi-pixel representable — so
it passed against the defect. Bitten now: with the raw value the new
case lands on 16.01, while the 0.37 case still passes, which is exactly
why it needed to be its own witness.

QUANTIZED WHERE USED, not at `set`. Sizes live in integer hundredths
end to end and `validate_font_size` already range-checks the original
and then rounds to the nearest hundredth; rounding the step is that
same operation one level up. A step of 0.015 is not a finer step in
this domain, it is 0.02 written imprecisely.

Enforcing at set time was considered and rejected: the registry cannot
express a precision constraint, and a validating wrapper is bypassed by
a direct `pmacs.config.set` — the seam `autosave` already documents
about its own interval_ms wrapper. Quantizing at the point of use
cannot be bypassed. Both descriptions say "quantized to hundredths", so
`describe-setting` shows it.

The framing header also still said "proposed, awaiting approval" while
the lane and this PR recorded it approved and implemented. Revision 5,
with §3.2 recording the gap and why quantization rather than rejection
closes it.

Verified: fmt, clippy, diff-check, --lib 1900/0, gui_zoom 15/15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:24:56 +02:00
Levi Neuwirth aa99ab39d2
feat(zoom): GUI zoom over the font preference that already existed
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>
2026-08-06 16:20:46 +02:00
Levi Neuwirth 899aaf2249
fix(frontend): honor full_grid — the flag existed and nothing read it
Zooming a terminal with Ctrl +/- left the TUI showing the previous
frame through the new one. Q#FG1 = A, as approved.

THE RULE WAS ALREADY WRITTEN DOWN, ON A PRIVATE FIELD.
src/instance_render.rs:36 says remote frontends "must blank their local
buffer before applying the deltas" — the binding contract, in the one
place a consumer author will never look. The protocol type said only
that full_grid marks "the initial sync ... versus an incremental
frame": a label, from which no obligation follows. So FG-INV now lives
on InstanceMessage::CellDelta, where whoever writes the next frontend
reads it. A resync is a picture of the screen's INK, not of the screen.

The producer diffs against a blank grid, so a cell that should be blank
produces no span. src/frontend.rs then took `CellDelta { spans, .. }`
and discarded the flag. That was correct for exactly one frame — the
fresh-attach frame, which follows Frontend::new's Clear — and wrong for
every resize after, which follows nothing. A font-size change is the
worst case because the terminal reflows in place rather than dropping
content, so the maximum number of stale glyphs survive.

emit_cell_delta joins emit_span and emit_status_overlay as a pure
helper over a writer; apply_message routes through it. No struct
change, no generic parameter, no new pattern.

WHY SEVEN TESTS MISSED IT. Every one asserts the producer SETS the
flag; none asserted a consumer ACTS on it, and no runtime reader
existed workspace-wide. "Add a test for the flag" had already been
done and did not help. Handoff §5's enforcement-vs-documentation drift,
in a second register.

Three unit witnesses, each bitten independently. The empty-spans case
earns its own test rather than folding into the others: under the
plausible `spans.is_empty()` early return the ordering test still
PASSES and only that one fails — and an empty resync is exactly the
frame whose entire content is the blanking.

The PTY acceptance drives a real SIGWINCH, and its mark is anchored to
CONTENT rather than time. A time-based settle was written first and is
unusable: a settled pmacs screen emits per-frame bytes forever, so
"output stopped growing" never becomes true. Anchoring just past the
first painted byte excludes both startup clears by construction —
Frontend::new clears before any frame exists, and the first frame is
itself a resync whose clear precedes its own spans. Bitten against the
original defect: 34,831 bytes after the first painted frame, no CSI 2 J
anywhere in them.

What it does not prove, stated here rather than found in review: the
suites assert on raw bytes, with no screen model and no vt100/termwiz/
vte dependency. This shows pmacs emitted a blank at the right moment,
not that the screen ended correct.

Verified: fmt, clippy, diff-check, --lib 1900/0, crdt 2085/0, m4 150/0,
gpu 221/0, and the grid-driving suites — full_grid_resync 1/1, vterm
1/2/3 9+9+5, m5_5 15, m5_8 5, bottom_panel_stage1 47.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:05:59 +02:00
Levi Neuwirth ef99b64f95
fix(listview): ids must also be unique and not NaN
The scalar contract said "identity" and enforced only "scalar", so two
ways to hold an id that is not one survived.

NaN passes `type(x) == "number"` and then errors at
`p.collapsed[row.id]` with "table index is NaN" — the one scalar Lua
accepts as a number and refuses as a key. Bitten with the check
removed, it reports exactly that, from inside listview, naming no row.

DUPLICATES do not merely collide. Every lookup here — `line_of_id`, and
toggle's scan for the row index — resolves an id to the FIRST row
bearing it, so selecting the second such row toggles the first and
re-seats the cursor onto it: a stray jump with nothing pointing at the
id. Bitten with the check removed, nothing is raised at all.

Both are enforced in `check_ids`, where rows already enter, so the
error names the offending row (and, for a duplicate, both of them)
instead of surfacing as a low-level error or a wrong jump later. The
error text says why, not just what, since the reason is not guessable
from the rule.

Verified: fmt, clippy, diff-check, --lib 1897/0, crdt 2082/0, listview
26/26, m4 150/0, gpu 221/0, bottom_panel_stage1 47/47.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:06:25 +02:00
Levi Neuwirth 7e27de63d8
fix(listview): item was load-bearing, and the id contract was two contracts
TWO REVIEW FINDINGS, both real, and neither reachable from the existing
tree tests.

1. `item` WAS EFFECTIVELY REQUIRED. `render` writes `line_to_item[n] =
row.item`, so that map is SPARSE whenever a row omits the optional
`item` — and `seat_cursor` took `#` of it. A display-only tree (a
grouping node with `on_visit` unused, which the API explicitly allows)
made that length 0, so the cursor never left the header, TAB found no
row, and folding was unusable. It now counts visible rows explicitly.
The old tests could not catch this because every one of them supplies
`item`: under the reverted fix `tr_5` fails `left: 0 / right: 1` while
`tr_1` still passes.

2. THE ID CONTRACT WAS TWO CONTRACTS. The docs said "opaque, compared
by equality". Selection does compare with `==`, honouring `__eq` — but
collapse state stores ids as TABLE KEYS, and Lua indexes tables by raw
identity, consulting no metamethod. So a table id would satisfy one
half and quietly fail the other: after a refresh minted fresh id
tables, the cursor would be restored and the fold silently lost. A
divergence that shows up as a missing fold, arbitrarily later, with
nothing pointing back at the id.

Narrowed rather than generalized. Equality-aware collapse lookup is the
alternative and it is worse: `hidden_by_ancestor` runs per row, so it
turns a linear render quadratic to support a key type no consumer has
asked for. The contract is now the one both halves can honour — string
or number, compared by value — enforced by `check_ids` where rows enter
(`open` and `refresh`), so a bad id is a named error at the call site
instead of a lost fold much later. Q#TR3 in the framing records the
narrowing and why.

Verified: fmt, clippy, diff-check, --lib 1897/0, crdt 2082/0, listview
24/24, m4 150/0, gpu 221/0. Both fixes bitten independently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:06:11 +02:00
Levi Neuwirth 3f6a2837de
Merge githubsucks/main into tree-primitive-framing
#216 landed while this branch was open. Both conflicts are in the
ledger and the handoff, and both sides had independently written up the
same shared-CARGO_TARGET_DIR hazard.

THE TWO WRITE-UPS ARE NOT ABOUT THE SAME OCCURRENCE, and merging them
carelessly would have been a real error. Stage 2's is established: seven
failures against a clean baseline, failure text naming its own cause,
pgrep confirmation, and a dedicated-target-dir re-run at 41/41. This
lane's is the one whose signatures were destroyed before being read —
it has no captured text to match against Stage 2's, and it keeps two
non-causal hypotheses. A mechanism established in one occurrence is not
evidence about a different occurrence that was never characterized, so
the merged bullet says so explicitly rather than letting proximity
imply it.

The ledger records #216 merged and stops saying Stage 2 is in flight.
It does NOT retire the arc, though rule 4 now would: R1 belongs to the
async-runtime lane and R3 is an unresolved possible product defect for
the process-signal lane, and neither has a block yet. Re-homing them is
an absorption pass — not something to fold into a feature PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:42:03 +02:00
Levi Neuwirth c59de959e7
fix(listview): flat panels keep their TAB, and a selection test that bites
FOUR REVIEW FINDINGS, and the first two were right about the tests.

1. THE SELECTION ACCEPTANCE WAS VACUOUS. `tr_1` toggles the selected
root, which sits on line 1 before and after collapsing — so it passes
unchanged under the line-based re-seating that id-keyed re-seating was
built to replace. It proves collapse hides descendants; it proves
nothing about selection. `tr_4` adds the case that discriminates: an
`on_refresh` inserts a row ABOVE the selected node, so the node moves,
and the assertion is that selection follows the NODE. Bitten by
restoring `seat_cursor(p, saved)`: `tr_4` fails with left "  kid2",
right "sibling", while old `tr_1` passes — which is the finding,
reproduced.

The substantive assertion is deliberately ordered first. It was second
at one point, behind the fixture check that the node moved, and a
regression then reported as "the insert must move the selected node" —
reading like a broken fixture rather than a broken re-seat.

2. FLAT PANELS WERE NOT BEHAVIOUR-IDENTICAL. `bind_local_keymap` binds
TAB on every listview, so a depthless panel that previously fell
through to the global binding — and to Q#P3's read-only intercept —
began answering "listview: no node here". `listview.toggle` now
delegates to `buffer.tab` when no row carries an id, restoring the
prior path exactly; leaf feedback is kept for panels that really are
trees. `tr_3` asserts the absence of both tree messages rather than
merely that the panel still renders.

3 and 4 are documentation. The lane now lists 38e94dc, and no longer
says the PR is held "pending review of the documentation" that the same
commit supplied — it is held pending the decision to open it. §20 said
to BUILD the tree primitive while §14 already carried ◐; it now says
what actually remains, which is adoption: dired's `i` is the next
constraint source, DAP's variables view is why this was worth building
before them.

ONE RED, CLASSIFIED RATHER THAN RERUN AWAY. The crdt lib gate failed
`composition_overhead_under_ten_percent` at 30.7%. It is an incumbent
handoff hazard, and the branch cannot reach it — the diff versus main
touches no src/, no crate, no manifest. Alone it ran 5/5 green at
-0.6% to +0.2%; the next full run was green. Recorded in the handoff as
a MEASUREMENT, not a cause: five isolated greens establish that the
ratio is nowhere near the threshold when alone, not that contention is
what pushed it over. Not a registry row either — that file judges red
CI runs, and this was local.

Verified: fmt, clippy, diff-check, --lib 1896/0, --lib --features crdt
2081/0, listview 22/22, m4 150/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:17:33 +02:00
Levi Neuwirth 8f64c3b2a1
test(listview): byte-identity for the flat consumers, and two findings
ACCEPTANCE 5, and it needed a real test rather than a weakened claim.
`listview_acceptance` says in its own header that the references panel
"needs a live LSP and is validated manually / via the m4 harness", so it
does not exercise `*references*` at all; the m4 hover test asserts
content PRESENCE, not exact output. Neither would notice a flat consumer
silently gaining an indent column — the regression a tree extension can
introduce. So the coverage is written against the real entry points
through the fake language server.

`*references*` is pinned EXACTLY: the row is the location string and
nothing else. `*lsp*` formats its own two-space indentation, so
"starts with a space" is not a violation there; what must hold is that
the primitive reproduces the consumer's text verbatim, matched as a
WHOLE LINE — a substring would still be found inside a further-indented
copy of itself. Volatile parts (pid, elapsed) are deliberately excluded,
the same normalization reasoning the CI registry uses.

THE FIRST BITE PASSED, AND THAT WAS THE FINDING. Injecting
`string.rep("  ", row.depth or 0)` did not fail the test — flat rows
carry no depth, so it added nothing. I had simulated a regression the
flat path is immune to and would have recorded the test as verified.
The regression this criterion actually guards is an UNCONDITIONAL
column, a fold gutter on every row; with that injected the test fails on
"the flat references row renders verbatim". A bite that passes validates
the pair, not the test — and injecting the wrong defect teaches nothing
while feeling like assurance.

A VERIFICATION RECORD, including one unclassified occurrence. The first
local crdt sweep of this branch reported 7 failures and its SIGNATURES
WERE DESTROYED before being read, piped through an aggregation that
emitted only totals. That is the failure the CI registry exists to
prevent, committed one lane after writing it, and it is why the cause
cannot now be established rather than merely being unknown.

It is recorded in this lane's own framing and deliberately NOT as a
registry row: that registry keys on a normalized signature, and an
occurrence with none would be granted a recognisability it cannot
support — the same reasoning that made the unevidenced incumbents audit
notes rather than rows.

Four re-runs are tabulated with what each supports. Two were not
isolated, including one where my own guard printed "aborting" and did
not abort. TWO GENUINELY ISOLATED RUNS ARE BOTH CLEAN, which supports
repeatability under isolation and establishes nothing about the cause.

Two mechanisms are recorded as NON-CAUSAL hypotheses, because both were
present and neither can now be tested: a shared CARGO_TARGET_DIR (whose
reciprocal case another lane observed independently, with `pgrep`
evidence and failing text that named its own cause), and ~40 resident
leaked daemons. Having two plausible mechanisms and no way to
discriminate IS the result; naming either would repeat the reasoning
this project has rejected — concluding something about an occurrence
from something that was not about that occurrence.

Both mechanisms are recorded as standing hazards in the handoff, and the
daemon leak gets its own candidate lane: 42 orphans, oldest four days,
reparented to systemd with deleted sockets, from
`gpu_invocation_acceptance`'s one-command tests, leaking 3-4 per sweep
as measured rather than estimated. It predates this work and belongs to
the reap-ledger family — a process outliving its supervisor with nothing
watching it — but the existing ledger arms only for `spec.group` and so
does not cover it.

Verified: fmt, diff-check, luajit sweep 3453/0 and crdt 3722/0, each
exactly +4 on its baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:31:31 +02:00
Levi Neuwirth 49a42ec9dc
feat(listview): the tree primitive — depth, collapse, and identity
COHERENCE.md §14's last missing workbench primitive. Q#TR1-TR4 decided
at review; this implements them.

EXTENDS LISTVIEW rather than adding a treeview (Q#TR1). A separate
primitive would either duplicate ~200 lines of panel discipline —
Q#GB18 handle identity, Q#GB13 `<2>` disambiguation, the read-only
intercept, `prev` capture, the quit chain, generated-buffer writes — or
require extracting them from a shipped primitive first, which is the
riskier change. Rows gain OPTIONAL `depth` and `id`; absent, they behave
exactly as before, which is what keeps the three flat consumers
untouched.

THE OBSERVATION THAT MADE THIS CHEAP: collapse only ever HIDES rows and
never changes a surviving row's depth. Combined with consumers emitting
parents before children in document order, a node's descendants are a
CONTIGUOUS RUN of following rows with greater depth. So collapse is
filtering an existing array, not re-deriving one — the primitive never
calls the consumer to re-render a fold, and pre-rendered indentation
stays correct. That is why `text` remains consumer-supplied (Q#TR4),
which also sidesteps the future conflict with dired's fixed-width
`_layout` column contract.

It is also why a panel with NO `on_refresh` can still fold. The anchor
consumer is exactly that panel: the outline has no refresh at all
(framing §1.5a), so a design requiring the consumer to re-supply rows on
every fold would not have worked for the only consumer that exists.

SELECTION IS RE-SEATED BY ID, NOT BY LINE (Q#TR3). A fold inserts or
removes rows above the cursor, so a line-keyed restore lands on an
unrelated node — the defect `listview.refresh` already had in milder
form. `id` is consumer-supplied and compared by equality; the primitive
never derives one. The outline uses `line:col`, unique per document and
stable across re-render, rather than the `::` parent chain, which
collides on overloads and same-named siblings — precisely where a stale
expansion would reattach to the wrong node.

`has_children` reads the FULL row array rather than the rendered subset.
A collapsed node's children are absent from `line_to_row` by
construction, so asking the rendered view would answer "no" for every
collapsed node and make expanding impossible.

TAB ON A LEAF REPORTS rather than silently doing nothing. The outline's
`g` is already a dead binding — bound, dispatched, no feedback — and
this primitive must not add a second one.

Tests: fold hides ALL descendants while the node and its SIBLING
survive; state and selection survive a re-render; a leaf reports; and a
depthless panel is unchanged by TAB. The fold test is bite-verified —
disabling only the ancestor filter fails it on "descendants hidden".

Verified: fmt, diff-check, clippy with and without crdt, --lib 1896, m4
149, listview 21/21, and the full serialized luajit sweep at 3453
passed / 0 failed. That count reconciles exactly: main is 3450 (Stage
3's 3449 sweep predated its capability-fallback pin) plus these three
tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:31:31 +02:00
Levi Neuwirth bd532dde6b
fix(test): retire R2 and R4 — two readiness predicates weaker than their assertions
Stage 2 of `docs/macos-ci-signal-integrity-framing.md` revision 3
(acceptance 6–9). Two test races, both the same shape: the thing waited
for was weaker than the thing asserted, so the wait could return inside
the window the assertion needs closed.

R4 — `wait_for_file` returned as soon as `fs::read` succeeded, which
succeeds on a ZERO-BYTE FILE. The probe publishes with
`open(path,'wb').write(b'1')` and `open()` creates the file before
`write()` fills it, so the helper handed `[]` to a caller asserting
`== b"1"`. It now takes the expected bytes and waits while the file
holds a STRICT PREFIX of them — the states a write in flight can be
observed in — returning anything else immediately so the caller's
`assert_eq!` stays the discriminating assertion rather than becoming a
timeout inside a helper that does not know what was expected.

All four callers pass their expectation. `wait_for_published_file`, one
function away in the same suite, gated the real-TUI smoke's
`assert_eq!(…, b"1")` on the identical predicate and is fixed with it:
leaving it would have let R4 recur under a different selector, which the
registry would then have had to judge a new incident.

R2 — the USR1 fixture waited on `ProcessEventKind::Started`, emitted at
SPAWN, not when `/bin/sh` has parsed `trap '' USR1`. SIGUSR1's default
disposition is terminate, so a signal inside that window kills the
child. The child now publishes a marker AFTER the trap and the test
waits for that marker's CONTENT (the same zero-byte trap applies to a
shell's `>` redirection). `exec` replaces the forked `sleep`, so the
group holds exactly one process and the ignored disposition survives by
POSIX rather than by the shell's fork-suppression optimization — an
unstated dependency the old fixture had, since these signals are
group-directed and a forked `sleep` is an untrapped group member.

Four witnesses, each verified by REVERTING the fix and observing the
failure rather than by reasoning about it:

- `wait_for_file_does_not_return_a_zero_byte_readiness_file` fails
  `left: []`, `right: [49]` — R4's two required fragments, verbatim;
- `wait_for_file_does_not_return_a_partial_write` fails on the torn read
  a length check alone would admit;
- `wait_for_file_returns_divergent_content_rather_than_timing_out` fails
  against an over-strict helper that waits for an exact match;
- `usr1_readiness_waits_for_the_trap_not_for_the_spawn` fails
  `left: Some("SIGUSR1")`, `right: Some("SIGTERM")` with the readiness
  wait removed. Its fixture sleeps before `trap` so the pre-trap window
  is deliberate rather than load-dependent, and it proves survival by
  the child's EXIT DISPOSITION rather than by an absence observed within
  a window.

R1 is NOT touched — referred to the async-runtime lane (Q#MCI3), because
widening its budget would make it pass and measure nothing more. R3 is
NOT touched and remains UNRESOLVED, owned by the process-signal /
reap-ledger lanes.

`docs/ci-red-signatures.md` moves R2 and R4 to a "Retired rows" section
with their dispositions and adds the rule the file needed and lacked: a
red matching a retired row is a RECURRENCE that puts the retirement in
question, never a known flake. `docs/active-work.md` carries this lane
from its first commit rather than after review asks for it.

Repetition sets, not single runs: the two `--lib` process tests 15/15,
the whole `vterm_stage2_acceptance` suite 15/15 at default parallelism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:41:05 +02:00
Levi Neuwirth fdbeb5cc32
fix(test): the GPU terminal suites measure geometry the flip changed
Stage 3 review round 2. `vterm_stage3_acceptance`'s three terminal
fixtures opened with no `display`, so the flip placed them in a 12-row
panel — and this suite measures RENDERED FRAMES and CHILD PTY GEOMETRY.
It was measuring the geometry the flip had changed underneath it.

I nearly recorded this as a flake, and the reasoning that stopped me is
worth keeping. Two CI runs on the same commit failed DIFFERENT suites:
the first `vterm_stage2`'s real-TUI smoke, the rerun `vterm_stage3`'s
a37 plus two GPU terminal tests. Different failures across runs is the
load-sensitivity signature, both suites are on the documented flake
surface, and the ledger says a red a37 is "ambiguous by construction".
Every indicator pointed at noise.

But the tests it kept landing on were GPU TERMINAL tests, and terminal
placement is exactly what this PR changed. Checking rather than
concluding: all three `terminal.open` fixtures omitted `display`. So the
flip did reach them. It did not BREAK them — they pass locally, and they
passed in one CI run each — it made them MARGINAL, by shrinking the
window whose rendered output they assert against. Marginal under
lavapipe on the heaviest job in the workflow reads exactly like a flake
until you ask which tests, and why those.

The distinction that matters: "my change made this fragile" is a
different finding from "this was always flaky", and only one of them is
mine to fix.

These take the explicit opt-out for the same reason `vterm_stage2`'s
smoke already did — their subject is rendering, geometry and input
round-trip over a full document window, not placement, which the panel
suites cover. The reason is stated at each fixture rather than once at
the top, because each asserts a different property of the geometry.

Verified: vterm_stage3 9/9 with a37 taking 4.33s rather than the 0.17s
that means it never ran; vterm_stage2 6/6; and the full crdt sweep at
3718 passed / 0 failed against a measured 3715/0 baseline, the +3 being
this PR's new pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:00:58 +02:00
Levi Neuwirth 6b4d52a5bc
review round 1: the crdt suite Stage 3 missed, and two stale explanations
P1 — compile_mode_crdt_acceptance was never revised. Its three
`compile.run` fixtures still omitted `display`, so the flip placed
output in the panel with `select = false`, the source buffer stayed
active, and `adopt_next_buffer` timed out waiting for a snapshot that
only arrives via the ACTIVE-BUFFER-FOLLOW path. Its subject is CRDT
convergence of a generated buffer, not placement, so it takes the
explicit opt-out — and the reason is recorded at `adopt_next_buffer`,
the helper that actually depends on it, rather than at each call site.

WHY MY OWN VERIFICATION MISSED IT, which matters more than the fix: the
Stage 3 census and every sweep I ran used `--features luajit` WITHOUT
`crdt`, so no crdt-gated suite was ever exercised. The census was
therefore blind to an entire configuration by construction, in exactly
the way #209 exists to prevent. The CI crdt job — added by that lane —
is what caught this, three days after it landed.

Baselined rather than assumed. A worktree at the branch base 21de0b2
sweeps 3715 passed / 0 failed under `luajit,crdt` with
PMACS_REQUIRE_GPU=1; the branch with this fix sweeps 3715 / 0. Identical.

An earlier branch sweep, taken before this fix, reported SEVENTY
failures across THIRTEEN suites. Twelve of those suites — m5_5,
gpu_invocation, gpu_initial_target, m10_11, vterm_stage3 and the rest —
are daemon, socket or GPU suites, and all of them recovered by fixing
three compile tests. The likeliest reading is that the failing
compile-crdt runs leaked daemon or PTY processes that poisoned every
subsequent socket-based suite; what is ESTABLISHED is narrower and
still useful: baseline green, branch green with the fix, and the
collateral confined to process-spawning suites. Count failures, not
causes — the same lesson this stage already learned once, at a
different layer.

Also of note: CI reported only 3 failures because `cargo test` halts
after a failing binary. The workflow does not pass `--no-fail-fast`, so
CI under-reports a multi-suite break exactly as my first census did.

P2 — builtin/runtime/compile.lua's recompile comment still said `_last`
stores only cmdline/cwd. This PR deliberately stores `display` too, so
an explicit opt-out survives replay. Corrected, with the
`display_omitted` arm's remaining purpose stated rather than implied.

P2 — the framing's §7 step 2 still called the resolver extraction
"provably behaviour-preserving", contradicting §1.6b's own record of the
intentional non-string normalization. It is DEFAULT-PRESERVING WITH ONE
INTENTIONAL NORMALIZATION, and now says so where a reader following the
branch plan will hit it.

Verified: fmt, diff-check, compile_mode_crdt 3/3, compile_mode 73/73,
and the full crdt sweep at 3715/0 against a measured baseline of 3715/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:08:28 +02:00
Levi Neuwirth 5f01edea87
test(panel): pin that the OMITTED default degrades on a pre-panel frontend
Stage 3 step 5, criterion 5 — the one the framing named as most likely to
be quietly wrong.

`acc14` already proves capability fallback for an EXPLICIT `request.side`
at the core level, and it is not this case. Stage 3 resolves the default
into a PANEL REQUEST inside the adopter, so a pre-panel semantic frontend
now has to degrade a request the caller never wrote. Nothing in
`listview.open { name, rows }` says "panel", yet the request reaching the
core does — which is exactly why this is invisible from the adopter's
side and needs its own pin rather than an inference from acc14.

Asserts the three things that must survive the degradation: no side
window, no side parameters on the document window, and NO QUIT ACTION
left behind. That last one is the subtle half — a quit action stranded on
a document window would make a later `q` try to restore a presentation
that never happened.

Bite-verified rather than assumed: flipping the fixture's frontend to
`panel_capable = true` fails it on "a pre-panel frontend gets no side
window from the omitted default", so the test is measuring the
capability and not merely the absence of a panel it never asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:16:08 -04:00
Levi Neuwirth c0eb16bd12
feat(panel): flip the adopter default — Arc 7's last step
Stage 3 steps 3 and 4. Omitting `display` now resolves to the PANEL for
listview, compile and terminal; dired keeps `"current"`, passed
explicitly to the shared resolver. Per-adopter `select` per Q#BP12:
listview true, compile false (passive output must not steal document
focus), terminal true.

The census predicted 37 failures across 5 suites and the flip produced
exactly that — same suites, same per-suite counts. The measurement was a
prediction, not an estimate, which is what the inverted step order was
for. Final sweep: 3449 passed / 0 failed against a 3447 baseline, the
+2 being new pins.

THE CENSUS COUNTED FAILURES, NOT CAUSES. Thirteen listview failures had
ONE root cause: a panel is derived-hidden while frame geometry is
unknown, and listview_acceptance never declared any — it never needed to
while listview defaulted to the current window. One helper took it from
13 to 2. The same applied to m4 and vterm_stage2. Geometry is
authoritative state and a grid frontend's real frame size IS its
declaration; the panel suites have always said so.

THREE DEFECTS THE FLIP EXPOSED, each fixed rather than tested around:

1. The OUTLINE panel's `on_visit` used `pmacs.window.switch_buffer` —
   the RAW switch, which replaces the buffer in the ACTIVE window. That
   was harmless while the outline opened into a document window. Once
   the panel became the default the active window WAS the outline panel,
   so RET clobbered the panel with the source and left nothing for `M-,`
   to return to. The references panel was migrated to `display_file`
   when the arc landed; the outline was missed because nothing exercised
   it from a panel until now. Q#BP11c names this exact corruption, and
   both the outline and compile tests now assert `M-,` FOCUSES the
   panel rather than cloning its buffer into the document — an
   assertion the previous one could not distinguish.

2. `pmacs.compile._last` stored only `{cmdline, cwd}`, so a recompile
   reached `start_run` with no `display` and took the new default. A
   user who ran `compile.run{display="current"}` would be moved into a
   panel the moment they pressed `g`. An opt-out that reverts on the
   next recompile is not an opt-out; `display` is stored and replayed,
   with nil kept as nil so an omitted value still resolves to the
   default rather than freezing at the first run's resolution.

3. `opts.display` on a nil `opts` — my own regression, introduced by
   fix 2 and caught by `journey_acceptance`, which is exactly what that
   ratchet is for.

COMPILE'S CHORDS ARE NOW PANEL-LOCAL, and that is a contract rather than
an accidental reachability loss. Every compile chord is bound
`scope = "buffer"`, so with `select = false` none dispatch from the
document — `C-c C-k` included. `acc34` pins it, and pins that
`M-x compile.kill` still reaches the running slot from anywhere via its
`or compile_slot()` fallback. A global chord is a command-surface
decision and belongs in its own framing.

TEST CLASSIFICATION WAS PER TEST, NOT PER SUITE. Two neighbouring
compile tests land on opposite sides: acc15 (RET-visits-error,
jump-back) asserts the NEW default, while acc16 (n/p within compile
output) genuinely needs the buffer selected and says so. compile's
suite-wide helper opts out because ITS subject is compile-BUFFER
behaviour; the placement-subject tests use a second helper that takes
the default. Every opt-out states why. Nothing was mass-added to make a
suite green.

s1_12's two concerns are split as directed: it keeps its Q#GB18
name-keyed-identity bite with explicit `display = "current"`, isolating
the buffer-level `p.prev` skip rule, while a new `s3_1` pins the
side-window presentation chain — C → B → A → delete, ending at the
document with the wrapper collapsed. The mechanisms are complementary:
presentation history chains in the side slot; `p.prev` prevents
raw-switch and capability-fallback loops.

Verified: fmt, diff-check, clippy with and without crdt, --lib 1896,
--lib --features crdt 2081, pmacs-protocol 19, m4 149, required GPU 221,
and the full serialized sweep at 3449/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:10:14 -04:00
Levi Neuwirth 41d37fcbcb
refactor(panel): one shared rule for the adopter `display` vocabulary
Stage 3 step 2 (Q#S3-1). DEFAULT-PRESERVING WITH ONE INTENTIONAL
NORMALIZATION — not "behaviour-preserving", which would be too broad a
claim. Every adopter keeps its current default, and the full serialized
suite is 3447 passed / 0 failed with ZERO suites differing from the
pre-change baseline. But invalid-input behaviour DID move, deliberately,
and that is pinned rather than asserted in prose.

Before this, FOUR adopters validated the same three-value vocabulary in
four places: Rust for the terminal, and hand-written Lua copies in
listview.lua, compile.lua and dired.lua, each carrying its own copy of
the error string. `parse_adopter_placement` read like the shared parser
its doc comment implied but had exactly one caller. Four copies of one
rule is how the next adopter gets it subtly wrong, and the next adopter
is DAP.

`resolve_adopter_display(operation, raw, default)` now owns exactly
three things: the vocabulary, the error text, and the default policy.
Reachable from Lua as the internal seam `pmacs.window._resolve_display`.

THE DEFAULT IS A PARAMETER, NOT A CONSTANT, and that is load-bearing
rather than stylistic. listview/compile/terminal will resolve omission
to the panel in step 3; DIRED MUST NOT, because
`pmacs.path.set_directory_handler` calls it with `{ dest = dest }` and
no `display` key at all — a flipped default would open `pmacs .` in a
bottom panel. Passing the default in makes dired's exemption visible at
its call site instead of hidden in a divergent copy.

TERMINAL'S `window` MUTUAL-EXCLUSION STAYS IN ITS OWN WRAPPER. Only the
terminal accepts a `window` id and only it must reject `window` combined
with `display = "panel"`. A helper pretending the four parsers were
identical would be its own defect.

THE NORMALIZATION, DECIDED AND PINNED. Terminal read
`get::<Option<String>>("display")?`, so a non-string value raised mlua's
TYPE error before reaching any custom message, while the Lua copies
stringified it into their own. Nothing pinned either behaviour — every
existing assertion passes an unknown STRING, which takes the same path
under both designs and therefore could not have caught a regression
here. The custom error wins because it names the legal vocabulary; the
value is rendered by TYPE ALONE (`unknown display (integer)`) so the
message cannot imply a string was passed.

Pinned at the terminal entry point in acc19 — the one adopter whose
behaviour changed — asserting the shared error AND that nothing is
created. The type SPELLING is deliberately not pinned: Lua 5.4 says
`integer` where LuaJIT has no integer subtype, so asserting either
literal would pass on one CI flavor and fail on the other. Verified
46/46 under both.

COMPILE NEEDED AN EXPLICIT OMISSION CAPTURE, and finding that out is
what this step is for. The resolver collapses omission into its default,
but compile's recompile gate distinguishes them: it fires on OMISSION
only, never on an explicit `display = "current"`, which is the
documented opt-out and must reach the raw switch even when the previous
run was panel-placed. Resolving first and testing `== "current"`
afterwards would have silently merged the two and broken the opt-out
with every test still green. `display_omitted` is captured before the
resolver call and the gate keys on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:25:27 -04:00
Levi Neuwirth 7a9cf5b812
fix(lint): make the crdt targets pass clippy for the first time
`cargo clippy --workspace --all-targets --features crdt -- -D warnings`
has never passed on main. The standing gate list runs clippy without
`crdt`, so these lints have never been enforced, and any CI job that
compiles the crdt targets would be red on arrival. This is framing §7
step 1: nothing else in the lane is testable until it lands.

Eight findings across four files, none behavioral:

  src/daemon.rs                              useless_conversion (u64)
  src/daemon.rs                              missing doc backticks
  src/daemon.rs                              too_many_lines (112/100)
  tests/auto_indent_crdt_acceptance.rs       missing doc backticks
  tests/bottom_panel_stage2b_gpu_acceptance  too_many_lines (104/100)
  tests/vterm_stage3_acceptance.rs           too_many_lines (122/100)
  tests/vterm_stage3_acceptance.rs           too_many_lines (132/100)
  tests/vterm_stage3_acceptance.rs           redundant `continue`

--keep-going is what made this an inventory rather than a lower bound.
docs/active-work.md recorded seven findings at 74301d1 and correctly
warned they were "a lower bound, not an inventory" because clippy
abandons remaining targets once one fails. With --keep-going the set is
complete, and it differs from the ledger's in both directions: the
`unneeded mut` at src/daemon.rs:4965 is gone (fixed incidentally by
later work), a finding in bottom_panel_stage2b_gpu_acceptance.rs is new,
and every src/daemon.rs line number had moved. A stale lint inventory is
worse than none — it invites fixing lines that no longer exist.

The four too_many_lines findings are silenced with a reason rather than
refactored. Refactoring a test body to satisfy a lint that has never run
would be a behavioral change riding a CI-configuration lane, and the
codebase already has ~20 `#[allow(clippy::too_many_lines)]` sites, the
best of them carrying `reason =`. Each reason states why the scenario is
one test: the GPU acceptances exist specifically to prove a real
daemon, a real PTY and real wgpu fit together, which splitting would
hide.

The redundant `continue` needed care. Replacing it with `Err(_) => {}`
traded the lint for `single_match` — the match then destructured one
pattern. Rewritten as an edition-2024 let-chain, which drops both
without changing semantics: an unreadable message still falls through
to the next loop iteration.

Verified: clippy green with and without `crdt` (the second confirming
no regression to the enforced gate), fmt, diff-check, --lib --features
crdt 2081 passed, and the three touched suites green — vterm_stage3 at
9/9 in 4.34s rather than 0.17s, so a37 really ran rather than reporting
ok on a missing binary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:38:36 -04:00
Levi Neuwirth 5cc1a83583 fix(help): forwarders must work programmatically, not only from M-x
CI caught this on all four test legs. The forwarder body called
`pmacs.command.invoke_interactive`, which raises when the alias is
reached through `pmacs.command.invoke` — and
`tests/config_registry_acceptance.rs` does exactly that, three times.

The acceptance pin passed throughout because it drives the M-x path,
which is the path the framing spent three review rounds getting right.
Being right about one entry point is not the same as covering the
command, and a rename touches every caller of the old name regardless
of how it is reached.

Plain `invoke` is also the correct semantics rather than merely the
working one: the interactive-command boundary is rotated once, by
whatever entry point the user actually used, for the name they actually
typed. Rotating again on the inner call would record a second boundary
for a command the user never invoked.

Adds `d8c`, which invokes both forwarders programmatically. Bitten by
restoring `invoke_interactive`: the new pin fails alongside the three
config-registry tests that found it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-31 20:31:00 -04:00
Levi Neuwirth f11af434cd merge: integrate main @ 28f878b; adopt the isolation seam
The isolation lane (#206) landed with an adoption ratchet, and it
caught this branch's brand-new suite on the first run after the merge:

  these suites construct an editor through the ambient entry points,
  so they read the developer's real init.lua and write into their real
  data root: ["discovery_acceptance.rs (1 site(s))"]

That is the ratchet working as designed against code written by someone
who was not looking at the isolation lane while writing it — which is
the case it exists for.

`discovery_acceptance` is therefore MIGRATED, not allowlisted: it
includes `common/iso.rs` and constructs through
`EditorState::new_with_roots(&iso::roots())`. Allowlisting would have
put a fresh ambient site into the census the same day the census was
built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-31 20:15:32 -04:00
Levi Neuwirth 4d4bb22035 Merge remote-tracking branch 'githubsucks/main' into discovery-stage1-commands 2026-07-31 20:10:02 -04:00
Levi Neuwirth 9ea522f3cc fix(isolation): the isolation suite must not itself be ambient
Review round 1: `isolated_construction_is_init_complete` asserted its
paired half — that the *ambient* constructor is unchanged — with an
ambient `EditorState::new()` in an ordinary parent test. That reads the
developer's real `init.lua` and materializes packages into their real
data root: the exposure this suite exists to remove, committed by the
suite itself.

The claim is worth keeping, so it moves rather than dies. It now lives in
the re-exec'd positive control, which runs only as a child under a
hostile-by-construction environment. That is the one place an ambient
constructor is safe, and so it is where every ambient claim this suite
makes belongs.

**The ratchet did not catch this, and that is the more important half.**
`ambient_isolation_acceptance.rs` was on the allowlist for its positive
control, and a bare file-level exemption licenses the named file to grow
new ambient sites forever — which is exactly what happened. So every
exemption now carries its **exact permitted site count**, and a file with
more sites than it was reviewed with fails even while allowlisted. A
count that drops fails too, so the allowlist stays a census rather than
drifting into a ceiling nobody rechecks.

The count immediately earned itself: it rejected the number written from
memory for `journey_acceptance` (47) and reported the real one (26 — 19
`new()` + 7 `open(`, after the scanner drops two assertion-message
mentions and the assembled `concat!` needle).

Verified in both directions: restoring the removed ambient site fails the
ratchet with `2 site(s), allowlist says 1`; and with the ambient half
gone, both init-complete pins still fail under the `if roots.is_ambient()`
mutation, so neither has become a test that passes for the wrong reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-31 19:46:18 -04:00
Levi Neuwirth 44bd2201e7 feat(help): the discovery command family — P4 Stage 1
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
2026-07-31 19:04:48 -04:00
Levi Neuwirth fb14dc9ec3 test(isolation): migrate the corpus off the ambient roots
The mechanical half, riding on the census in the previous commit.

* 342 in-process construction sites in 65 files now take
  `new_with_roots` / `open_with_roots` with `iso::roots()`. The isolated
  base is a pure function of `CARGO_TARGET_TMPDIR` — no counter, no
  `OnceLock` — so two copies of the module in one binary agree instead of
  racing, and the tree lives somewhere `cargo clean` owns rather than
  leaking into `/tmp` once per run. It is shared deliberately:
  materialization is content-gated and idempotent, so a per-test
  directory would repeat it ~330 times per run for a byte-identical
  result.

* `journey_acceptance` keeps the ambient `EditorState::open`, because
  proving the production entry point has a caller is the whole of what
  that ratchet is for. Rev 2's "isolated by the environment its binary is
  launched with" was not a mechanism — cargo launches each test binary
  with the caller's environment, and a binary cannot re-point its own
  roots before its tests run. Each test is now a thin parent that
  re-execs this binary for its own name with controlled roots, and the
  child runs the body against production's call. Two pins guard it: the
  child asserts all four roots resolve inside the controlled base, and
  the suite asserts against its own source that it has not quietly taken
  the seam. The parent also asserts the child ran `1 passed` — a stale
  `--exact` filter would otherwise hollow the whole thing out silently.

* The shared spawners take all five storage variables.
  `spawn_daemon_process_with_env` set `HOME` and `XDG_CONFIG_HOME` only;
  `HOME` is a FALLBACK, so it isolates a root only while the matching
  `XDG_*` is unset — the harness's apparent adequacy was a property of
  one developer's environment. The PTY spawner backfills whichever of the
  five its caller did not pin. The 10 direct `Command::new` daemon and
  attach spawns get the same treatment.

Three suites had `mod common;` behind `#[cfg(feature = "crdt")]`;
`common::iso` is needed in every build, so those are ungated. Files that
already pull in `common` reach `iso` through a `use` rather than a second
`#[path]` declaration — loading one file as two modules is
`clippy::duplicate_mod`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-31 18:48:45 -04:00
Levi Neuwirth fcaf0b36fa test(isolation): census, hostile-environment proof, adoption ratchet
The census first, because it decides how large the mechanical edit is
(framing §7). Every occurrence was listed with its enclosing context and
read; a grep for the bare name over-counts, which is how revision 1
reported 18 by grepping `Editor::new` — a pattern that does not match the
real constructor.

  in-process   342 calls in 66 of 97 files
               (330 of 334 `EditorState::new()` occurrences; 4 are prose)
               (12 of 14 `EditorState::open(` occurrences; 2 are strings)
  spawned      14 real `pmacs` spawns in 8 files
               (of 36 `CARGO_BIN_EXE_pmacs` hits, 18 are the fake-LSP and
                fake-MCP siblings and 4 are path derivations for
                `pmacs-gpu`, not spawns)
  mixed        5 files are both, so sites — not files — are the unit

The full census, with per-site attribution, is the module doc of
`tests/ambient_isolation_acceptance.rs`.

Four things it pins:

* Isolated construction still finishes initialization, asserted twice —
  the flag, and the behaviour it gates (`pmacs.attach` must refuse).
  Falsified by wrapping the config block in `if roots.is_ambient()`;
  `m8_2_acceptance` does NOT catch that, because reopening an already-open
  init phase is a no-op.
* The writes land in the redirected data root — content produced, not an
  invariant preserved. A "the real root did not change" check would pass
  vacuously wherever it already holds identical bytes, since
  `write_if_changed` is content-gated.
* Bet 3, in two children with opposite jobs. The positive control proves
  the hostile environment IS hostile (an ambient editor loads its
  `init.lua` and writes its data root); without it the isolation half
  asserts nothing. The isolated child then stays green under the same
  environment and leaves its hostile root byte-identical.
* A durable adoption ratchet, not a one-time census: a source scan that
  fails when a new ambient constructor appears outside a named allowlist,
  plus a check that no allowlist entry has gone dead. Its scanner strips
  comments, strings and raw strings, and that stripping has its own pin —
  the corpus contains all three shapes, and a grep-shaped answer already
  cost this lane a review round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-31 18:48:26 -04:00
Levi Neuwirth 94036774a8 fix(welcome): notify the core after writing scratch; unstale the ledger
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
2026-07-31 16:43:02 -04:00
Levi Neuwirth 35cc9ff0c5 merge: integrate main @ 5376af1; move the §18 and scorecard grades
The journey suite conflicted additively — step 4 from this lane, step 6
from #204 — and both are kept: 44 pins now cover steps 2, 3, 4, 5, 6
and 9.

Per §25 the audited claims this stage falsifies are updated on the
landing PR rather than deferred: the scorecard's row 18 and §18's
ground truth both read "Missing" / "missing entirely", and a welcome
buffer plus a reachable cheat sheet makes both false. They move to
Partial. §2's step-4 row stays Partial, because `C-h` still deletes a
word and there is no tutorial.

§18's ground truth now records WHY `C-h` stays as it is, so the
help-prefix question reaches the discovery arc as a stated trade rather
than an oversight: non-kitty terminals cannot disambiguate
Ctrl+Backspace from Ctrl+H, so rebinding it would break Ctrl+Backspace
on every legacy terminal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-31 16:01:29 -04:00
Levi Neuwirth df500b115b feat(welcome): greet an unconfigured launch — journey step 4
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
2026-07-31 15:54:05 -04:00
Levi Neuwirth bc3a465c9e merge: integrate main @ 1f290d5; discharge #203's doc-flip obligation
Stage 1b-1 landed as #203, so the four places it deliberately left
saying "in flight" are flipped here rather than in a standalone docs
PR: this branch already touches all three files, and a separate PR
would re-conflict on every merge.

- `COHERENCE.md` §2's step-9 row: Partial -> **Works**.
- §2's keybinding-inversion paragraph: all three examples answered. The
  quote itself is deliberately unchanged — it names a bias, and three
  fixes do not retire a bias.
- §20 Priority 1 and the arc list: 1b-1 landed, 1b-2 in flight, 1b-3
  remaining.
- `docs/agent-handoff.md` §1: IMPLEMENTED -> LANDED.

Conflicts were additive on both sides and are resolved keeping both:
the journey suite carries step 9 and step 6 (34 pins), and §24 keeps
both drift entries — the `ProjectKind::Cargo` naming error and §1.2's
wrong frequency note.

The two journey lanes are unified into one arc lane rather than one
being deleted. Rule 4 removes a lane when its ARC is done, and the
journey arc is not: 1b-2 is in flight and 1b-3 is unframed. Stage 1a
and 1b-1 are summarized there with their facts in the handoff, which is
rule 4's precondition satisfied rather than deferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-30 22:13:34 -04:00
Levi Neuwirth 2d1812c431 feat(lsp): say when the language server did not start — journey step 6
Implements `docs/journey-stage1b2-lsp-guidance-framing.md` (approved at
revision 4, after three review rounds). Lua, tests and docs; no Rust
change and no protocol change.

`COHERENCE.md` §1.2's canonical silence: a preconfigured server that is
not installed failed with no status message, no record and no modeline
marker, while tree-sitter highlighting kept working and masked it. Now
the status line names the command, the language and the errno; the
modeline reads `LSP:!` instead of nothing; and `M-x lsp.status` renders
a durable `*lsp*` panel.

Half of this was already built. `status_buffer_text()` and
`last_error()` have existed since M4.8, exposed to Lua and tested, with
no production caller and no buffer to render into — several doc
comments already referred to "the `*lsp*` buffer" as though it existed.
The reporting shape was likewise already adopted twice inside
`lsp.lua`; the canonical case was silent because nobody had converted
it.

Three tables with three lifetimes, because one cannot do the job:
`reported` is never cleared and includes the command, so repointing at
another missing executable reports again; `failures` is cleared by a
successful spawn so the panel goes quiet on recovery; and a
buffer-keyed projection feeds the modeline, because that provider runs
for every window on every paint and deriving an affinity key inside it
would invoke root resolvers during painting.

The memo is on the report, not the failure: the spawn is still
attempted on every file open, so installing the binary mid-session
recovers with nothing to invalidate.

Adds `tests/lsp_spawn_guidance_acceptance.rs` (16 pins) and a step-6
row to the journey ratchet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-30 22:07:30 -04:00
Levi Neuwirth 0b1716b5cd fix(journey): canonicalize the compile-directory expectation; unflip the grades
Review round 1 on #203, two blocking findings, both accepted.

The compile-directory assertions used the suite's lexical `canon()`,
but `pmacs.project.detect` canonicalizes before walking
(`canonicalize_or_passthrough`, `src/project.rs:509-511`), so the
compile cwd is filesystem-canonical. On macOS `/var` is a symlink to
`/private/var` and the two spellings disagree — both macOS legs failed
while Ubuntu, where `/tmp` is not a symlink, stayed green.

Fixed with a `detected_root()` expectation, and pinned by a fixture
that launches through an explicit **symlink** so lexical and canonical
paths disagree on every platform. That matters more than the fix: the
original bite ran only on Linux, where nothing could make the two
differ, so no amount of local mutation testing would have caught this.
The new pin is the only one that goes red when the lexical expectation
is restored.

Second: `COHERENCE.md` §2's step-9 row was flipped to **Works** and the
handoff said Stage 1b-1 was **LANDED**, while this PR is open. §25 is
explicit that grades change only with landed evidence, never
aspirationally. Both now describe the real state — the row stays
Partial and names #203 as the open PR that closes it, §20 and the arc
list say "in flight", and the handoff says IMPLEMENTED with the PR
number.

The flip is not dropped, it is owned: the active-work lane records the
four places to change on merge, because an unowned doc flip is exactly
how this ledger's drift starts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-30 21:38:47 -04:00
Levi Neuwirth 52d7e5e1eb style(journey): satisfy clippy::manual_assert in the arming gate
`-D warnings` with pedantic rejects an `if`-then-`panic!`. Same
semantics: the arming variable only makes a missing binary fatal, it
never decides whether the pin runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-30 21:16:29 -04:00
Levi Neuwirth 6bee82c1a5 feat(compile): make building discoverable — journey step 9
Implements `docs/journey-stage1b1-compile-defaults-framing.md`
(approved at revision 2). Lua, tests and docs; no Rust change and no
protocol change.

`C-c c` now runs `compile.run`, and the first prompt is prefilled from
the detected project kind through `pmacs.compile.defaults` — seeded
`rust = "cargo build"` and extensible from `init.lua`. `_last` still
wins, so a session that has compiled keeps its own command.

The prompt CAPTURES its directory rather than re-resolving it. Sharing
one resolver between the prompt and the run is necessary and not
sufficient: `pmacs.minibuffer.read` is asynchronous and nothing freezes
the active window while a prompt is open, so two calls to the same
resolver at two different moments are still two different answers — the
user could be offered `cargo build` for A and handed a run in B by
clicking away mid-prompt. This is Journey Stage 1a's `commit_to`
discipline on a smaller seam.

`pmacs.compile.defaults` is public and assignable, so the lookup is
guarded: a throwing `__index`, a non-string entry and a non-table
replacement all degrade to the pre-stage empty prompt and never
prevent compiling.

Only `rust` ships seeded. Rust has one answer; npm/yarn/pnpm,
make/cmake, and `go build` versus `go test` do not, and a wrong prefill
costs more than an empty one.

Adds eight step-9 rows to the journey ratchet and five module pins to
the compile suite. Corrects `COHERENCE.md`, which named a
`ProjectKind::Cargo` that does not exist — the variant is `Rust`, line
77 is its doc comment, and Lua only ever sees the tag string "rust".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-30 21:13:12 -04:00
Levi Neuwirth 7a271f13f5 merge: integrate main @ 391d38a (PRs #197, #196, #191) into Stage 2B-3
`docs/active-work.md` was the only conflicting file, in the same shape
as #191's: `main` inserted the generated-buffer Stage 1 lane immediately
above the bottom-panel header this branch had rewritten. The resolution
keeps both.

Each side's newer text wins where that side owns the fact: `main` carries
the corrected #188 status (MERGED/APPROVED, replacing "OPEN, PROPOSED —
do not implement"), and this branch carries the bottom-panel lane's 2B-3
state and the newer snapshot date, replacing main's "2B-2 MERGED; 2B-3 IS
NEXT" and its 2B-2 boundary paragraphs.

Verified: no conflict markers; every line absent from either parent is a
deliberate supersession by the other, enumerated and checked one by one
rather than counted; all three lane headers present exactly once.
2026-07-30 11:16:23 -04:00
Levi Neuwirth 80985ed6dc test(protocol): pin the version-mismatch divergence, not the old constant
`version_mismatch_clean_disconnect` asserted that the `VersionMismatch`
`server` field equals `ADVERTISED_PROTOCOL_VERSION`. Until this stage
that constant and `PROTOCOL_VERSION` were both 20, so the assertion could
not distinguish them and happened to pin the wrong one. Stage 2B-3 splits
them — the advertised value is a compatibility floor, `PROTOCOL_VERSION`
is the ceiling a frontend may counter-offer up to — and the daemon
correctly reports the ceiling, so the stale assertion failed on all four
CI Test legs.

The production behaviour is right and is unchanged here. Only the test
moves, and it now pins the divergence in both directions: the `Hello`
assertion above holds the advertised floor, a new `assert_ne!` holds the
fact that the reported version is deliberately not that floor.

That second assertion is why this is not a one-character edit. Stage
2B-3's own pin for this rule, `an_unsupported_offer_is_refused_by_name`,
is `#[cfg(feature = "crdt")]` and CI never enables `crdt` — so it is dark,
and `m5_5_acceptance` is the only live guard CI runs on this behaviour.

Bite: reverting `src/daemon.rs:757` to `ADVERTISED_PROTOCOL_VERSION`
fails the test with `left: 20, right: 21` and the named message; restored,
it passes. Verified against the whole suite under an isolated
`XDG_CONFIG_HOME` — 3303 tests, 100 binaries, zero failures.
2026-07-30 10:45:27 -04:00
Levi Neuwirth 12e2cff466 merge: integrate main @ c14f2de (PRs #197, #196) into the Stage 1 lane
`docs/active-work.md` was the only conflicting file. #196 added the dired
Stage 2a lane at the position this branch had used to relabel the #188
framing lane header; the resolution keeps both, changing neither side's
wording.

`src/editor_core.rs` auto-merged. Both lanes touch it, so a clean
textual merge is not evidence of a clean semantic one — the gate suite
is re-run in full on the merged tree rather than inherited from the
pre-merge head.

Resolution verified for line loss in both directions: the resolved file
differs from `main` only by this branch's own authored edits, and
differs from this branch only by additions taken from `main`.
2026-07-30 10:13:37 -04:00
Levi Neuwirth 5fe7eb30b6 fix(bottom-panel): finish the panel port, not one omission at a time
Review round 1: six findings, four sharing one shape — the panel layer was a
partial port of the document/terminal layer, and the tests asserted the
declaration side only, so each omission was invisible. Audited as a port
rather than patched as a list.

GEOMETRY AGREEMENT (P1). Three grids had drifted apart. The declaration
subtracted `TEXT_LEFT` from its width against the parent framing's explicit
contract ("`total.cols` describes the full-width panel grid beginning at
x=0; document `TEXT_LEFT`/gutter padding is unrelated"), while painting and
hit-testing used the document-dependent `mono_advance` and the declaration
used the stable probe. So daemon columns could overflow the surface and a
click could resolve to a different cell than the one painted — and the new
test separated the two advances and then asserted only the declaration, so
it saw none of it.

The fix is structural, not three edits: the advance is cached BEHIND the
declaration (`PanelBand::declared_advance`) and painting and hit-testing read
it. They cannot disagree, because there is one value. The band's rect is now
x = 0 across the full surface width, and the fractional right-edge remainder
is band background that maps to no cell — which is what the framing says and
what `hit_test_cell`'s column bound already enforced.

GESTURES (P1). Only `Move` was sent. Left press never armed, so `Drag(Left)`
was never emitted and panel selection could not work; releases outside the
band were dropped, leaving the daemon holding a button down; right-click and
wheel never consulted the band at all and were applied to the document
underneath.

The root cause is that four handlers each decided for themselves whether the
band owned a pixel, and three did not ask. There is now ONE authority —
`PointerSurface` / `classify_pointer_surface` — and all four route through
it, so a future handler cannot quietly forget the band. `PanelBackground` is
its own arm: the remainder is the band's pixel even though it emits no
`PanelPointer`, so it must not fall through either.

PASSIVE CARET (P1). The producer ships `cursor` for a passive panel too — it
is the window's real point and the daemon does not suppress it — so painting
it unconditionally put a second insertion caret on screen. Gated on
`frame.focused`, the presentation bit Q#BP14b reserves for exactly this.

UNDERLINES (P2). `build_grid` planned them and nobody consumed them. Straight
forms now ride the quad batch and curly rides the squiggle pipeline, the same
split the terminal path makes for the same reason.

VERSION MISMATCH (P2). The daemon reported the advertised baseline as the
server version while its own `PROTOCOL_VERSION` is 21, contradicting the wire
field's own documentation and inverting the upgrade advice. The field doc now
states what each side can know, and the acceptance is re-pinned — it had been
holding the wrong value in place.

Two gaps the audit found beyond the six, same shape:
  * the headless probe never armed the panel wire at all, so no probe could
    ever exercise a band;
  * a disconnect left the band on screen — the frozen, live-looking surface
    the terminal arm already refuses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 22:29:24 -04:00
Levi Neuwirth a131c880e2 test(stage2a): make acceptance 53's attribution assertion bite
Bite-verifying the round-1 pins caught one of them passing with the bug
restored. `contains("only.txt")` was satisfied by the status message's
own `deleted only.txt:` prefix — the deleted path's basename — so
stripping the `buffer "…"` attribution changed nothing the assertion
could see.

Both halves now assert the buffer's OWN name, which for a path-backed
buffer is the full path and which only the attribution can produce.
Dropping either name — the refusal reason's or the kept-modified list's
— now fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 21:34:42 -04:00
Levi Neuwirth 44135fcd73 fix(stage2a): close review round 1 — four unreported failures
All four findings were the same shape: a failure that left state wrong
and told nobody.

**Delete refusals reach the user.** `reconcile_delete_and_fire` returned
`kept_modified` and `refused` and both production callers discarded
them, so a last-buffer refusal or the asynchronous modified-buffer race
left the file gone and the buffer still bound to it — and the next
`C-x C-s` recreates the deleted file. Reporting now happens inside the
shared seam, not at its call sites, for the same reason the
reconciliation does: a caller that has to remember to report is a caller
that will forget. The message names the buffers (capped, with a count
for the rest) and states the consequence, and it is written to
`EditorCore::status`, not `pmacs.error` — that channel is defined only
by a test stub, so a report there would be the same silence.
`reconcile_delete` now prefixes `kill_buffer`'s reason with the buffer
name, because "cannot kill the last remaining buffer" does not say which
buffer is now bound to a deleted path.

**The LSP subscribers stop swallowing their own failures.** Ignored
`pcall`s around `did_close`, `forget_uri`, `did_open` and overlay
re-rooting made the callback return successfully, so the
`all-must-succeed` logger had nothing to log — concretely, a stale server
made `forget_uri` raise while the callback carried on with the old
stores, routes and `documents` entry all live. A shared failure sink
attributes each step, reports on both channels, and raises **after** the
loop, so one unreachable server cannot leave every other attachment
unreconciled.

**`forget_uri` abandons requests through the established path.** It
purged `pending_routes` and `pending_external` but not the same ids
`send_request` put in `LspClient.pending`, and recorded nothing in
`cancelled_rids`. The per-rid work is extracted from
`drain_cancelled_externals` as `abandon_request` and reused, rather than
a second incomplete copy: route, client pending, cancelled record and
`$/cancelRequest` now happen together.

**Acceptance 35 is pinned.** With a plain delete the forbidden fallback
was unobservable — `find_or_open` raises out of `load_file` and the
`pcall` swallows it — so both assertions passed with the fallback
present. The plan now deletes the origin's file and recreates it, which
gives the fallback something to open and makes "restores nothing"
falsifiable. The corrected G1 explanation also reaches the production
comments, which still repeated the false `resolve_target_buffer::NotFound`
story.

New pins: acceptance 53 and 53b assert the status channel; a stale-server
row asserts attribution on both channels *and* that the healthy
attachment still reconciles; an `lsp.rs` unit test asserts the client-side
abandonment with an unrelated request as its control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 21:28:16 -04:00
Levi Neuwirth e00cdd7ca6 fix(bottom-panel): satisfy the strict clippy gate in both configurations
Three float comparisons became explicit epsilon checks, one const-valued
assertion moved into a const block, and the crdt-only half of the new
acceptance suite is now gated import-by-import.

That last one is the interesting part: the negotiation-rule tests are pure
and run in BOTH configurations, while everything needing a real daemon
needs the crdt feature — a semantic session is necessarily a text replica,
so a non-CRDT build cannot host one at all. Splitting the imports along
that line is what keeps the default clippy configuration clean while
leaving the version-ladder assertions where CI can actually reach them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 20:28:41 -04:00
Levi Neuwirth 8ee08d93e5 docs(bottom-panel): record Stage 2B-3 and close Stage 2
Handoff section 1 gains the 2B-3 bullet and the protocol anchor moves to
v21 with the rule that matters stated once: advertise the baseline,
negotiate up from the frontend's AttachRequest, and reserve moving the
advertised version for a change that cannot be expressed additively at
all. 2B-1's forward-looking constraint is marked discharged rather than
deleted, because its acceptance still passes unchanged and that is the
evidence.

COHERENCE section 14 grades the bottom/side panel primitive as complete on
BOTH frontends rather than 'Stage 2 pending its own framing', section 20
P5 follows it, and the section-19 protocol bullet records that the v21
family is live in production without an incompatible handshake change.

The active-work lane is rewritten to the shipped slice, including the
rejected activation alternatives and why the server-first shape forces
each one out, and the one-way compatibility window it leaves open.

The 2B-2 acceptance suite's header said production keeps panel_capable
false for every semantic session. That is no longer true, and its
assertions did not change — which is the point, so the header now says so
rather than being quietly left stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 19:12:36 -04:00
Levi Neuwirth 71cc31bfbb test(bottom-panel): close three vacuous assertions the bites exposed
23 falsifying mutations, each executed. Three of the first-pass assertions
were VACUOUS and the mutation runs are what found them:

  * The contrast assertion compared status_band_top before and after
    installing a panel — a FIXED POINT. The blanket rewrite the framing
    exists to prevent (subtract the band from the status boundary too)
    moved both readings together and passed. It is now anchored to an
    independent formula: the physical window bottom minus the band height.

  * The criterion-46 pixel test only checked that no pixel moved above the
    band and none below it. Installing a panel reshapes the document to the
    smaller height, and THAT produced the whole diff — so the test passed
    with the band painting nothing at all. It now counts differing pixels
    in the divider row and the band's cell rows directly: content produced,
    not an invariant preserved.

  * A2B-3's fixture compared two ASCII documents, which in a monospace
    family have identical glyph advances — so it could not tell the stable
    probe from the document-glyph fallback. It now separates the two
    derivations explicitly and asserts they produce different column counts
    in the fixture, so the claim about which one the declaration uses is
    discriminating.

And one about the CODE, not the tests: 'a v20 semantic session receives no
panel frame' is defence in depth, not the placement gate. The producer's
peer flag and the write-loop filter both suppress PanelFrame below the
panel version independently of panel_capable, so that claim passed with
the capability gate removed entirely. The load-bearing claim is placement:
the adopter's buffer must land in the pre-panel session's own DOCUMENT
window, because a side window it cannot render is simply invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 19:06:33 -04:00