pmacs/docs/kill-ring-framing.md

17 KiB

Kill ring — framing (Arc 2, editing table stakes) — rev 3

pmacs has Emacs keybindings on a one-slot clipboard: C-w/M-w/C-y cut/copy/paste through EditorCore.clipboard_slot, a single Vec<u8>. Kill something, kill something else, and the first kill is gone. There is no C-k, and M-y is unbound. This arc adds the real thing: kills accumulate in a ring, consecutive kills append, C-y yanks the head, M-y immediately after a yank cycles older entries.

Roadmap: docs/roadmap-2026-07.md Arc 2 ("real kill ring + M-y").

Rev 2 rebuilt the design around per-frontend command boundaries after review showed dispatch_key is not the only input path. Rev 3 closes the second review's blockers: pointer gestures join the boundary table; the shared ring gets stable entry identities so per-frontend state survives other frontends' mutations; and two shipped bugs the review surfaced move into scope — GPU Ctrl-V paste is silently dropped for semantic frontends, and inbound paste fires no buffer.after-edit anywhere.

Ground truth (scouted + twice review-verified; as of 4c4295d)

Input paths that reach a buffer or move point — only the first runs Lua command bodies:

  1. Round-tripped chords (dispatch_key): every CTRL/ALT chord (src/optimistic.rs:134-142) — all kill/yank commands, both frontends — plus all TUI keys. Commands run via Action::Run (src/editor.rs:707) or the self-insert fallback (:730); the post-command revision check fires buffer.after-edit (:739).
  2. GPU optimistic edits: bare typing, Enter/Tab, Backspace/Delete arrive as CRDT ops at handle_remote_crdt_op (src/daemon.rs:1941) — no command, no dispatch_key. (Fires after-edit itself.)
  3. Pointer gestures: grid Mousedispatch_mouse (src/editor.rs:1114) and the semantic Pointer path (:1253) move the cursor and set/clear selections — no command boundary. Right-click additionally opens the context menu.
  4. Inbound OS paste: FrontendEvent::Pastepaste_inbound (editor_core.rs:2096) — but only on the grid path (apply_event, src/daemon.rs:2241). The semantic input dispatcher drops Paste (apply_semantic_input_event, src/daemon.rs:2200, _ => {} with a "no grid-less effect yet" comment). pmacs-gpu always negotiates semantic render (pmacs-gpu/src/attach.rs:259), so GPU Ctrl-V is a no-op today — a shipped bug. Where paste does land (TUI bracketed paste), it fires no buffer.after-edit — a second shipped gap (LSP never sees pasted text). And the grid handler trusts the client-supplied payload id: it sets core.active_frontend from Paste.frontend_id rather than the dispatcher's authenticated source (DispatcherEvent::FrontendEvent { source, event }, src/daemon.rs:1428) — a third shipped gap: a forged payload id pastes into another frontend's active window.
  5. Context menu: menu_invoke_active (src/editor.rs:946) calls invoke_command directly — no rotation, no post-command check.
  6. M-x: the minibuffer shadow returns before the post-command check (src/editor.rs:664), so M-x-invoked editing commands also miss after-edit today.

Clipboard: clipboard_slot + pending_clipboard: Option<(FrontendId, Vec<u8>)> (editor_core.rs:213,219); InstanceSignal::Clipboard (v6-floor) goes to the originating frontend only (daemon.rs:949-956); GPU writes arboard, TUI writes OSC 52. External content is visible to the daemon only via path 4.

buf:replace (mod.rs:1216) uses the Lua buffer edit path (intercepts, then notify_buffer_edit_to_windows, mod.rs:1344) and queues a daemon-origin CRDT op — rotation needs no protocol change, but intercepts may alter/reject the edit, so callers must verify the applied text.

Frontend lifecycle: SessionDetached (src/daemon.rs:1574) already prunes six per-frontend maps; anything new that is keyed by FrontendId must join that cleanup, and Lua-side per-frontend tables need a detach signal (none exists).

Nothing tracks the previous command; no per-command hook; C-k/M-y unbound; M-d/M-BS/C-BS/C-h/C-DEL discard deleted bytes; no Emacs mark; ed.region/cursor/delete_region/goto_byte, buf:slice, pmacs.frontend.id() (reads active_frontend) all exist; no clipboard_get/clipboard_set.

Decisions

Q#KR1 — Lua ring on a Rust command-boundary substrate

The ring, append policy, yank sessions, and commands live in builtin/runtime/killring.lua. Rust grows: the command-boundary substrate (Q#KR2), ed.clipboard_set(bytes) + ed.clipboard_get(), the semantic-paste fix and the after-edit delivery fix (Q#KR10), the detach cleanup + frontend.detached hook (Q#KR11), and the module load line in EditorState::new. The ring table is daemon-global (shared across frontends, like the Emacs daemon); everything session-shaped is per-frontend and identity-checked against the shared ring (Q#KR4/6/7).

Q#KR2 — Per-frontend command boundaries; every input path updates them

EditorCore.command_history: HashMap<FrontendId, CommandBoundary>, CommandBoundary { this: Option<String>, last: Option<String> }. ed.last_command() reads the active frontend's entry. Two operations: rotate(fid, name) (last = this; this = Some(name)) and break_chain(fid) (this = None — the next rotation yields last = None, failing every chain/session check).

The boundary table — one row per input path (rev 3 adds pointer gestures and the unified paste route):

path site operation
keybound command dispatch_key Action::Run rotate(fid, name)
typed char (round-trip) self-insert fallback rotate(fid, "buffer.self-insert")
unbound key dispatch_key unbound arm break_chain(fid)
GPU optimistic edit handle_remote_crdt_op break_chain(source)
pointer gesture dispatch_mouse (grid) and the semantic Pointer handler — any press/drag/release that moves point or changes the selection; menu-opening right-click included break_chain(fid)
inbound OS paste the unified paste route (Q#KR10) break_chain(source) — the authenticated id, never the payload's
menu item menu_invoke_active rotate(fid, name)
M-x accept pmacs.command.invoke_interactive (new) rotate(fid, name)

Pointer scope note: scroll-wheel events that move only the viewport do not break the chain (point is untouched); anything that relocates the cursor or edits the selection does. Emacs behaves the same way (mwheel-scroll preserves last-command chains; mouse-set-point breaks them).

invoke_interactive(name) rotates then invokes; editor.execute- command's accept uses it. Plain pmacs.command.invoke stamps nothing (public programmatic API). This preserves rev 2's Emacs-verified M-x matrix: C-kM-x kill-line no-append; M-x kill-lineC-k append; M-x twice no-append.

Q#KR3 — What feeds the ring (v1 command set)

Unchanged from rev 2: C-w/M-w/C-y bodies become ring-aware (context menu inherits by name); new C-k edit.kill-line (chunked buf:slice newline scan; at EOL kills the newline) and M-y edit.yank-pop. Deferred: word kills — M-d, M-BS, C-BS, C-h, C-DEL all share the discard-the-bytes deleters and keep plain deleting until the Rust deleters return bytes.

Q#KR4 — Append requires success and an unmoved head (stable ids)

Rev 2's per-frontend last_kill_ok: bool corrupts the shared ring under interleaving (review blocker): A kills, B kills, A kills again — A's flag is still true, so A appends onto B's entry.

So ring entries carry a stable identity: each push mints a monotonically increasing id; entries are {id, text}. The per-frontend kill state is last_kill_id (nil = no live kill chain), and the append condition becomes:

ed.last_command() is a kill command and this frontend's last_kill_id is non-nil and equals the current head's id.

A-kill/B-kill/A-kill: A's last_kill_id names A's entry, the head is B's → push fresh, B's kill intact. A successful push/append sets last_kill_id = head.id; every failed or no-op kill-family invocation clears it (rev 2's success rule, carried forward). Appends keep the entry's id (the entry is extended, not replaced — a same-frontend chain keeps appending). The appended head re-syncs to the acting frontend's OS clipboard.

Q#KR5 — The ring

Entries {id, text}, most-recent first, shared. Cap default 60; pmacs.killring.max([n]) validated as in rev 2 (non-finite rejectedNaN, math.huge; ≥ 1; floored; shrink trims immediately). Duplicate-of-head pushes collapse (no new id). pmacs.killring.list() returns texts for introspection/tests.

Q#KR6 — Yank: per-frontend sessions, snapshot + stable cursor

C-y (unchanged flow from rev 2, session shape corrected):

  1. Slot check: ed.clipboard_get() non-empty and ≠ head text → push it (external content joins the ring).
  2. Ring empty → "kill ring empty", no session.
  3. start = region lo or cursor; ed.clipboard_set if the slot differs; ed.clipboard_paste() (existing insert-over-region, cursor = start + len).
  4. Session (per FrontendId): {buffer, start, end, entry_id, text}text is the snapshot actually inserted (rev 2 claimed to verify against it but never stored it — review catch), entry_id is the ring entry's stable id (an index would be shifted by any other frontend's push — review blocker). Created only on successful paste.

OS clipboard scope (unchanged, explicit): the ring is shared; OS mirroring is local to the acting frontend — pending_clipboard's existing (FrontendId, bytes) shape, and the right privacy call for frontends on different machines.

Named limitation (unchanged): an external copy is invisible until pasted; C-y before any paste yanks the ring head. GPU Ctrl-V covers the direct gesture — and actually works after this PR (Q#KR10 fixes the semantic drop).

Q#KR7 — Yank-pop: stable-id rotation, snapshot-verified

M-y is valid only when, for the acting frontend: (1) last_command is edit.paste/edit.yank-pop; (2) a live session exists; (3) the session's buffer is active; (4) buf:slice(start, end) == session.text. Any failure → refuse with status, drop nothing into the buffer, and create/keep no session (a second invalid M-y cannot ride the first's name-stamp).

When valid:

  1. Locate session.entry_id in the ring; absent (evicted or trimmed) → invalidate the session and refuse. Otherwise step to the next-older entry, wrapping. Because the cursor is an id, another frontend pushing entries mid-session shifts positions but not identity — rotation continues from where this frontend actually was (Emacs's kill-ring-yank-pointer behavior; an integer index would silently re-yank the wrong entry — review blocker).
  2. buf:replace(start, end, entry.text), then verify by re-slice.
  3. On match: ed.goto_byte(start + #entry.text), update end, entry_id, text.
  4. On mismatch (a buffer intercept altered the edit): accepted post-hoc semantics, stated as a decision — the interceptor's result is left in place, the session is dropped, and the status says so. Kill/yank inside intercepted (round-trip/REPL-prompt) buffers is niche; a preflight refusal would need a new Lua-visible intercept probe, which is not worth the seam in v1. (Review offered both options; this is the explicit choice of the simpler one.)

The OS slot is untouched by M-y; undo treats each rotation as a normal edit.

Q#KR8 — No mark in v1

Unchanged: CUA selections only; C-SPC/set-mark deferred; C-w requires a selection.

Q#KR9 — Module shape and loading

Unchanged from rev 2: killring.lua holds the ring, the per-frontend tables (keyed by pmacs.frontend.id()), commands, and bindings; default.lua bodies delegate at invoke time; loaded explicitly in EditorState::new.

Q#KR10 — Unified paste + after-edit delivery (two shipped bugs, in scope)

(a) Semantic paste, on the authenticated source. FrontendEvent::Paste is handled only in the grid apply_event; the semantic dispatcher drops it, and the GPU is always semantic — so GPU Ctrl-V does nothing today. Fix: route Paste before the grid/semantic split (one handler for both attachment kinds) → paste_inbound + break_chain.

That handler keys everything off the dispatcher's authenticated source: FrontendIdDispatcherEvent::FrontendEvent { source, .. } is stamped per attach session, while Paste.frontend_id is client-supplied bytes. The current grid handler trusts the payload and sets active_frontend from it, so a forged id pastes into another frontend's active window (review blocker). The unified handler ignores the payload id (logging a mismatch), targets source's active view, and calls break_chain(source). A forged-id acceptance test pins it.

(b) After-edit delivery — for the three scoped paths, not "every command". Three sites run edits outside dispatch_key's revision check and never fire buffer.after-edit: the minibuffer accept callback (M-x), the menu invoke, and paste. Extract the revision-compare-then-fire into a helper and call it at those three sites. No double-fire on the keybound path.

Scope honesty (review correction): the helper keeps the existing active-buffer before/after comparison, which is sound for these three paths — kill, yank, menu Cut, and paste all edit the active buffer and do not switch away mid-command. It is not a general guarantee: a command that edits buffer A and then switches to B still evades the compare (the check would diff B's revision). The general fix is a buffer-aware edit epoch (any-buffer mutation counter + per-buffer hook targeting) — deferred, named, and out of this PR's scope.

Q#KR11 — Frontend lifecycle cleanup

SessionDetached already prunes six per-frontend maps (daemon.rs:1574); command_history joins them. For the Lua side, the same arm fires a new frontend.detached hook carrying the raw frontend id; killring.lua subscribes and drops that frontend's session/last_kill_id entries. (Frontend ids are monotonic, so without this both maps grow for the daemon's lifetime — review note.) The hook is tiny and generally useful (the first frontend-lifecycle hook).

Phasing

One PR. In-diff order: Rust substrate (command boundaries + all eight table rows, clipboard_get/set, unified paste route, after-edit helper

  • three call sites, frontend.detached + detach cleanup, module load) → killring.lua + default.lua bodies → acceptance.

Bets (score at close)

  1. The eight-row boundary table is complete — no further input path can edit a buffer or move point without rotating or breaking. (Falsified twice at smaller sizes; this is the bet to watch.)
  2. Stable entry ids make shared-ring interleaving safe — no append-corruption, no index-shift mis-yanks, eviction invalidates cleanly.
  3. The unified paste route regresses nothing on the grid path while making GPU paste work at all.
  4. Q#KR10's helper closes the after-edit hole at all three sites with no double-fire.

Deferred (named)

Unchanged from rev 2: word kills (five bindings) pending bytes-returning deleters; C-SPC/mark; clipboard watching; ring browser; ring persistence; C-u C-y / C-M-w. Plus: a Lua-visible intercept probe (would upgrade Q#KR7's post-hoc accept to a preflight), and a buffer-aware edit epoch so after-edit delivery covers commands that edit one buffer then switch to another (Q#KR10b's named general fix).

Acceptance (dispatch_key-driven; multi-frontend via distinct FrontendIds)

Chain mechanics — rev 2 set, plus:

  • Pointer break, kill side: C-k, click elsewhere in the same buffer (grid mouse path and semantic pointer path), C-k → two entries.
  • Pointer break, yank side: C-y, click elsewhere, M-y → refused (not a yank), buffer unchanged at both locations.
  • Wheel-scroll does not break a kill chain.

Shared-ring interleaving:

  • A-kill / B-kill / A-kill → three entries; B's text intact (the last_kill_id blocker case).
  • A C-y / B kills / A M-y → A rotates from its own entry (stable id), not from B's shifted position.
  • Eviction mid-session (B pushes past the cap until A's entry drops) → A's M-y refuses cleanly.

Paste (Q#KR10a):

  • Paste reaches the buffer on the semantic path (the GPU case — asserting the shipped bug is fixed), breaks the chain, and fires buffer.after-edit exactly once; grid path unchanged.
  • Forged-id paste: frontend A sends Paste whose payload frontend_id names B → the text lands in A's active view, A's chain breaks, and B's state (view, chain, sessions) is untouched.

Hook delivery (Q#KR10b):

  • after-edit probe fires exactly once for menu Cut, M-x edit.kill-line, and a paste; no double-fire for keybound C-k.

Lifecycle (Q#KR11):

  • SessionDetached clears the frontend's command_history entry and fires frontend.detached; the killring module's tables for that id are gone (probe via pmacs.killring._debug_sessions() or list introspection).

Everything else carries over from rev 2: M-x three-direction matrix, failed-kill/failed-yank no-ops, GPU-optimistic and unbound-key breaks, slot sync + external-content integration, yank-over-selection, cap validation (math.huge/NaN) and shrink-trim.