docs: auto-pairing framing (Arc 2) + handoff snapshot
Auto-pairing framing at revision 3 (two pre-branch review rounds):
pair chars leave both optimistic classifiers and round-trip through
dispatch (adjacent daemon-peer undo units, real type-over, no
transient skip paint); reaction hook pair.lua loads before lsp.lua
so the synchronous sighelp didChange flush ships the closer; exact
one-shot typed-edit provenance (pmacs.editor.take_typed_edit) gates
pairing fail-closed against transformed, relocated, or
context-switched source self-inserts and non-typed origins.
Handoff: main @ 7e127ab (auto-indent #109 merged), auto-pairing in
flight on this branch, Arc 2 deferrals consolidated into §6.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
parent
7e127ab836
commit
ac8c46f2d3
|
|
@ -1,6 +1,6 @@
|
||||||
# Agent handoff — cross-machine continuity
|
# Agent handoff — cross-machine continuity
|
||||||
|
|
||||||
**Last updated: 2026-07-10, on the laptop, by the auto-indent
|
**Last updated: 2026-07-11, on the laptop, by the auto-pairing
|
||||||
session.** This file is the bridge between development machines. If you
|
session.** This file is the bridge between development machines. If you
|
||||||
are an agent reading this on a fresh clone: this document plus the
|
are an agent reading this on a fresh clone: this document plus the
|
||||||
`docs/*-framing.md` files ARE your memory. Read this fully before
|
`docs/*-framing.md` files ARE your memory. Read this fully before
|
||||||
|
|
@ -8,28 +8,31 @@ taking on work, seed your persistent memory from it, and **update this
|
||||||
file (and commit it) whenever project state changes materially** — the
|
file (and commit it) whenever project state changes materially** — the
|
||||||
next machine reads it the way you just did.
|
next machine reads it the way you just did.
|
||||||
|
|
||||||
## 1. Where the project stands (2026-07-10)
|
## 1. Where the project stands (2026-07-11)
|
||||||
|
|
||||||
- `main` @ `efa41cb`, protocol **v15** (`SUPPORTED=[6..15]`).
|
- `main` @ `7e127ab` (auto-indent #109 merged), protocol **v15**
|
||||||
- **Auto-indent on newline (Arc 2) in flight on this branch** —
|
(`SUPPORTED=[6..15]`).
|
||||||
framing `docs/auto-indent-framing.md` is at revision 6 (five
|
- **Auto-pairing (Arc 2, last item) in flight on this branch** —
|
||||||
pre-branch review rounds plus PR #109 round 1). RET now binds
|
framing `docs/auto-pairing-framing.md` at revision 3 (two
|
||||||
`edit.newline-and-indent`; plain Enter is no longer GPU-optimistic
|
pre-branch review rounds). Shape: pair chars leave both frontends'
|
||||||
(round-trips like the TUI). Rode along: Q#AI8 search invalidation is
|
optimistic classifiers (dispatch-routed, daemon-peer undo units);
|
||||||
shared by dispatch, direct notification, undo, and redo (stale
|
reaction hook in `builtin/runtime/pair.lua` loaded BEFORE lsp.lua;
|
||||||
step/summary fail closed; live origins translate through edits), and
|
exact one-shot typed-edit provenance via
|
||||||
Q#AI9 clears empty selections only after successful core inserts and
|
`pmacs.editor.take_typed_edit()` (Q#AP9). When this merges, Arc 2
|
||||||
in the daemon's optimistic CRDT source arm for both frontends. The
|
closes.
|
||||||
|
- Auto-indent (#109) landed: RET binds `edit.newline-and-indent`;
|
||||||
|
plain Enter round-trips on both frontends. Rode along: shared
|
||||||
|
search invalidation across dispatch/notification/undo/redo (Q#AI8),
|
||||||
|
empty-selection clearing after successful inserts (Q#AI9). The
|
||||||
TUI's missing nonempty-selection optimistic type-over gate and
|
TUI's missing nonempty-selection optimistic type-over gate and
|
||||||
generated-buffer search invalidation remain named deferrals.
|
generated-buffer search invalidation remain named deferrals (§6).
|
||||||
- Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position:
|
- Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position:
|
||||||
- **Arc 1 (LSP utility surface) COMPLETE** — completion popup
|
- **Arc 1 (LSP utility surface) COMPLETE** — completion popup
|
||||||
(#92/#93), panels/references/outline/hover (#94–#96), plus
|
(#92/#93), panels/references/outline/hover (#94–#96), plus
|
||||||
hardening follow-ups (#102, #105, #106).
|
hardening follow-ups (#102, #105, #106).
|
||||||
- **Arc 2 (editing table stakes)** — query-replace (#97), kill ring
|
- **Arc 2 (editing table stakes)** — query-replace (#97), kill ring
|
||||||
+ `M-y` (#103/#105/#106), comment-toggle (#107), auto-indent (this
|
+ `M-y` (#103/#105/#106), comment-toggle (#107), auto-indent
|
||||||
branch). **Remaining after this merges: auto-pairing**, as its own
|
(#109), auto-pairing (this branch — the last item).
|
||||||
small framing + PR.
|
|
||||||
- **Arc 3 (persistence) COMPLETE** — saveplace/recentf (#98),
|
- **Arc 3 (persistence) COMPLETE** — saveplace/recentf (#98),
|
||||||
desktop-save (#99), autosave/crash-recovery (#100), save-clobber
|
desktop-save (#99), autosave/crash-recovery (#100), save-clobber
|
||||||
fix (#101).
|
fix (#101).
|
||||||
|
|
@ -172,7 +175,12 @@ comment padding.
|
||||||
Substrate: buffer-aware edit epoch (after-edit currently compares the
|
Substrate: buffer-aware edit epoch (after-edit currently compares the
|
||||||
ACTIVE buffer only), wire provenance for CRDT self-insert
|
ACTIVE buffer only), wire provenance for CRDT self-insert
|
||||||
classification, Lua intercept probe, completion.lua still on the old
|
classification, Lua intercept probe, completion.lua still on the old
|
||||||
cursor-delta heuristic (migrate to `this_command`).
|
cursor-delta heuristic (migrate to `this_command`), the TUI's
|
||||||
|
nonempty-selection optimistic type-over gate, generated-buffer search
|
||||||
|
invalidation, cross-peer chronological undo arbitration (mixed
|
||||||
|
source/daemon history; pinned by auto-pairing acceptance),
|
||||||
|
origin-pinned `buffer.after-edit` fan-out (a context-switching
|
||||||
|
intercept changes what later callbacks — LSP, completion — observe).
|
||||||
LSP/persistence: hidden-buffer LSP attach, daemon desktop-restore, the
|
LSP/persistence: hidden-buffer LSP attach, daemon desktop-restore, the
|
||||||
*warning* half of external-change detection (verify-visited-file-
|
*warning* half of external-change detection (verify-visited-file-
|
||||||
modtime), config registry (no unified config surface yet).
|
modtime), config registry (no unified config surface yet).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,490 @@
|
||||||
|
# Auto-pairing — framing (Arc 2, editing table stakes)
|
||||||
|
|
||||||
|
Typing `(` should give `()` with the cursor between; typing `)` when
|
||||||
|
the next char is already `)` should step over it instead of doubling
|
||||||
|
it. Language-aware pair sets, conservative insertion predicate. Last
|
||||||
|
Arc 2 item; after it merges, Arc 2 closes and the compile-mode vs
|
||||||
|
themes decision discussion is due.
|
||||||
|
|
||||||
|
Roadmap: `docs/roadmap-2026-07.md` Arc 2 ("auto-pairing").
|
||||||
|
Revision 2: R1 findings — pair chars now route through dispatch
|
||||||
|
(peer-bound undo falsified the pure-reaction undo story), pair.lua
|
||||||
|
loads before lsp.lua (the sighelp path flushes didChange
|
||||||
|
synchronously mid-hook), rejected/transformed intercept outcomes
|
||||||
|
separated (the effective edit has already landed), the type-over
|
||||||
|
claim is path-qualified with a region guard, and the CRDT acceptance
|
||||||
|
gains a second replica plus undo cases for both routing models.
|
||||||
|
|
||||||
|
Revision 3: R2 findings — the dispatch route no longer claims global
|
||||||
|
chronological undo (an older source-peer edit still wins the TUI's
|
||||||
|
local undo arbitration), source self-inserts gain an exact ephemeral
|
||||||
|
typed-edit record so a relocated opener can never be inferred from
|
||||||
|
unrelated text, the context-switch/LSP limit is stated rather than
|
||||||
|
hidden by the cursor guard, non-typed acceptance now drives callbacks
|
||||||
|
that actually fire, and redo is exercised rather than merely named.
|
||||||
|
|
||||||
|
## Ground truth (as of `7e127ab`)
|
||||||
|
|
||||||
|
- **Dispatch is keymap-first for printables** — `Char('(')` resolves
|
||||||
|
through the keymap stack before the self-insert fallback
|
||||||
|
(`src/editor.rs:713-717`, fallback `:748-761`); printable keys are
|
||||||
|
bindable (the buffer list binds `n`/`p` buffer-locally,
|
||||||
|
`builtin/commands/default.lua:407-423`).
|
||||||
|
- **But a key binding cannot carry pairing.** The GPU applies plain
|
||||||
|
printables optimistically **even mid-line** — classifier
|
||||||
|
`pmacs-gpu/src/main.rs:1525-1533`, eligibility `:2054-2079` — so a
|
||||||
|
`(`-binding would never run for GPU typing. The TUI applies
|
||||||
|
printables optimistically **at end of line** (the F19 paint
|
||||||
|
constraint: mid-line inserts round-trip, EOL appends do not —
|
||||||
|
`src/optimistic.rs:269-271`, contract `:168-200`). And a binding
|
||||||
|
that inserted the pair atomically would break classification
|
||||||
|
(below) even where it did run.
|
||||||
|
- **The typed-char substrate exists and has a working consumer.** The
|
||||||
|
daemon classifies single-codepoint optimistic inserts as
|
||||||
|
`buffer.self-insert` (exact byte decode,
|
||||||
|
`src/daemon.rs:1985-2002`, `:2085-2087`), explicitly "for
|
||||||
|
typed-char consumers (signature help; …)". `buffer.after-edit`
|
||||||
|
fires on BOTH paths — dispatch (`src/editor.rs:772-776`) and the
|
||||||
|
optimistic CRDT arm (`src/daemon.rs:2156-2169`) — with core
|
||||||
|
borrows released, so a hook may edit the buffer. Signature-help
|
||||||
|
auto-trigger (`builtin/runtime/lsp.lua:761-791`) is the in-tree
|
||||||
|
template: `this_command() == "buffer.self-insert"` plus
|
||||||
|
`char_before`; a paste can never trigger it.
|
||||||
|
- **An atomic 2-byte `"()"` insert breaks that classification**
|
||||||
|
(`is_single_codepoint_insert` decodes the actual bytes,
|
||||||
|
`src/daemon.rs:2079-2084` names signature help as the reason), and
|
||||||
|
**intercepts cannot rewrite `(` into `()`** (M6.4: kind and payload
|
||||||
|
immutable, `src/lua_bindings/mod.rs:1061-1073`, `:1657-1705`). Any
|
||||||
|
pairing design must keep the opener a genuine single-codepoint
|
||||||
|
self-insert.
|
||||||
|
- **Hook mechanics**: callbacks run in registration order
|
||||||
|
(`src/hook.rs:240`, snapshot `:255-259`; fan-out `:291-297`);
|
||||||
|
`buffer.after-edit` is **all-must-succeed**
|
||||||
|
(`builtin/hooks/default.lua:41-45`) — one callback's error or
|
||||||
|
return value cannot suppress the others. Hook-created edits do
|
||||||
|
**not** re-fire `buffer.after-edit` (fired once per dispatch cycle
|
||||||
|
/ per optimistic op).
|
||||||
|
- **The LSP hook flushes synchronously mid-fan-out.** The after-edit
|
||||||
|
callback in lsp.lua marks the buffer dirty, and — when the typed
|
||||||
|
char is a signature trigger — calls `signature_help_quiet`, which
|
||||||
|
calls `flush_did_change_for(rec)` **synchronously**
|
||||||
|
(`builtin/runtime/lsp.lua:739-743`: "The server must see the
|
||||||
|
character we just typed") before requesting. `flush_did_change`
|
||||||
|
reads `buffer_text` at flush time and clears the pending entry
|
||||||
|
(`:268-279`). Consequence: a closer inserted by a hook registered
|
||||||
|
**after** lsp.lua's would miss that flush AND stay unsynchronized
|
||||||
|
(no re-fire) until the next edit. Coalescing does not save this
|
||||||
|
path; registration order does.
|
||||||
|
- **Undo is peer-bound, per frontend.** Loro's `UndoManager` binds to
|
||||||
|
one peer at construction; each frontend's `BufferMirror` undoes
|
||||||
|
only its own peer's ops (`src/buffer_mirror.rs:541-555`), and the
|
||||||
|
daemon's `buffer.undo` covers only daemon-peer edits
|
||||||
|
(`src/buffer.rs:1360-1380`). The TUI's undo key tries the mirror
|
||||||
|
first and **round-trips to the daemon when the mirror has nothing
|
||||||
|
to undo** (`src/optimistic.rs:238-247`). Consequence: a pair whose
|
||||||
|
opener is a source-peer optimistic op and whose closer is a
|
||||||
|
daemon-peer Lua edit is **not undoable coherently by either
|
||||||
|
frontend** — the TUI removes the opener first (leaving `)`), the
|
||||||
|
daemon removes the closer then unrelated daemon edits. Edits that
|
||||||
|
route through dispatch are all daemon-peer.
|
||||||
|
- **The TUI's mirror-empty fallback is not chronological
|
||||||
|
arbitration.** If the mirror has *any* older source-peer edit, its
|
||||||
|
optimistic undo succeeds and the key never reaches the daemon
|
||||||
|
(`src/optimistic.rs:230-247`). Thus `a` (optimistic) then `()`
|
||||||
|
(daemon-routed) followed by the TUI's single-key undo removes `a`,
|
||||||
|
not the closer. `C-x u`, which always dispatches, removes the
|
||||||
|
daemon closer instead. Routing the pair's two edits to one peer
|
||||||
|
makes their daemon-local order coherent; it does not merge that
|
||||||
|
order with older source-peer history. Global chronological undo is
|
||||||
|
existing cross-peer substrate work, not something pairing can
|
||||||
|
truthfully claim to solve.
|
||||||
|
- **Mutator effects land before the caller sees them.** The effective
|
||||||
|
edit is applied and only then reported as the returned triple
|
||||||
|
(`src/lua_bindings/mod.rs:1184`, `:1246-1259`) — a transformed
|
||||||
|
(relocated/expanded) edit has already happened; it can be
|
||||||
|
*repaired around*, never *withheld*. Lua mutators move no cursors
|
||||||
|
and reconcile no window state (PR #109 ground truth); the
|
||||||
|
context-guarded right-gravity repair in
|
||||||
|
`builtin/runtime/indent.lua:60-64`, `:100-121` is the established
|
||||||
|
pattern.
|
||||||
|
- **The producing self-insert has the same intercept problem, and
|
||||||
|
`after-edit` currently carries no payload.** `insert_char` advances
|
||||||
|
the cursor from the requested position after `apply_active_edit`
|
||||||
|
(`src/editor_core.rs:1738-1749`), even if an insert intercept moved
|
||||||
|
the effective insertion. `this_command` proves only the input
|
||||||
|
class; `char_before(cursor)` does not prove which character was
|
||||||
|
typed or where it landed. Pairing therefore needs exact ephemeral
|
||||||
|
source-edit provenance, not the signature-help heuristic alone.
|
||||||
|
- **A hook-inserted closer lands correctly by construction**: insert
|
||||||
|
at the cursor, cursor stays before it (mutators move no cursors);
|
||||||
|
the daemon's `CursorByte` re-grounds both frontends; the GPU
|
||||||
|
applies incoming ops and rebases unconfirmed edits without moving
|
||||||
|
`own_cursor` (`pmacs-gpu/src/main.rs:2463-2564`).
|
||||||
|
- **Broadcast ordering quirk on the optimistic path**: the after-edit
|
||||||
|
hook fires (`src/daemon.rs:2167`) **before** the source opener op
|
||||||
|
is queued for broadcast (`:2178`), so a hook-queued DaemonKey
|
||||||
|
closer reaches **non-source replicas before the opener it depends
|
||||||
|
on**. Loro is expected to buffer and converge; nothing pins that
|
||||||
|
today.
|
||||||
|
- **Region-active typing**: CUA type-over consumes the region on the
|
||||||
|
dispatch path. The GPU round-trips when a selection decoration
|
||||||
|
exists; the TUI's optimistic gate consults no selection state (PR
|
||||||
|
#109 named deferral) — a nonempty TUI selection ending at EOL can
|
||||||
|
optimistically insert without consuming the region, and the daemon
|
||||||
|
arm deliberately preserves nonempty anchors.
|
||||||
|
- **A callback may switch context, and later callbacks observe the
|
||||||
|
switch.** The hook runner snapshots callbacks, not editor context;
|
||||||
|
lsp.lua reads `pmacs.window.buffer()` when its callback runs. If a
|
||||||
|
pair reaction's intercept switches from A to B, a local cursor guard
|
||||||
|
can avoid touching B, but it cannot make the later LSP callback see
|
||||||
|
A. Origin-pinned hook fan-out is named substrate work below.
|
||||||
|
- **Direct Lua mutation and plain `pmacs.command.invoke` do not fire
|
||||||
|
`buffer.after-edit`.** A non-typed regression that merely calls
|
||||||
|
either API is vacuous; the test must explicitly run the hook or use
|
||||||
|
a production path such as paste which fires it.
|
||||||
|
- **No pair knowledge exists anywhere**; `pmacs.comment.strings`
|
||||||
|
(`builtin/runtime/comment.lua:26-42`) is the per-language table
|
||||||
|
precedent. No node-at-byte API on `pmacs.parse` (manual descent
|
||||||
|
only; async/stale trees) — syntax-aware inhibit is not v1-viable.
|
||||||
|
- Rust uses `'` for lifetimes — pairing `'` per-language is a
|
||||||
|
correctness matter, not taste.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Q#AP1 — Carrier: after-edit reaction, with pair chars routed through dispatch
|
||||||
|
|
||||||
|
Two coupled decisions, each grounded in the constraints above:
|
||||||
|
|
||||||
|
**The reaction carrier** (unchanged from R1): on
|
||||||
|
`this_command() == "buffer.self-insert"`, the pairing hook reads the
|
||||||
|
exact ephemeral typed-edit record from Q#AP9 and reacts with a second
|
||||||
|
edit. This keeps the opener a genuine single-codepoint self-insert —
|
||||||
|
the classification signature help depends on — without guessing its
|
||||||
|
identity or position from surrounding buffer text.
|
||||||
|
|
||||||
|
**The routing change** (new, R1 finding): the built-in pair charset
|
||||||
|
`( ) [ ] { } " ' `` ` is **removed from both optimistic
|
||||||
|
classifiers** — the GPU's `optimistic_insert_text` and the TUI's
|
||||||
|
`classify_key` — so those chars always round-trip through dispatch.
|
||||||
|
Without this, the opener is a source-peer op and the closer a
|
||||||
|
daemon-peer op, and peer-bound undo makes the pair uncleanly
|
||||||
|
undoable on every frontend (ground truth). With it, both edits are
|
||||||
|
adjacent daemon-peer undo units. A daemon-routed undo removes the
|
||||||
|
closer and then the opener; the TUI's single-key optimistic undo does
|
||||||
|
the same **only when its older source-peer stack is empty**. This is
|
||||||
|
pair-local coherence, not global chronological arbitration (Q#AP5).
|
||||||
|
The exclusion also restores CUA type-over for pair chars on the TUI
|
||||||
|
(a round-tripped `(` consumes the region the optimistic path would
|
||||||
|
have skipped) and removes transient pair/skip paint from both
|
||||||
|
frontends.
|
||||||
|
|
||||||
|
Costs, named: one daemon round-trip per built-in-charset keystroke —
|
||||||
|
on the TUI only EOL appends change (mid-line already round-trips); on
|
||||||
|
the GPU all nine chars do. `'` and `` ` `` round-trip even in languages
|
||||||
|
whose sets don't pair them — uniform routing beats per-language
|
||||||
|
classifier state the frontends don't have. **User-extended pair
|
||||||
|
chars beyond the built-in nine still arrive optimistically**: they
|
||||||
|
pair correctly (the reaction fires either way) but their undo is
|
||||||
|
cross-peer-degraded — documented limitation, pinned in acceptance,
|
||||||
|
full fix deferred with the pre-existing mixed-history problem
|
||||||
|
(cross-peer chronological undo arbitration).
|
||||||
|
|
||||||
|
`builtin/runtime/pair.lua`; a `pmacs.pair.*` namespace.
|
||||||
|
|
||||||
|
### Q#AP2 — Pair sets: per-language table, conservative default
|
||||||
|
|
||||||
|
`pmacs.pair.sets` — the `pmacs.comment.strings` shape: language →
|
||||||
|
array of 2-byte pair strings, plus a `default` entry used when the
|
||||||
|
language is unknown or has no entry (pairing is useful in scratch
|
||||||
|
buffers):
|
||||||
|
|
||||||
|
- `default = { "()", "[]", "{}", '""' }` — no `'` (prose
|
||||||
|
apostrophes), no backtick.
|
||||||
|
- `python`, `lua`, `javascript`/`typescript` (+react), `sh`/`bash`
|
||||||
|
add `''`; javascript/typescript/markdown add `` `` `` pairs.
|
||||||
|
- `rust`, `c`, `cpp`, `go`, `zig` = default (lifetimes, char
|
||||||
|
literals). Users opt in from init.lua: `pmacs.pair.sets.rust = …`.
|
||||||
|
|
||||||
|
### Q#AP3 — Insert-pair semantics (open char typed)
|
||||||
|
|
||||||
|
React when ALL hold:
|
||||||
|
|
||||||
|
- `this_command() == "buffer.self-insert"` **and** Q#AP9 returns a
|
||||||
|
live typed-edit record for this callback (pastes, manual hook runs,
|
||||||
|
and programmatic inserts have no record and never pair);
|
||||||
|
- the record's buffer/window match the current context, its source
|
||||||
|
edit is clean (effective triple equals the requested insert or
|
||||||
|
replace), and the current cursor equals its recorded post-edit
|
||||||
|
cursor. A relocated/expanded or context-switching source
|
||||||
|
self-insert stands as the intercept produced it and gets no pair
|
||||||
|
reaction. A non-clean triple reports *"auto-pair skipped: source
|
||||||
|
self-insert transformed"*; a context/cursor mismatch reports
|
||||||
|
*"auto-pair skipped: source context changed"*. This is the
|
||||||
|
fail-closed answer to R2 finding 2;
|
||||||
|
- **no active region survives the edit** (`ed.region() == nil`) —
|
||||||
|
on the dispatch path type-over has already consumed and cleared
|
||||||
|
it; a surviving nonempty region means the edit arrived through the
|
||||||
|
TUI's selection-blind optimistic gate (custom chars only), where
|
||||||
|
reacting would pile a closer onto an unconsumed region;
|
||||||
|
- the record's exact typed codepoint is an opener in the buffer's pair
|
||||||
|
set (language via `pmacs.lsp.active_buffer_language()`, resolved
|
||||||
|
**at callback time**, nil-guarded — pair.lua loads before lsp.lua,
|
||||||
|
Q#AP7). `char_before` is not input provenance;
|
||||||
|
- **conservative predicate**: the char at the cursor is EOL,
|
||||||
|
whitespace, or a closing bracket from the pair set — `foo|bar` +
|
||||||
|
`(` gives `(bar`, never `()bar`;
|
||||||
|
- for symmetric pairs (quotes), the skip check (Q#AP4) runs first.
|
||||||
|
|
||||||
|
Reaction: one pcall'd `buf:insert(cursor, closer)`. Outcomes,
|
||||||
|
separated (R1 finding):
|
||||||
|
|
||||||
|
- **Rejected** (intercept threw): nothing landed; the opener stands
|
||||||
|
alone; status *"auto-pair closer rejected by buffer intercept"*.
|
||||||
|
- **Transformed** (effective triple deviates): the edit has already
|
||||||
|
landed wherever the intercept put it — the positional result
|
||||||
|
stands. Report, then **context-guarded cursor repair**: with the
|
||||||
|
window+buffer snapshot taken before the mutator, right-gravity-
|
||||||
|
translate the pre-edit cursor through the effective edit and
|
||||||
|
`goto_byte` (clamps); skip all repair if the intercept switched
|
||||||
|
window or buffer. (The clean path needs no cursor motion at all —
|
||||||
|
the asymmetry is deliberate: repair only on deviation, because the
|
||||||
|
clean at-cursor insert must leave the cursor *before* the closer,
|
||||||
|
which translation would not.)
|
||||||
|
|
||||||
|
### Q#AP4 — Skip-over-close, reactive
|
||||||
|
|
||||||
|
When Q#AP9's exact typed char is a closer in the pair set AND the char
|
||||||
|
at the recorded post-insert cursor equals it: one pcall'd
|
||||||
|
`buf:delete(cursor, cursor+len)`. Net text and cursor are exactly
|
||||||
|
Emacs's skip: `(|)` + `)` → `()|`; nested closers skip likewise;
|
||||||
|
`"` at `"|"` exits the string. With pair chars on the dispatch route
|
||||||
|
there is no transient duplicate to paint — the frontends never
|
||||||
|
locally applied the typed closer.
|
||||||
|
|
||||||
|
Same outcome separation as Q#AP3: rejected delete → the duplicate
|
||||||
|
stays (`())`), status, no retry; transformed delete → already
|
||||||
|
landed, report, context-guarded translate-and-clamp repair.
|
||||||
|
|
||||||
|
### Q#AP5 — Undo grain
|
||||||
|
|
||||||
|
Built-in pairs are two adjacent daemon-peer edits. On the GPU, and on
|
||||||
|
the TUI when the mirror has no older source-peer unit (or when the
|
||||||
|
user invokes the always-dispatched `C-x u`), daemon undo removes the
|
||||||
|
closer and then the opener. Still two steps, not one; a skip's daemon
|
||||||
|
undo restores the swallowed duplicate.
|
||||||
|
|
||||||
|
This does **not** make undo globally chronological. With optimistic
|
||||||
|
`a` already in the TUI mirror, then daemon-routed `()`, the TUI's
|
||||||
|
single-key optimistic undo removes `a` first because the mirror has a
|
||||||
|
local unit and never falls back. The GPU can undo the two daemon pair
|
||||||
|
units but cannot subsequently reach the older source-peer `a` through
|
||||||
|
daemon undo. Custom optimistic pair chars additionally split the pair
|
||||||
|
itself across peers. All three behaviors are pinned explicitly; the
|
||||||
|
general fix is a cross-peer chronological undo arbiter, deferred as
|
||||||
|
existing collaboration substrate rather than charged to pair.lua.
|
||||||
|
|
||||||
|
### Q#AP6 — Type-over composes on the dispatch path; wrap is deferred
|
||||||
|
|
||||||
|
For built-in pair chars (always dispatch-routed), typing an opener
|
||||||
|
over an active region type-overs the region, then the reaction runs
|
||||||
|
under the Q#AP3 predicate — Emacs-with-delete-selection semantics,
|
||||||
|
guaranteed for a clean, context-preserving source edit. A transformed
|
||||||
|
source edit fails closed under Q#AP9. For custom optimistic chars the
|
||||||
|
TUI selection gap persists; the Q#AP3 region guard suppresses the
|
||||||
|
reaction there rather than compounding the gap. **Wrapping** the region
|
||||||
|
in the pair is deferred (needs a command carrier plus the TUI
|
||||||
|
selection-gate fix).
|
||||||
|
|
||||||
|
### Q#AP7 — Load order: pair.lua BEFORE lsp.lua
|
||||||
|
|
||||||
|
Registration order is execution order (ground truth), and lsp.lua's
|
||||||
|
after-edit callback synchronously flushes didChange on the signature
|
||||||
|
trigger path — the closer must already be in the buffer when that
|
||||||
|
callback runs, or the server receives `(`-only text and the closer
|
||||||
|
stays unsynchronized until the next edit (hook edits don't re-fire).
|
||||||
|
So pair.lua's loader entry in `src/editor.rs` goes **before**
|
||||||
|
lsp.lua's (`:288`), with a comment naming this ordering contract;
|
||||||
|
all `pmacs.lsp.*` lookups inside the callback are lazy and
|
||||||
|
nil-guarded (lsp.lua defines them later in the load sequence).
|
||||||
|
Acceptance asserts the ordering by its observable: the **first**
|
||||||
|
didChange after an ordinary `(` carries `()`; when the closer is
|
||||||
|
position-transformed without switching context, that first didChange
|
||||||
|
carries the complete effective post-reaction text rather than an
|
||||||
|
opener-only intermediate.
|
||||||
|
|
||||||
|
Scope: if the reaction intercept itself switches active window or
|
||||||
|
buffer, later callbacks observe that new context. The pair callback's
|
||||||
|
cursor guard cannot repair hook-wide context, so the first didChange
|
||||||
|
guarantee does not extend to that legal-but-pathological case; A may
|
||||||
|
remain pending while lsp.lua observes B. This is recorded under
|
||||||
|
Deferred as origin-pinned after-edit fan-out and is no longer hidden
|
||||||
|
behind a cursor-only acceptance claim.
|
||||||
|
|
||||||
|
### Q#AP8 — Interactions, verified
|
||||||
|
|
||||||
|
- **Signature help**: the opener still classifies as self-insert on
|
||||||
|
both routes; the closer insert moves no cursor, so `char_before`
|
||||||
|
still reads `(`; the synchronous flush ships `()` (Q#AP7). Pinned
|
||||||
|
with the `sighelp` fake-LSP mode. Same context-switch exception as
|
||||||
|
Q#AP7.
|
||||||
|
- **Completion popup**: `(` never belonged to the popup's key set;
|
||||||
|
`completion_popup_validate` runs after the hook fan-out
|
||||||
|
(`src/editor.rs:778-783`) and judges the post-pair buffer when
|
||||||
|
context is preserved. The Q#AP7 context-switch exception applies to
|
||||||
|
completion as well.
|
||||||
|
- **Kill ring / boundaries**: the closer is a plain Lua mutator —
|
||||||
|
stamps nothing, rotates nothing.
|
||||||
|
- **No recursion**: hook edits don't re-fire after-edit; the
|
||||||
|
autosave-driven manual `pmacs.hook.run("buffer.after-edit")`
|
||||||
|
(`builtin/runtime/autosave.lua:233`) is inert because Q#AP9 exposes
|
||||||
|
no typed-edit record outside the Rust-owned hook boundary, even if
|
||||||
|
`this_command` is stale.
|
||||||
|
- **Auto-indent**: RET inside `{|}` yields `{\n␣␣|}`; the electric
|
||||||
|
closer-on-own-line split stays deferred with language-aware
|
||||||
|
indent.
|
||||||
|
|
||||||
|
### Q#AP9 — Exact typed-edit provenance, ephemeral, one-shot, fail-closed
|
||||||
|
|
||||||
|
`this_command` remains the coarse input-origin signal used by existing
|
||||||
|
consumers, but pairing additionally requires a new
|
||||||
|
`pmacs.editor.take_typed_edit()` record. The record is per frontend,
|
||||||
|
is armed only while Rust is running the one `buffer.after-edit`
|
||||||
|
fan-out for that input, and can be consumed exactly once:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{ buffer, window_id, codepoint, requested_start, requested_end,
|
||||||
|
effective_start, effective_end, inserted_len, post_cursor, clean }
|
||||||
|
```
|
||||||
|
|
||||||
|
The dispatch path arms the record from the self-insert codepoint and
|
||||||
|
the requested Insert/Replace, then completes it from the effective
|
||||||
|
`Edit` returned by `insert_char_over_region`. The optimistic CRDT arm
|
||||||
|
already has both the decoded single codepoint and effective `Edit`, so
|
||||||
|
it builds the same record before firing the hook. A small EditorCore
|
||||||
|
outcome/report refactor is required because `apply_active_edit`
|
||||||
|
currently discards the effective range on its way back to
|
||||||
|
`insert_char`; payload immutability means the codepoint itself remains
|
||||||
|
authoritative.
|
||||||
|
|
||||||
|
The pair callback takes the record; later callbacks and a nested
|
||||||
|
manual re-run of `buffer.after-edit` see nil. The dispatcher/daemon
|
||||||
|
also clears any untaken slot immediately after the hook returns,
|
||||||
|
including error paths and the no-revision-change path. Plain
|
||||||
|
`pmacs.hook.run`, paste, programmatic mutation, and a stale
|
||||||
|
`this_command == "buffer.self-insert"` therefore see nil. Pairing
|
||||||
|
requires `clean == true`, matching buffer/window, and
|
||||||
|
`cursor == post_cursor`; otherwise it reports and does nothing. This
|
||||||
|
is deliberately narrower than teaching every command to expose its
|
||||||
|
edit: one producer class, one consumer contract, and no persistent
|
||||||
|
history.
|
||||||
|
|
||||||
|
## Bets
|
||||||
|
|
||||||
|
1. **Nine round-tripped chars are imperceptible.** The TUI already
|
||||||
|
round-trips every mid-line char; the GPU pays one local-socket hop
|
||||||
|
on pair chars. The concrete return is real dispatch type-over, no
|
||||||
|
optimistic duplicate paint during skip, and adjacent daemon-local
|
||||||
|
pair units — not a false promise of global chronological undo.
|
||||||
|
2. **The conservative predicate kills the hate-mail cases** — no
|
||||||
|
pairing before words, no apostrophe pairing in the default set.
|
||||||
|
3. **Uniform routing beats language-aware classifiers** — `'`
|
||||||
|
round-trips in Rust for nothing, and nobody notices.
|
||||||
|
4. **`default`-set pairing in language-less buffers is wanted**, not
|
||||||
|
surprising.
|
||||||
|
|
||||||
|
## Deferred (named)
|
||||||
|
|
||||||
|
- Wrap-region on opener with active selection (command carrier + the
|
||||||
|
TUI selection-gate fix).
|
||||||
|
- Pair-aware backspace (delete both of a fresh empty pair).
|
||||||
|
- RET inside a pair → closer on its own line (with language-aware /
|
||||||
|
electric indent).
|
||||||
|
- In-string/in-comment inhibit — needs a node-at-byte `pmacs.parse`
|
||||||
|
binding and freshness guarantees.
|
||||||
|
- Undo amalgamation (pair = one undo step); cross-peer undo grouping
|
||||||
|
and chronological arbitration (would un-degrade custom optimistic
|
||||||
|
pair chars and mixed source/daemon history generally).
|
||||||
|
- Origin-pinned `buffer.after-edit` context. Today a legal intercept
|
||||||
|
that switches window/buffer changes what all later callbacks see;
|
||||||
|
pair.lua can guard its own cursor repair but cannot keep LSP and
|
||||||
|
completion on the producing context without a hook-wide substrate.
|
||||||
|
- Balance-aware quote handling (odd/even counting).
|
||||||
|
- A per-buffer toggle (config-registry-blocked).
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
`tests/auto_pair_acceptance.rs` (dispatch-driven):
|
||||||
|
|
||||||
|
- `(` at EOL → `()`, cursor between; mid-line before whitespace and
|
||||||
|
before `)`; before a word char → no pair.
|
||||||
|
- Skip: `(|)` + `)` → `()`, cursor after; nested; `"` at `"|"` exits.
|
||||||
|
- Quotes pair under the predicate.
|
||||||
|
- Per-language: `'` pairs in `.py`, not in `.rs`; scratch pairs the
|
||||||
|
default set.
|
||||||
|
- Non-typed provenance, with the callback actually exercised:
|
||||||
|
production `FrontendEvent::Paste("(")` after a prior self-insert
|
||||||
|
leaves a lone pasted opener; `buf:insert("(")` followed by explicit
|
||||||
|
`pmacs.hook.run("buffer.after-edit")` also leaves it lone even when
|
||||||
|
`this_command` was deliberately left as `buffer.self-insert`;
|
||||||
|
`pmacs.command.invoke`d self-insert plus the same explicit hook run
|
||||||
|
also has no record and no reaction.
|
||||||
|
- Type-over: region + `(` (dispatch route) → region consumed, then
|
||||||
|
the predicate decides; selection cleared.
|
||||||
|
- Daemon undo grain pinned in the non-replica harness: `(` then undo →
|
||||||
|
`(` alone; undo → empty; redo → `(`; redo → `()`; skip undo restores
|
||||||
|
the duplicate.
|
||||||
|
- Intercepts: rejected closer → opener stands, status; **relocated
|
||||||
|
closer** → landed at the intercept's position, reported, cursor
|
||||||
|
translated not teleported; rejected skip-delete → duplicate stays;
|
||||||
|
**expanded/relocated skip-delete** → landed, reported,
|
||||||
|
translate-and-clamp. The **source self-insert** gets separate cases:
|
||||||
|
relocated opener and expanded/relocated type-over produce exactly
|
||||||
|
the intercept's positional result, Q#AP9 reports/skips, and no
|
||||||
|
unrelated closer is inserted; a source context switch likewise
|
||||||
|
fails closed.
|
||||||
|
- Context-switching **reaction** intercept → pair cursor repair
|
||||||
|
skipped, new context's text/cursor untouched by pair.lua. A probe
|
||||||
|
callback registered after pair.lua observes the switched context,
|
||||||
|
explicitly pinning (not concealing) the origin-context deferral; do
|
||||||
|
not combine this case with the Q#AP7 first-didChange guarantee.
|
||||||
|
- Signature help: fake-LSP `sighelp` mode — auto-trigger still fires
|
||||||
|
with pairing active, and the **first didChange after `(` contains
|
||||||
|
`()`** when the reaction preserves context (the Q#AP7 ordering
|
||||||
|
observable). A preserving-context relocated closer instead asserts
|
||||||
|
that the first didChange contains the complete effective text.
|
||||||
|
- Hook fan-out: one fire per keystroke; the closer edit does not
|
||||||
|
re-fire.
|
||||||
|
- Typed-edit lifecycle: `take_typed_edit()` yields the exact
|
||||||
|
codepoint/effective triple once during both dispatch and optimistic
|
||||||
|
self-insert hooks; a second take (including a nested manual
|
||||||
|
after-edit run) is nil. It is also nil before/after the fan-out, for
|
||||||
|
paste, for standalone manual hook runs, and after a rejecting edit.
|
||||||
|
Two frontends cannot see or consume each other's slot.
|
||||||
|
|
||||||
|
Classifier flips (in-crate): GPU `optimistic_insert_text` returns
|
||||||
|
`None` for the nine pair chars (test updated alongside Enter's);
|
||||||
|
TUI `classify_key`/orchestrator equivalents round-trip them (test
|
||||||
|
updated). Exercise both `Modifiers::NONE` and the `SHIFT` shapes real
|
||||||
|
keyboards use for `(){}"` so the test cannot pass only for synthetic
|
||||||
|
unshifted punctuation.
|
||||||
|
|
||||||
|
CRDT (`--features crdt`), **two replicas** (source + observer):
|
||||||
|
|
||||||
|
- Dispatch route: round-tripped `(` → both replicas converge to
|
||||||
|
`()`; daemon cursor between; `)` skip converges; **undo/redo**:
|
||||||
|
two daemon undos restore empty on both replicas, then two daemon
|
||||||
|
redos restore `(` and `()` in order on both.
|
||||||
|
- Mixed history, dispatch route: source optimistically inserts `a`,
|
||||||
|
then round-tripped `(` produces `a()`. On the TUI routing model the
|
||||||
|
single-key optimistic undo removes `a` first (leaving `()`). From a
|
||||||
|
fresh identical state, an always-dispatched `C-x u` removes the
|
||||||
|
closer first (leaving `a(`). On the GPU routing model two daemon
|
||||||
|
undos remove the pair but a further daemon undo cannot reach
|
||||||
|
source-peer `a`. These are assertions of the named substrate limit,
|
||||||
|
not frontend-equivalence claims.
|
||||||
|
- Optimistic route (custom pair char added to the set): source ships
|
||||||
|
the opener op; the hook's DaemonKey closer is queued **before**
|
||||||
|
the opener's broadcast — the observer receives the causally
|
||||||
|
dependent closer first and must still converge (pairing and skip
|
||||||
|
both). **Undo**: pinned degraded behavior — the source mirror's
|
||||||
|
undo removes the opener, leaving the closer.
|
||||||
Loading…
Reference in New Issue