# 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.
