pmacs/docs/auto-indent-framing.md

519 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Auto-indent on newline — framing (Arc 2, editing table stakes)
Pressing RET in pmacs inserts a bare `\n`; every indented line means
re-typing the indentation by hand. This adds the table-stakes behavior:
RET carries the current line's indentation onto the new line,
language-agnostic, as one undoable edit. Second-to-last Arc 2 item;
auto-pairing follows as its own framing + PR.
Roadmap: `docs/roadmap-2026-07.md` Arc 2 ("auto-indent on newline").
Revision 6 (PR #109 round 1): Q#AI8's invalidation is one helper
invoked from all four edit paths — dispatch, direct notification,
undo, redo — and the empty-anchor clear moved daemon-side, closing
the optimistic case for BOTH frontends (the residual was never
GPU-only: the TUI mirror tracks no selection state). The acceptance
matrix now matches what the suites actually pin. Earlier revisions:
success-gated Q#AI9, live-origin translation, M6.4 same-kind
intercept contract, modal-context corrections.
## Ground truth (as of `efa41cb`)
- **RET → `buffer.newline``ed.insert_char_over_region(10)`**
(`builtin/keymaps/default.lua:52`,
`builtin/commands/default.lua:110`): one `Replace` when a region is
active (CUA type-over, one undo step), plain insert otherwise. The
no-region path does **not** clear an empty selection, and
`Window::region()` returns `None` when anchor == cursor while the
selection object persists (`src/window.rs:244-255`). So a
zero-length selection (e.g. `S-Left` at BOF) survives the edit, and
the moment the edit advances the cursor away from the anchor the
region is **already nonempty** — the very next self-insert
type-overs the just-inserted text. The same no-region arm serves
`buffer.self-insert` and `buffer.tab`, so ordinary typing has the
identical bug today (`S-Left` at BOF, `x`, `y` → the `y` type-overs
the `x`) — it is not RET-specific. `insert_char` catches the
intercept rejection internally — status message, return `()`
(`src/editor_core.rs:1681-1692`) — so its caller cannot observe
failure; and `insert_char_over_region`'s doc comment already claims
"any selection is cleared" while only the region arm does it
(`src/editor_core.rs:1698`, `:1717`).
- **Runtime modules are individually embedded** — each
`builtin/runtime/*.lua` has an explicit `include_str!` + eval entry
in `src/editor.rs` (comment.lua's at `src/editor.rs:336`). There is
no discovery mechanism; a new module ships with a loader entry or it
never loads.
- **The two frontends' Enter paths diverge.** The TUI attach client
round-trips Enter to the daemon keymap by design —
`src/optimistic.rs:90-93` names "indentation, newline-with-indent"
as the reason, locked by `classify_enter_and_tab_are_round_trip`.
The GPU frontend treats plain Enter as optimistic-eligible
(`optimistic_insert_text`, `pmacs-gpu/src/main.rs:1520-1530`) and
ships a raw `"\n"` CRDT op that never touches the keymap — justified
in its doc comment by "`buffer.newline` reduces to plain
`insert_char(10)`", the exact premise this feature invalidates. The
GPU already round-trips Enter when a selection is active, the
completion popup is open, or the dispatcher is busy/stale. The
selection gate reads `DecorationKind::Selection` decorations
(`pmacs-gpu/src/main.rs:2054-2060`); an **empty** selection paints
no decoration, so it does not gate — GPU optimistic typing proceeds
over an empty anchor.
- **Consequence of the GPU bypass: RET rebindings are dead in the GPU
frontend.** The classic buffer list binds RET buffer-locally to
`editor.buffer-list-visit` (`builtin/commands/default.lua:407-411`)
through normal dispatch; unlike listview it is not marked for
round-trip input, so GPU RET there inserts a raw newline instead of
visiting. Any user rebind of RET is bypassed the same way.
- **Dispatch supplies an empty active-mode list** — the keymap stack
is resolved with `&[]` for modes (`src/editor.rs:710`), so
mode-scope bindings do not resolve anywhere today, on any frontend.
- **GPU test seams**: `optimistic_insert_text` is a free function
with an existing in-crate unit test
(`pmacs-gpu/src/main.rs:7058-7104`), and it is the sole gate at the
top of `optimistic_crdt_insert` (`:2077-2079`). The
eligibility/routing layer above it lives in private `App`/`State`
methods (`:2050`, `:2077`) with no constructible unit seam — the
winit handler's fall-through to `send_key` is not unit-reachable.
`pmacs-gpu` declares **no cargo features**
(`pmacs-gpu/Cargo.toml`); `crdt` is a root-package feature only.
- **Modal contexts that consume Enter before the keymap**: isearch
accept (`src/editor.rs:1831`), context-menu invoke
(`src/editor.rs:1934`), completion-popup accept
(`src/editor.rs:1984`), minibuffer accept
(`src/minibuffer.rs:482`), query-replace prompt
(`src/editor.rs:968`). These never reach the global keymap; a RET
rebind cannot touch them. Acceptance pins them anyway.
- **Search staleness is asymmetric across edit paths, and staleness
is only half-honored by consumers.** Accepted isearch matches
deliberately stay highlighted "until the next edit marks them
stale" (`src/editor_core.rs:861-865`). `apply_active_edit` — the
path today's `buffer.newline` takes — honors that via
`SearchStore::mark_stale` (`src/editor_core.rs:1184-1193`), and
highlight producers suppress stale matches. But `search_step`
navigates stored ranges without checking `is_stale`
(`src/editor_core.rs:844`), and `search_match_summary` exposes
stale counts to the n/m prompt (`src/editor_core.rs:719`).
`SearchStore::set` clears staleness (`src/search.rs:100`), so any
pattern re-run refreshes. Meanwhile direct buffer edits notify
through `notify_buffer_edit` (`src/editor_core.rs:1207`), which
refreshes views/overlays only — it never marks search state stale.
Its **three** callers: the CRDT-op apply path (`src/daemon.rs:2133`),
the general Lua mutator path (`notify_buffer_edit_to_windows`,
`src/lua_bindings/mod.rs:1390-1395`), and the LuaHost
errors-buffer append (`src/lua.rs:442`). A pre-existing
stale-highlight/stale-step bug for all three, which a
Lua-implemented RET would inherit on the most common keystroke.
- **A live search's origin is a raw byte offset.** `SearchSession`
stores `origin: (BufferId, byte)` at `search_begin`
(`src/editor_core.rs:740-753`); every recompute focuses from that
unchanged offset (`src/editor_core.rs:803-810`), and cancel
restores the cursor to it directly (`src/editor_core.rs:866`). No
edit path translates it — an insert or delete strictly before the
origin skews both the recompute focus and the cancel restore even
when the match set is fresh. (`apply_active_edit` marks stale right
there yet leaves the origin alone, so the other-frontend dispatch
path carries the same pre-existing skew.)
- **Direct buffer edits do not reconcile window state** —
`notify_buffer_edit` adjusts no cursors and no selections. An
intercept that expands a replace past the cursor can leave
`cursor > buf:len()` and a dangling selection; nothing downstream
clamps. The daemon's optimistic-CRDT arm shows the canonical
repair: right-gravity cursor translation through the effective edit
(`src/daemon.rs:2103-2130` — `pos < start` → unchanged;
`pos > pre-edit end``pos - old_len + inserted_len`; within →
`start + inserted_len`). Note the mutators' returned triple
`(start, end, inserted_len)` has `end` = the **pre-edit**
replaced-range end (`src/lua_bindings/mod.rs:1246`); the post-edit
end is `start + inserted_len`.
- **Intercepts may only move an edit, never change what it does.**
M6.4 forbids kind-changing transforms and keeps the payload bytes
immutable; same-kind position/range overrides are the entire
surface (`src/lua_bindings/mod.rs:1061-1074`, `:1653-1657`).
Consequences: an intercepted `insert` can only be **relocated**
the buffer always grows by the payload — while only an intercepted
`replace` can **shrink** the buffer, by expanding its replaced
range past the payload length.
- **Intercepts can switch buffers and windows.** Phase 2 of
`run_managed_edit` runs the intercept chain with the registry
borrow released (`src/lua_bindings/mod.rs:1287-1324`); an intercept
body may legally change the active window or buffer. Any post-edit
fix-up that blindly targets "the active window" can corrupt
unrelated state. `pmacs.window.current()` exposes the active window
id to Lua (`src/lua_bindings/mod.rs:10617-10622`).
- **No indent infrastructure exists.** No indent command; TAB inserts
a literal `\t` (`buffer.tab`); there is no tabs-vs-spaces or width
setting anywhere (config registry is a standing deferral); grammars
drive highlighting only — no `indents.scm` in the repo. Hardcoded
tab widths exist at **five** divergent sites: `TAB_WIDTH = 8` in
four core renderers (`src/text_view.rs:35`, `src/highlight.rs:228`,
`src/diag.rs:364`, `src/completion.rs:594`) and the GPU minimap's
own leading-whitespace scan at width **4**
(`pmacs-gpu/src/main.rs:5510`, `:5529-5531`; frontend-local, not
reusable for editing).
- **The comment.lua pattern covers the Lua mechanics**: line scans
over `buf:slice` (`line_start_before`,
`builtin/runtime/comment.lua:47-57` — there is no `buf:line()` API;
the scan is the idiom), `ed.cursor()/region()/goto_byte()/
clear_selection()`, pcall'd mutators with the exact effective-edit
triple check. `ed.goto_byte` clamps to buffer length.
- `C-j` is bound nowhere in `builtin/`.
## Decisions
### Q#AI1 — Lua feature + four named Rust touches
`builtin/runtime/indent.lua` (new), following the comment.lua module
shape. Rust changes, each deliberate and small:
1. **Loader entry** in `src/editor.rs``include_str!` + eval after
the comment.lua entry (`:336`). Without it the module never loads
and RET would resolve to an undefined command. (Defining the
command in `builtin/commands/default.lua` was the alternative; a
feature with helpers gets its own module per the comment.lua
precedent.)
2. **Search-stale substrate fix** (Q#AI8): mark stale in the shared
notify path, make the two stale-blind consumers fail closed, and
translate the live origin.
3. **GPU eligibility fix**: remove the `ProtocolKey::Enter` arm from
`optimistic_insert_text` so plain Enter round-trips through the
daemon keymap, as the TUI already does. The arm's own documented
justification (byte-identical to what the daemon would do) stops
being true the moment RET means newline-and-indent. Tab keeps its
arm: `buffer.tab` is still a plain `insert_char(9)`; the doc
comment is rewritten Tab-only.
4. **Empty-selection core fix** (Q#AI9): the no-region arm of
`insert_char_over_region` clears a lingering selection.
### Q#AI2 — Command + binding
One new command, `edit.newline-and-indent` (the Emacs name), and the
default keymap's RET line changes to it. `buffer.newline` keeps its
command definition and plain-newline role — the escape hatch via
`M-x` or a rebind — unchanged, with one deliberate exception: its
empty-selection behavior improves through the shared primitive
(the Q#AI9 fix).
No new default chord for plain newline in v1: the natural candidate
`C-j` is LF itself — the same terminal-ambiguity class as the C-/
undo note in the comment-toggle framing — so it's a named deferral,
not a casual bind.
### Q#AI3 — Indent = verbatim copy, clipped at the split point
On the line containing the split point:
indent = bytes[line_start .. min(first_non_ws, split_point)]
where whitespace is space/tab only. Insert `"\n" .. indent` as ONE
edit; cursor lands after the indent.
- **Verbatim copy** (not width math): with no tabs-vs-spaces or width
config in the editor, reproducing the existing bytes is the only
policy that cannot be wrong about the file's convention. Tabs,
spaces, and mixed runs all round-trip untouched.
- **Clip at the split point**: splitting *inside* the leading
whitespace would otherwise double-indent the carried text. Clipping
preserves the carried text's total indentation exactly —
`··|··foo``··` / `····foo` (split after 2 of 4 spaces: new line
still starts at column 4).
- **No language awareness in v1**: no extra indent after `{`, no
dedent of `}`. Electric indent needs a per-language width opinion
pmacs has no surface for (deferral, below).
- Whitespace-only lines copy what's before the split point; the
abandoned line keeps its trailing whitespace (cleanup is a named
deferral, not an accident).
### Q#AI4 — Region type-over preserved; selection always cleared
Region active → one `buf:replace(start, end, "\n" .. indent)`, indent
computed on the line containing the region start (clipped at region
start); cursor after the indent. Matches `insert_char_over_region`'s
single-`Replace` / one-undo-step / one-CRDT-op contract that the
GPU's selection round-trip relies on.
**The selection is cleared after every successful edit, region or
not.** A zero-length selection (anchor == cursor) reports no region,
so the edit takes the no-region path — but the edit itself advances
the cursor off the anchor, making the region nonempty **immediately**:
the very next self-insert type-overs the newline (`S-Left` at BOF,
RET, `x` → the `x` replaces the newline). Unconditional
`clear_selection()` closes that for this command. (Q#AI9 fixes the
same lingering-anchor bug in the core command family; this command
edits via `buf:insert`/`buf:replace`, not `insert_char_over_region`,
so it must clear its own.)
### Q#AI5 — Edit discipline: comment.lua contract + window-state safety
Snapshot first, edit second, guarded fix-up third:
1. **Snapshot** `pmacs.window.current()` and the target buffer id
before the mutator runs.
2. `pcall`'d mutator; a rejecting intercept → status message, no
edit, no throw, no dangling state.
3. **Guard**: intercepts run with the registry borrow released and
may switch the active window or buffer (ground truth). If the
active window or its buffer no longer matches the snapshot, skip
all fix-up — report only; touching whatever window is now active
would corrupt unrelated state.
4. **Cursor repair, one formula for both paths**: translate the
pre-edit cursor through the *effective* edit with the established
right-gravity shape (`src/daemon.rs:2103-2130`): `pos < start`
unchanged; `pos > pre-edit end``pos - old_len + inserted_len`;
within the replaced range → `start + inserted_len`. Then
`goto_byte` (which clamps). For the clean insert-at-cursor case
this lands exactly at `start + inserted_len` — the normal
after-the-indent position — so there is no special-cased "clean"
placement to drift from the repaired one. Never jump to a fixed
edit endpoint: an intercept that relocates the edit elsewhere in
the buffer must not teleport the cursor there.
5. The effective triple is compared EXACTLY against the request; on
deviation → status *"newline-and-indent altered by buffer
intercept"*; the interceptor's **positional** result stands —
kind and payload are immutable under the M6.4 contract (ground
truth). Concretely: the plain path's `buf:insert` can only be
relocated, so the buffer always grows and the `"\n" .. indent`
payload lands wherever the intercept moved it; only the region
path's `buf:replace` can shrink the buffer, via a same-kind range
expansion. This is a named deviation from the comment.lua
precedent (which skips fix-up entirely): skipping here would
bless `cursor > buf:len()` whenever an expanded replace shrinks
the buffer past the cursor.
6. `clear_selection()` (Q#AI4), under the same context guard.
### Q#AI6 — Consequences of the GPU change, named
- Plain Enter in the GPU costs one daemon round-trip before echo —
parity with every TUI keypress and with the GPU's own
selection/popup/busy Enter today.
- **Global and buffer-local RET rebindings become effective in the
GPU frontend.** Concretely: buffer-list RET now visits the selected
buffer instead of inserting a raw newline into the list (today's
behavior — the optimistic path bypasses the buffer-local binding).
This is an incidental bug fix, named and tested, not a side effect.
**Mode-scope bindings are excluded from this claim**: dispatch
resolves the keymap stack with an empty mode list
(`src/editor.rs:710`), so they stay unresolved on every frontend
until the mode system is wired.
- `this_command` after Enter in the GPU becomes
`edit.newline-and-indent` (was `buffer.self-insert` via the
exact-decode CRDT classification) — now consistent across
frontends; kill chains break across a newline in both (already true
in TUI).
- GPU unit tests locking Enter's optimistic eligibility flip to lock
the round-trip.
### Q#AI7 — Chain/hook plumbing: nothing to build
Keybound RET rotates the command boundary and `buffer.after-edit`
fires from dispatch's revision check; `M-x edit.newline-and-indent`
gets both via `invoke_interactive`. Same Arc 2 substrate as
comment-toggle; the acceptance suite asserts the hook fires once
anyway.
### Q#AI8 — Search staleness: mark it, honor it, and keep the origin true
Three parts, all required:
1. **Mark**: one helper, `search_invalidate_for_edit` (mark stale +
translate the origin), invoked from **all four edit paths**:
`apply_active_edit` (dispatch), `notify_buffer_edit` (applied CRDT
ops `src/daemon.rs:2133`, general Lua mutator edits
`src/lua_bindings/mod.rs:1390-1395`, and the errors-buffer append
`src/lua.rs:442` — normally a no-op, unless the *errors* buffer
itself carries accepted search state, in which case marking it
stale is exactly right), and — round-1 finding — `undo` / `redo`,
which receive precise `Edit` values but previously invalidated
nothing. Generated-buffer rebuilds via `rebuild_views_for` remain
a named lower-frequency bypass (deferral).
2. **Honor (fail closed)**: `SearchStore::step` returns `None` while
stale — C-s / `search.next` stops navigating byte ranges that no
longer exist instead of teleporting the cursor to them — and
`search_match_summary` reports `(None, 0)` while stale, so the
TUI/GPU n/m prompt cannot show counts for suppressed highlights.
The consumers then match what the highlight producers already do.
A stale **live** search un-sticks on the next pattern keystroke:
the re-run calls `SearchStore::set`, which clears staleness
(`src/search.rs:100`). Auto-recomputing from the stored query on
step (instead of failing closed) is a named deferral — nicer UX,
separate change.
3. **Translate the live origin**: a small `EditorCore` helper
right-gravity-translates `SearchSession::origin` through the
effective edit, invoked alongside `mark_stale` in **both**
`apply_active_edit` and `notify_buffer_edit` (the formula is the
`src/daemon.rs:2103-2130` shape; the dispatch path has the same
pre-existing skew, and the two call sites already mirror each
other for `mark_stale`). Without it, a fresh recompute focuses
from a skewed offset and cancel restores the cursor to the wrong
place whenever an external edit lands before the origin.
Together these close a pre-existing bug family — lingering
highlights, stale stepping/counts, and skewed origins after any
direct Lua edit or optimistic GPU edit — that a Lua-implemented RET
would otherwise put on the most common keystroke in the editor.
### Q#AI9 — Empty-selection type-over: fix the core arm, name the GPU residual
The lingering-anchor bug is not RET's alone (ground truth): the
no-region arm of `insert_char_over_region` leaves an empty selection
armed, so ordinary typing already type-overs its own previous
keystroke (`S-Left` at BOF, `x`, `y` → the `y` replaces the `x`).
**In scope**: `insert_char` returns success — today it catches the
intercept rejection and returns `()` (ground truth), so the caller
cannot distinguish a landed edit from a rejected one — and the
no-region arm clears the lingering selection **only on success**.
Clearing unconditionally would mutate state on a *failed*
self-insert/Tab/plain-newline, which the rejecting-intercept contract
forbids. This fixes `buffer.self-insert`, `buffer.tab`, and the
retained `buffer.newline` escape hatch for all daemon-dispatched
input, on both frontends' round-trip paths, and makes
`insert_char_over_region`'s existing "any selection is cleared" doc
claim true.
**Also in scope (round 1: the residual was never GPU-only)**: the
TUI attach mirror tracks buffers and cursors but no selection state
(`src/buffer_mirror.rs:113`), and its optimistic gate checks cursor
freshness and EOL only (`src/optimistic.rs:250`) — so both
frontends' optimistic paths could re-arm the type-over. The fix
lives in the daemon's CRDT source arm (`handle_remote_crdt_op`):
before applying the source cursor update, a selection whose anchor
equals the pre-edit cursor — i.e. one that was EMPTY — is cleared.
Nonempty selections stand untouched.
**Out of scope, named**: the TUI optimistic gate performs no
type-over check at all — a NONEMPTY selection ending at EOL
optimistically inserts instead of consuming the region (the GPU
gates on Selection decorations, which nonempty selections do paint,
so it round-trips correctly there). That is a pre-existing TUI gate
gap, deferred with the substrate reconciliation work.
## Bets
1. **Verbatim copy is the right v1 policy** — no tabs-vs-spaces
complaints are possible when we only ever reproduce what's already
on the line.
2. **GPU Enter round-trip latency is imperceptible** — it matches the
TUI's every keypress and the GPU's existing non-optimistic paths.
3. **Clip-at-split matches muscle memory** — mid-indent splits
preserving total indentation is what vi/Emacs users expect; nobody
files "my line double-indented".
4. **Fail-closed staleness has no workflow regressions** — a stale
step no-op (until the pattern re-runs) surprises nobody, because
the highlights it would have navigated are already suppressed;
nothing legitimate consumes stale offsets once step and summary
are guarded.
5. **Clearing a lingering empty selection on the core insert arm
breaks nothing** — no workflow depends on an anchor surviving
plain typing; mainstream editors deactivate the mark on edit.
## Deferred (named)
- Language-aware indent (electric `{`/`}`, tree-sitter `indents.scm`)
— blocked on width/style config, which is blocked on the config
registry deferral.
- TAB as reindent (`indent-for-tab-command`) — TAB stays a literal
tab, and stays GPU-optimistic.
- Doc-comment continuation on newline — re-deferred from the
comment-toggle framing; needs in-comment detection
(grammar/comment-span work); its own framing after auto-pairing.
- Strip the abandoned line's trailing whitespace on split.
- A plain-newline default chord (`C-j` is LF — terminal-ambiguous).
- `C-o` open-line-with-indent.
- Substrate-level window-state reconciliation for direct buffer edits
(cursor clamp / selection repair in the notify path, rather than
per-command — including windows other than the acting one), and
aligning comment.lua's transformed-intercept fix-up with Q#AI5's
translate-and-clamp discipline.
- **TUI optimistic type-over gate gap** (Q#AI9 round 1): the TUI
attach gate consults no selection state, so a NONEMPTY selection
ending at EOL optimistically inserts instead of type-over. (The
empty-anchor half was fixed daemon-side in round 1.)
- **Generated-buffer rebuilds** (`rebuild_views_for`) bypass search
invalidation — a lower-frequency Q#AI8 gap, named not handled.
- Auto-recompute of a stale search from the stored query on step
(Q#AI8 fails closed instead).
- Unifying the five hardcoded tab-width sites (4× core `TAB_WIDTH=8`,
GPU minimap `4`) behind a real setting — config-registry work.
- Mode-scope keybinding resolution (dispatch passes `&[]` today) —
the mode system's wiring, not this feature's.
- A constructible unit seam for the GPU routing layer above
`optimistic_insert_text` (private `App`/`State` today).
## Acceptance (`tests/auto_indent_acceptance.rs`, dispatch-driven)
- RET at EOL of a space-indented line: new line carries the indent,
cursor after it; tab-indented and mixed `\t··` lines round-trip
verbatim.
- Mid-line split: `····foo|bar``····foo` / `····bar`, cursor
before `bar`.
- Split inside leading whitespace: `··|··foo``··` / `····foo`
(clip rule — no double indent).
- Zero-indent line and empty buffer: byte-identical to old
`buffer.newline`.
- Whitespace-only line: new line copies it; abandoned line keeps it.
- Region active: ONE `Replace` — region replaced by `"\n" .. indent`,
selection cleared; a single `buffer.undo` restores exactly.
- **Zero-length selection, command level**: `S-Left` at BOF, RET,
`x``x` inserts plainly, the newline survives (selection cleared
unconditionally). **Core level (Q#AI9)**: `S-Left` at BOF, `x`,
`y` → the `y` does not replace the `x`; likewise via
`M-x buffer.newline`. **Failure regression**: empty selection
armed, self-insert rejected by an intercept → the anchor REMAINS
(no state mutation on a failed edit). The GPU-optimistic variant
is the named residual, not claimed here.
- One undo step in the plain case too.
- Intercept discipline: rejecting intercept → status, buffer
unchanged, no throw; transforming intercept → reported, positional
result stands. **Relocating transform** (plain path: the insert's
`pos` moved — the only transform an insert admits) → payload
inserted at the new site, cursor translated, NOT teleported.
**Shrinking transform** (region path: the replace's range expanded
past the payload, shrinking the buffer below the cursor) → cursor
right-gravity-translated and clamped within `buf:len()`, selection
cleared — the tests assert validity, not immobility.
**Context-switching intercept** (switches window or buffer
mid-edit) → fix-up skipped; this proves the *new* context is
untouched — the original window's validity after such an intercept
belongs to the substrate-reconciliation deferral, and the test does
not claim it.
- **Search staleness** (Q#AI8; acceptance + lib split, per what each
seam can reach):
- Acceptance: isearch, accept, RET → post-accept `search_step`
no-op; the same via a direct `buf:insert` (notify path).
- Lib (editor_core): edit during **active** isearch → step no-op
and summary `(None, 0)`, then the next pattern keystroke
refreshes and stepping resumes; origin translation through
**inserts and deletes on both edit paths** (dispatch and
notify), pinning recompute focus and cancel restore; **undo and
redo** stale accepted matches and translate the live origin.
- Lib (search store): stale `step` fails closed; a fresh `set`
un-sticks it.
- Lib (daemon, `--features crdt`): the optimistic source arm
clears an EMPTY anchor and leaves a NONEMPTY selection alone.
- `after-edit` fires exactly once per RET (keybound and `M-x`).
- Kill-chain break: `C-k`, RET, `C-k` → two ring entries.
- Modal contexts: isearch accept and minibuffer accept pinned here
(plus buffer-list RET below). Query-replace and completion-popup RET
are pinned by `tests/query_replace_acceptance.rs` and
`tests/completion_popup_acceptance.rs`; context-menu RET is pinned by
`editor::tests::menu_enter_invokes_command_and_closes`. Those tests
run in the gates and are not duplicated here.
- Giant minified line (64 KiB, unindented and indented): splits
correctly — pins the forward-chunked indent scan's behavior
(boundedness is by construction).
- Buffer-list RET still visits via dispatch (now also reachable from
the GPU frontend).
- `this_command()` after RET is `edit.newline-and-indent`.
- **GPU coverage, two named seams (not "end to end")**:
- In-crate (`pmacs-gpu`): the existing
`optimistic_insert_text_covers_plain_chars_enter_and_tab` test
flips — Enter returns `None`, Tab still optimistic. This is the
sole gate at the top of `optimistic_crdt_insert` (`:2079`), so
classifier-`None` forces the handler's round-trip branch; the
handler itself has no constructible unit seam (named deferral).
- Root package (`--features crdt`): a synthetic attached replica
plus the root TUI optimistic orchestrator drive pending
optimistic self-inserts followed by Enter on an indented line,
asserting the daemon dispatches `edit.newline-and-indent` and
the resulting multi-byte CRDT op reaches the replica with
correct final text — the daemon side of the wire path the GPU
takes once it round-trips.