F1 (real, small-window misbehavior). `dired.revert`'s re-seat runs after
the read settles, and `pmacs.editor.move_to_line` is AMBIENT -- it moves
whatever window is active. A user who switched buffers (or hit `q`)
while the re-read was in flight had an unrelated buffer's cursor moved
to a line index that only means something in the dired listing. The
paint was already safe because it names its buffer; the seat now runs
only while dired is still the active buffer, and `seat_cursor`'s doc
says which callers are unconditionally in the right place and why.
Pinned by a test that starts the revert, switches to a six-line file
before the pump, and asserts that buffer's cursor never moved -- and
that the dired buffer still reverts when it IS active.
F2 (a trap set for Stage 3). `fmt_size` used `%10d`, so a size past ten
digits -- 10 GB and up, ordinary for VM images and core dumps -- widened
the field and shifted mtime and name right on that line alone. Cosmetic
today, but `_layout` is exported as a contract and Stage 3's
column-classifying intercept is planned against it. It now takes
`fmt_mtime`'s discipline: exact bytes while they fit, else a
fixed-width magnitude, so precision yields to the invariant rather than
the other way round. This is not the deferred human-readable column --
the exact count still shows right up to where it cannot. Pinned with a
sparse 12 GB fixture that skips if the filesystem refuses it.
F3 (honesty and a doubled read). The symlink arm claimed the probe cost
"one syscall"; it was a full `read_dir` -- opendir plus one lstat per
child -- and on success `open_directory` immediately read the same
directory again. Since `open_directory` reads before touching editor
state and raises having changed nothing (acceptance 15's invariant), its
failure IS the "not a directory" answer: the probe is gone, one read
remains, and the comment says what it actually does. New test pins both
arms -- a symlink to a directory descends under the path the user walked
(canonicalization is lexical, so the link is not resolved), and a
symlink to a file opens with the target's contents.
F4 (deliberate failure mode). A tolerant listing recorded readdir
iterator errors without bound, and `std::fs::ReadDir` need not terminate
after yielding one. Cancellation is NOT an adequate backstop here --
which is the reason for a constant rather than a comment saying it is: a
dired listing carries no supersede key, so nothing cancels it. A
directory whose iterator produces nothing but errors now fails with the
last error the way an unopenable directory does, after
READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS; the counter resets on any entry
that materializes. Documented as untested and why: faking a failing
iterator needs the walk generic over it, a refactor with no other
consumer.
Smaller notes, all taken: READ_ONLY_LIMIT renamed NAME_VARIANT_LIMIT (it
caps `<2>`..`<99>`, nothing read-only); `fmt_perms`' omission of
setuid/setgid/sticky documented as a decision tied to the M8.3 fixture's
nine-bit parser; `format_outcome` binds the slice in the pattern instead
of re-traversing; and `pmacs.path.canonicalize`'s `to_string_lossy` is
noted as inside the existing non-UTF-8-path deferral rather than an
exception to it.
Process note, learned the hard way twice now: the round-1 dired.lua
fixes were briefly wiped because a mutation-bite helper restores with
`git checkout --`, which reverts to HEAD -- so a fix must be committed
BEFORE it is bitten, not after.
Dired is the file surface, not a rider on one: before Stage 0 (#162)
pmacs had no way to open a file by path, and browsing is the half a
user reaches for when they do not already know the path. Stage 1 ships
the view.
builtin/runtime/dired.lua: one buffer per directory named by the
canonical path (Q#DR2) with an ownership check before any paint (F7);
read-only intercept plus round-trip input (Q#DR3); a `dired` major mode
carrying mode-scoped keys (Q#DR8) -- RET/f visit, ^ parent, n/p, g
revert, q quit, s sort; cursor re-seated by basename across every
wholesale repaint (Q#DR9); file visits through
`pmacs.window.display_file` and directory descent through dired's own
window (Q#DR10); `C-x d` / `C-x C-j`; and `dired.kill-when-opening`
through the config registry.
Two Rust changes, both narrow:
* `read_dir` grows per-entry tolerance behind an opt (Q#DR6). Five
per-entry conditions used to fail the entire listing, so a plain
refresh of a busy directory could just fail; the module doc's claim
that a tolerant wrapper was "the package's job" was false, because
the primitive hands Lua one structured error and no partial vec.
Per-entry readdir/lstat/readlink failures and non-UTF-8 symlink
targets now land in an `errors` channel; parent-level failures and
non-UTF-8 *names* stay fatal. The tolerance travels in the settled
payload, so the Lua boundary keeps the bare-array shape the frozen
M8.2 fixture consumes and never has to look the job back up. The read
ops' opts parsing now rejects unknown keys, so a typo'd `tolerant`
cannot silently degrade to the fatal contract.
* `normalize_buffer_path` is exposed as `pmacs.path.canonicalize`
rather than mirrored in Lua. Q#DR2 named exposure the preferred end
state; it needs no borrow plumbing, so dired's name-dedup and
`display_file`'s `find_buffer_for_path` dedup cannot fork, and the
mirror's Stage 2 removal is not owed.
tests/dired_acceptance.rs covers framing items 1-16 (22 tests), driven
through real key dispatch. Item 17 is the m8_1/m8_2/m8_3 gate.
One framing claim is corrected by the substrate: R2-3 expected a
dedicated dired panel to carry its dedication across a descent, but
`display_buffer` never replaces the buffer in a slot dedicated to
another one -- it discards every side-specific parameter and falls back
to the document window (Q#BP3 2.iii). Dired does not try to unpin the
user's panel; both arms are pinned.
PR #155 review round 2, self-review of the round-2 commit.
The round-2 change labelled "minor" — resolving both arms of
pmacs.window.buffer() through the acting frontend for uniformity — made
the NO-ARGUMENT arm fallible. `acting_frontend` follows the interactive
origin, which can name a frontend that has no registered view: a bare
`dispatch_key` from an unattached peer does exactly that. `selected_window`
then raises "acting frontend has no layout" instead of answering.
Nothing surfaced that error, because the runtime callers do not pcall it.
killring, syntax, autosave, pair, indent and comment all read
pmacs.window.buffer() on ordinary edits, so the raise silently dropped
the operation: kill_ring_acceptance went 30/30 to 25/5, with
frontend_detached_drops_per_frontend_state reporting only "B has kill
state". main is 30/30, and reverting this one file restored it.
The no-arg arm is back on ambient active_buffer_id() and now documents
why that is deliberate rather than an oversight: dispatch sets
active_frontend to the acting frontend before running a command, so the
two agree on every real path, while only the ambient resolver has the
fallback that makes it total. The explicit-window arm keeps its Q#BP11
layout validation, which is what the arc actually needed.
acc19c pins it through the real path — a buffer.after-edit subscriber
reading pmacs.window.buffer() during a viewless peer's dispatch_key —
rather than by calling the binding directly. Bite-verified:
scripts/bite bbe4152 src/lua_bindings/mod.rs --test
bottom_panel_stage1_acceptance -- acc19c goes red with the exact
"acting frontend has no layout" traceback.
The ledger also records two gating facts found on the way: the workspace
sweep must run with an isolated XDG_CONFIG_HOME, because the real user
init.lua installs a local package and the losing race leaks a status
message into painted-frame comparisons; and a latent pre-existing main
bug in the buffer CRDT undo path, which is not this branch's and whose
proptest seed is deliberately not committed here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j4omtTMn9v1UfmHQb9ap6
PR #155 review round 2.
Finding 1 (must fix): Q#BP7 item 1 — "growth reaching the live tail
re-arms follow (top -> None), only when no selection is active" — was
never implemented. `at_bottom` is the instantaneous geometric readout
`scroll_offset == 0`, which a still-anchored view satisfies whenever it
happens to be tall enough to reach the tail, so the round-1 assertion
could not see the gap: the next rows the child printed pushed the
anchored view back into history.
`rearm_follow_on_growth` now clears `top` when a viewport-size
declaration makes the view cover the tail and no selection is frozen,
and every size-declaring path (`snapshot_for_view`, `record_view_size`,
`view_status_for_size`) routes through one `declare_view_size` helper so
grid and semantic declarations cannot disagree. `scroll_view` and
`begin_selection` deliberately stay out: they write `top` themselves,
and `scroll_view` already owns the scroll-driven arm.
New acc32b is the pin the review asked for: scroll into history, grow
past the tail, then release a SECOND burst of child output through a
filesystem gate and assert the view moved with it.
Finding 2: the PTY fixtures emitted LF-only output, which staircases
rightward until every row clips to blanks past the viewport width — so
the round-1 anchor assertions compared "" with "" and could not fail.
Both fixtures now emit CRLF, and each anchor comparison is guarded by
`assert!(!top_before.is_empty())`.
Finding 3: acc33's contrast case asserted nothing, and the behavior it
claimed was false as coded. With the re-arm in place it is true and now
asserted: clearing the selection at the same geometry re-arms follow and
leaves the frozen anchor.
Finding 4: `start_run` gated the panel branch on `display == "panel" or
already_in_panel(..)`, so an explicit `display = "current"` lost to the
inference — and that value is the documented user-facing opt-out from
the Stage 3 default flip. Now gated on OMISSION. acc19b gains the
explicit-"current" case.
Finding 5: `window_drag` is a `HashMap<FrontendId, WindowDragState>`, so
a peer's mode-line press can no longer steal or clear another
frontend's in-flight gesture, and concurrent drags are legal. Cleared on
detach. acc30c gains the mode-line-press case.
Minor: `pmacs.window.buffer()` resolves both arms through the acting
frontend using the shared `lookup_window` / `selected_window` validators
rather than re-implementing them beside an ambient `active_buffer_id()`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #155 review round 1.
Finding 1 (must fix): `try_split_active` had no production caller —
`pmacs.window.split_horizontal` / `split_vertical`, and therefore
`C-x 2` / `C-x 3`, still went through plain `split_active`. Splitting a
focused panel made the root wrapper's final child a split rather than
`Leaf(side)`, which both `Layout::compute`'s fixed pass and
`document_subtree` key on: the panel band reverted to 1:1 weight
division and an ordinary window ended up living inside it. Both bindings
now route through the guard, and acc26 asserts through the real Lua
path — a direct core call passes with the guard unwired, which is how it
survived the first round.
Finding 2: the armed-drag early return now checks the arming frontend,
so one frontend's in-flight gesture cannot cancel or swallow another's
mouse events. New acc30c.
Finding 3: `paint_mode_line_graphemes`'s doc block was left heading
`paint_divider_segment`; moved back.
Finding 4: a recompile carries no `display`, so it took the raw switch
and duplicated a panel-placed `*compilation*` into the document window.
`start_run` now detects that the buffer already owns the panel slot.
`pmacs.window.buffer` gained an optional window argument so an adopter
can ask without selecting the panel first. New acc19b.
Stage-2 hazard pins the review asked for, both in `src/daemon.rs`:
a fresh attach while LOCAL is focused in a panel inherits LOCAL's
document buffer, and an initial-target bootstrap whose `after-load`
hook creates and selects a panel still reasserts into a document window.
Minor: dropped listview's dead `p.side`; documented `focus_window`'s
caller-validates contract; `jump_back` restores through `focus_window`
so the "every focus change" contract holds; `params` / `resize` default
to the acting frontend's selected window rather than the ambient one;
widened the flexible-division math to u64 intermediates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- `listview.open`, `compile.run`, and `pmacs.terminal.open` all take the
same strict `display = "current" | "panel"`, validated before any
buffer, session, process, or wrapper exists. Omission keeps today's
behavior; Stage 3 flips the default.
- `listview.quit` / `compile.quit` delegate to `window.quit` only when
the buffer really is in a side window, so the presentation is deleted
or restored instead of leaving a source buffer stranded in the slot.
- LSP `visit_location`, LSP go-to-definition, and compile `visit_error`
route through `display_file`, so a visit from a panel lands in the
document target and fires its hook with that window active.
- `window.quit`'s Delete arm focuses the revalidated remembered origin.
- Capability fallback discards an accompanying `height` rather than
rejecting the call.
- `window.min-height` clamps a below-floor value on read instead of
refusing the write.
- `tests/bottom_panel_stage1_acceptance.rs`: 42 tests over the framing's
Stage 1 criteria, including the two production `Layout::compute`
callers, the recursive minima, hide/reappear, the final-focus matrix,
quit chains at the depth cap, per-frontend jump origins, the divider,
and a real-PTY pin of Bet B1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stage 1 substrate for the bottom-panel arc (docs/bottom-panel-framing.md).
- `WindowParams` (side / fixed_rows / dedicated + implementation-owned
quit action and remembered document origin), `Side`, `QuitAction` with
a bounded replacement history, and the `MIN_WINDOW_OUTER_ROWS` floor.
- `Layout::compute(area, fixed)` allocates fixed rows before dividing the
remainder by weight; both production callers feed the same shared map,
including the peer-presence overlay pass that derives its own rect.
- `subtree_min_rows` / `interactive_min_rows`: the recursive minima, and
`boundary_below` for the shared drag / keyboard resize boundary rule.
- `FrontendView` gains `panel_capable`, `frame_geometry`, and the derived
`panel_hidden`, each spelled explicitly at every construction site.
- `EditorCore`: `primary_document_window`, the non-side target rule,
`display_buffer` + placement policy, `quit_window`, side-window removal
on `kill_buffer`, per-frontend jump entries with origin windows, and the
shared resolve/load-without-switch seam the initial-target bootstrap now
uses too.
- `EditorState`: the panel reconciliation transaction, geometry
declaration, the side-window `dispatch_idle_for` gate, divider paint,
and divider drag.
- `pmacs.window.display / display_file / quit / panel / params /
set_params / resize / display_target`, plus `builtin/runtime/window.lua`
with `window.panel-height`, `window.min-height`, and the resize commands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #149 review round 5, finding 1 — correct, and both round-4 bugs did
survive through the intercept path.
Round 4 moved the widening off the point and onto `edit_start_of(&op)`,
but ran it in `run_buffer_edit` BEFORE `run_managed_edit`. A managed
buffer intercept may legally rewrite `pos` / `start` / `end`
(`LuaInterceptView::intercept_edit`), so the requested op is not where
the edit lands:
- requested outside -> intercept relocates inside: the edit stayed
hidden;
- requested inside -> intercept relocates outside: an unrelated fold
opened.
The seam still covers BOTH paths — hooking only `run_managed_edit` would
let an interactive `bypass_intercept` edit escape, which is why the
framing put it on the common entry — but each path now keys on its own
effective site:
- `run_bypass_edit` applies its op verbatim, so `run_buffer_edit`
hooks it there;
- `run_managed_edit` hooks after the intercept chain settles and
before the apply, on the op the chain returned. A chain that raises
applies nothing, so it unfolds nothing.
The registry borrow is released at that point, and the helper reads only
`SharedCore` + the fold registry, so no borrow conflicts with phase 3.
Three new tests, all through real `M-x` with a real
`pmacs.buffer.add_intercept`: relocate-into-a-fold must unfold,
relocate-out-of-a-fold must not, and a rejected chain must not. Each
asserts the buffer text first, so the test fails loudly if the intercept
stops relocating rather than silently passing.
Suite 45 -> 48.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
All four findings were correct.
F1 (major) — the Lua widening keyed on the POINT, not the edit site.
Q#FD19 condition (iii) is `edit.range.start`; `run_buffer_edit` discarded
`op` and opened whatever fold contained `window.cursor`. Two wrong
behaviors followed: a command inserting elsewhere opened an unrelated
fold at the point, and — worse — a command editing INTO a fold from an
outside point left its edit hidden. `run_buffer_edit` now reads
`edit_start_of(&op)` before `op` is consumed and passes it down. The
`apply_active_edit` funnel stays point-keyed, which is correct and
deliberate: the six primitives, yank, and query-replace all place point
at the edit site first, and only the Lua path can diverge — now said so
in the doc comment.
The acceptance test encoded the wrong behavior (inserted at byte 0,
expected the cursor's fold to open). Replaced by two tests that pin both
directions, plus the framing's named **comment-toggle** case driven
through the real `M-;` on a file-backed Rust buffer rather than a
synthetic mutator.
F2 (major) — hidden-cursor motion normalized only the row. `move_up` /
`move_down` clamped `coord.row` but derived `goal` from the hidden
line's raw column, and returned early at a buffer boundary without
normalizing at all, so `Up` inside a fold headed on line 0 left the
logical cursor hidden. Added `normalize_cursor_to_visible`, which
projects the whole POSITION through `visible_position` as a real
mutation before any step is computed (and drops the sticky goal column,
since the jump is discontinuous). Paging shares it — the same latent
bug. The old test used equal-width lines and asserted only the line, so
it could not tell the two apart; the new fixture is deliberately ragged
and asserts the resulting BYTE in both directions, plus the boundary
case.
F3 (moderate) — `set_view_top` bypassed the fold clamp. Q#FD12/Q#FD18
say `view_top` is always set through `clamp_view_top`, naming the
`set_view_top` contract specifically. Rendering repaired it at the next
frame, but until then `view_top()` handed out a hidden line and
command/event reckoning could start from a non-visible origin. The
clamp now lives in the setter — the contract's home, what `saveplace`
and `pmacs.editor.set_view_top` call. New test reads `view_top()`
directly after the setter, before any paint.
F4 (moderate) — the suite claimed items 1–14 but omitted approved
clauses. Added: peer SELECTION endpoint projection with hidden interiors
dropped; the crossing-fold repeat for the peer cursor AND a peer
selection endpoint; a style/syntax span straddling a fold (alignment on
the shifted row); the completion popup anchored below a fold; and peer
presence in the split-buffer case using the RECIPIENT window's map.
Suite: 35 -> 45 tests. Eight new bite cases, each falsifying exactly the
line that implements its claim.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
Implements docs/folding-stage2-framing.md rev 4. The daemon grid
renderer now consults the fold store: hidden lines are omitted, rows
below shift up, and every consumer that assumed
`display_row = source_line - view_top` routes through one shared
projection. No wire schema change and no protocol bump (Bet B6) — the
collapse is entirely daemon-side; the GPU path is Stage 3.
The spine (Q#FD12) is `src/fold_view.rs`: a `VisibleLineMap` derived
from `FoldRegistry::folds` plus a window's line offsets and never
stored. Its unit is a merged **hidden component** — overlapping OR
adjacent hidden intervals unioned, each keeping the one visible
`head_line` and that line's exact `head_position`. Adjacent intervals
merge because the later fold's head is itself hidden, which is what
makes nesting, shared heads, and crossing overlap all resolve to a
head that can actually render (round-3 F2).
Instances are short-lived and built **per rendered window** and **per
command/event operation**, never once per frame: `paint_frame` renders
several windows that may show different buffers, so a singleton would
leak one pane's folds into another (round-2 F2). The render instance
rides on a lifetime-bearing `Viewport<'a>` as `Option<&'a
VisibleLineMap>` — a shared ref is `Copy`, so `Viewport` stays `Copy`
(Bet B7).
Rendering:
- `TextView::render` walks visible lines; the head line gets a
trailing content-area ellipsis (Q#FD13).
- The gutter walks visible lines too: Absolute keeps the raw `line+1`,
Relative/Hybrid measure VISIBLE distance anchored on the cursor's
visible head (Q#FD14). The fold glyph takes the col-0 sign cell only
when a gutter exists — line numbers default to Off, so with no gutter
the ellipsis is the sole marker (Q#FD20, round-1 F3). A diagnostic
clamped onto the head wins that cell by paint order.
- A diagnostic on a hidden line clamps its SIGN to the outermost
visible head (most-severe merge); the squiggle needs a real row, so
only the sign clamps (Q#FD15).
- Caret, local selection endpoints, and peer cursors project via
`visible_position_of` — the head row AND the head's end-of-content
column, never an arbitrary column (round-2 F3). Peer presence derives
the RECIPIENT window's map.
- Style/search/completion overlays route through
`Viewport::row_offset_of`; the mode-line indicator reckons in
visible-line space.
Command/event time is scoped per frontend (Q#FD21): a
`fold_projection` flag on `FrontendView`, set at attach from the
negotiated `semantic_render` bit (grid ⇒ true, semantic ⇒ false until
Stage 3, LOCAL ⇒ true) and never inferred from a `FrontendId` (Bet
B8). Without it, shared `EditorCore` motion would make a simultaneous
unfolded GPU session's cursor skip lines it still displays. The map's
two axes stay separate (round-3 F1): the acting frontend supplies the
policy, the operation's TARGET window supplies the buffer — a wheel
event names a pane without activating it.
Motion (Q#FD17, ruled: include), paging, wheel, the click inverse, and
the auto-scroll clamp all step by visible lines under that gate;
motion from a hidden logical cursor normalizes to the visible head
first. `view_top` stays a source-line index (Bet B5), set only via
`clamp_view_top` so it never rests hidden.
Unfold widening (Q#FD19): the pre-edit unfold moves to the top of
`apply_active_edit` — one funnel that subsumes the six primitives'
calls and covers yank + query-replace, both of which place point at
the edit site first. Interactive Lua mutators hook the common
`run_buffer_edit`, above the managed/bypass split, gated on
`InteractiveCommandOrigin` AND the edit targeting that frontend's
active-window buffer. The remote/optimistic-CRDT path stays excluded
(Stage 3); undo/redo unfold stays deferred.
Acceptance: `tests/folding_stage2_acceptance.rs`, 35 tests asserting
on the real `paint_frame` cell grid, covering framing items 1–14
including crossing folds, a nested deeply-hidden cursor, a split of
two different buffers with an inactive-pane wheel, and simultaneous
grid+semantic motion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
- **Finding 1 (bug):** a delete starting exactly at a fold's `end`
removed the `\n` that `end` names — the last hidden line's terminator —
but the strictly-after arm (`os >= e`) kept the fold, leaving a mid-line
end. `translate`'s after-arm is now `os > e || (os == e && old_len == 0)`
so a pure insert at `e` still stays outside while a delete at `e` falls
to the drop arm, symmetric with the head side. New unit test
`delete_starting_at_tail_boundary_drops_fold` (bite-verified).
- **Finding 2:** `pmacs.buffer.kill` didn't clean the fold registry.
Added `FoldRegistry::forget_buffer(id)` (id-keyed; the view died with the
buffer) and wired it into `after_buffer_removed`, mirroring the
keymap/config cleanup; the registry is now stashed as Lua app-data.
`forget(&mut Buffer)` is clarified as the revert/reload reset.
- **Finding 3:** `fold.close-all` now moves the invoking point to the head
when it closes a fold around it (Q#FD3); the data-API `fold` exemption
(programmatic, no invoking point) is named in the module doc.
- Nits: dropped the dead `!(both empty)` conjunct in `fold_state_msg`;
replaced the trivial fresh-registry assert; added coverage for the
stale-tree refuse via a fold command, the read-only-buffer rejection
(Q#FD11), and unfold normalizing an arbitrary range.
Gates green: fmt, clippy --workspace --all-targets, --lib (1786),
--features crdt (1962), folding_acceptance (24), m4 (skip basedpyright),
required-GPU, git diff --check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
The fold engine behind `docs/folding-framing.md` (approved rev 5): a
per-buffer fold store, a structural tree-sitter fold source, the
state-aware Lua command + data-API surface with the Emacs hideshow
`C-c @` bindings, the dispatch-layer pre-edit unfold, and `FoldState`
production. No rendering — Stages 2 (grid) and 3 (GPU) consume the store.
- `src/fold.rs`: `FoldStore` (a buffer-attached `View` that translates
ranges on every edit and drops any whose head/tail the edit crosses,
provenance-blind — Q#FD6), the structural source (nearest block-like
node >= 2 source lines -> introducer<->body -> **derived head line**,
the line immediately above the first hidden line, so wrapped signatures
and `where` clauses stay visible per R3-1 -> **closer-aware tail**, a
closing-delimiter line stays visible per R2-5), injection-layer walk,
`(start, end]` containment, and the state-aware ops (close innermost
open / open outermost closed / org-TAB cycle). Stale/absent tree
refuses (Q#FD10).
- `src/lua_bindings/fold.rs`: `pmacs.fold.*` — explicit-buffer data API
(`fold`/`unfold`/`folds`/`toggle`) + interactive helpers, validation
(Q#FD11: document buffer, UTF-8 boundaries, >= 1 hidden line — Q#FD9
falls out of the last clause), point-moves-to-head (Q#FD3).
- `builtin/runtime/fold.lua`: `fold.toggle/close/open/close-all/open-all`
commands + the `C-c @` prefix set (Q#FD4).
- `src/editor_core.rs`: the six point-anchored edit primitives run the
pre-edit unfold keyed on the authenticated source's point (Q#FD5,
command path); `EditorCore` owns the shared `FoldRegistry`.
- `src/semantic_render.rs`: the `FoldState` producer —
authoritative-empty, diff-suppressed, baseline resets on
`BufferSnapshot` (Q#FD8); the "never emitted" pin split so
`BlockAdornments` stays unproduced.
- `tests/folding_acceptance.rs` (16) over real Rust/Python/markdown
grammars + `fold_state_producer_transitions` + 15 engine unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
Require the fixed C-c escape before editor-local terminal bindings, reject context-implicit Lua operations from document windows, and make controller replacement atomic per frontend. Borrow screen rows during view projection instead of deep-cloning retained history, preserve view anchors through zero-area layouts, and remove redundant detach paths.
Add focused child-input coverage for unescaped bound keys and C-c C-c, plus controller, zero-area, context-error, and clone-free projection assertions.
Co-authored-by: OpenAI Codex <codex@openai.com>
Resolve post-main integration drift in authenticated routing, terminal view projection, Lua installation, and inherited acceptance callers. Preserve the terminal statusline provider alongside the landed Themes provider and record the final Stage 2 gate evidence.
Co-authored-by: OpenAI Codex <codex@openai.com>
Store a detected major mode on each buffer and expose it through Lua.
Resolve mode-scoped bindings in dispatch, describe-key, and help links,
including exact encoded mode context after entering the help buffer.
Initialize modes once at buffer load, preserve explicit overrides and
clears across switches, and publish the mode through a per-window
statusline provider. Add daemon acceptance for the complete mode lifecycle.
Add cross-surface Lua, shared-view, clipboard, authenticated routing,
BEL, resize, and real-host PTY acceptance. Ensure terminal-local keymaps
run before raw child transport and document the criterion-to-test map.
Co-Authored-By: Claude <noreply@anthropic.com>
Resolve terminal commands and buffer switches against the authenticated invoking
frontend, publish copied selections to that frontend, and wire daemon rendering,
resize, focus, paste, bell, and detach lifecycle through exact view identities.
Co-Authored-By: Claude <noreply@anthropic.com>
Install strict owned terminal specification parsing, fresh global state tables,
default-name uniquification, durable view/controller lifecycle, and the builtin
terminal command/statusline surface. Route daemon key and mouse input by the
authenticated source and add non-replaying per-frontend BEL baselines.
Co-Authored-By: Claude <noreply@anthropic.com>
Add exact frontend/window/buffer view identities, logical-cell selections,
durable controller ownership, lifecycle cleanup, default-name uniquification,
and a nested authenticated interactive-command origin shared with Lua. These
contracts let the terminal core and TUI lanes proceed without inventing
parallel state.
Co-Authored-By: Claude <noreply@anthropic.com>
A third registry beside CommandRegistry and HookRegistry, per
docs/config-registry-framing.md. Unblocks the per-buffer auto-pair
toggle, the first of the five backlog items the missing config surface
was gating.
Substrate (src/config_registry.rs):
* ConfigRegistry keyed by name with definition order preserved, R42
mandatory descriptions, R50 typo detection, duplicate rejection,
and SourceLocation provenance -- the command/hook vocabulary.
* Closed scalar kinds: boolean, integer, number, string, enum. Owned
Rust values; Lua tables, functions and userdata are never stored.
Integer exactness is checked by value, never math.type, so the
luajit and lua54 builds agree.
* Two scopes. get(name, buf) resolves buffer-local -> global ->
default; get(name) with no buffer resolves the global chain only
and never consults an ambient buffer. Buffer-locals live in a
registry-owned side table purged at after_buffer_removed, beside
the keymap purge already there.
* An override is ALWAYS stored, even when equal to the value it
shadows; only value_epoch and listener dispatch key on effective
change. Without this a buffer pinned to the current value stores
nothing and a later global set flips it -- the pin silently never
existed. equal_valued_local_override_is_still_stored_and_shields_buffer
fails against the naive reading.
Bindings (src/lua_bindings/config.rs):
* define/get/set/set_local/reset/is_set/describe/list/on_change.
Spec tables are read raw, so neither an unknown key nor a
metatable-provided value can smuggle a field in.
* Listeners commit inside the borrow, snapshot, drop the borrow, and
only then re-enter Lua -- verified by holding the borrow and
watching the test panic with "RefCell already borrowed". A raising
listener is logged without blocking later ones or rolling back, and
a depth bound turns an accidental cycle into a pointed error.
Listeners persist until explicitly disposed; there is no Gc path,
matching the rest of the codebase.
* StartupOnly freezes off the existing InitCompleteFlag at write
time, so this arc adds no editor.rs call at all.
Adopters, each defining its own key so SourceLocation names the owning
module: editing.auto-pair (pair.lua, read per-buffer against the typed
edit's SOURCE buffer), editing.trim-on-save (editops.lua),
autosave.interval-ms (autosave.lua). No public function is removed or
deprecated, and both migration wrappers keep their legacy coercion --
trim_on_save("yes") still enables, interval_ms(1500.7) still floors to
1500 -- coercing before handing the strict registry a conforming value.
M-x describe-setting renders into *help*, modeled on describe-command.
Framing revision 3 records four defects implementation found in the
document itself: acceptance 30 and 31 contradicted each other; the
planned builtin/runtime/config.lua had nothing to hold and would have
broken the source-location contract had it held the one helper it might
have; F5 asked define to police a call it cannot see, moved to
set_local; and list() ordering was underspecified.
No protocol change; SUPPORTED stays [6..18]. No wire surface. Zero
changes to src/editor.rs.
Gates: fmt, clippy -D warnings, --lib (1683), --lib --features crdt
(1857), the new config_registry_acceptance (13) plus auto_pair (45),
editops (72), autosave (29) and m9_6 (25), m4 --skip basedpyright
(114), PMACS_REQUIRE_GPU=1 pmacs-gpu (109), the lua54 backend build,
and the full workspace sweep (2795 tests, exit 0). git diff --check
clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add typed IND, NEL, and RI operations with exact screen semantics, preserve
application tab stops across resize, and default terminal children to the
supported xterm-256color capability set.
Make shutdown liveness acceptance portable with kill(pid, 0), and document the
public TerminalScreen methods consumed by later stages.
Add compatibility-preserving full-screen ANSI operations, the bounded terminal
screen and input encoders, and a transactional TerminalManager owning one
read-only identity buffer, PTY process, and screen per session.
Drain terminal-owned process events before process.after-tick, retain exact
final output and PID/outcome annotations, reap killed buffers and shutdown
children safely, and enforce buffer-owned read-only checks across ordinary,
host, undo/redo, and CRDT mutation paths.
Cover split parser and grapheme boundaries, screen/reflow/history invariants,
device responses, lifecycle cleanup, and a real adversarial alternate-screen
PTY. Record the fully gated Stage 1 delivery and downstream TUI/GPU contracts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Document and pin the GPU built-in-only narrow-band clipping policy,
including the intentional ability of a wide right group to hide the
left identity. Guard the fixed UI face ordering used by binary search,
preserve flattened provider tracebacks in *errors*, and rename the
phase-one unavailable reason to cover missing layout contexts.
Refresh the implementation verification record after the full gate
suite.
Co-Authored-By: Claude <noreply@anthropic.com>
Add the strict pmacs.statusline provider registry, deterministic
borrow-released per-window evaluation, context-scoped failure latches,
and a pure built-in LSP provider.
Preserve the legacy TUI modeline while composing faced custom runs,
and append authoritative complete StatuslineSegments replacements for
semantic frontends. Expand dynamic ThemeFacts, reset producer/frontend
baselines symmetrically, and gate all provider work off protocol v18.
Teach the GPU to atomically validate, resolve, shape, clip, and cache
custom modeline runs without displacing the protected status suffix.
Document the public Lua lifecycle, wire ownership, snapshot semantics,
and the fully gated Arc 4 stage-3 delivery state.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
tests/gpu_font_acceptance.rs (items 2-7; the header manifest routes
item 1 to src/protocol.rs pins, item 8 to the src/frontend.rs TUI
drop unit, items 9-14/16-19 to pmacs-gpu's headless suite, and item
15 to the docs):
- 2: a fresh attachment's first frame carries the REAL (None, None)
default; unchanged ticks are silent; a late joiner receives the
current preference without post-attach mutation
- 3: a mid-session set_font emits exactly one FontFacts on the next
frame; an identical re-set advances the epoch without emitting
- 4 (producer half): on_buffer_snapshot_sent re-ships buffer facts
but never the bufferless FontFacts
- 5: real-daemon probe -- a v17 semantic session receives FontFacts,
a v16 peer never does (crdt feature)
- 6: the strict Lua contract -- 5.999/72.01/0/-16/NaN/inf/non-number
sizes error naming `size`; empty/non-string family errors
naming `family`; unknown keys rejected by name; hostile
__index/__pairs metatables never invoked and never inject
values; quantization pins 15.994->1599 / 15.996->1600 and both
boundaries; set_font {} resets both axes; the getter returns a
fresh quantized plain table; every rejected shape leaves the
preference and the wire untouched
- 7: a load_user_config_at fixture's init.lua set_font lands in the
handle installed before user config, and a pre-attach
preference ships on the first frame
Bites (mutate, observe the acceptance test fail, restore): the
Option-seeded first-frame send, the payload compare, the snapshot
survival of the font baselines, the v17 gate (BOTH halves widened --
producer for_peer alone leaks nothing because the daemon skip arm
still filters; the wire test only fails when belt AND braces are
cut), the unknown-key rejection, the range-check-original ordering,
and the raw_get metatable isolation.
Docs (item 15): docs/semantic-frontend-protocol.md gains the
FontFacts variant entry (v17 gate, authoritative default, no-pixels
preference relay, fail-closed receiver) and folds FontFacts into the
BufferSnapshot reset-contract paragraph (bufferless facts survive;
the frontend's caret-follow scroll residual is the buffer-scoped
part). docs/pmacs-gpu-design.md's "future customization needs no
wire-protocol changes" claim is corrected in place and points at the
framing as design of record; the Lua-override bullet now names the
landed set_font shape. The framing status bumps to implemented.
Also re-homes the ProcessSupervisor doc comment that the make_font_pref
insertion had orphaned onto the wrong function.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Protocol v16->17: InstanceMessage::FontFacts { family,
size_centi_px } appended after ThemeFacts (integer hundredths of a
logical pixel -- the enum derives Eq, f32 cannot; range 600..=7200
documented on the wire). Pins updated: version 17, ladder accepts
6..=17 rejects 18, FontFacts round-trip (populated + all-None), and
a ThemeFacts byte pin ([23, 0]) guarding the appended placement.
Daemon half: FontPref { family, size_centi_px, epoch } behind a
shared handle on EditorState, installed with the new pmacs.gpu Lua
module BEFORE load_user_config so init.lua set_font lands in the
state the first attachment reads. set_font is strict plain data
(raw_get, unknown raw keys rejected by name, metatables never
consulted, parse/validate/quantize fully before locking -- range-
check the ORIGINAL value so 5.999 errors, then nearest-hundredth
round); pmacs.gpu.font() returns a fresh quantized table.
Producer: font_facts_msg (the theme_facts_msg discipline --
Option-seeded epoch + payload baselines, advance on computation,
one authoritative send per attachment incl (None, None),
bufferless so on_buffer_snapshot_sent never touches it); for_peer
gains peer_knows_font_facts (>= 17); daemon write-loop skip arm;
TUI silent-drop arm + regression test; first-frame count test now
expects 6 messages. GPU application follows in the next commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Four review findings + a cleanup bundle.
[P1] Wire flattener was O(spans²) and ran over the WHOLE buffer (the
file-style summary uses a whole-buffer viewport, not the visible one).
Replaced the per-interval full scan with an ordered active-set event
sweep (activate on start, expire on end, fold the active set) — linear
in practice. Added full_buffer_summary_scales_on_large_grammar_file
(1500-line rust) as the perf gate.
[P2] _parse_now used the empty alias map from make_request while
_dispatch snapshotted the registry map, so a `py` fence injected async
but not sync. Snapshot aliases on both paths; pinned by
sync_parse_now_resolves_alias.
[P2] The multi-range inline test used a one-line paragraph, whose block
inline node has no named children (link/emphasis are child-grammar
structures) — one range, so it couldn't falsify multi-range. Replaced
with a multi-line blockquote whose inline node carries a named
block_continuation: content_node_ranges now asserts >1 collected range
and emphasis parses on both lines.
[P2] The layer backstop dropped regions silently; the framing requires
a surfaced warning. run_parse now sets ParseTreeBundle::injection_capped;
syntax.lua's settle tick raises it once per buffer via pmacs.error
(_injection_capped). Added injection_layer_cap_surfaces_and_preserves_root
(drives >4096 fences, asserts the flag + bounded count + intact root).
Cleanup:
- The GPU acceptance test now drives the real StyleSpans full-frame
transform (spans_from_segments, extracted from replace_style_spans)
instead of a hand-rolled sort.
- content_node_ranges excludes NAMED children (documented as a round-1
refinement); framing mechanic #3 / Q#IJ5 updated to match.
- parse_duration doc now says root parse; the markdown entry no longer
describes inline as unhighlighted/future.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
Teach the syntax engine that one buffer can hold more than one
language. After the root parse, run the grammar's injections.scm, parse
each embedded region with the injected language, and merge every
layer's highlight spans. First consumer: markdown fenced code + inline
(zero new grammars — the block grammar already ships an injection query
and the injected langs already have grammars from #118).
Engine (src/syntax.rs):
- ParseTreeBundle now holds Vec<Layer> (root layer 0 + injected
children, depth-ascending); installed atomically so the existing
Arc::ptr_eq style gate and highlight cache keep working (Q#IJ1).
- run_parse builds layers on the worker: run injections.scm, resolve
the injected language, compute Vec<Range> (exclude NAMED children,
intersect the parent's ranges), set_included_ranges cold-parse,
recurse — bounded by depth (3), a layer backstop (4096), and a
(lang,ranges) visited guard; any child failure drops that child only
(Q#IJ3/IJ5). LanguageEntry gains injections_query; markdown_inline is
registered (retires the M9.7 block-only floor); markdown/rust carry
injection queries.
- Injected languages resolve off the static BUILTIN_LANGUAGES table
(Send loaders + query sources), preserving lazy loading. Dynamic
fence names go through a case-folded alias map seeded with defaults
and Lua-extensible via pmacs.parse.injection_aliases, snapshotted into
ParseRequest at dispatch so the worker never touches the Rc registry
or a Lua table (Q#IJ2/IJ4). Highlight queries are resolved at settle
(resolve_layer_queries), keeping query compilation main-thread/cached.
Producers:
- SyntaxHighlightView (grid) iterates layers shallow-to-deep so a
deeper layer's styling wins within its region (Q#IJ6/IJ7).
- scoped_style_spans (wire) flattens all layers into DISJOINT effective
spans via a boundary sweep, since the GPU re-sorts spans by start
(replace_style_spans / merge_style_spans) and would otherwise destroy
producer order. The GPU source_color_at consumer is fixed to fold all
covering spans (matching semantic_client's effective_style_at) rather
than returning the first.
Named-children exclusion: content ranges exclude only NAMED children
(matching tree-sitter-md's own inline splitter) — excluding a block
inline node's anonymous text tokens would shred the paragraph into
unparseable fragments.
13 acceptance gates (framing docs/multi-language-injections-framing.md):
layer structure, absolute child offsets, alias resolution (static +
case-folded dynamic + unknown-skip + Lua-async override), multi-range
inline, recursion bounds, wire + grid + GPU producers, incremental edit
/ new fence, many-paragraph settle budget with tail coverage, and the
single-layer regression guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
Finding 1: accepting a FileStyleSummary drops the GPU minimap vertex
cache -- theme recolors and diagnostic republishes arrive at an
unchanged generation, and the cache keys only on (generation, dims,
scroll), so stale strokes survived until an edit/resize/scroll. The
daemon payload-suppresses identical summaries, so the invalidation
is precise. GPU test drives two same-generation summaries;
hand-bitten by reverting the single invalidation line (script-bite
is vacuous here: fix and test share main.rs).
Finding 2: lua_to_style propagates every Table::get error -- the
lookups run __index, so a raising metatable previously parsed as an
all-default style and the merge SUCCEEDED, committing valid siblings
against the Q#TH6 all-or-nothing contract. Boolean fields keep Lua
truthiness by design (mlua bool), so only raising lookups fail the
transaction. Acceptance reproduces the reviewer's trap shape;
runtime bite vs pre-fix mod.rs.
Finding 3: SemanticRenderState::for_peer records the negotiated
version; below v16 no ThemeFacts is produced and no ui.diag.* face
folds into the FileStyleSummary marks -- the summary is an ungated
pre-v16 channel, and a v15 peer must not get face-derived minimap
colors while its other severity surfaces stay unthemed. The summary
cache key zeroes its face-epoch component for such peers. Acceptance
drives v15/v16 producers side by side; compile-fail bite disclosed
(the test needs for_peer, absent pre-fix).
Finding 4: framing revision 5 weakens the canonical-severity claim
to what is true -- the daemon-RESOLVED color is canonical, while the
GPU's built-in squiggle/sign/counter defaults are historical bright
RGBs that differ from the minimap's converted Indexed marks, a
pre-existing divergence kept because unset faces must render
byte-identically to before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Two theme.lock() calls inside one tuple expression self-deadlock:
the first guard lives to the end of the full expression, so the
second lock blocks the same thread forever. Observed as an 84-minute
hung lib suite; one lock per observation via a helper closure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Arc 4 stage 1 (docs/theme-faces-framing.md, revision 4). Faces are
theme entries under the reserved ui/ui.* namespace -- zero new Lua
API. Theme::face() resolves with the dotted-prefix walk but never
falls back to default_style; each face applies owns-surface within
its stage-1 component mask, identical on both frontends.
Substrate: two monotonic theme mutation counters (syntax/face) with
transactional set/merge/clear/default (parse before locking, commit
all-or-nothing, bump from the prior value); the StyleGate and the
minimap summary key on the counters -- fixing the pre-existing bug
where a mid-session pmacs.theme.set never re-shipped StyleSpans --
with the summary gaining payload-equality suppression that still
advances its key on computation.
Wire: InstanceMessage::ThemeFacts appended after CompletionPopup
(postcard discriminants are ordinal; a byte pin guards placement),
PROTOCOL_VERSION 15 -> 16, daemon-gated >= 16, one authoritative
table per attachment (None-seeded baselines), TUI silent-drop arm.
Grid: paint_frame resolves ui.modeline / ui.statusline /
ui.minibuffer(.candidate) / ui.gutter / ui.selection faces;
SearchView and DiagnosticView take the theme handle through the real
attachment paths (EditorCore injection, install_diag threading); the
canonical severity color resolves ui.diag.* with the Default ->
built-in policy that keeps the minimap presence encoding sound.
GPU: exact-name face table applied per draw with the Q#TH5 Default
mapping (plain text / window bg, reverse swap), local/peer wash
split, candidate-dropdown glyph site, and the status-band
shaping-cache invalidation without which a diag-face recolor with
constant counts kept stale counter colors.
Tests: 18-test acceptance suite (grid, wire, daemon gate, atomicity,
monotonicity, late join), 7 GPU headless tests incl. decoded vertex
colors, units for the face walk / transactional commits / producer
caches; protocol pins for v16 + the CompletionPopup byte pin.
Bites vs 3cbb9de (scripts/bite): semantic_render.rs (8 runtime test
failures), editor.rs (5 runtime), daemon.rs (v15 gate, runtime);
lua_bindings/mod.rs, pmacs-gpu/main.rs, search.rs, diag.rs, and
highlight.rs bite as compile failures (weaker evidence, disclosed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Finding-by-finding (framing revision 13; bites via scripts/bite
against fe04aa4):
1. attach_style_overlay validates the handle. A handle's translator
follows edits to ITS buffer only, so attaching it to another
buffer created a render view showing spans nobody maintains —
rejected now, with the message naming the recorded owner and
pointing at add_style_overlay for the target buffer. A disposed
handle's translator is gone, so re-attachment resurrected
rendering with frozen coordinates — the disposed state is shared
across handle clones (FromLua clones) via Arc<AtomicBool> and
attachment after dispose() fails, pointing at add_style_overlay
for a fresh handle. Bite: r7f1 pins cross-buffer rejection,
same-buffer acceptance, dispose-then-attach rejection, and both
message shapes.
2. dispose() detaches the translator through the always-registered
SharedRegistry; only the window cleanup rides the optional
SharedCore. Pre-fix all cleanup lived inside the SharedCore
branch, so an install-only/headless host got success with the
translator left attached — paying on every edit for the buffer's
lifetime. Registry-only unit asserts the buffer's view count
returns to baseline (and stays there on double dispose); the
acceptance-crate twin r7f2 builds the same install-only host and
bites via the mod.rs swap (the in-crate unit vanishes with it).
Gates: fmt; clippy workspace all-targets; lib 1535; crdt lib 1709;
compile acceptance 65; crdt acceptance 3; m4 101; m6.4 15; m6.5 11;
m6.8 8; GPU 59; workspace sweep 2526/0; git diff --check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Finding-by-finding (framing revision 12; bites via scripts/bite
against a49adc2):
1. Render-view attachment is idempotent and split-complete. Overlays
expose overlay_identity (the span store's allocation address);
Window::ensure_overlay attaches a store-backed render view AT
MOST once per window — pre-fix every switch into the buffer
blindly pushed another copy onto EVERY matching window, so
passive panes accumulated duplicates, each cloning all spans and
rescanning the buffer per frame. A same-buffer split copies
clonable overlays to the new pane via clone_for_split (splits
fire no switch hook and started with an empty overlay list — the
new compilation pane rendered unstyled). Bites: the acceptance
test asserts both panes styled with exactly one attachment
IMMEDIATELY post-split (before any switch could heal the pane
through the attach-to-all path — the first draft asserted only
after bouncing and was vacuous against the split fix), then
re-asserts after three bounce cycles; fails against pre-fix
editor_core.rs (split half) and pre-fix mod.rs (accumulation
half) independently. Units pin ensure-once and split-copy/no-copy.
2. The translator ignores pure no-op edits (buffers deliberately
broadcast empty inserts/deletes for callers that count calls):
pre-fix each interior no-op split the containing span into two
adjacent fragments — unbounded list growth for repeated no-ops at
distinct positions, and a no-op at a UTF-8 continuation byte
minted a mid-codepoint span boundary. Units now cover genuine
EditOp::Insert (the round-5 "insertion" unit only replaced) and
no-ops at five interior positions including the continuation
byte; the Lua twin (r6f2) bites via the overlay.rs swap — as a
compile failure, since that file also carries the round-6
identity machinery (weaker evidence, per the bite script's
caveat; the in-crate unit pins the behavior directly).
3. StyleOverlayHandleLua retains the buffer and translator ViewId
and exposes idempotent dispose(): detaches the buffer-attached
translator (later edits stop paying for it) and removes every
window render view over the store. Documented lifetime contract:
one handle per buffer incarnation (the compile/REPL discipline)
needs no disposal — the buffer's death frees it; repeated
creation on a long-lived buffer must dispose retired handles.
Bite: r6f3 (translate → dispose → edit must NOT move the span,
render views gone, double-dispose safe) fails against pre-fix
mod.rs.
Gates: fmt; clippy workspace all-targets; lib 1534; crdt lib 1708;
compile acceptance 63; crdt acceptance 3; m4 101; m6.4 15; m6.5 11;
m6.8 8; GPU 59; workspace sweep 2523/0 (one m8-class flake, clean on
rerun); git diff --check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Finding-by-finding (framing revision 11; bites via scripts/bite
against 6793edc):
1. Style-span coordinate translation belongs to the BUFFER. A new
BufferStyleSpanTranslator is attached by
pmacs.buffer.add_style_overlay and sees every edit exactly once —
bypass writes, undo/redo, remote CRDT ops — independent of window
count or visibility; the window-attached BufferStyleOverlay
copies are render-only (on_edit removed). Pre-fix each attached
view translated the shared store: start_run's explicit attach
duplicated the after-switch hook's (switch_buffer fires it
synchronously), so the normal path shifted later spans TWICE per
byte-delta rewrite, splits multiplied further, and a hidden
buffer shifted ZERO times. The redundant attach is removed;
correctness no longer depends on attachment discipline. Bites:
per-cell rendered assertions active (red a, blue bc, CR, red é →
é red, b/c blue) and hidden (run finishes with the buffer in no
window; switch back renders true colors); three direct units pin
exactly-once with extra render views attached.
2. Translation preserves the untouched fragments of a partially
overlapped span: left of the replaced range keeps its styling,
right of it shifts by the length delta, only the rewritten bytes
lose theirs (the writer styles what it writes; inserted bytes
inherit nothing). Pre-fix any overlap dropped the WHOLE span —
red abc, SGR reset, CR, X left bc unstyled; zero translation
painted the default X red instead. Bite: exact (glyph, fg) cells
X=default, b/c=red — any_styled_cell cannot see either failure.
3. The per-CR/BS/erase-line whole-prefix scan is gone:
slot.line_start is tracked — advanced at every \n (append helper
+ the mid-line newline branch), read O(1) by the rewind paths,
reset on run start/resync/raw marker appends. Measured on 2 MB of
output + 3000 CR updates (release): 2.52s pre-fix → 0.67s
post-fix (remainder is fixture-bound; pre-fix cost grows with
buffer size). No correctness bite is possible for a pure perf fix
— the committed test pins the tracked value's behavior across
multi-line appends, batch-boundary CR, repeated CR, erase-line,
and recovery paths, and passes on both implementations by design.
Gates: fmt; clippy workspace all-targets; lib 1531; crdt lib 1705;
compile acceptance 60; crdt acceptance 3; m4 101; m6.4 15; m6.5 11;
m6.8 8; GPU 59; workspace sweep 2517/0; git diff --check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Finding-by-finding (framing revision 9; bites via scripts/bite):
1. The CR/backspace renderer is UTF-8-safe: overwrite ranges consume
WHOLE existing codepoints (range end aligned forward past
continuation bytes) in ONE atomic replace of the complete text
event — never a split of either side — and backspace steps to the
previous codepoint boundary; out_pos stays on boundaries by
induction. Pre-fix, byte-counted splits left malformed bytes on
the plain rope, and under CRDT the byte-native edit rejected the
mid-codepoint range, aborting the pump after events_take had
consumed the batch (terminal event lost, record leaked). Bites:
default acceptance (é\rX, X\ré, é\bX with exact-content, marker,
clean-*errors*, baseline asserts) and a CRDT twin that pre-fix
times out never reaching its exit marker.
2. parser:finish()'s reset is observable: balancing events —
AlternateScreenExit for an unclosed enter, a default SetStyle for
a non-default running style (now also cleared; reset() preserved
it) — let consumers unwind mirrored state from the event stream
alone. New Rust unit applies events to consumer state; Lua twin
(r3f2) bites via the ansi.rs swap.
3. stdin/group spec fields are RAW reads: spec tables are plain
data, metatable-provided fields are deliberately not honored (the
compile.lua rawget posture), and a raising __index can no longer
be silently absorbed as group=false, quietly disabling
process-group isolation. Regression test pins both shapes:
metatable-provided group=true is ignored (pgid != pid), and a
hostile raising metatable spawns cleanly.
Gates: fmt, clippy workspace all-targets, lib 1526, crdt lib 1700,
compile acceptance 53, crdt acceptance 2, m4 101, GPU 59, workspace
sweep 2505/0, git diff --check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Finding-by-finding (framing revision 8; bites via scripts/bite):
1. Rule validation is a stable, total snapshot: validated scalar
fields are copied into per-run plain tables via raw reads
(rawget; metatable-provided fields deliberately not honored), so
post-run mutation of the user's rule objects cannot alter an
in-flight run and a hostile __index is a counted skip, not an
error thrown through the pump mid-batch. The container traversal
is itself pcall-protected; traversal-raise semantics are
Lua-flavor-dependent (5.2+ ipairs consults __index, LuaJIT reads
raw) and the test pins both flavors.
2. Capture indexes must be FINITE (floor(math.huge) == math.huge, so
integrality alone passed it); math.huge is now a counted
malformed entry.
3. Shell-command never touches the rule table: no spurious
compile-rule warnings on M-!, and no rule-container state can
block a run that performs no parsing.
4. AnsiParser::finish() (and parser:finish()) now fully resets the
parser — in-flight CSI/OSC/escape state and alt-screen
suppression included — so a post-finish feed parses a fresh
stream. Three direct unit tests in ansi.rs plus a Lua-driven twin
in the acceptance suite (the twin exists because a scripts/bite
file swap replaces the in-file units along with the fix).
5. Comment corrections: fractional capture indexes read a distinct
absent key (not a neighboring capture); the group-coercion
comment describes truthiness, not false; the AnsiParserLua
rustdoc lists finish().
Bites: r2f1 (both shapes), r2f2, r2f3 fail against pre-fix
compile.lua; r2f4 fails against pre-fix ansi.rs. Gates: fmt, clippy
workspace all-targets, lib 1525, crdt lib 1699, compile acceptance
50, crdt acceptance 1, m4 101, GPU 59, workspace sweep 2501/0 (one
flaky-suite rerun per the standing m8 rule), git diff --check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Finding-by-finding (framing revision 7; every fix bite-verified via
scripts/bite against the pre-fix tree):
1. Stored coordinates must be finite integers, and both cursor walks
are movement-bounded — they clamp at EOF, and the column walk
clamps at the target row's EOL instead of marching onto later
rows. An astronomical %d+ capture can no longer hang the editor.
2. The grep panel gains the same immediate buffer.after-edit
recovery trigger as the compile slots: M-x buffer.undo after a
COMPLETED search is marked synchronously.
3. The rustc arrow rule uses the framing's ([^:]+) spelling — paths
with spaces capture whole.
4. All pattern captures are collected (index 4+ reads the real
capture, not nil-as-column-0); capture indexes must be positive
integers; a rule naming a column its match didn't produce rejects
the match.
5. emit_text_raw is module-local — a user global could shadow the
helper the terminal-event path depends on, and its error consumed
the terminal event before pump cleanup/forget ran.
6. stdin/group spec fields reject wrong Lua types as hard errors;
group is matched as a raw Value because mlua's bool conversion
applies Lua truthiness ("true" would silently coerce).
7. resync also nils the public line_start_byte — total pre-marker
anchor invalidation includes the byte anchor.
8. The inherited cwd resolves through
pmacs.instance.identity().working_directory; the header always
names a real path and relative error files get an explicit base.
9. New AnsiParser::finish() + parser:finish() (additions #5): a
truncated multibyte sequence at process EOF surfaces as U+FFFD
before the exit marker instead of vanishing.
10. The built-in default rules are a private deep copy — in-place
mutations of the public table no longer survive the "using
built-in defaults" degradation.
Eleven new tests (r1f1a/b–r1f10); bites: 9 fail against pre-fix
compile.lua, r1f2 against pre-fix default.lua, r1f6 against pre-fix
lua_bindings/mod.rs — all clean assertion failures. Gates: fmt,
clippy workspace all-targets, lib 1522, crdt lib 1696, compile
acceptance 45, crdt acceptance 1, m4 101, GPU 59, workspace sweep
2493/0, git diff --check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
cargo fmt over the new files; doc-markdown backticks; is_ok_and in
the recompile counter wait; m4_6's M-g n/p pin updated to the Q#CM5
takeover contract (error.next/error.previous with the diag commands
as the dispatchers' fallback — the test's no-attachment status
behavior is unchanged). Handoff §1: main @ 0efb5cd, compile-mode
branch in flight at framing revision 6, themes named as the
standing runner-up.
Gate results on this machine (laptop, basedpyright live): fmt,
clippy --workspace --all-targets, lib 1522, crdt lib 1696,
compile_mode_acceptance 34, compile_mode_crdt_acceptance 1,
m4_acceptance 101 (no skip), PMACS_REQUIRE_GPU gpu 59, workspace
sweep 2482/0, git diff --check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Supervisor (Q#CM3, framing additions 1-2): ProcessSpec gains
stdin="null" (Stdio::null, no writer thread, immediate EOF) and
group=true — process_group(0) spawn, group-directed fatal signals,
liveness-probed TERM-to-KILL reap ledger (insert-if-absent arming,
per-tick kill(-pgid,0) probe, GROUP_TERM_GRACE=500ms), leader-exit
group TERM before the final drain with in-drain deadline enforcement
plus ESRCH quiescence window and absolute cancel cap, poll-based
cancellable readers (nix poll feature added), shutdown ledger
force-kill + probe-to-ESRCH, maybe_restart gated once shut_down.
Both options are pipe-mode-only and rejected at spawn under PTY.
Nine unit tests cover framing acceptance 34, including the
TERM-ignoring redirected survivor, the pipe-holding descendant tick
bound, and setsid-escapee resource reclamation via the per-runtime
active-reader counter.
Bindings (additions 3-4): buf:revision() exposes the edit revision
(bumped by edit/undo/redo — unit-pinned); pmacs.editor.jump_back now
fires buffer.after-switch exactly when the jump changed buffers,
matching pmacs.window.switch_buffer; pmacs.process.spawn parses the
stdin/group spec keys.
Deviation from the framing letter, called out for review: the
active-reader counter field is always present (one Arc + two atomics
per reader lifetime) rather than cfg(test)-gated — gating the field
would spread cfg attributes through every construction site; only
the probe accessor is test-gated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Finding 1 (medium): pair entries validate full UTF-8 well-formedness
(Unicode Table 3-7), not just lead-byte length — continuation-byte
shape on every trailing byte, overlong encodings (C0/C1, E0 80-9F,
F0 80-8F), UTF-16 surrogates (ED A0-BF), and beyond-U+10FFFF (F5+,
F4 90+) all disqualify, so "(\xC2x" can no longer inject invalid
bytes as a closer. char_at shares the validator and returns the raw
byte for malformed buffer content: the predicate treats junk as
word-like (no pairing before it), never as EOL. Bite:
malformed_utf8_pair_entries_are_rejected (four ill-formed shapes).
Finding 2 (low): relevance and reporting resolve against the SOURCE
buffer the record names, not whatever buffer a context-switching
command left active. New pmacs.lsp.buffer_language(buf) is the
parameterized primitive (active_buffer_language delegates), backed by
a new buf:path() query on buffer handles. Bites: rust→python `'` now
stays silent; python→rust `'` now reports "source context changed".
Finding 3 (low): non-table set containers degrade
language→default→empty instead of throwing from the after-edit
callback on every keystroke. Bites: a string default pairs nothing
with a clean *errors* buffer; a junk language entry falls back to the
default set.
Framing synced to revision 5 (Q#AP2 well-formedness + container
degradation + source-buffer resolution, Q#AP3 predicate junk-byte
posture, acceptance list).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Typing an opener inserts the closer with the cursor between; typing a
closer over its twin steps over it. Q#AP1: the nine built-in pair
chars leave both optimistic classifiers (shared charset in
pmacs-protocol) and round-trip through dispatch, so the opener and the
hook's closer are adjacent daemon-peer undo units, dispatch CUA
type-over applies, and skip never paints a transient duplicate.
Q#AP9: exact one-shot typed-edit provenance. EditorCore's
apply_active_edit now returns the effective Edit; the dispatch
fallback arms a per-frontend record (codepoint + requested vs
effective ranges + post-cursor + clean verdict) that insert primitives
complete and the daemon's optimistic CRDT arm builds directly. The
record is takeable exactly once via pmacs.editor.take_typed_edit()
during the one after-edit fan-out, then cleared — paste, programmatic
edits, manual hook runs, nested re-runs, rejected edits, and stale
this_command all observe nil, and transformed / relocated /
context-switched source self-inserts fail closed with a status.
pair.lua (loaded BEFORE lsp.lua — ordering contract in editor.rs):
per-language pmacs.pair.sets with a conservative default (no ' or `),
EOL/whitespace/closer insertion predicate, reactive skip-over-close,
rejected/transformed intercept outcomes with context-guarded
translate-and-clamp cursor repair.
Acceptance: 32 dispatch-driven cases (predicate, skip, per-language
sets, non-typed provenance incl. production-shaped paste, type-over,
undo/redo grain, intercept outcomes on both the source and reaction
edits, context-switch probe, record lifecycle, frontend isolation) +
first-didChange ordering against the fake LSP's sighelp mode via a
new PMACS_FAKE_LSP_CHANGE_SINK replay file. Six two-replica CRDT
cases pin dispatch-route convergence with cursor-between, undo/redo
walking the pair on both replicas, both mixed-history undo models as
named substrate limits, and the optimistic custom-char route
(closer-broadcast-before-opener convergence, degraded cross-peer
undo). TestDaemon gains spawn_with_config for init.lua-extended pair
sets.
Framing: docs/auto-pairing-framing.md (revision 3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
Addresses the four post-merge findings against PR #102 (merged as
2d157d8). Stacked on the kill-ring branch (PR #103): the trigger
redesign rides its command-boundary substrate.
- BLOCKING delta without the capability: pull_semantic_tokens_quiet (and
the pre-existing manual pmacs.lsp.semantic_tokens(), same bug) used
any stored resultId to request /full/delta while only checking that a
provider exists. A resultId does not imply delta support --- servers
may return one from /full regardless --- and a conforming full-only
server rejects the delta request; the pull path swallows the error, so
styling stayed silently stale after the first edit. Both sites now
require semanticTokensProvider.full.delta == true. The fake's default
mode truthfully advertises { "full": { "delta": true } } (it
implements delta); a new `fullonly` mode advertises "full": true,
REJECTS /full/delta, and bumps its resultId per /full response so the
test can observe WHICH pull refreshed the store. Verified the test
bites: with the capability check reverted, the post-edit rid stays
rid-1 (stale) and the test fails.
- HIGH false-positive trigger + cross-frontend misclassification: the
cursor-delta heuristic ("same buffer, cursor +1") fired on any
one-byte edit --- including a one-byte paste of "(" once PR #103 made
paste fire buffer.after-edit --- and its singleton last_typed was
shared across frontends. Replaced with the input-origin signal from
the #103 substrate: inside after-edit,
pmacs.editor.this_command() == "buffer.self-insert" names an edit
produced by typing, per frontend, with nothing inferred from cursor
deltas. New ed.this_command() binding; handle_remote_crdt_op now
classifies a single-codepoint optimistic insert as buffer.self-insert
(rotation, not just break --- kill-chain semantics identical since
self-insert is not a kill, and GPU typing now carries the same origin
signal as TUI typing). Paste/pointer/undo/unbound leave this_command
as something else and can never trigger.
- MEDIUM first-trigger-ignored: the origin signal needs no prior-edit
snapshot, so the very first "(" typed in a buffer triggers. The test
that had encoded the warm-up keystroke as "correct" now types a single
"(" as the first character.
- MEDIUM non-ASCII trigger characters: char_before read one byte and
rejected multi-byte strings; LSP trigger characters are strings. Now
codepoint-aware (read up to 4 bytes back, take the suffix from the
last non-continuation byte). The sighelp fake declares a two-byte
trigger ("«") and a test types it.
Tests (m4_acceptance 94 -> 97 after +4/-1 rework):
arc1c_full_only_server_repulls_via_full_not_delta (bites --- verified),
arc1d_signature_help_auto_triggers_on_trigger_char (now first-char),
arc1d_signature_help_triggers_on_non_ascii_trigger_char,
arc1d_signature_help_ignores_non_typed_edits (movement-stamped
programmatic "(" insert + manual after-edit must not trigger --- the
case cursor-delta inference cannot distinguish). Daemon unit test
updated for the insert classification (break-then-classify: `this` =
buffer.self-insert, `last` = None, chain still dead).
Note: completion.lua still uses the Q#C9 cursor-delta heuristic and
inherits its weaknesses; migrating it to this_command is a named
follow-up, out of scope here.
Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; m4 97;
killring 28; completion 9; GPU 58; git diff --check clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the PR #103 round-3 review: length-delta verification is
defeated by an intercept that rewrites an op to a DIFFERENT
equal-length range, and "replacement text appears at start" is defeated
by one that enlarges `end` by a byte.
The buffer mutators (buf:insert/delete/replace) now RETURN the
effective edit — `(start, end, inserted_len)` of the post-intercept
operation actually applied (they returned nothing before, so no caller
breaks). killring compares those against what it requested:
- C-k / cut: any deviation (shifted range, resized range, nonzero
insertion) means the bytes removed are not the bytes sliced — the
ring and OS clipboard receive nothing, the chain clears, and the
interceptor's result stands. cut now goes through buf:delete (for
the effective edit) with explicit clear_selection + goto_byte.
- M-y: any deviation from (s.start, s.stop, #entry.text) drops the
session — including the end+1 enlargement that silently deleted an
extra byte while passing the old text-at-start check. The redundant
post-replace slice verify is gone; the exact contract replaces it.
Tests (kill_ring_acceptance now 30):
equal_length_shifted_delete_does_not_feed_the_ring (delete shifted +2,
same length — the case a length delta cannot see),
stop_enlarging_replace_ends_the_yank_session (mid-buffer yank so the
enlarged range is valid and the transform path — not range validation —
is what fires; at buffer end the same intercept fails validation and
takes the rejection path, which also drops the session).
Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; killring 30;
cua 5; m6_4/m6_5 repl (mutator-heavy) 15/11; git diff --check clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Arc 2 (docs/kill-ring-framing.md, rev 3 — three review rounds). Kills
accumulate in a ring; consecutive kills append; C-y yanks the head; M-y
right after a yank cycles older entries; C-k (kill-line) exists at last.
The ring is daemon-global (Emacs-daemon model); chains and yank sessions
are per-frontend.
The substrate (Q#KR2): EditorCore.command_history maps FrontendId ->
{this, last} command. Every input path updates it --- the rev-1 design
treated dispatch_key as the only input path and review falsified that
twice:
keybound command dispatch_key Run arm rotate
typed char (round-trip) self-insert fallback rotate
unbound key dispatch_key unbound arm break
GPU optimistic edit handle_remote_crdt_op break
pointer gesture dispatch_mouse + dispatch_pointer break
inbound OS paste unified paste route break
menu item menu_invoke_active rotate
M-x accept pmacs.command.invoke_interactive rotate
invoke_interactive gives Emacs's execute-extended-command semantics
(M-x kill-line then C-k appends; C-k then M-x kill-line does not); the
public pmacs.command.invoke stamps nothing. Wheel scroll deliberately
does NOT break (mwheel-scroll vs mouse-set-point, as in Emacs).
Three shipped bugs fixed en route (Q#KR10):
- Semantic-path Paste was dropped ("no grid-less effect yet"), and the
GPU always negotiates semantic render --- GPU Ctrl-V was a no-op. Paste
is now a dispatcher-level arm serving both attachment kinds.
- That arm keys off the dispatcher's AUTHENTICATED source; the old grid
arm trusted the client-supplied payload frontend_id, letting a forged
id paste into another frontend's active window (unit-tested).
- Paste, M-x-invoked commands, and menu-invoked commands never fired
buffer.after-edit (each runs outside dispatch_key's revision check),
so LSP/syntax/autosave missed those edits. A shared
with_after_edit_check helper now wraps all three sites; scope is
honest --- active-buffer compare, sound for these paths, not a general
any-buffer guarantee (buffer-aware edit epoch deferred).
The ring (killring.lua, Q#KR4-7): entries carry stable monotonic ids.
Append requires last_command in the kill family AND this frontend's
last_kill_id == the head's id --- A-kill/B-kill/A-kill pushes fresh
instead of corrupting B's entry. Yank sessions store {buffer, start,
stop, entry_id, text}: M-y validates last_command + live session + same
buffer + slice(start,stop) == text (out-of-bounds reads as changed ---
pcall'd; an early test caught the guard throwing on an upstream
deletion instead of refusing), rotates by locating the entry_id's
CURRENT position (positions shift under other frontends' pushes; ids
don't), verifies the applied replace (intercepts may alter it; accepted
post-hoc semantics), then goto_byte. Failed kills clear last_kill_id;
failed/refused yanks create no session, so a second invalid M-y cannot
ride the first's name-stamp.
OS clipboard: ring head mirrors to the ACTING frontend's OS clipboard
only (pending_clipboard's existing shape; frontends may be different
machines). External content joins the ring at yank time via the
clipboard_get slot check (an OS copy reaches the daemon only when
pasted). New core seams: clipboard_set(bytes) / clipboard_get.
Lifecycle (Q#KR11): SessionDetached prunes command_history and fires the
new frontend.detached hook (raw id); killring.lua drops that frontend's
tables.
pmacs.killring.max([n]) validated (non-finite rejected --- math.huge
would defeat the cap; shrink trims immediately), default 60.
Deferred, named: word kills (M-d/M-BS/C-BS/C-h/C-DEL discard bytes ---
needs bytes-returning deleters), C-SPC/set-mark, clipboard watching,
ring browser/persistence, C-u C-y / C-M-w, buffer-aware edit epoch,
Lua-visible intercept probe.
Tests: tests/kill_ring_acceptance.rs (24) --- chain mechanics incl. all
break rows, the M-x three-direction matrix, per-frontend interleaving
(A-kill/B-kill/A-kill; stable-id rotation under B's pushes; eviction
mid-session; upstream-edit invalidation), menu Cut via real right-click
+ menu pointer (feeds ring, fires after-edit once, chains with C-k),
external-paste integration, cap validation + shrink-trim, detach
cleanup. Plus daemon unit tests: forged-id paste lands in the
authenticated source's window and leaves the claimed frontend's chain
untouched; optimistic CRDT op breaks only the source's chain.
Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; killring 24;
cua 5; query-replace 16; completion 9; autosave 29; desktop 11;
persistence 5; clobber 6; m4 90; m8 10+15; m10/m11 crdt; GPU 58;
git diff --check clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EditorCore::save() wrote unconditionally via save_atomic and only THEN
recorded the new FileMeta. It never compared the on-disk identity against
the one the buffer read. So another editor's writes, or a `git checkout`,
were destroyed without a word --- the single worst data-loss path in the
editor, on its most-used command.
The comparison seam already existed and no caller used it: FileMeta is
PartialEq (mtime + size, sized so same-second edits still differ) and
file_io::current_meta reads it. The file_io module docstring even said
callers "should" compare before saving. Nobody did.
save() now refuses when writing would destroy content the buffer has
never seen:
* the buffer recorded a meta and the on-disk meta differs --- someone
else wrote the file;
* the buffer recorded NO meta (a `[new file]`, or a path set without
reading) yet a file now exists --- it was created underneath us.
A *missing* file is not a clobber: there is nothing to destroy, so
recreating a deleted file saves normally. An unstattable path falls
through and save_atomic reports the real error.
On refusal the status line says what happened and how to override, the
buffer keeps its unsaved edits, and `buffer.after-save` does not fire.
`M-x buffer.save-anyway` (ed.save_ignoring_disk_changes) overwrites
deliberately and re-syncs the meta, so an ordinary save works again.
Named `buffer.save-anyway`, not `save-buffer-anyway`, for two reasons: it
belongs in the `buffer.` namespace next to `buffer.save`, and the latter
outranked `buffer.save` as an M-x completion for "save" (which a lib test
caught).
This is the bug the autosave arc kept circling: Q#AS5's Fresh/Stale guard
refuses to auto-offer a recovery for an externally-changed file, but
nothing stopped save() from overwriting that same file.
Tests: tests/save_clobber_guard_acceptance.rs (6) --- refuses and leaves
their content intact, after-save does not fire on refusal, save-anyway
overwrites and re-syncs meta, unchanged files save repeatedly (the guard
must not trip on our own writes), a deleted file is recreated not
refused, and a `[new file]` buffer refuses once someone else creates the
file. Verified the tests bite: 4 of 6 fail with the guard disabled.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; clobber 6; m1/m3
/m4 90/m5.8/m7.8/m8 10+15; autosave 29; desktop 11; persistence 5;
query-replace/completion/listview/overlay/cua green; GPU 58;
git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the PR #100 review round 2. Q#AS12's ownership rule guarded the
sweep but not the RELEASE paths, so three doors were still open.
The rule is now total: exactly two things may release an unclaimed
recovery file --- recover-file (which adopts it) and discard-recovery
(explicit user intent). Not a sweep, not a save, not a kill.
- HIGH: buffer.after-save called _discard_buffer unconditionally, which
removed the live buffer's current-path key without checking ownership.
Repro: session 1 autosaves and crashes; session 2 opens the file, does
not recover, then saves --- the crash artifact was deleted. Same door
was open on kill. discard_buffer now removes ONLY keys this session
owns. The unclaimed copy survives (reported Stale, so never
auto-offered, but still recoverable/discardable). The on-disk file holds
the new work; the crash copy holds work never written anywhere, so
deleting it was the same data loss by a different door.
- MEDIUM/LOW: _adopt only recorded the path in `owned`, not an
association with the buffer. A removal callback fires after the buffer
has left the registry, so discard_buffer had no path to read and no
`written` entry to fall back on --- recover-then-kill leaked the copy
and it was offered again. adopt now takes the BUFFER and records a
`written` entry at the revision whose contents the file holds. That is
correct twice over: the skip cache declines to rewrite an identical
copy, and a kill can find and retire it.
- LOW: _discard(path) removed the file and unowned the hash but left
matching `written` entries, so a still-dirty buffer hit the unchanged
(path_hash, revision) fast path and went unprotected until its next
edit. discard_path now clears those entries; the next sweep re-protects
immediately.
Tests (autosave_acceptance now 24):
saving_without_recovering_preserves_unclaimed_crash_data,
killing_without_recovering_preserves_unclaimed_crash_data,
recover_then_kill_retires_the_adopted_recovery,
discard_recovery_lets_the_next_sweep_reprotect_immediately.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 24;
desktop 11; persistence 5; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>