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:
- 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 viaAction::Run(src/editor.rs:707) or the self-insert fallback (:730); the post-command revision check firesbuffer.after-edit(:739). - 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. (Firesafter-edititself.) - Pointer gestures: grid
Mouse→dispatch_mouse(src/editor.rs:1114) and the semanticPointerpath (:1253) move the cursor and set/clear selections — no command boundary. Right-click additionally opens the context menu. - Inbound OS paste:
FrontendEvent::Paste→paste_inbound(editor_core.rs:2096) — but only on the grid path (apply_event,src/daemon.rs:2241). The semantic input dispatcher dropsPaste(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 GPUCtrl-Vis a no-op today — a shipped bug. Where paste does land (TUI bracketed paste), it fires nobuffer.after-edit— a second shipped gap (LSP never sees pasted text). And the grid handler trusts the client-supplied payload id: it setscore.active_frontendfromPaste.frontend_idrather than the dispatcher's authenticatedsource(DispatcherEvent::FrontendEvent { source, event },src/daemon.rs:1428) — a third shipped gap: a forged payload id pastes into another frontend's active window. - Context menu:
menu_invoke_active(src/editor.rs:946) callsinvoke_commanddirectly — no rotation, no post-command check. M-x: the minibuffer shadow returns before the post-command check (src/editor.rs:664), soM-x-invoked editing commands also missafter-edittoday.
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-k→M-x kill-line no-append; M-x kill-line→C-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'slast_kill_idis non-nil and equals the current head'sid.
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
rejected — NaN, 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):
- Slot check:
ed.clipboard_get()non-empty and ≠ head text → push it (external content joins the ring). - Ring empty →
"kill ring empty", no session. start= region lo or cursor;ed.clipboard_setif the slot differs;ed.clipboard_paste()(existing insert-over-region,cursor = start + len).- Session (per
FrontendId):{buffer, start, end, entry_id, text}—textis the snapshot actually inserted (rev 2 claimed to verify against it but never stored it — review catch),entry_idis 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:
- Locate
session.entry_idin 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'skill-ring-yank-pointerbehavior; an integer index would silently re-yank the wrong entry — review blocker). buf:replace(start, end, entry.text), then verify by re-slice.- On match:
ed.goto_byte(start + #entry.text), updateend,entry_id,text. - 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: FrontendId — DispatcherEvent::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.luabodies → acceptance.
Bets (score at close)
- 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.)
- Stable entry ids make shared-ring interleaving safe — no append-corruption, no index-shift mis-yanks, eviction invalidates cleanly.
- The unified paste route regresses nothing on the grid path while making GPU paste work at all.
- 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_idblocker case). - A
C-y/ B kills / AM-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-yrefuses cleanly.
Paste (Q#KR10a):
Pastereaches the buffer on the semantic path (the GPU case — asserting the shipped bug is fixed), breaks the chain, and firesbuffer.after-editexactly once; grid path unchanged.- Forged-id paste: frontend A sends
Pastewhose payloadfrontend_idnames 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-editprobe fires exactly once for menu Cut,M-x edit.kill-line, and a paste; no double-fire for keyboundC-k.
Lifecycle (Q#KR11):
SessionDetachedclears the frontend'scommand_historyentry and firesfrontend.detached; the killring module's tables for that id are gone (probe viapmacs.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.