request_rename and request_prepare_rename sent raw byte columns instead
of routing through outbound_position — the same bug class as the
semantic-range and code-action fixes that just merged (#105). On a
UTF-16 server, a rename at a position past non-ASCII text resolves the
wrong character (or an invalid one) and renames the wrong symbol.
Both single-Position builders now convert. The posecho fake validates
request positions on its rename/prepareRename arms in UTF-16 units, and
the new test drives both requests at byte offset 3 of "éx" (UTF-16
character 2) — both stores filling proves both builders converted.
(Fix authored locally by Levi during the round-5 review; recovered from
the working tree after the #105 merge and landed verbatim, plus a
cargo fmt pass.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the round-5 finding: the whole-document range that serves a
RANGE-ONLY semantic-token provider derived its columns from UTF-8 byte
counts and sent them unchanged — unlike the inlay path, it skipped
outbound_position. A UTF-16 server receives an invalid end character
for non-ASCII text ("é" is two bytes, one UTF-16 unit) and may reject
the request; since /range is a range-only provider's ONLY pull path,
that means no semantic styling at all.
Both bounds of request_semantic_tokens_range now go through
outbound_position. request_code_action had the identical bug (byte
columns, no conversion) and is fixed in the same stroke — same class,
same one-line shape, commented as such.
Fixture: `rangeonly16` fake mode = rangeonly + negotiated UTF-16 +
STRICT UTF-16 bounds validation on /range (fail-closed: a missing
didOpen record or absent uri also rejects, so the fixture can never
pass vacuously). An env-gated PMACS_FAKE_RANGE_SINK records the
received range for debugging. Test opens a file whose last line ends
in non-ASCII and asserts tokens arrive; verified it bites — with the
conversion removed the wire carries the byte column (13 vs the valid
11), the fake rejects, and the test fails.
Honest note: an earlier bite-check in this session produced a vacuous
pass because short, non-unique edit patterns hit the WRONG json! block
(temporarily regressing the inlay conversion and accidentally
converting code-action). The final diff is anchored uniquely and
verified: inlay unchanged (whitespace only), semantic + code-action
converted, bite-check red/green confirmed against the exact lines.
Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; m4 99;
killring 30; completion 9; GPU 58; git diff --check clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the round-4 findings against the stack (PR #104 portion).
- HIGH range-only semantic-token servers: LSP defines
semanticTokensProvider.full and .range as optional, INDEPENDENT
capabilities, but the old any-provider gate sent /full regardless — a
range-only server rejects it and the swallowed error means no styling,
ever. Both the auto-pull and the manual command now gate each request
kind on its own capability: /full (delta under full.delta) when
negotiated; a range-only provider gets a WHOLE-DOCUMENT /range request.
New `rangeonly` fake mode (advertises range without full, rejects
/full) + test proving tokens arrive via the range path.
- MEDIUM completion acceptance left this_command stale: the popup accept
applies its edit and fires after-edit outside command dispatch, so
this_command could still read "buffer.self-insert" from the typing that
raised the popup — a candidate ending in "(" would spuriously
auto-trigger signature help. Accept now stamps its own boundary
("completion.accept"); asserted in the popup acceptance suite.
- MEDIUM GPU shape inference tightened: the 1-4-byte predicate accepted
a 2-byte "a(" insert (two ASCII codepoints). The classifier now decodes
the inserted bytes from the post-edit rope and requires the leading
byte's UTF-8 sequence length to equal inserted_len — exactly one
codepoint. The daemon unit test now drives an "a(" op and asserts it
breaks the chain instead of classifying as typing. Exact wire
provenance on the CRDT op remains the named deferred general fix.
Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; m4 98;
completion 9; killring 30; GPU 58; git diff --check clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Addresses the PR #103 review.
- BLOCKING semantic right-click: the dispatcher routes
PointerKind::Context directly to open_menu_at_byte, bypassing
dispatch_pointer's break — so GPU C-k, right-click, dismiss, C-k still
appended, and M-y survived the click. open_menu_at_byte now breaks the
chain like the grid right-click path.
- HIGH C-k under intercepts: kill_line captured text then called
buf:delete un-pcall'd. A REJECTING intercept threw before fail_kill,
leaving the old chain live (the next C-k appended to a kill that never
happened); a TRANSFORMING intercept could delete different bytes while
the ring and OS clipboard kept the original text. The delete is now
pcall'd and verified by length delta: rejection clears the chain with a
status; a transformed delete feeds nothing (the interceptor's result
stands — accepted post-hoc semantics), also clearing the chain. Same
discipline applied to cut's delete_region.
- HIGH rejected M-y: buf:replace ran outside pcall, so a rejecting
intercept threw through command dispatch and left sessions[fid] live —
a second M-y could reuse the supposedly-invalid session. The replace is
pcall'd; rejection drops the session with a status.
Tests (kill_ring_acceptance now 28): semantic_context_right_click_breaks
_the_chain (drives open_menu_at_byte directly — the GPU route);
rejecting_intercept_clears_the_kill_chain (reject-once intercept: the
kill after the rejection pushes fresh, not append);
transforming_intercept_does_not_feed_the_ring (delete shrunk to one
byte: ring untouched, interceptor's result stands);
rejecting_intercept_ends_the_yank_session (second M-y refuses on
no-session, no splice).
Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; killring 28;
cua 5; m6_4 repl (intercept suite) 15; 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>
Q#KR1-11: Lua ring on a per-frontend command-boundary substrate.
Eight-row boundary table (keybound/self-insert/unbound/GPU-optimistic/
pointer/paste/menu/M-x-interactive); stable ring-entry ids so shared-
ring interleaving cannot corrupt per-frontend chains or yank sessions;
sessions carry {buffer,start,end,entry_id,text} snapshots with slice
verification. Three shipped bugs in scope (Q#KR10): semantic-path Paste
drop (GPU Ctrl-V is a no-op today), missing after-edit for M-x/menu/
paste edits, and paste trusting the client-supplied payload id instead
of the dispatcher's authenticated source. Q#KR11: frontend.detached
hook + command_history detach cleanup. Incorporates three review
rounds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes Arc 1 of docs/roadmap-2026-07.md.
1c --- semantic tokens never appeared (a shipped bug).
Semantic tokens are pull-model: the store only fills from a
`textDocument/semanticTokens/*` response. The ONLY automatic pull was in
reply to a server-initiated `workspace/semanticTokens/refresh`, which
most servers never send. So `LspStyleView` attached to a store nothing
ever filled, and semantic styling silently never appeared unless the user
ran `M-x lsp.semantic-tokens` by hand --- while inlay hints, on the exact
same pull model, were pulled at three points.
`pull_semantic_tokens_quiet` now mirrors `pull_inlay_hints_quiet` at all
three: on `initialized`, on attach, and on edit-flush. The `initialized`
handler is the one that matters --- buffers attach before the server
finishes initializing, so the attach-time pull is a no-op for the first
file (its `server_is_initialized` guard is false). That is precisely why
the file that starts the server never got semantic color. Delta when a
resultId is held, full otherwise, matching the manual command.
1d --- signature help auto-triggers on a trigger character.
A typed character is reconstructed the way `completion.lua` already does
(Q#C9): same buffer, cursor advanced by exactly one byte. Paste, undo,
kill, and remote CRDT edits produce any other delta and never trigger.
The trigger set comes from the server's declared `triggerCharacters` +
`retriggerCharacters`; a provider declaring neither gets `(` and `,`; no
provider means no auto-trigger at all. The request is silent --- an
auto-trigger that announced "no signature help" on every `(` in a comment
would be unusable --- so only a real signature reaches the status line.
It fires after the pending didChange is queued and flushes it first, so
the server sees the character being asked about.
Test helper: `pmacs_fake_lsp` gains a `sighelp` mode that advertises
`signatureHelpProvider`; every other mode omits it, so no existing test
changes behavior.
Tests (m4_acceptance 90 -> 94):
arc1c_semantic_tokens_auto_pull_on_attach (default fake: advertises
the provider, never sends refresh --- exactly the broken case)
arc1c_semantic_tokens_repull_after_edit_flush (clear store, type, flush)
arc1d_signature_help_auto_triggers_on_trigger_char
arc1d_signature_help_does_not_trigger_on_ordinary_typing
Verified the 1c tests bite: both fail with the `initialized`-handler pull
reverted. Named `arc1c_`/`arc1d_` rather than `m4_NN_`, since the m4
numbering maps to spec acceptance bullets and these are not those.
Gates: fmt + workspace clippy clean; lib 1499; m4 94; m9_1 18;
completion 9; listview 6; overlay 2; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <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 4.
- MEDIUM stale skip-cache entry after a slot transfer. adopt() set
owner[hash] = new buffer but left the previous owner's `written` entry
pointing at the same hash, breaking the invariant
`written[id] => owner[hash] == id`. Repro: A and B are duplicate buffers
on one path; A owns the slot; B adopts (recover-file); B is killed
without saving, which frees the slot and deletes the file. A is still
dirty, but its stale written[A] = (hash, revA) makes the next sweep call
it "unchanged since its last copy" --- silently unprotected until its
next edit. adopt() now drops any other buffer's written entry for that
hash. Verified the new test fails without the fix (sweep writes 0).
- MEDIUM autosave write failures were swallowed. write_private can fail
(ENOSPC, a permission change, a clobbered state dir), but the tick and
before-quit paths did `pcall(sweep)` and dropped the error. For a
data-protection feature that is the worst failure mode: the user keeps
working, believing edits are captured, while nothing is written. Both
paths now go through a reporting wrapper --- status line "autosave
FAILED: ... --- your work is NOT being protected" on every failing sweep,
each distinct fault logged once via pmacs.error. The quit path reports
too (a failure there means the quit is about to discard work that was
never written anywhere) and still never vetoes.
Tests (autosave_acceptance now 29):
adopting_clears_the_previous_owners_stale_skip_cache,
a_failing_sweep_is_reported_not_swallowed (plants a regular file where
autosave/ must be a directory, standing in for ENOSPC).
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 29 + 8
units; desktop 11; persistence 5; m7_8 5; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the PR #100 review round 3.
pmacs.buffer.from_file does not dedup, so two buffers can visit one path.
Ownership was tracked as a path-wide `owned: HashSet<path_hash>`, which
made the duplicate case silently corrupting: both dirty buffers queued a
write to autosave/<same hash>, the later write won on disk, and BOTH were
recorded in `written` --- so the loser skipped future sweeps while its
contents were unrecoverable. The path-wide set also let either buffer's
save/kill retire the other's recovery.
A recovery file must stay keyed by path (a later session knows only
paths, never old BufferIds), so two divergent buffers cannot both be
protected under one key. Ownership is now `owner: path_hash -> BufferId`:
- the first modified buffer to reach a free slot claims it, including
within a single pass (the write loop updates `owner`, so the gather
loop tracks slots queued this pass --- otherwise two duplicates both
queue a write);
- any other buffer on that path is counted `conflicted` and reported
("autosave paused for N buffer(s): another buffer is visiting the same
file"), never silently mis-protected. It records no `written` entry, so
it re-attempts each sweep instead of believing itself saved;
- `discard_buffer` (save/kill) retires ONLY slots this buffer owns, which
now enforces both invariants at once: an unowned slot is unclaimed
crash data (Q#AS12), and a slot owned by another buffer is that
buffer's recovery;
- saving or killing the owner releases the slot; the duplicate claims it
on the next sweep;
- `recover-file` adopting into a buffer makes that buffer the owner --- the
file's contents are now its contents, and the previous owner truthfully
becomes conflicted.
sweep() now returns (written, blocked, conflicted). Its gather phase is
extracted into `gather()` (clippy too-many-lines).
This is honest rather than clever: pmacs cannot protect two divergent
buffers over one file, and now says so instead of pretending.
Tests (autosave_acceptance now 27):
duplicate_buffers_on_one_path_conflict_instead_of_corrupting (owner's
copy on disk; the dup never wins the slot by editing),
a_duplicate_buffers_save_does_not_retire_the_owners_recovery,
killing_the_owner_frees_the_slot_for_the_duplicate.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 27 + 8
units; desktop 11; persistence 5; m7_8 5; 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>
Addresses the PR #100 review.
- HIGH data loss: sweep could overwrite an existing crash recovery before
the user ran recover-file. Reopen a file after a crash, edit it, and the
next autosave wrote the current buffer over the recovery key --- losing
exactly what autosave exists to protect. New ownership rule (Q#AS12): a
per-session `owned` set records which path hashes THIS session wrote or
adopted. A recovery file at a key we do not own is unclaimed crash data;
the sweep refuses to write that buffer, counts it `blocked`, and says so
("autosave paused for N file(s) with unclaimed recovery"). recover-file
ADOPTS the copy once its contents are in the buffer; discard-recovery
removes it. Either resumes normal autosave. sweep() now returns
(written, blocked).
- MEDIUM cleanup missed paths autosave can write. Kill/save cleanup now
goes through `discard_buffer(BufferId)`, which removes BOTH the buffer's
current-path key and the key its last sweep actually wrote (they differ
after a rename --- an LSP WorkspaceEdit changes the path while the
BufferId stays; a path-captured callback deleted the wrong key). And a
sweep-time GC deletes the recovery of any buffer that left the registry,
which is the backstop for argv `[new file]` buffers: they fire no
after-load, so no removal callback is ever registered for them.
- LOW/MEDIUM recover-file pinned only on the active path. Two buffers can
visit one path (pmacs.buffer.from_file does not dedup), so focus drift
could recover into the wrong buffer. It now captures and compares the
origin buffer handle as well as the path.
- LOW write_private left a pre-existing lax autosave/ directory alone. The
birth-mode only applies to dirs that call creates, so a 0755 autosave/
from an older run still leaked recovery-file names, sizes, and mtimes
despite 0600 contents. It is now tightened to 0700 --- but never `base`
itself, which is shared with history/recentf/desktop and may predate us.
New `state::exists` (an existence check, no read) backs the ownership
gate.
Tests (autosave_acceptance now 20): sweep_never_overwrites_unclaimed_
crash_recovery (blocked, crash copy byte-identical, adopt resumes),
discarding_an_unclaimed_recovery_unblocks_the_sweep,
killing_a_new_file_buffer_gcs_its_recovery,
saving_after_a_rename_removes_the_recovery_written_under_the_old_path,
a_pre_existing_lax_autosave_dir_is_tightened.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 20;
desktop 11; persistence 5; m4 90; m7_8 5; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Framing: docs/autosave-recovery-framing.md (Q#AS1-11). Closes the
persistence arc. Every modified file buffer is periodically written to a
private recovery copy; if pmacs dies, the next session says so and
`M-x recover-file` installs it. Emacs's auto-save-mode + recover-file.
Hybrid, forced by the same two gaps as phase 2: Lua has no per-buffer
path getter and FileMeta is neither Lua-visible nor serde. Rust owns the
sweep and the external-change guard; Lua owns cadence, config, and UX.
src/autosave.rs (new):
- One atomic envelope per recovery: a JSON header line + `\n` + raw
buffer bytes. Split at the FIRST newline, so contents may hold newlines
and non-UTF-8. A crash can never leave a torn header/contents pair.
- `origin` is NULLABLE: a `[new file]` buffer (a path with nothing on
disk) has no FileMeta, and its unsaved contents are exactly the work
most worth recovering.
- status(): Fresh / Stale / Corrupt / None. Only Fresh is announced;
Stale (file changed, deleted, or created underneath us) is never
auto-offered; Corrupt is typed, quiet, and discardable.
- sweep(): all modified file buffers, skipping clean/scratch and those
unchanged since their last copy. The skip cache is keyed
BufferId -> (path_hash, revision), not revision alone: a buffer keeps
its BufferId across a path change (LSP WorkspaceEdit rename), so a
revision-only cache would skip the write and orphan the old key.
- pending(): enumerates ALL open file buffers in Rust, which is what
covers argv `[new file]` buffers -- they fire no hook at all.
Private storage (Q#AS11, a precondition for default-on): autosave stores
unsaved FILE CONTENTS, not metadata. New `file_io::save_atomic_with_mode`
sets the temp's mode BEFORE the rename (a chmod-after-write leaves a
window where the file is 0644), and `state::write_private` creates the
dir 0700 and the file 0600. Plus `state::read_bytes` (state::read is
read_to_string, which non-UTF-8 buffer contents would fail).
builtin/runtime/autosave.lua:
- Cadence is `process.after-tick` + monotonic_ms, NOT workers.sleep: a
long sleep parks one of only `available_parallelism - 1` pool threads,
and re-reading the interval each tick makes it live-reconfigurable.
- pmacs.autosave.interval_ms([ms]) -- validated getter/setter following
the async_config.frame_target_ms shape. Default 30000, floor 1000.
pmacs.autosave.enable(on). On by default.
- Notify, never prompt: `after-load` only raises a flag; the tick emits
ONE aggregate message ("3 files have autosave recovery"). A modal
prompt from after-load would stack N modals during a desktop restore.
- recover-file confirms, pins to the origin buffer, replaces contents,
then explicitly fires `buffer.after-edit` -- the mutators only notify
windows and queue CRDT, and after-edit comes from dispatch_key's
post-command check, which the minibuffer shadow returns before. Without
the explicit fire, LSP didChange and the syntax reparse never see the
recovery. discard-recovery deletes a copy (including a Corrupt one).
- Cleanup: after-save discards; per-buffer on_removed discards on kill
(there is no global kill hook); before-quit does one final synchronous
sweep and never vetoes.
src/hash.rs (new): one pub(crate) sha256_hex, shared by desktop, autosave,
and packages::fetcher -- which had two private duplicates (Q#AS9).
Not daemon-gated (unlike desktop-save): autosave is per-buffer, not
per-frontend, and a daemon holds the unsaved work.
Tests: 8 autosave units + 13 state/hash units + tests/autosave_acceptance
(15): sweep round-trip, non-UTF-8 envelope, [new file] null-origin
Fresh->Stale, 0600/0700 perms, skip clean/scratch/unchanged, path-change
rewrites new key + discards old, save/kill cleanup, Stale not offered,
Corrupt typed+quiet+discardable, recover-file installs + fires after-edit
+ leaves modified, tick aggregation (3 loads -> 1 message, no repeat),
single-file naming, interval validation + live change, enable gate,
before-quit sweeps without vetoing.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 15;
desktop 11; persistence 5; m4 90; m7_8 5; m8 10; GPU 58; git diff --check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Q#AS1-11: hybrid (Rust sweep+guard, Lua cadence/config/UX);
process.after-tick + monotonic_ms cadence (no parked worker thread,
live-reconfigurable interval); validated-setter config
(pmacs.autosave.interval_ms, default 30s, 1s floor); one atomic
header-line+bytes envelope with NULLABLE origin meta (new files);
Fresh/Stale/Corrupt/None status table; pull-based aggregated notify on
the tick (covers argv [new file] buffers that fire no hook); explicit
hook.run(buffer.after-edit) after recover-file's replace; path-aware
(path_hash, revision) skip cache; shared sha256_hex extraction;
private 0700/0600 storage as a precondition for default-on.
Incorporates the 2026-07-09 review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the PR #99 review:
- HIGH daemon local-only was not reliable: run_daemon sets DaemonMode
only after EditorState::new() has run init.lua, so desktop_mode(true)
in init saw is_daemon()==false and the raw bindings were ungated. Now
save_session/restore_session early-return in Rust when the DaemonMode
marker is present — set right after the daemon's new(), so it holds for
every save/restore that can run after startup (before-quit hook, manual
commands, direct binding calls).
- MEDIUM desktop_mode(false) could not unarm startup restore: arm_restore
is now a boolean (arm_restore(on)) that sets/removes the marker, and
desktop_mode(on) calls arm_restore(on). enable-then-disable no longer
restores.
- MEDIUM/LOW same-file multi-pane missed per-window overlays: restore now
fires buffer.after-load once PER LEAF (per window), not once per buffer.
Syntax attaches its overlay to the active window, so each pane gets its
own; LSP attach_buffer is idempotent, so the same file in two panes
attaches LSP once but syntax to both.
- MEDIUM hidden restored buffers: documented as registry-only in v1 (they
are live/openable/in recentf, but do not fire after-load, so they
attach syntax on first visit via after-switch and LSP when next shown).
Full initial attach for hidden buffers is deferred. Noted in the
framing + a code comment.
- LOW trailing whitespace in docs/desktop-save-framing.md.
Tests (desktop_acceptance now 11): same_file_..._fires_per_pane asserts
after-load fires twice for two panes of one file; daemon_mode_disables_
save_and_restore; disabling_desktop_mode_unarms_restore.
Gates: fmt + workspace clippy clean; lib 1487; crdt 1658; desktop 11;
persistence 5; m4 90; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Framing: docs/desktop-save-framing.md (Q#DS1-10). Save the open file
buffers, window layout, and per-window positions on quit; rebuild them
on startup. Emacs desktop.el, opt-in, local-mode only in v1.
All-Rust (Q#DS1) --- the core window enums are not serde and there is
no Lua tree API, so the layout mirror + structural rebuild live in Rust.
Lua adds only the opt-in switch and manual commands.
src/desktop.rs (new):
- Serde mirror (SavedDesktop / SavedBuffer / SavedNode / SavedLeaf /
SavedOrientation): every open file buffer (visible OR hidden, so a
switched-away file survives), the layout tree with orientation +
weights + nesting, per-leaf cursor/view_top, and an active-leaf
preorder index with a nearest-neighbor fallback (Q#DS10).
- session_key: SHA-256, name.<hex> when a socket name is set else
cwd.<hex> (charset-safe for the pmacs.state key).
- save_session / restore_session take the &Lua that carries the
SharedCore / StateDir / LocalInstanceInfo app-data, so they run
identically from a pmacs.session.* binding and the startup trigger.
- restore ordering (Q#DS3): open all buffers; prune EVERY window of the
old LOCAL layout (not just scratch); rebuild the tree; then per leaf
in preorder activate its window and fire buffer.after-load once per
newly-loaded buffer (hooks read active state), and write the exact
cursor/view_top AFTER so desktop wins over saveplace (same file in two
panes keeps distinct positions). A missing file collapses its leaf.
src/editor_core.rs: get_or_load_buffer(path) --- find_by_path else
load fresh, WITHOUT switching the active window; returns (id, newly).
src/lua_bindings: pmacs.session.{save_desktop, restore_desktop,
arm_restore, is_daemon}; DesktopRestoreArmed + DaemonMode markers;
fire_after_load_hook seam.
builtin/runtime/desktop.lua: pmacs.session.desktop_mode(on) wires
before-quit save + arms restore; desktop-save / desktop-restore
commands. No-op under a daemon (Q#DS9).
Startup trigger (Q#DS7): editor::run captures had_file before the match
consumes `file`, and restore_desktop_if_armed runs INSIDE the RunLocal
arm (after attach dispatch) so a hand-off to attach never populates an
EditorState it is about to drop. Daemon marks DaemonMode → desktop
stays local-only.
Tests: src/desktop.rs units (tree collapse, active-leaf fallback,
key/json round-trip) + tests/desktop_acceptance.rs (9): nested weighted
round-trip, hidden-buffer survival, after-load-active probe, same-file
two-pane distinct positions, missing-file collapse + focus fallback, no
orphan windows, name-vs-cwd key scoping, modified warning, startup gate.
Gates: fmt + workspace clippy clean; lib 1487; crdt 1658; desktop 9;
persistence 5; m4 90; m8 daemon 10/15; 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 #98 review:
- HIGH symlink escape: resolve() did only a lexical starts_with, so a
base/autosave symlink -> /tmp/out let state.write("autosave/x") write
outside the state dir. Now every existing component the key adds under
base is lstat'd and a symlink (live OR broken) is rejected; base itself
may still be a symlink (dotfile-managed ~/.local/state). Unix symlink
escape test added (live + broken + plain-subdir-ok).
- MEDIUM integration-test state leak: the state/history dir wiring moved
out of EditorState::new() into EditorState::install_state_dirs(),
called only by the real entry points (editor::run, run_daemon). Unit
AND integration tests construct EditorState directly, so they never
configure a real dir -> default-on recentf/saveplace write nothing to
~/.local/state/pmacs during cargo test. The inertness test now asserts
a bare new() leaves StateDir unconfigured (direct proof).
- MEDIUM saveplace never recorded view_top: exposed the missing
pmacs.editor.view_top() getter (set_view_top existed but no getter, so
the Lua stored 0). saveplace now records+restores the viewport;
acceptance asserts view_top restores, not just the cursor byte.
- MEDIUM/LOW relative XDG_STATE_HOME / PMACS_STATE_HOME: a relative
value rooted state at a cwd-relative pmacs/... (same footgun class as
the empty case). Both are now required absolute; relative values are
ignored (XDG falls through to HOME). Test added.
- LOW trailing blank line at recentf.lua EOF (git diff --check).
Gates: fmt + workspace clippy clean; lib 1483; crdt 1654; persistence 5;
m4 90; m8_1/m8_2 daemon 10/15; 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>
Framing: docs/persistence-framing.md. The four Rust primitives the
Lua-vs-Rust scout said were unavoidable, plus two Lua policy modules.
Rust:
- src/state.rs: state_dir(xdg,home) returning .../pmacs (generalizes
the baked-in history path). Deliberate empty-XDG fix (Q#PS2): a blank
XDG_STATE_HOME fell through to a RELATIVE pmacs/... path (a cwd-write
bug); now treated as absent so it falls to HOME. Confined key->file
store: validate_name rejects absolute / .. / empty / // / control
chars, plus a canonical-prefix belt; read/write/remove go through
file_io::save_atomic, never raw io.open. A PMACS_STATE_HOME override
lets CI / privacy-conscious users / integration harnesses redirect
all state to a scratch dir. History routed through the shared
resolver so it honors the override too.
- pmacs.state.{write,read,remove,path,available}: a no-op when the
state dir is unconfigured (cfg(test) / no HOME), so default-on
builtins write nothing in the lib suite. Configured once at startup
like history_dir, skipped under cfg(test).
- pmacs.editor.goto_byte / set_view_top: byte-exact restore (switch
zeroes the cursor).
Lua (builtin/runtime):
- saveplace.lua: record the active file's cursor+view_top on
before-save / before-quit; restore on after-load. LRU-capped places
state file. On by default; pmacs.saveplace.enable(false).
- recentf.lua: MRU record on after-load AND after-switch (re-visits
refresh the order); deduped/capped recentf file; a recent-files
command bound C-x C-r opens the minibuffer picker.
Tests: state.rs units (validate/resolve/round-trip/empty-XDG),
tests/persistence_acceptance.rs (state round-trip + confinement
rejections, inert-when-unconfigured, recentf MRU/dedup, saveplace
restore-on-reload, disable knob) injecting a tempdir state root. One
describe-hook test made robust to a builtin now subscribing to
buffer.before-save.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review High (merge blocker): query-replace searched the origin buffer's
bytes but applied edits and moved the cursor through apply_active_edit /
search_place_cursor, which target whatever is ACTIVE. Focus can drift
mid-session — a click into another split, a key from another frontend,
both changing the active buffer outside the shadow — so a match found in
the origin buffer could be applied to an unrelated one. Buffer
corruption.
Fix: query_replace_on_origin() verifies the active buffer still equals
the session's origin buffer before every edit; on mismatch it ABORTS
without editing (clears the highlight, drops the session, status
'query-replace aborted: active buffer changed'), so an origin match can
never land in a foreign buffer. Guards replace/skip/all/replace-and-quit.
The dispatcher's after-edit revision compare now targets the ORIGIN
buffer (query_replace_origin_buffer + buffer_revision) not the active
one, so a drift-abort — which edits nothing — never spuriously fires
the hook. The forward-search clamp uses the origin bytes' length, not
active_buffer_len.
Also (review Low/med): query_replace_active() added to the
completion-popup modal-close guard, so a popup opened via the direct
Lua start (ed.query_replace_start) can't linger rendered-but-unreachable
while QR swallows keys.
Tests: core drift-abort (both buffers untouched) + end-to-end
focus-drift regression; ! fires after-edit exactly once for the batch;
RET/Esc/C-g quit paths (keep replacements); DEL skips. Acceptance
header corrected to match actual coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Emacs query-replace, built on isearch with zero protocol change
(framing: docs/query-replace-framing.md).
- search.rs: find_first_from (literal) + find_first_regex_from (cached
engine, zero-width-skip) + compile_search_regex (shared smart-case
compile). Q#QR2's forward-scan-past-replacement primitive.
- QueryReplaceSession + core methods (editor_core.rs): begin (invalid
regex refuses, Q#QR2), replace/skip/all/replace-and-quit/finish;
matches run forward from next_from on the LIVE buffer, so offset
shifts and never-re-matching-replacements (a->aa) fall out for free;
current match highlighted via a single-element search_store set
(SearchMatchActive, both frontends free) + cursor reveal; quit keeps
replacements, only nothing-matched restores origin (Q#QR10).
- Dispatcher shadow (editor.rs): QueryReplaceKey (y/SPC, n/DEL, !, .,
q/RET/Esc/C-g) + dispatch_query_replace_key, the 5th modal shadow;
added to dispatch_idle disjunction (GPU round-trips keys) and fires
buffer.after-edit itself (Q#QR1 — a shadow returns before the normal
post-command check; once per !-batch).
- Lua: ed.query_replace_start/query_replace_active; query-replace /
query-replace-regexp commands (chained minibuffer.read, separate
from/to history buckets, empty-from reject / empty-to deletion);
M-% / C-M-% bindings.
- Per-match prompt via core.status → v15 StatusFacts.message band.
Tests: 7 core unit + 11 dispatch_key acceptance (replace/skip/!/./quit,
nothing-matched restore, empty-to deletion, a->aa non-loop, regex incl
invalid, after-edit fires, dispatch_idle gate, explicit M-% AND C-M-%
binding tests) + 5 search unit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three PR #96 review findings (documentation accuracy):
- Q#P7 coordinates section claimed panels inherit a byte==UTF-16 wire
assumption with position-encoding hardening deferred. False as-built:
the transport layer negotiates general.positionEncoding and converts
every Position at the request/response boundary (PositionEncoding +
char_to_byte/byte_to_char, src/lsp.rs), so location rows reach Lua as
byte offsets. Reworded to record what landed; the true residual is
the codepoint-vs-byte cursor walk in move_active_cursor_to (shared
with go_to_definition, not introduced by panels).
- Intro described pre-arc behavior in present tense (references throw
rows away, code actions apply acts[1] blind, ...). Marked as the
pre-arc baseline with a status banner + inline as-built pointers.
- Drifted hard-coded line refs (editor_core.rs:2052-2071,
lsp.lua:658-662, lsp.lua:1187-1213) replaced with symbol names.
Also fixed the move_active_cursor_to comment in lsp.lua itself — it
was the same 'v0.2 hardening' false trail the doc's stale ref pointed
at, now naming the real residual (codepoint-walk, not wire encoding).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes the too-many-lines clippy deny the previous commit shipped with
(masked locally by a swallowed exit code in the gate chain); the
shared open_against_fake helper also de-duplicates the two new tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR #95 review P3: the new panel paths had no direct coverage. Two
end-to-end tests against the fake server's canned responses:
- outline_panel_opens_visits_and_restores: depth-indented rows with
kind tags, n + RET visits inner's selectionRange (3,7) in the
source buffer, M-, returns to the outline row, q restores.
- hover_doc_panel_shows_full_contents_via_binding: driven through the
REAL C-c H chord (Char('H') + SHIFT through the dispatcher) --
doubling as the shifted-letter binding's parse check, which passes
-- multi-line contents render, q restores.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pure Lua on the phase-1 substrate (framing Q#P5).
Outline: lsp.document-symbols (C-c o) opens *outline* -- the store's
FLAT symbol rows indent by their depth field with an LSP SymbolKind
tag; RET pushes the jump ring, restores the source buffer, and moves
to the symbol (M-, returns to the outline row, the references-panel
semantics).
Code actions: lsp.code-actions (C-c a) applies a single action
directly (previous behavior, now correct instead of lucky) and opens
the minibuffer dropdown when several are available -- 'N: title'
candidates; a bare typed index also accepts. The apply branch is
extracted as apply_code_action, shared by both paths. The m4_14/m4_15
acceptance tests (written against blind-first-apply; the fake LSP
returns two actions) now drive the picker: pump until the prompt is
live, type '1', RET -- same command-only action as before.
Hover doc: new lsp.hover-doc (C-c H) renders the full multi-line
hover contents into a non-visitable *lsp-help* panel; lsp.hover
(C-c h) keeps its one-line echo-area summary.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-2 regression (TUI could not attach): the follow path fired for
every crdt_replica session, and its first-tick send delivered a
GUARANTEED duplicate BufferSnapshot right after the attach sweep. The
grid TUI's BufferMirror is init-once -- the duplicate errored ('buffer
already has a CRDT snapshot applied') on every attach, and every TUI
buffer switch would have produced another. Display-follows-snapshot is
a grid-less-frontend concept: the GPU rebuilds its replica wholesale
on every snapshot and is the only consumer that needs the follow. The
send is now gated on negotiated_capabilities.semantic_render -- TUI
sessions get zero follow-sends, byte-identical wire behavior to main.
Verified: PTY-doubled real-binary attach tests pass with the gate;
the one failing PTY test (m10_11_doubled_pty_fixture_propagates_
keystrokes_from_both_sides, 'AXYB' interleaving) fails identically on
main 3/3 -- pre-existing, tracked separately, not this branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two PR #94 validation findings.
1. (High, blocking) GPU stuck after leaving a panel: the GPU only
swaps its displayed buffer on BufferSnapshot, and the daemon only
sent one on the first CRDT upgrade (F29's ensure returns None for
an already-backed buffer). A panel's q / RET switched the daemon's
active buffer back to the already-known source and sent nothing --
the GPU kept rendering the panel while input targeted the source: a
typing-into-a-buffer-you-can't-see hazard. Fix: the per-tick loop
now FOLLOWS each replica frontend's own active buffer -- when it
differs from the last snapshot sent to that frontend, ship that
buffer's snapshot to that frontend only (the F29 broadcast records
itself so the upgrade tick doesn't double-send). First-tick send
also repairs the attach-time last-snapshot-wins ambiguity. Snapshot
export extracted and shared with the F29 broadcast; per-fid state
cleaned on both detach paths.
2. (High, wider than reported) 'LSP doesn't activate on navigate':
switch_active_buffer clears the window's overlays, and the runtime
dedup tables (highlighted_buffers, styled_buffers,
diag_viewed_buffers) blocked re-attachment -- so EVERY buffer
switch (plain C-x b included, long-latent) permanently stripped
syntax color, LSP semantic style, and diagnostic underlines;
verified: overlay kinds [syntax-highlight, lsp-style, diagnostic]
-> [] after one away-and-back. Fix: a new additive
buffer.after-switch hook, fired by the window.switch_buffer binding
and find_or_open's existing-buffer branch; syntax.lua and lsp.lua
subscribe and re-push their views (the just-cleared window makes
that exactly-once per switch; fresh loads keep firing after-load).
Regression: tests/overlay_reattach_acceptance.rs (double round-trip
counts exactly one highlight overlay; panel q restores styling).
The daemon follow path is validated live (daemon + GPU) -- its unit
seam is the shared export helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Arc 1b phase 1 (framing: docs/lsp-panels-framing.md).
Q#P6 (the one Rust change): EditorCore.round_trip_buffers +
pmacs.buffer.set_round_trip_input(buf, on); dispatch_idle() reports
false while a marked buffer is active, so semantic frontends'
optimistic-apply stays off -- RET reaches a panel's buffer-local visit
binding instead of locally inserting a newline, and typing dispatches
into the edit path where the read-only intercept rejects it (a CRDT
import would bypass the intercept chain entirely). Pruned on kill.
Q#P1/P2/P3: builtin/runtime/listview.lua generalizes the *buffer-list*
idiom -- pmacs.listview.open{name, header, rows, on_visit, on_refresh}
owns ensure-buffer (recreates if user-killed), wholesale render with
bypass_intercept, line->item map, buffer-local RET/SPC/n/p/g/q keymap,
previous-buffer capture + q restore (never another panel; scratch
fallback), cursor re-seat after render, the read-only intercept, and
the Q#P6 mark. Panels are buffers: both frontends render them with
zero protocol change.
Q#P4: lsp.find-references (M-?) opens *references* -- one row per
location, paths shortened against the project root, RET visits via the
shared SP-4 template (jump ring, find_or_open, cursor walk; extracted
as visit_location for the phase-2 outline to reuse).
Acceptance: tests/listview_acceptance.rs -- open/seat/visit, header
non-visitable, q restore, read-only rejection, dispatch_idle gate,
refresh re-render + re-seat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Q#P1-P7: panels are buffers (a shared listview runtime module
generalizing the *buffer-list* idiom — zero protocol change, both
frontends render them for free); switch-in-place presentation with q
restore (the GPU cannot show splits); read-only via intercept with its
limits recorded; the Q#P6 round-trip buffer seam so semantic frontends
never optimistic-apply into panels (RET visits instead of inserting a
newline); references list, outline, minibuffer code-action picker,
hover-doc panel; byte==UTF-16 caveat inherited, not multiplied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two pre-merge review findings on PR #93:
1. (High) The BufferSnapshot arm switched current_buffer_id and
dropped every other buffer-local mirror but left self.completion
intact -- and the producer's first-sight-closed silence means no
close message ever arrives for a viewport that no longer exists,
so a stale popup rendered against the new buffer's rope and kept
hijacking Esc/RET/TAB. Fixed three-deep: the snapshot arm clears
the mirror; CompletionLocal now carries its buffer_id; and the
shared completion_open_for_current_buffer() predicate gates both
the key routing and the anchor mapping, so a foreign-buffer popup
can neither paint nor steal keys even if a stale mirror survives
by some other path. Regression: completion_popup_is_scoped_to_its_buffer.
2. (Medium) Optimistic typing (the CrdtOp path, the bulk of GPU
keystrokes) never cleared core.status, so once v15 shipped the
transient message over StatusFacts, '12 references' stayed wedged
in the GPU band through ordinary typing -- only a round-tripped
key's dispatch_key entry clear released it. handle_remote_crdt_op
now clears the status when an edit applies, mirroring dispatch_key.
Regression: handle_remote_crdt_op_clears_the_transient_status.
Also checked: the a_closed_outbox_shuts_the_socket_down... hang seen
once during review did not reproduce in 10 isolated runs -- a
pre-existing timing flake in the F-008 shutdown test, untouched here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Validation finding: LSP command summaries ('12 references', hover
first-lines, error reports -- everything pmacs.editor.set_status
writes) showed in the TUI's bottom bar but never in the GPU band,
regardless of which frontend initiated. The attached TUI gets the
message for free through the rendered cell grid's bottom row; a
semantic frontend only sees the wire, and StatusFacts never carried
the message.
Fix inside the still-unreleased v15: StatusFacts gains
message: Option<String> (encoding change to that variant; its daemon
gate moves 8 -> 15, the v10 SearchPrompt / v14 LineNumbers shape --
an old peer's band goes dark rather than mis-decoding). Producer reads
core.status into the cached-compare facts; the GPU band shows the
message echo-area style (under the minibuffer and search prompts,
over the buffer name), returning to the name when the daemon's next
keypress clears it. Producer + postcard round-trip tests added.
The finer-grained results UI (references list, panels, error surfaces)
is Arc 1b on the roadmap; this closes the parity gap until then.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CompletionLocal mirrors the v15 wire (anchor byte, windowed rows,
selection); a close always applies even for a switched-away buffer
(dropping it would wedge a stale popup), while opens follow the CrdtOp
current-buffer rule. Rendering is a dedicated dropdown layer (fourth
TextRenderer + quad batch, the mb_dropdown_* shape): the anchor byte
maps to its glyph rect via the caret walk, rows draw below the anchor
line growing toward the band (flipping above when nothing fits), width
clamps to the window with the left edge shifted back from the right
margin, and the visible slice windows around the selection (F-007
discipline). Kind glyphs replicate the TUI popup's mapping.
Key routing (Q#C6) turned out narrower than framed: C-n/C-p/C-g
already round-trip as command chords and Up/Down as forwarded motion
keys, so only two defaults are wrong under a popup and get gated on
completion_open -- Esc (dismisses via round-trip instead of the local
quit) and RET/TAB (skip the optimistic insert so they accept via
dispatch_completion_key instead of typing a newline/tab). Typing stays
fully optimistic; the daemon's after-edit refresh re-ships the popup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>