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
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
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
Adopt Q#GB6's clamp-or-clear rule in both window-coordinate
normalization paths. Preserve shortened selections, clear only those
collapsed by a moved endpoint, and pin both outcomes through the real
generated-write and view-rebuild callers.
Make listview refresh rely on the generated-write notification before
reseating, so Stage 1 criterion 7's fan-out mutation bites both
adopters. Align criteria 5, 11, and 12 with framing revision 7.
Review finding 1 on PR #191. `notify_buffer_edit` clamped `cursor` and
`view_top` but not `win.selection.anchor`, and `rebuild_views_for` had
the same gap. Clamping the cursor is not enough to make the region safe:
`Window::region` orders `(anchor, cursor)`, so a stale anchor above a
clamped cursor is still the region's high end and `region_bytes` slices
the rope with it. Reproduced before the fix as
`assertion failed: end <= self.len()` at `src/rope.rs:145`, reached from
`EditorCore::clipboard_copy` after a generated rewrite.
The anchor is DROPPED, not clamped. A window must always have a cursor,
so clamping one is the only available answer; a window need not have a
selection, and a clamped anchor asserts a region boundary the user never
placed --- after a wholesale rewrite the surviving offsets address
unrelated bytes. This is not a new rule: `window.quit`'s restore already
answers the same question the same way with
`selection.filter(|sel| sel.anchor <= len)` (`src/editor_core.rs:3259`).
One rule, now three call sites.
Both exits are pinned separately, because fixing one and trusting the
other is how the gap arose: `acc16h` drives `notify_buffer_edit` through
a generated write, `acc16i` drives `rebuild_views_for` through
`pmacs.help.show_command`, which is the `*help*` renderer's real path.
Deleting either call site fails only its own test. The pin also
discriminates DROP from CLAMP, because that is the decision a revised
Q#GB6 could overturn.
The wording is marked PROVISIONAL in both the implementation and the
pins. The rule belongs to Q#GB6, and PR #188's approved revision 5 does
not mention the anchor; a revision request carrying this defect is with
that lane. If the landed revision says clamp or translate, this changes
to match rather than standing as a third description.
Also in this commit, review findings 2 and 3 --- the tree asserting what
the record does not support:
- Criterion 5's restatement is withdrawn in BOTH suites. The tests now
quote the approved criterion, are renamed `*_provisional_*`, and say
they do not satisfy it; the evidence (`ensure_writable` precedes the
intercept chain, with the measured `ReadOnly` message) is recorded as
what was sent to #188, not as a replacement contract. The framing's
own bite is unchanged and still fails them.
- Criterion 7's "for each adopter" is restored: the listview half now
exists as its own test. Its inability to carry the framing's mutation
bite --- `window.switch_buffer` rebuilds the `TextView`, verified by
applying the mutation and watching this half stay green while the
dired half fails --- is recorded in the test and filed with #188,
not resolved here.
- Criteria 11 and 12 are relabelled from `main` bites to mutation bites.
Both fail on `main` only at their disambiguation premise and never
reach the assertions they exist for, so a revert is not evidence for
what they assert.
How a restated contract passed the previous gate run, since the next
lane can use this: nothing in the gate suite reads a framing document,
so a test that quietly narrows its criterion is indistinguishable from
one that satisfies it --- both are green, and `scripts/bite` only proves
an assertion bites some pre-image, never that the assertion is the one
that was approved. The gate can catch a test that does not bite; it
cannot catch a test that bites the wrong contract, so that check has to
happen where the criterion is read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Stage 1 of generated-buffer immutability
(docs/generated-buffer-immutability-framing.md, revision 5). Closes the
two families the bug is reachable on WITHOUT `M-x`: `compile.lua` and the
search panel rebind all seven undo chords to a no-op, but `dired.lua` and
`listview.lua` rebind nothing, so a bare `C-/` emptied a listing and a
panel. The cheap half is also the exposed half.
An intercept is not read-only. `Buffer::undo` reaches the rope through
`ensure_writable` and never consults the intercept chain, so the
erroring-intercept-plus-`bypass_intercept`-over-a-writable-rope idiom
guarded the edit path and left the history path open. Rebinding chords
does not close it: `M-x buffer.undo` is dispatchable on every buffer in
the tree.
- `dired.lua`'s `paint` and `listview.lua`'s `render` write through
`pmacs.buffer.set_generated_contents` — lift the lock, whole-buffer
replace skipping intercepts, discard history, re-assert the lock, fan
the `Edit` out. Zero `bypass_intercept` writes remain in either file.
- Both keep their named erroring intercept and `set_round_trip_input`.
The layering at `terminal.lua:351-366` is unchanged: the rope lock
protects the daemon copy, round-trip input protects a semantic
frontend's own mirror, and neither substitutes for the other.
- Q#GB13 — `listview.ensure_panel` stops adopting a same-named foreign
buffer. Ownership is the `panels` table; a collision disambiguates
`<2>`..`<99>` and raises at the limit, matching `dired.lua:476-504`.
This is a prerequisite of the lock, not a follow-up: the arc removes
the `M-x buffer.undo` that was the only recovery from a clobber.
- Q#GB18 — `panels` becomes a compacting list keyed by identity. It was
written under the requested name and read back under the actual name,
which a disambiguated panel breaks: `RET`, `g` and `q` fail closed and
silently, and `listview.open`'s capture guard fails OPEN, capturing a
panel as its own `q` target — the chained-panel loop its comment says
it prevents. Ships in the same commit as the disambiguation by the
framing's ordering constraint.
- Q#GB6 — `EditorCore::notify_buffer_edit` clamps each window coordinate
against its own post-edit bound, unconditionally. `cursor` is a byte
position bounded by `Buffer::len`; `view_top` is a line index bounded
by `TextView::line_count`, and a replace can grow in bytes while
collapsing lines, so "the buffer shrank" is not a usable trigger. This
fixes a shipped defect that reaches terminal copy mode.
- Q#GB16(a) — locking these families disables fold CREATION on them,
because `document_bytes` is spelled `is_read_only()`. Accepted and
stated rather than shipped silently; the status string now names the
read-only lock instead of claiming "not a document buffer".
Acceptance: 10 new criteria in `listview_acceptance` (16 total), 6 in
`dired_acceptance` (31 total), 2 in `terminal_copy_mode_acceptance`.
Every criterion's falsifying mutation was run: 5 bite by revert against
`githubsucks/main`, 9 by a named one-line mutation.
Two framing corrections, both recorded in the tests rather than worked
around silently:
- Stage 1 criterion 5 is unreachable as written. `Buffer::apply_edit`
(`src/buffer.rs:773`) and `begin_edit` (`:725`) call `ensure_writable`
as their FIRST statement while the intercept chain runs later inside
`apply_edit_inner` (`:1072`), so once this arc's lock is installed an
ordinary edit can never reach the intercept. Restated at the one point
where the two are distinguishable — the lock lifted — which is the
state the intercept genuinely still covers.
- Criterion 7 cannot bite at the listview adopter. `listview.refresh`
and `listview.open` both follow `render` with `window.switch_buffer`,
which rebuilds the `TextView` from scratch and masks a dropped
fan-out. `dired.revert` does not, so the dired half carries the bite;
it fails under the mutation with the reported
`assertion failed: end <= self.len()`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Review round 4, P2. A fix can invalidate a test that was never written.
Criterion 17 still specified the pre-round-2 world: remove
`set_round_trip_input` and the optimistic op "passes `ensure_writable()`
and mutates BOTH sides, silently, with no divergence to notice". That
was true while no Lua binding set `read_only`. Since
`set_generated_contents` does, the daemon refuses the op — so only the
frontend's own mirror mutates, and the copies diverge.
The gap matters precisely because 17 is unpinned. A real-GPU test
written to the old spec would hunt for a daemon-side edit that can no
longer occur and pass for the wrong reason, quietly readmitting the
round-2 regression through a test not yet built. The specification is
the artifact under review here, not the code.
Restated around unauthorized MIRROR mutation plus daemon refusal —
divergence — in all four places carrying the obsolete claim: the
criterion itself, the Q#TC6a heading, the acceptance-16 doc comment, and
the bite roster. The heading's "ONLY thing" now says what it is the only
thing FOR: the replica's own mirror. `docs/active-work.md` also still
described acceptance 16b as asserting `is_read_only()` is false, which
round 2 flipped.
Why round-trip input stays load-bearing rather than redundant, now
stated wherever the daemon guard is mentioned: a refusal arrives after
the frontend has already applied optimistically and painted. It buys
divergence instead of silent agreement; it does not prevent the mutation
the user is looking at.
Also recorded, after capturing it properly this time: the gate-run flake
in `cargo test --lib --features crdt` is
`process::tests::setsid_escapee_is_not_reaped_and_teardown_reclaims_readers`
(`active_reader_probe` -> None, "live runtime probe"), ~1 run in 5.
Pre-existing and unrelated — this branch does not touch
`src/process.rs`, the test passes 10/10 standalone and 2017/2017 at
`--test-threads=1`, and it is another instance of the known `drain_until`
trap: draining for `Started` also ticks, and a tick reaps the leader.
That also explains the unattributed "2 failed" run noted in round 2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer
Review round 3, on the round-2 primitive itself. One lesson covers all
three findings: a rope write is only half of an edit, and "discard
history" means whichever history the buffer actually has.
P1 — the binding swallowed the edit. `set_generated_contents` returned
`()`, so nothing reached `notify_buffer_edit_to_windows`. Two
consequences, both reproduced by the reviewer. In the default build a
window showing the buffer kept a `TextView` line index describing the
PREVIOUS contents, and the next paint indexed the new rope with stale
ranges — `assertion failed: end <= self.len()` in `src/rope.rs`. In the
CRDT build `pending_crdt_ops` stayed empty, so replica mirrors never
imported the owner's write and their optimistic edits were generated
against content already replaced. The `delete`+`insert` pair this
replaced had done that fan-out for free.
Now applies ONE whole-buffer `Replace`, returns its `Edit`, and notifies
from the binding. The doc comment states the obligation, because the
next owner to adopt the primitive inherits it.
P2 — "discard history" was false in CRDT mode. The v0.1 stacks are
bypassed entirely there; the history lives in loro's `UndoManager`.
`read_only` stops the replay but not the retention, which is the memory
cost the contract claims to eliminate. `UndoManager` exposes no clear,
but needs none: it records only what happens after it is constructed,
the same property `CrdtState::from_bytes` already uses to keep the seed
insert out of undo. `CrdtState::clear_undo_history` rebinds a fresh
manager to the same doc.
P2 — the docs described the pre-fix architecture. Q#TC6a said no Lua
binding sets `read_only` and round-trip input is the only guard; the
acceptance text still said `is_read_only() == false` while 16b had been
flipped to true; `terminal.lua`'s comment repeated the obsolete claim.
The architecture is layered and now says so: rope-level read-only
protects the daemon copy, round-trip input protects the replica's
optimistic mirror, and neither substitutes for the other. Q#TC6a keeps
its analysis under a superseded-in-part box rather than being silently
rewritten — its conclusion survives, two of its premises do not.
New pins. acc16d paints the window after a SHRINKING generated write:
stale offsets then point past the buffer end, so the failure is the
reported crash rather than merely stale pixels. acc16e asserts the
refresh is queued for mirrors, through the real copy-mode path;
`crdt`-gated and therefore dark in CI, which is why 16d drives the
binding rather than the terminal. Plus a CRDT unit test that ten renders
leave the `UndoManager` with nothing recorded.
Bites: dropping the notify panics acc16d at `rope.rs:145` and fails
acc16e with `queued: []`; dropping the `UndoManager` rebind fails the
new unit test on `can_undo`.
Still open, and recorded in COHERENCE.md §14: the fan-out obligation
makes `*compilation*`/listview adoption more than a one-line swap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer
Review round 2, P1. Undo could empty the "read-only" snapshot.
`render_snapshot` wrote with bypass_intercept, which leaves ordinary
undo history behind, and `Buffer::undo` reaches the rope through
`ensure_writable` without ever consulting the intercept chain. So a
single `C-/` — or `M-x buffer.undo`, which needs no keymap at all —
replaced a freshly rendered snapshot with an empty buffer.
`set_round_trip_input` does not help: it routes the key into the daemon
command path, which is exactly where undo runs.
Rebinding the undo chords buffer-locally would not have closed this,
and `compile.lua` already says so in a comment: "command/menu undo
stays dispatchable". `*compilation*` and listview panels therefore
carry the same latent defect today.
Adds `Buffer::set_generated_contents` (Lua:
`pmacs.buffer.set_generated_contents`): lift `read_only`, replace the
contents skipping intercepts, discard the resulting history, re-assert
`read_only`. This ships the framing's deferred immutability lane as ONE
primitive rather than exposing the setter — a bare `set_read_only`
would let a caller lock a buffer it can no longer refresh, which is
precisely why that lane was deferred. Discarding history is
load-bearing twice: it removes what undo would replay, and it stops a
periodically refreshed buffer accumulating rope clones that `read_only`
guarantees nothing can ever pop.
New acceptance 16c drives the real M-x path
(`command.invoke_interactive`), the chord, and redo, and asserts the
owner's own refresh still works — the operation plain `read_only` would
have broken. Acceptance 16b flips from asserting `is_read_only()` is
false to true, because the property it documented is the one that was
wrong. Three `buffer.rs` unit tests cover the primitive directly,
including that ten refreshes leave an empty undo stack.
Bite: restoring the delete+insert render reproduces the report exactly
— `left: Some("")` against the full snapshot — failing 16c and 16b.
Still open, and now named in the framing, COHERENCE.md §14 and the
ledger: `*compilation*` and listview have not adopted the primitive and
remain emptiable by `M-x buffer.undo`; a streaming-friendly variant is
needed for the append case. In CRDT mode `read_only` is what refuses
undo, since loro's UndoManager exposes no clear through `CrdtState`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer
Four findings, all real, and they rhyme in pairs. Two implementation
defects and two vacuous pins, all four tracing to one root: a name is
not an identity, and a context-free readout is not a state observation.
A foreign buffer carrying the snapshot's name was adopted and then
overwritten. `pmacs.buffer.create` accepts any caller-chosen name and
snapshot writes use bypass_intercept, so found-by-name adoption
clobbered user data — the reviewer reproduced "do not clobber" becoming
23 newlines. Now follows dired's F7 rule: ownership means "in copy
mode's own handle table", never "found by name", and a taken name yields
a `<2>` variant.
Snapshot identity was keyed by terminal NAME.
`TerminalManager::open` uniquifies only the derived name — an explicit
`name = ...` is inserted verbatim — so two valid terminals can share
one, and a name-keyed table handed them a single snapshot: the second
invocation retargeted it, `q` returned to the wrong terminal, and
killing either removed the shared buffer. Identity is now the terminal
buffer, compared in an array, because BufferIdLua implements `__eq` but
each wrapper is a distinct table key: comparison works, hashing does
not. The kill-with-terminal callback now closes over its own record
rather than looking the name up again.
The refresh pins were vacuous. Acceptance 19 compared a quiet
terminal's snapshot against itself and 18 counted buffers, so both
passed with render_snapshot replaced by a no-op. The child is
`exec cat`, so the tests now type a marker into the focused terminal,
require it ABSENT from the existing snapshot, and only then refresh —
via `g` and via re-invocation respectively.
The tail-follow pin could not observe view state.
`TerminalManager::snapshot(buffer_id)` is context-free and always
returns the live screen, so it reported "at the tail" even for a view
forced to the oldest retained row. Now read through
`snapshot_for_view`'s at_bottom and its projected cells.
Adds acceptance 18a (a foreign same-named buffer is never adopted or
clobbered) and 18b (two same-named terminals get two independent
snapshots, each `q` returns to its own source, and killing one leaves
the other's snapshot alive).
Four new bites, all discriminating: restoring adopt-by-name fails 18a
AND 18b; restoring name-keyed identity fails 18b; making
render_snapshot a no-op fails BOTH 18 and 19, which is the vacuity
demonstrated rather than argued; and forcing the view off the tail
fails 20.
Criterion 17 stays a named follow-up, per review agreement, until the
real GPU probe is non-skipping and CI-executed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer
Stage 2 of docs/terminal-config-and-copy-mode-framing.md (rev 4,
approved). `M-x terminal.copy-mode`, or `C-t` in a terminal buffer —
physically `C-c C-t`, since every unescaped key goes to the child —
materializes the retained scrollback into an ordinary read-only,
path-less buffer, with `g` to re-snapshot and `q` to return.
No protocol change.
Materializing is the whole design. isearch, motion, selection and the
kill ring work with no new substrate because the snapshot is a rope, so
SearchStore and the existing match painting apply unchanged. And "keys
must not reach the child" dissolves structurally rather than being
guarded: the transport arm keys on is_terminal(buffer_id), and a
snapshot is not a terminal, so the arm never fires. The
dispatch-shadow count stays at six and describe-key keeps telling the
truth — asserted directly, since that is the observable difference
between the buffer-local idiom and a shadow.
One serializer, not two (Q#TC7). `copy_retained` builds a whole-range
selection and hands it to `copy_selection_bytes`; a second walk would
re-derive soft-wrap joining, wide-glyph continuation, cluster bytes and
per-row trailing-blank trimming, and the two would drift. Four unit
pins in view.rs assert exact bytes against the same projection fixtures
that pin the serializer itself.
Q#TC6a is implemented as two calls, and the second is the load-bearing
one: an intercept guards dispatch only, and no Lua binding sets
Buffer::read_only, so set_round_trip_input is what keeps a replica
frontend from applying optimistically and emitting an op that would
pass ensure_writable and mutate both sides. Acceptance 16 pins that
UNGATED, because CI never compiles the crdt feature.
Eight of nine criteria. Criterion 17's semantic-frontend end-to-end pin
is deliberately absent: the optimistic apply lives only in
pmacs-gpu/src/main.rs and the headless SemanticClient has no optimistic
path, so a faithful test needs the real GPU binary — the a37
foundation, which CI never compiles, silently returns ok when the
binary is unbuilt, and is load-sensitive. Both halves of the mechanism
are pinned ungated instead (16, and 16b for the hazard); the wire-level
half stays an explicit obligation of the CI crdt-coverage lane.
Substrate fact found while wiring lifecycle: TerminalManager::prune
REACTS to a buffer already gone from the registry rather than removing
one, so a child exiting leaves both the terminal and its snapshot
alive. That is why on_removed is a sound teardown hook, and why a
finished command's output stays readable.
Five bites, five different wrong implementations, each failing exactly
one test: removing set_round_trip_input fails acceptance 16 in the
DEFAULT configuration; a naive independent serializer fails all four
unit pins, with the diffs naming each drift mode; making re-invoke
create a fresh buffer fails 18; dropping the kill-with-terminal
teardown fails 18; removing the intercept fails 16b.
COHERENCE.md: §6 gains this as the worked example that a modal-looking
feature need not become a shadow; §11 records the scope="global"
deferral's second live case, making the argument for both registry
deferrals cumulative; §2 step 8 gains copy mode and keeps the
still-missing close command named.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer