`docs/active-work.md` was the only conflicting file. #196 added the dired
Stage 2a lane at the position this branch had used to relabel the #188
framing lane header; the resolution keeps both, changing neither side's
wording.
`src/editor_core.rs` auto-merged. Both lanes touch it, so a clean
textual merge is not evidence of a clean semantic one — the gate suite
is re-run in full on the merged tree rather than inherited from the
pre-merge head.
Resolution verified for line loss in both directions: the resolved file
differs from `main` only by this branch's own authored edits, and
differs from this branch only by additions taken from `main`.
All four findings were the same shape: a failure that left state wrong
and told nobody.
**Delete refusals reach the user.** `reconcile_delete_and_fire` returned
`kept_modified` and `refused` and both production callers discarded
them, so a last-buffer refusal or the asynchronous modified-buffer race
left the file gone and the buffer still bound to it — and the next
`C-x C-s` recreates the deleted file. Reporting now happens inside the
shared seam, not at its call sites, for the same reason the
reconciliation does: a caller that has to remember to report is a caller
that will forget. The message names the buffers (capped, with a count
for the rest) and states the consequence, and it is written to
`EditorCore::status`, not `pmacs.error` — that channel is defined only
by a test stub, so a report there would be the same silence.
`reconcile_delete` now prefixes `kill_buffer`'s reason with the buffer
name, because "cannot kill the last remaining buffer" does not say which
buffer is now bound to a deleted path.
**The LSP subscribers stop swallowing their own failures.** Ignored
`pcall`s around `did_close`, `forget_uri`, `did_open` and overlay
re-rooting made the callback return successfully, so the
`all-must-succeed` logger had nothing to log — concretely, a stale server
made `forget_uri` raise while the callback carried on with the old
stores, routes and `documents` entry all live. A shared failure sink
attributes each step, reports on both channels, and raises **after** the
loop, so one unreachable server cannot leave every other attachment
unreconciled.
**`forget_uri` abandons requests through the established path.** It
purged `pending_routes` and `pending_external` but not the same ids
`send_request` put in `LspClient.pending`, and recorded nothing in
`cancelled_rids`. The per-rid work is extracted from
`drain_cancelled_externals` as `abandon_request` and reused, rather than
a second incomplete copy: route, client pending, cancelled record and
`$/cancelRequest` now happen together.
**Acceptance 35 is pinned.** With a plain delete the forbidden fallback
was unobservable — `find_or_open` raises out of `load_file` and the
`pcall` swallows it — so both assertions passed with the fallback
present. The plan now deletes the origin's file and recreates it, which
gives the fallback something to open and makes "restores nothing"
falsifiable. The corrected G1 explanation also reaches the production
comments, which still repeated the false `resolve_target_buffer::NotFound`
story.
New pins: acceptance 53 and 53b assert the status channel; a stale-server
row asserts attribution on both channels *and* that the healthy
attachment still reconciles; an `lsp.rs` unit test asserts the client-side
abandonment with an unrelated request as its control.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Running the bites found three that did not falsify anything.
Item 28's rename row cannot pin the walk's containment rule:
`reconcile_rename` calls `Path::strip_prefix` to rebuild a descendant's
tail, and that is component-aware too, so a string-prefix walk is
silently corrected a second time. Deletion has no such second guard —
the walk's verdict IS the kill list — so the row moves there, and a
string prefix now provably destroys a buffer on `foobar.txt` when
`foo/` is deleted.
Item 30's composition-order assertion was a tautology: the LSP attach
leaves `diagnostic` LAST in the stack, and moving the last element to
the end is a no-op, so a remove-and-re-push was indistinguishable from
an in-place mutation. The row now pushes one more overlay after it and
asserts that precondition explicitly.
Item 34 needed both a restructure and a correction. §5's G1 says a
stale captured path "materializes a phantom" via
`resolve_target_buffer`'s `NotFound` arm.
It does not: `pmacs.buffer.find_or_open` calls `file_io::load_file`
directly and maps the error, so a missing path RAISES, and the
`NotFound` arm belongs to `resolve_target_buffer`, which serves
`pmacs.window.display_file` and the startup target rather than this
binding. The real defect is smaller and still real — the `pcall`
swallows the raise and the user is stranded wherever the last applied
op left them — so the plan now edits another file first, which is what
makes the restore observable at all. The correction is recorded at the
test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
One shared walk query (`buffers_bound_under`), lifted out of #190's
`delete_verdict` so the guard and both reconciliation seams cannot
disagree about which buffers an operation touches: every buffer, both
sides normalized, component-aware containment.
`EditorCore::reconcile_rename` moves the stored path and — only for a
`PathDerived` name — the buffer name. `EditorCore::reconcile_delete`
composes the same two removal phases `pmacs.buffer.kill` composes,
preflighting `editing_in_progress` because a `ConcurrentEdit` refusal
arrives after `kill_buffer` has already moved windows. Phase 2 stays
with the caller; `EditorCore` gains no Lua handle.
`AsyncRuntime::tick` now returns a `TickOutcome` carrying the settled
ids plus the successful resource mutations, in bus-arrival order, which
is documented as not being execution order. `PendingJob.resource`
retains the paths the dispatchers move into the worker closure.
`LspManager::forget_uri` purges the routes carrying a URI, drains the
awaiters joined to them on the rid, and clears all fourteen stores plus
`documents`. A generation-scoped exact-pair tombstone gates the two
uncorrelated writers that can otherwise resurrect what it cleared:
`publishDiagnostics` and `mark_document_stale`, which now takes a
server id. `ResponseRoute::scoped_uri` is the one variant list, with
`uri()` delegating to it.
New Lua surface: `pmacs.buffer.set_name`, `pmacs.lsp.forget_uri`,
`pmacs.diag._rename_resource`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
`BufferNameOrigin` records where a buffer's name came from instead of
inferring it from the string: a path-backed buffer's name is the path
*as given*, so a relative open is named `foo.rs` while its stored path
is absolute, and a user may legitimately choose a name that normalizes
to its own file's path. Rename reconciliation asks the bit.
Every path-backed creation site is audited onto the new
`set_path_derived_name` door: `EditorCore::get_or_load_buffer`, the
`NotFound` arm of `resolve_target_buffer`, `pmacs.buffer.from_file`,
and `pmacs.buffer.find_or_open`. Ordinary `Buffer::set_name` records
`Explicit`.
`View::rename_resource` is the seam that re-roots a URI-keyed overlay
in place, so it keeps its position in the window's composition order;
`DiagnosticView` overrides it, whose `uri` is private and set once at
construction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Adopt Q#GB6's clamp-or-clear rule in both window-coordinate
normalization paths. Preserve shortened selections, clear only those
collapsed by a moved endpoint, and pin both outcomes through the real
generated-write and view-rebuild callers.
Make listview refresh rely on the generated-write notification before
reseating, so Stage 1 criterion 7's fan-out mutation bites both
adopters. Align criteria 5, 11, and 12 with framing revision 7.
Review finding 1 on PR #191. `notify_buffer_edit` clamped `cursor` and
`view_top` but not `win.selection.anchor`, and `rebuild_views_for` had
the same gap. Clamping the cursor is not enough to make the region safe:
`Window::region` orders `(anchor, cursor)`, so a stale anchor above a
clamped cursor is still the region's high end and `region_bytes` slices
the rope with it. Reproduced before the fix as
`assertion failed: end <= self.len()` at `src/rope.rs:145`, reached from
`EditorCore::clipboard_copy` after a generated rewrite.
The anchor is DROPPED, not clamped. A window must always have a cursor,
so clamping one is the only available answer; a window need not have a
selection, and a clamped anchor asserts a region boundary the user never
placed --- after a wholesale rewrite the surviving offsets address
unrelated bytes. This is not a new rule: `window.quit`'s restore already
answers the same question the same way with
`selection.filter(|sel| sel.anchor <= len)` (`src/editor_core.rs:3259`).
One rule, now three call sites.
Both exits are pinned separately, because fixing one and trusting the
other is how the gap arose: `acc16h` drives `notify_buffer_edit` through
a generated write, `acc16i` drives `rebuild_views_for` through
`pmacs.help.show_command`, which is the `*help*` renderer's real path.
Deleting either call site fails only its own test. The pin also
discriminates DROP from CLAMP, because that is the decision a revised
Q#GB6 could overturn.
The wording is marked PROVISIONAL in both the implementation and the
pins. The rule belongs to Q#GB6, and PR #188's approved revision 5 does
not mention the anchor; a revision request carrying this defect is with
that lane. If the landed revision says clamp or translate, this changes
to match rather than standing as a third description.
Also in this commit, review findings 2 and 3 --- the tree asserting what
the record does not support:
- Criterion 5's restatement is withdrawn in BOTH suites. The tests now
quote the approved criterion, are renamed `*_provisional_*`, and say
they do not satisfy it; the evidence (`ensure_writable` precedes the
intercept chain, with the measured `ReadOnly` message) is recorded as
what was sent to #188, not as a replacement contract. The framing's
own bite is unchanged and still fails them.
- Criterion 7's "for each adopter" is restored: the listview half now
exists as its own test. Its inability to carry the framing's mutation
bite --- `window.switch_buffer` rebuilds the `TextView`, verified by
applying the mutation and watching this half stay green while the
dired half fails --- is recorded in the test and filed with #188,
not resolved here.
- Criteria 11 and 12 are relabelled from `main` bites to mutation bites.
Both fail on `main` only at their disambiguation premise and never
reach the assertions they exist for, so a revert is not evidence for
what they assert.
How a restated contract passed the previous gate run, since the next
lane can use this: nothing in the gate suite reads a framing document,
so a test that quietly narrows its criterion is indistinguishable from
one that satisfies it --- both are green, and `scripts/bite` only proves
an assertion bites some pre-image, never that the assertion is the one
that was approved. The gate can catch a test that does not bite; it
cannot catch a test that bites the wrong contract, so that check has to
happen where the criterion is read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Stage 1 of generated-buffer immutability
(docs/generated-buffer-immutability-framing.md, revision 5). Closes the
two families the bug is reachable on WITHOUT `M-x`: `compile.lua` and the
search panel rebind all seven undo chords to a no-op, but `dired.lua` and
`listview.lua` rebind nothing, so a bare `C-/` emptied a listing and a
panel. The cheap half is also the exposed half.
An intercept is not read-only. `Buffer::undo` reaches the rope through
`ensure_writable` and never consults the intercept chain, so the
erroring-intercept-plus-`bypass_intercept`-over-a-writable-rope idiom
guarded the edit path and left the history path open. Rebinding chords
does not close it: `M-x buffer.undo` is dispatchable on every buffer in
the tree.
- `dired.lua`'s `paint` and `listview.lua`'s `render` write through
`pmacs.buffer.set_generated_contents` — lift the lock, whole-buffer
replace skipping intercepts, discard history, re-assert the lock, fan
the `Edit` out. Zero `bypass_intercept` writes remain in either file.
- Both keep their named erroring intercept and `set_round_trip_input`.
The layering at `terminal.lua:351-366` is unchanged: the rope lock
protects the daemon copy, round-trip input protects a semantic
frontend's own mirror, and neither substitutes for the other.
- Q#GB13 — `listview.ensure_panel` stops adopting a same-named foreign
buffer. Ownership is the `panels` table; a collision disambiguates
`<2>`..`<99>` and raises at the limit, matching `dired.lua:476-504`.
This is a prerequisite of the lock, not a follow-up: the arc removes
the `M-x buffer.undo` that was the only recovery from a clobber.
- Q#GB18 — `panels` becomes a compacting list keyed by identity. It was
written under the requested name and read back under the actual name,
which a disambiguated panel breaks: `RET`, `g` and `q` fail closed and
silently, and `listview.open`'s capture guard fails OPEN, capturing a
panel as its own `q` target — the chained-panel loop its comment says
it prevents. Ships in the same commit as the disambiguation by the
framing's ordering constraint.
- Q#GB6 — `EditorCore::notify_buffer_edit` clamps each window coordinate
against its own post-edit bound, unconditionally. `cursor` is a byte
position bounded by `Buffer::len`; `view_top` is a line index bounded
by `TextView::line_count`, and a replace can grow in bytes while
collapsing lines, so "the buffer shrank" is not a usable trigger. This
fixes a shipped defect that reaches terminal copy mode.
- Q#GB16(a) — locking these families disables fold CREATION on them,
because `document_bytes` is spelled `is_read_only()`. Accepted and
stated rather than shipped silently; the status string now names the
read-only lock instead of claiming "not a document buffer".
Acceptance: 10 new criteria in `listview_acceptance` (16 total), 6 in
`dired_acceptance` (31 total), 2 in `terminal_copy_mode_acceptance`.
Every criterion's falsifying mutation was run: 5 bite by revert against
`githubsucks/main`, 9 by a named one-line mutation.
Two framing corrections, both recorded in the tests rather than worked
around silently:
- Stage 1 criterion 5 is unreachable as written. `Buffer::apply_edit`
(`src/buffer.rs:773`) and `begin_edit` (`:725`) call `ensure_writable`
as their FIRST statement while the intercept chain runs later inside
`apply_edit_inner` (`:1072`), so once this arc's lock is installed an
ordinary edit can never reach the intercept. Restated at the one point
where the two are distinguishable — the lock lifted — which is the
state the intercept genuinely still covers.
- Criterion 7 cannot bite at the listview adopter. `listview.refresh`
and `listview.open` both follow `render` with `window.switch_buffer`,
which rebuilds the `TextView` from scratch and masks a dropped
fan-out. `dired.revert` does not, so the dired half carries the bite;
it fails under the mutation with the reported
`assertion failed: end <= self.len()`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
All five review findings reproduced with a failing test before any fix,
and every fix falsified by reverting it.
R1-1 — the wire-area clamp lived only in `panel_grid_size`, so the daemon
shipped an authoritative `Absent` while `panel_hidden` stayed false:
keys kept reaching the invisible window and a panel terminal kept its
controller. Q#BP2b calls hiding a DURABLE state transition and the
exhaustion arm had made it a per-frame effect. Fixed structurally rather
than pointwise: `presentable_panel_grid` is now the one derivation behind
both the renderer and `reconcile_panel_layout_core`, so the two cannot
drift apart again.
R1-2 — closing and reopening the same PERSISTENT buffer inside one
dispatcher burst left the shipped declaration intact while the window it
described was already dead, and same-buffer/same-size made the successor
indistinguishable by every other field. A presentation epoch only
identifies a presentation if something checks that the presentation it
names is still on screen, so `panel_declaration_matches` now takes the
live side window and buffer.
R1-3 — the semantic terminal-layout twin consulted only the full-document
declaration, which a panel terminal deliberately lacks, so the child kept
its opening geometry through the drain. `sync_semantic_panel_terminal_
layout` is the missing case; it resolves through `side_window_for` while
its sibling resolves through `primary_document_window`, so the two are
disjoint by construction and nothing is resized twice per tick.
R2-4 — `NoMessage` means publish nothing, not publish empty. Treating it
like `Invalidated` removed the band's provider text on a transient
buffer-follow mismatch. The band repaints its whole mode line every
frame, so "publish nothing" has to be a retained baseline; it is keyed by
window id so a replaced panel inherits nothing.
R2-5 — non-`Move` activation is Q#BP16's TERMINAL clause, because the
shared adapter claims the controller for wheel steps too. A document
panel keeps scroll-without-focus, matching `dispatch_mouse`.
The sweep for R1-1's and R1-3's shape found one more, and it is the same
bug as R1-1: a panel wider than the terminal subsystem's per-axis cap is
legal on the wire (Bet B5') but its content rect was refused by
`snapshot_for_view`, collapsing the projection to `None` — a per-frame
`Absent` with the durable state still saying visible, reachable with one
`FrontendCellGeometry` declaration. The band is legitimately that wide,
so the child is clamped to the columns a PTY can have and the remainder
paints as band background, exactly as a narrower snapshot already does.
One knowingly per-frame `Absent` remains and is recorded in the code
rather than fixed: presentation-epoch exhaustion, which takes 2^64
shipped presentation changes in one session and cannot be reached by any
frontend.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Bottom-panel Stage 2B-2, second half: the producer, the presentation
epoch, and the three inbound event gates.
The producer lives beside the terminal pass in `semantic_render.rs` and
follows its shape: compare the complete payload first, validate only a
payload that differs, and store only what was actually shipped. The
presentation epoch is allocated from the side window and its buffer, so
a new side window, a replaced buffer, and every `Absent` -> `Present`
transition each take a fresh identity; `Absent` clears the identity,
which is what makes close/hide/reopen of the SAME persistent buffer
unaddressable by a stale `PanelPointer`. Allocation is checked and
exhaustion fails closed to `Absent` rather than wrapping into a live
identity. The `Absent` baseline is seeded rather than left empty: a
fresh session has no band, so the opening state is a fact the peer
already holds.
The band rides both render paths and does not wait for a declared byte
viewport: it is a separate surface, and gating it on the document
declaration would leave the first panel unpaintable. Its mode line takes
the side window's segments from the SAME provider invocation that serves
the document's wire segments.
Inbound, `peer_may_send_panel_events` checks four facts together — an
installed semantic projection, the negotiated version, the daemon's own
capability bit, and (via the transport source) that the payload's
claimed id is never consulted. `panel_event_epochs_are_current` then
runs Q#BP16 steps 2-4 as one predicate so no caller can check the
geometry epoch and forget the presentation epoch.
Two daemon gates moved from `panel_capable` to `!semantic_render`. Stage
1 could conflate them because panel capability implied grid; now that a
semantic view can be panel-capable, a capability-keyed gate would feed it
the permanent 24x80 attach placeholder that Q#BP15a forbids, and parent
acceptance 40 would fail through the attach line rather than through the
projection. Not a live defect — no production semantic session is
panel-capable yet — but it is the landmine Stage 2B-3 would have stepped
on.
`panel_capable` is unchanged for production negotiation and the
unsolicited `Hello` still advertises v20. Nothing here is reachable by a
user.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Bottom-panel Stage 2B-2, first half: the daemon-side primitives the
panel producer needs.
`GeometryUpdate` is three-valued rather than a boolean because the
caller must act differently on each arm. `declare_frame_geometry` stays
the grid/LOCAL allocator, keeps value dedup, and moves from
`saturating_add` to checked allocation with a fail-closed exhaustion
arm: it clears the declaration back to unknown, which is already
non-presentable, so reconciliation hides the panel rather than painting
one sized to a frame that no longer exists.
`accept_frame_geometry` is the separate semantic path. No value dedup —
a font or scale change can invalidate a panel frame while `CellSize` is
identical, which is exactly what daemon-side dedup cannot see (Q#BP2S1)
— and a lower epoch is rejected even when it carries identical data.
`panel_grid_size` derives Q#BP15a's third geometry: full declared
width, `fixed_rows` clamped by the recursive document minimum and then
by the shared wire area budget, with the stored request left alone.
`prepare_panel_projection` paints the side window through the Stage 2A
extracted painter, gating folds on the OWNING frontend rather than
`fold_map_for_window`'s active-frontend gate (Q#BP17), and takes the
side window's statusline segments as a parameter so one provider
invocation serves both surfaces. `window_cursor_cell` is `paint_frame`'s
caret derivation lifted out so the band does not become a second,
drifting copy of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
Journey Stage 1a's core: `pmacs .` opens the directory instead of
exiting 1, and local startup stops being a second implementation of
path resolution.
`EditorState::open` now calls `EditorCore::resolve_target_buffer` --
the primitive whose own doc comment says it exists "so two
path-normalization, dedup, and hook transactions cannot drift apart",
and which local startup had never been a caller of.
`resolve_target_buffer` returns a typed `ResolvedTarget` rather than
`(BufferId, HookKind)`, with a `Directory` arm checked ahead of the
load. Without it the load runs and fails: `File::open` succeeds on a
directory and `read_to_end` returns EISDIR, which is not `NotFound`, so
the `[new file]` arm never fired.
A directory creates no buffer. It dispatches a resolver chain: the
short-circuit `path.open-directory` hook, which no builtin subscribes
to, and then `pmacs.path.directory_handler`, which dired defaults. The
split is forced rather than chosen -- hook callbacks only append and
builtins load before init.lua, so a subscribing builtin would always
claim before any user listener could run. A raising listener stops the
chain and suppresses the fallback.
The listing is async and the daemon bootstrap is not, so the whole
post-await commit runs inside a new `pmacs.window.commit_to`: it
validates the destination -- frontend live, window live, buffer
unchanged, window replaceable -- BEFORE invoking its callback, then
scopes the acting frontend for its extent. Validating at display time
would be four dired mutations too late.
That scope is deliberately not `InteractiveCommandOrigin`, which does
not reach the core-ambient APIs and is authenticated user-command
authority a background continuation must not acquire.
The dedication rule is extracted into one `window_accepts_buffer`
shared by exact display, the display probe, and the new preflight, with
`incoming: Option<BufferId>` -- `None` means "the replacement does not
exist yet" and refuses a dedicated window.
`display_file` keeps its directory-is-an-error contract and does not
enter the chain; find-file's accept arm depends on it.
Framing: docs/journey-stage1a-framing.md rev 5 (Q#JR1-JR15).
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 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>
Integrate folding Stage 2 and its landed-state documentation with the
protocol-v20 GPU initial-target branch. Preserve per-session fold projection
selection in the target bootstrap transaction and retain v19 compatibility
coverage after the later protocol bump.
Keep foreign BufferSnapshot publications out of existing semantic GPU
sessions while retaining grid-replica coherence. Treat dead peer writes as
peer-local failures, restore active-frontend cleanup, deterministic probe
readiness, GPU logging, shared tilde expansion, and accurate docs.
Add focused publication and cleanup coverage and record the two-window
Wayland/Vulkan smoke plus the complete post-review gate results.
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
Add protocol-v20 semantic bootstrap and readiness result framing so
`pmacs --gpu FILE` opens the requested path before the GPU window becomes
ready. Keep target identity scoped to the authenticated frontend, preserve
legacy/no-target attach behavior, and publish fresh buffers coherently to
existing replicas.
Carry Unix path bytes and launcher cwd through the root broker, resolve paths
lexically in the daemon, reuse or create buffers without ambient-view state,
and preserve the managed daemon lifecycle from #141. Add focused parser,
wire, lifecycle, hook, isolation, and real-connector acceptance coverage.
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
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>
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 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 1 (medium): the typed-edit record now pins the edited
buffer's revision after the completing edit; typed_edit_finish
re-reads it at dispatch end and drops the record if the command
edited again — a redefined buffer.self-insert that replaces the typed
char (cursor unmoved) no longer leaves a stale-but-clean record, so
`(`-then-replace-with-`[` yields `[`, not `[)`. Bite:
post_insert_mutation_by_the_command_kills_the_record.
Finding 2 (medium): pair-set relevance is established before the
clean/context gates, so a transformed or relocated character outside
the active set stays silent instead of drawing an auto-pair report.
Bite: transformed_non_pair_char_stays_silent.
Finding 3 (medium): split_pair parses EXACTLY two codepoints and
rejects trailing bytes — a "()x" (or "«»x") entry is skipped
entirely, never honored as `(` → `)x`; valid multibyte pairs ("«»")
pair and skip at byte-correct cursors. Bites:
malformed_pair_entries_are_skipped_not_partially_honored,
multibyte_pair_entries_pair_and_skip.
Finding 4 (low): the record-capture seam is gated behind the opt-in
pmacs.pair._capture_records test facility, off by default — no
consumed record is retained in production, restoring the Q#AP9
ephemerality the seam had defeated. Seam-reading tests opt in;
record_capture_is_off_by_default pins the default.
Finding 5 (low): the equal-revision source-context-switch twin is
covered — the fan-out is skipped by the active-buffer revision
compare, pairing fails closed silently, and no report is possible;
the framing scopes the context-change report as best-effort until the
buffer-aware edit epoch lands.
Framing synced to revision 4 (Q#AP2 entry rule, Q#AP3 relevance-first
+ best-effort report scope, Q#AP9 revision postcondition + capture
facility + the dispatch-path intercept borrow note, 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
Finding 1: the empty-anchor optimistic residual was never GPU-only
(the TUI mirror tracks no selection state; its gate checks cursor
freshness/EOL only). The fix moves daemon-side: handle_remote_crdt_op
clears a selection whose anchor equals the pre-edit cursor (= empty)
before applying the source cursor update; nonempty selections stand.
Covers both frontends. The TUI gate's missing type-over check
(nonempty selection at EOL) is a named deferral.
Finding 2: Q#AI8 invalidation is one helper
(search_invalidate_for_edit) invoked from all four edit paths --
apply_active_edit, notify_buffer_edit, and now undo/redo, which
received precise Edits but invalidated nothing. rebuild_views_for is
named as a lower-frequency bypass (deferral).
Finding 3: acceptance matrix trued up -- added active-search
fail-closed + retype recovery, delete translation on both paths,
undo/redo staleness + origin tests; modal contexts narrowed to what
this suite pins (query-replace/menu/completion ride their own
suites).
Finding 4: indent extraction is a forward-chunked scan stopping at
the first non-whitespace byte -- Enter at the end of a giant
minified line no longer materializes the line. Functional pin at
64 KiB.
Both medium fixes are bite-verified (tests fail with the fix
disabled).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATiKMwJ4864d82D39EvsU6
RET now runs edit.newline-and-indent (builtin/runtime/indent.lua):
one insert/replace of "\n" plus the current line's leading whitespace,
copied verbatim and clipped at the split point (Q#AI3). Region RET
stays a single Replace (CUA type-over, one undo step, one CRDT op);
the selection clears after every successful edit (Q#AI4). Fix-up is
snapshot-guarded against context-switching intercepts and repairs the
cursor by right-gravity translation through the effective edit
(Q#AI5). buffer.newline remains the plain-newline escape hatch.
GPU (Q#AI1/Q#AI6): plain Enter is no longer optimistic-eligible --
its classifier arm's premise (byte-identical to a self-insert) died
with the new binding. Enter round-trips like the TUI, which also
makes global and buffer-local RET rebindings (buffer-list visit)
reachable from the GPU frontend.
Substrate fixes that RET would otherwise ship on top of:
- Q#AI8 search staleness: notify_buffer_edit now marks matches stale
and right-gravity-translates the live session origin, matching
apply_active_edit; SearchStore::step and search_match_summary fail
closed while stale (a live search un-sticks on the next pattern
keystroke, since set() clears staleness).
- Q#AI9 empty selections: insert_char reports success and the
no-region arm of insert_char_over_region clears a lingering anchor
only on Ok -- ordinary typing no longer type-overs its own previous
keystroke after S-Left at BOF, and a rejected insert mutates no
state.
Acceptance: tests/auto_indent_acceptance.rs (20 dispatch-driven
cases), tests/auto_indent_crdt_acceptance.rs (pending optimistic
input then round-tripped Enter converges on the source replica),
flipped GPU classifier test, and lib tests for the store, core, and
dispatch seams.
Framing: docs/auto-indent-framing.md (five review rounds).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATiKMwJ4864d82D39EvsU6
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>
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>
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>
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>
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>
Five findings from the manual validation pass, all in-branch:
1. LSP-only words never queried the server: the auto-open path fired
request_completion only when the sync providers already produced
rows. An empty sweep now leaves a pending session and the request
always fires; isIncomplete responses re-request on further typing.
Corollary: attachment_for_request now flushes-if-attached but NEVER
attaches -- the first cut wrapped attached_for_active, which spawns
servers on demand, i.e. per-keystroke spawn attempts in unattached
buffers (wedged the parallel m4 suite; serial ran 3x slower).
Attachment stays buffer-open policy.
2. Cross-buffer LSP leak: the built-in provider's no-uri fallback was
the legacy global store drain, so scratch/unattached buffers could
show another file's cached completions. Strict now: no uri, no rows.
3. Pending prefixes own the keyboard: Action::Pending (C-x ...)
dismisses the popup and the popup shadow is guarded on an empty
dispatcher prefix, so the sequence's continuation and its C-g abort
reach the dispatcher instead of the popup.
4. Window-scoped sessions: CompletionPopupState.window_id (stamped by
completion_popup_open; Lua never sees it). Only the owning window's
overlay paints -- same-buffer splits each carry a persistent
overlay -- and a focus change invalidates the session.
5. Flaky worker test: the /proc thread-count probe and the idempotence
check both build EditorStates and could run concurrently, polluting
the baseline; merged into one test (non-Linux keeps a portable
idempotence variant).
Regression tests for 1-4; framing doc gains the as-built notes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Q#C2: CompletionPopupState (buffer + byte anchor + prefix + candidates
+ selection) behind SharedCompletionPopup on EditorCore — the
completion twin of SharedMenu. Q#C4: CompletionView reworked from the
dormant M4.7 store-keyed full-viewport painter into a self-positioning
overlay (MenuView model): windows candidates around the selection,
maps the byte anchor to a screen cell via the diag-view walk, places
below the anchor row (flips above when nothing fits), self-suppresses
when closed or on a foreign buffer. Q#C3: dispatch_key gains a PARTIAL
shadow — only TAB/RET/C-n/C-p/Up/Down/Esc/C-g intercept while the
popup is open; everything else falls through so typing keeps
self-inserting, with post-dispatch validation (active buffer, cursor
at/after anchor, word bytes between) closing broken sessions after the
after-edit hook has had its chance to refresh. Q#C7: accept
re-validates at the moment of accept and applies a single Replace
(one undo step; empty-prefix trigger sessions degrade to Insert),
firing buffer.after-edit through the existing revision check.
Framing: docs/in-buffer-completion-framing.md. Lua driver + bindings
follow in this branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`close_active` and `close_others` operated on the global `self.windows`
set, which holds *every* attached frontend's windows. With more than one
frontend attached (e.g. a headless `--daemon` plus a pmacs-gpu — two
windows total), closing from one frontend reached across into another's:
- `close_others` did `self.windows.retain(|id| *id == keep)`, deleting the
OTHER frontend's window. Its `view.active` was then dangling and the next
per-tick `active_window()` panicked ("active window present in
core.windows", editor_core.rs:324) — a daemon crash.
- `close_active`'s "only one left" guard checked the global count
(`self.windows.len() <= 1`), so it also proceeded across frontends and
could empty a frontend's layout, panicking on the successor pick.
Both now scope to the active frontend's layout: `close_active` gates on
`active_layout().iter_ids().len()`, and `close_others` prunes only the
active layout's own window ids from `self.windows`.
Regression tests: closing from a second frontend must not remove another
frontend's window, and close-active refuses a frontend's last window even
when other frontends have their own. fmt + clippy clean both flavors; 1442
lib tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
The core-side machinery the menu and clipboard ride on, plus the Lua
resolver. Still no dispatch wiring (that needs the protocol/frontend
commit), so this builds but nothing is reachable yet.
- Clipboard (Q#CM6): an in-core slot + `copy`/`cut`/`paste`/`select-all`
on `EditorCore`, plus a one-shot `pending_clipboard` the dispatcher
will drain. `region_bytes` / `word_at_cursor` (the latter feeds the
`symbol` context).
- Menu core (Q#CM1): `SharedMenu` field + `menu_open/close/step/
set_active_row/active_command/hit` + `ensure_menu_overlay`.
- `pmacs.menu` install (item/list/remove/clear/_raw) and `ed.*` bindings
(clipboard_copy/cut/paste, select_all, word_at_cursor); the `install`
signature gains the menu registry, threaded through `lua.rs`.
- `builtin/menus/default.lua`: `pmacs.menu.build` resolves visible items
(predicate or context tag), groups/sorts, and emits rows (Q#CM3). The
default items reference commands by name (resolved at invoke).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Wires regex matching into the search session and the terminal
frontend.
SearchSession gains `regex` and `invalid` flags. search_begin takes a
`regex` argument; recompute dispatches find_all_regex (regex) vs
find_all (literal), recording `invalid` when the pattern won't
compile (an invalid pattern clears the matches and shows [invalid]
rather than a stale count). search_toggle_regex flips the mode and
re-runs the current query.
Input: C-M-s / C-M-r start a regex search (search.forward-regex /
search.backward-regex commands → ed.search_start(forward, regex)).
M-r toggles literal <-> regex mid-search — a new SearchKey decoded in
dispatch_search_key, so it works the same in both frontends (the GUI
already round-trips every key while searching). The TUI prompt reads
"Regex I-search:" in regex mode and "[invalid]" when the pattern
won't compile.
Multi-line: SearchView now washes each row a match spans, mirroring
paint_local_selection's per-row clip (newline excluded so a spanning
match doesn't paint a phantom trailing cell). Single-line matches —
every literal match — touch exactly one row, unchanged. The GPU
already fans multi-line ranges per-line, so it needs no change here.
Tests: regex match / smart-case / invalid-flags-and-recovers /
toggle-reinterprets-query (core); C-M-s starts regex + M-r toggles
mid-search (dispatch); multi-line per-row wash (SearchView render).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix for "seems to only search for the first character." The TUI's
`SearchView` overlay was written (commit 2) but never attached to a
window, so the terminal frontend painted no match highlights — the
only feedback was the cursor jumping to the first match, which made
refining the query past the first character look like a no-op even
though the search was working (verified: the query accumulates
correctly through the full run-loop path).
`search_begin` now attaches a `SearchView` to the active window
(deduped by overlay kind, so repeat searches don't stack it). The
view self-suppresses when the store has no matches or is stale, so a
persistent attach is safe — it paints only while a search has live
matches and stops the moment an edit invalidates them.
`SearchView` now keys on the *rendered* buffer (`Buffer::id`) instead
of a fixed id captured at construction, so one attached instance
keeps highlighting correctly even if the window later switches
buffers (the store is per-buffer; a buffer with no entry paints
nothing).
Tests: a render-level test that paints a real frame mid-search and
asserts both the match wash (bright `Indexed(11)` on the active
match) and the full `I-search: foo` prompt land on the grid — the
coverage that was missing, which would have caught the unattached
overlay. Plus a run-loop-fidelity test (renders interleaved with
keystrokes) pinning that the query accumulates rather than sticking
at the first character.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the live-typing half of in-buffer search for the terminal
frontend, on a frontend-agnostic core so the GPU (next commit) can
share it.
EditorCore gains a `search: Option<SearchSession>` (query + origin
cursor + direction) and the `search_*` methods that drive it:
begin records the origin, input_char/backspace re-run `find_all`
against the origin buffer and refocus the match nearest the origin
(failing searches anchor the cursor back at the origin), step walks
the store's active match (wrapping, also usable post-accept), and
finish either keeps the cursor + matches (accept) or restores the
origin and clears them (cancel). The matches live in the shared
`search_store`, so the decorations producer and the TUI SearchView
light up live as you type.
Input routing is intercepted in `EditorState::dispatch_key`: while
a search runs, every key flows through `dispatch_search_key`
(SearchKey::from_chord) instead of the global keymap — printable
chars extend the query, C-s/C-r (and Down/Up) step, RET accepts,
C-g/Esc cancel, BS shortens. This is the same dispatch path the
daemon's `FrontendEvent::Key` round-trip uses, so the daemon-side
search already works; the GPU just needs to route keys + show the
prompt (commit 4). The TUI paints an `I-search: <query> (n/m)`
prompt on the bottom row while keeping the terminal cursor in the
buffer at the active match.
C-s / C-r start the search (search.forward / search.backward Lua
commands → ed.search_start). Both keys were free in the default
map (save is C-x C-s, redo is C-x r), so isearch lands without
disturbing the CUA / Emacs editing keys — no cursor.right rebind
needed (the framing doc had flagged C-f for veto; C-s is cleaner
and Emacs-faithful).
Any edit now marks the buffer's matches stale in apply_active_edit
(M11.8), closing the headline "stale-after-edit linger" bet:
accepted highlights vanish the moment the text they described
changes, rather than painting at wrong offsets.
Tests: EditorCore-level (begin/type/step/wrap/focus-from-origin/
cancel/accept/backspace/smart-case/stale-on-edit) and dispatch-
level acceptance (C-s drives the whole loop; Esc restores; query
keys never self-insert).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The search store now hangs off EditorCore (reachable by the
producer, the Lua commands, and the TUI view). The decorations
producer emits SearchMatch for every visible match and
SearchMatchActive for the active one, byte-range-direct (no
line/col conversion), viewport-clipped, and stale-skipped on the
M11.8 model. pmacs-gpu wires the two decoration_kind_to_bg_color
arms (translucent yellow / stronger amber). The TUI gets a
SearchView overlay mirroring DiagnosticView (black-on-yellow wash,
brighter for the active match), reusing diag.rs's now-pub(crate)
line/col helpers.
Nothing populates the store yet (commit 3 wires the input), so the
paths are dormant until then — verified by populating the store
directly: producer emits the right kinds + stale-suppresses, the
TUI view washes the cells.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typing over a selection composed two edits in Lua —
delete_region() + insert_char() — so it recorded two undo steps:
one undo left the half-replaced text, two restored the original.
Undo granularity is per apply_edit in both modes (v0.1 pushes one
UndoEntry per edit; CRDT commits per edit via export and groups by
commit, with record_checkpoint unused), so the fix is to make
type-over one edit.
New core EditorCore::insert_char_over_region emits a single
EditOp::Replace when a region is active (cursor past the inserted
bytes, selection cleared) and delegates to insert_char otherwise.
The three type-over commands (buffer.newline / tab / self-insert)
call it via a new Lua binding instead of the delete+insert pair.
delete_region and insert_char are unchanged for their other
callers; region-aware backspace/delete already emit one op.
Verified one undo unit in BOTH modes (dual_mode
replace_is_a_single_undo_step covers v01 + crdt — a CRDT Replace is
delete-then-insert internally but one commit) plus an end-to-end
acceptance test through the key-dispatch path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PointerKind::TripleDown — the cheap additive bump shape returns:
PROTOCOL_VERSION 7, SUPPORTED [6, 7], the new variant kept off
pre-v7 wires by a frontend send-gate that downgrades it to the
plain Down a third click produced before. The GPU's click history
deepens to a chain count (1 → Down, 2 → DoubleDown, 3 →
TripleDown, then restart). Daemon side, select_line_at_cursor
selects the line including its trailing newline, so consecutive
triple-click lines abut.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per docs/pmacs-gpu-mouse-framing.md (resolves the deferred Q#B5):
a pixel frontend cannot express the daemon's cell coordinates —
inline adornments shift visual columns invisibly to cell space and
the design contract forbids hit-test round trips — so the frontend
hit-tests locally and ships source-byte gestures.
- protocol v5: FrontendEvent::Pointer { buffer_id, byte, kind, mods }
with PointerKind { Down, Drag, Up, DoubleDown }. Double-click
detection is frontend-side (only it knows pixel proximity).
SUPPORTED_PROTOCOL_VERSIONS gains 5; the send gate runs in the
frontend (an older instance cannot decode the variant).
- daemon: dispatch_pointer replays the existing mouse gesture
semantics in byte space against the semantic session's window —
Down places + anchors, Drag grows, Up collapses an empty click,
DoubleDown selects the word. Routed by the authenticated source
(CrdtOp/Viewport trust rule); hit bytes clamp + snap to UTF-8
boundaries (a hit can race an in-flight edit).
- word_range_at fix (pre-existing CUA bug the new test surfaced):
double-clicking a word's FIRST character selected the previous
word too — backward_word from pos sees the non-word char behind
the hit and crosses over; walk from pos + ch_len instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>