Merge pull request #103 from levineuwirth/kill-ring

feat(edit): kill ring + yank-pop on a per-frontend command-boundary substrate
This commit is contained in:
Levi Neuwirth 2026-07-09 22:05:14 -04:00 committed by GitHub
commit 1873a96955
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 2142 additions and 55 deletions

View File

@ -200,21 +200,19 @@ cmd { name = "region.cancel",
-- bracketed paste also refreshes. The default bindings are the Emacs
-- kill/yank set (M-w / C-w / C-y, C-x h), which were all free.
-- The cut/copy/paste trio delegates to the kill ring (Arc 2): kills
-- accumulate, C-y yanks the head, M-y cycles older entries. Resolution
-- happens at invoke time, so chunk load order doesn't matter; the
-- context menu invokes these by name and inherits the ring for free.
cmd { name = "edit.copy",
description = "Copy the active region to the clipboard.",
fn = function()
if not ed.clipboard_copy() then ed.set_status("no region") end
end }
description = "Save the active region to the kill ring (and OS clipboard).",
fn = function() pmacs.killring.copy() end }
cmd { name = "edit.cut",
description = "Cut the active region to the clipboard.",
fn = function()
if not ed.clipboard_cut() then ed.set_status("no region") end
end }
description = "Kill the active region into the kill ring (and OS clipboard).",
fn = function() pmacs.killring.cut() end }
cmd { name = "edit.paste",
description = "Paste the clipboard at the cursor, replacing any region.",
fn = function()
if not ed.clipboard_paste() then ed.set_status("clipboard empty") end
end }
description = "Yank the most recent kill at the cursor, replacing any region.",
fn = function() pmacs.killring.yank() end }
cmd { name = "edit.select-all",
description = "Select the whole buffer.",
fn = function() ed.select_all() end }
@ -628,7 +626,11 @@ cmd { name = "editor.execute-command",
history = "command",
on_accept = function(name)
if name == nil or name == "" then return end
local ok, err = pcall(pmacs.command.invoke, name)
-- invoke_interactive records the command boundary (kill
-- ring Q#KR2), so chain-sensitive commands behave as under
-- Emacs's execute-extended-command: M-x kill-line then C-k
-- appends; C-k then M-x kill-line does not.
local ok, err = pcall(pmacs.command.invoke_interactive, name)
if not ok then
-- mlua's `tostring(err)` includes a Lua stack traceback
-- separated by newlines. The status line is one row;

View File

@ -67,6 +67,15 @@ define {
kind = "short-circuit",
}
define {
name = "frontend.detached",
description = "Fired when an attached frontend's session ends. " ..
"Receives the raw frontend id (integer). Modules keying " ..
"state by pmacs.frontend.id() (kill-ring sessions) drop " ..
"that id's entries here (Q#KR11).",
kind = "all-must-succeed",
}
define {
name = "process.after-tick",
description = "Fired once per editor frame, immediately after the process " ..

View File

@ -0,0 +1,358 @@
-- killring.lua --- the Emacs kill ring (Arc 2, kill-ring framing).
--
-- Kills accumulate here instead of overwriting the one clipboard slot:
-- consecutive kills append into one entry, `C-y` yanks the head, `M-y`
-- right after a yank cycles older entries. The ring is daemon-global
-- (shared across attached frontends, like the Emacs daemon); kill
-- chains and yank sessions are per-frontend, keyed by
-- `pmacs.frontend.id()` and checked against stable ring-entry ids so
-- one frontend's activity can never corrupt another's (Q#KR4/6/7).
--
-- The Rust substrate this rides on (Q#KR2): every input path either
-- rotates the per-frontend command boundary (commands) or breaks it
-- (optimistic CRDT edits, pointer gestures, pastes, unbound keys), so
-- `pmacs.editor.last_command()` is trustworthy on both frontends.
--
-- OS clipboard: the ring head is mirrored to the *acting frontend's*
-- OS clipboard on every kill/append (`ed.clipboard_set`); external
-- content joins the ring at yank time via the slot check (an OS copy
-- only reaches the daemon when pasted). `M-y` never touches the slot.
--
-- Framing: docs/kill-ring-framing.md.
pmacs.killring = pmacs.killring or {}
local ed = pmacs.editor
local DEFAULT_MAX = 60
local ring = {} -- array of { id, text }, most-recent first (shared)
local next_id = 1
local max_entries = DEFAULT_MAX
-- Per-frontend state (Q#KR4/KR6). Keyed by pmacs.frontend.id().
local last_kill_id = {} -- fid -> ring-entry id of that frontend's last kill
local sessions = {} -- fid -> { buffer, start, stop, entry_id, text }
-- Commands whose success may extend a kill chain (Q#KR4).
local KILL_CHAIN = { ["edit.kill-line"] = true, ["edit.cut"] = true }
local function trim()
while #ring > max_entries do
table.remove(ring)
end
end
-- max([n]) --- getter when nil; validated setter otherwise. Rejects
-- non-numbers, NaN, and non-finite values (math.huge would defeat the
-- cap); floors; lowering the cap trims existing entries immediately.
function pmacs.killring.max(n)
if n == nil then return max_entries end
if type(n) ~= "number" or n ~= n or n == math.huge or n < 1 then
error("pmacs.killring.max: expected a finite number >= 1")
end
max_entries = math.floor(n)
trim()
return max_entries
end
-- The ring's texts, most-recent first (introspection / tests).
function pmacs.killring.list()
local out = {}
for i, e in ipairs(ring) do out[i] = e.text end
return out
end
-- Test/debug seam (Q#KR11 lifecycle assertions).
function pmacs.killring._debug_state(fid)
return { session = sessions[fid], last_kill_id = last_kill_id[fid] }
end
-- Push `text` as a fresh entry (duplicate-of-head collapses, keeping
-- the existing id). Returns the head entry.
local function push_entry(text)
if ring[1] and ring[1].text == text then return ring[1] end
table.insert(ring, 1, { id = next_id, text = text })
next_id = next_id + 1
trim()
return ring[1]
end
-- A kill-family command failed or was a no-op: it must not leave a
-- live chain for the next kill to append to (Q#KR4).
local function fail_kill(fid)
last_kill_id[fid] = nil
end
-- Chain-aware kill (Q#KR4): append to the head iff the previous
-- command was a chain kill AND this frontend's last kill IS the
-- current head (another frontend's push in between means the head is
-- not ours — append would corrupt their entry). Mirrors the head to
-- the acting frontend's OS clipboard either way.
local function kill_push(fid, text)
local chained = KILL_CHAIN[ed.last_command() or ""]
and last_kill_id[fid] ~= nil
and ring[1] ~= nil
and ring[1].id == last_kill_id[fid]
local head
if chained then
ring[1].text = ring[1].text .. text
head = ring[1]
else
head = push_entry(text)
end
last_kill_id[fid] = head.id
ed.clipboard_set(head.text)
return head
end
-- edit.cut body (C-w): kill the active region into the ring.
function pmacs.killring.cut()
local fid = pmacs.frontend.id()
local region = ed.region()
local buf = pmacs.window.buffer()
if not region or not buf then
fail_kill(fid)
ed.set_status("no region")
return false
end
local rstart, rstop = region.start, region["end"]
local text = buf:slice(rstart, rstop)
-- Same intercept discipline as kill_line, via the mutator so the
-- EFFECTIVE edit is checkable exactly. The selection is cleared
-- explicitly (ed.delete_region did that as a side effect).
local dok, estart, estop, einserted = pcall(function()
return buf:delete(rstart, rstop)
end)
ed.clear_selection()
if not dok then
fail_kill(fid)
ed.set_status("kill rejected by buffer intercept")
return false
end
if estart ~= rstart or estop ~= rstop or einserted ~= 0 then
fail_kill(fid)
ed.set_status("kill altered by buffer intercept; ring not updated")
return false
end
ed.goto_byte(rstart)
kill_push(fid, text)
return true
end
-- edit.copy body (M-w): save the region to the ring without deleting.
-- Not a chain command (Q#KR4's family is kill-line + cut): a copy
-- pushes fresh (duplicate-of-head collapses) and neither extends nor
-- starts an append chain.
function pmacs.killring.copy()
local fid = pmacs.frontend.id()
local region = ed.region()
local buf = pmacs.window.buffer()
if not region or not buf then
fail_kill(fid)
ed.set_status("no region")
return false
end
local text = buf:slice(region.start, region["end"])
push_entry(text)
ed.clipboard_set(text)
fail_kill(fid) -- a copy is not an appendable kill
return true
end
-- edit.kill-line body (C-k): kill from the cursor to end of line; at
-- the newline itself, kill the newline (Emacs kill-line with
-- kill-whole-line nil). Consecutive C-k's append (Q#KR4), so
-- C-k C-k C-k builds one multi-line entry.
function pmacs.killring.kill_line()
local fid = pmacs.frontend.id()
local buf = pmacs.window.buffer()
if not buf then
fail_kill(fid)
return false
end
local cursor = ed.cursor()
local len = buf:len()
if cursor >= len then
fail_kill(fid)
ed.set_status("end of buffer")
return false
end
-- Find the next newline by chunked scan (lines are almost always
-- shorter than one chunk; a chunk loop keeps giant lines safe).
local eol = nil
local p = cursor
while p < len do
local chunk_to = math.min(p + 4096, len)
local chunk = buf:slice(p, chunk_to)
local nl = chunk:find("\n", 1, true)
if nl then
eol = p + nl - 1
break
end
p = chunk_to
end
local kill_to
if eol == cursor then
kill_to = cursor + 1 -- at the newline: kill the newline itself
else
kill_to = eol or len -- rest of the line (or of a final bare line)
end
local text = buf:slice(cursor, kill_to)
-- The delete runs the buffer's edit intercepts, which may REJECT
-- (error) or TRANSFORM the operation. A rejection must clear the
-- kill chain (or the next C-k would append to a kill that never
-- happened); a transformation means the bytes actually removed are
-- not `text`, so pushing `text` would put never-killed bytes on the
-- ring and the OS clipboard. The mutators return the EFFECTIVE edit
-- (post-intercept start/end/inserted), so this is an exact check —
-- a length delta would be defeated by an equal-length rewrite to a
-- different range.
local ok, estart, estop, einserted = pcall(function()
return buf:delete(cursor, kill_to)
end)
if not ok then
fail_kill(fid)
ed.set_status("kill rejected by buffer intercept")
return false
end
if estart ~= cursor or estop ~= kill_to or einserted ~= 0 then
fail_kill(fid)
ed.set_status("kill altered by buffer intercept; ring not updated")
return false
end
kill_push(fid, text)
return true
end
-- Drop a frontend's yank session (invalid M-y must not leave state a
-- second M-y could ride, Q#KR7).
local function drop_session(fid, msg)
sessions[fid] = nil
if msg then ed.set_status(msg) end
end
-- edit.paste body (C-y): yank the ring head (Q#KR6).
function pmacs.killring.yank()
local fid = pmacs.frontend.id()
-- Slot check: content that arrived via an OS paste (paste_inbound
-- refreshes the slot) joins the ring the first time it is yanked.
local slot = ed.clipboard_get()
if slot and slot ~= "" and (not ring[1] or ring[1].text ~= slot) then
push_entry(slot)
end
local head = ring[1]
if not head then
drop_session(fid, "kill ring empty")
return false
end
local region = ed.region()
local start = region and region.start or ed.cursor()
if not slot or slot ~= head.text then
ed.clipboard_set(head.text)
end
local ok = ed.clipboard_paste()
if not ok then
drop_session(fid) -- failed paste creates no session (Q#KR6)
return false
end
local buf = pmacs.window.buffer()
sessions[fid] = {
buffer = buf and tostring(buf) or "",
start = start,
stop = ed.cursor(),
entry_id = head.id,
text = head.text,
}
return true
end
-- edit.yank-pop body (M-y): replace the just-yanked text with the
-- next-older ring entry (Q#KR7). Valid only immediately after a yank
-- or another pop, with a live, still-verifiable session.
function pmacs.killring.yank_pop()
local fid = pmacs.frontend.id()
local lc = ed.last_command()
local s = sessions[fid]
if not (lc == "edit.paste" or lc == "edit.yank-pop") or not s then
drop_session(fid, "previous command was not a yank")
return false
end
local buf = pmacs.window.buffer()
if not buf or tostring(buf) ~= s.buffer then
drop_session(fid, "yank was in another buffer")
return false
end
-- Invalidation guard: the remembered range must still hold exactly
-- the text this session yanked. A concurrent edit (another
-- frontend, a hook) that moved or altered it fails here — refuse
-- rather than splice garbage. The slice is pcall'd: an upstream
-- deletion can shrink the buffer below `stop`, and an out-of-bounds
-- range must read as "changed", not throw.
local ok, current = pcall(function() return buf:slice(s.start, s.stop) end)
if not ok or current ~= s.text then
drop_session(fid, "buffer changed since the yank")
return false
end
-- Stable-id rotation: find where this session's entry sits NOW
-- (other frontends' pushes shift positions, not ids) and step to
-- the next older, wrapping. An evicted id invalidates.
local pos = nil
for i, e in ipairs(ring) do
if e.id == s.entry_id then
pos = i
break
end
end
if not pos then
drop_session(fid, "kill ring entry expired")
return false
end
local entry = ring[pos % #ring + 1]
-- The replace runs buffer intercepts, which may REJECT (error) or
-- TRANSFORM. A rejection must still end the session — letting the
-- error propagate would leave `sessions[fid]` live for a second M-y
-- to reuse. And the verification must be EXACT: the mutator returns
-- the effective edit, and any deviation from the requested
-- (start, stop, #text) — e.g. an intercept enlarging `stop` by one
-- byte, silently deleting extra content — ends the session (the
-- interceptor's result stands; accepted post-hoc semantics, Q#KR7).
local rok, estart, estop, einserted = pcall(function()
return buf:replace(s.start, s.stop, entry.text)
end)
if not rok then
drop_session(fid, "yank-pop rejected by buffer intercept")
return false
end
if estart ~= s.start or estop ~= s.stop or einserted ~= #entry.text then
drop_session(fid, "yank-pop altered by buffer intercept; stopped")
return false
end
ed.goto_byte(s.start + #entry.text)
s.stop = s.start + #entry.text
s.entry_id = entry.id
s.text = entry.text
return true
end
-- Q#KR11: a detached frontend's chain/session state must not outlive
-- it (ids are monotonic; these tables would grow forever).
pmacs.hook.add("frontend.detached", function(fid)
sessions[fid] = nil
last_kill_id[fid] = nil
end)
pmacs.command.define {
name = "edit.kill-line",
description = "Kill from the cursor to the end of the line (into the kill ring).",
fn = function() pmacs.killring.kill_line() end,
}
pmacs.command.define {
name = "edit.yank-pop",
description = "Replace the just-yanked text with the previous kill (after C-y).",
fn = function() pmacs.killring.yank_pop() end,
}
pmacs.keymap.bind { scope = "global", sequence = "C-k", command = "edit.kill-line" }
pmacs.keymap.bind { scope = "global", sequence = "M-y", command = "edit.yank-pop" }

352
docs/kill-ring-framing.md Normal file
View File

@ -0,0 +1,352 @@
# 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 `Mouse``dispatch_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::Paste``paste_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-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'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
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):
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: 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.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 `FrontendId`s)
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.

View File

@ -1540,6 +1540,30 @@ fn handle_dispatcher_event(
editor.dispatch_menu_pointer(source, index, invoke);
}
}
FrontendEvent::Paste {
frontend_id: claimed_fid,
data,
} => {
// Kill ring Q#KR10a — the unified paste route, for
// BOTH attachment kinds. Handled here (not in
// `apply_event`) for two reasons:
//
// 1. The semantic input dispatcher used to drop
// `Paste` entirely, so GPU Ctrl-V was a no-op
// (pmacs-gpu always negotiates semantic render).
// 2. The authenticated `source` is in scope. The
// event's `claimed_fid` is client-supplied and
// not trusted (the CrdtOp / Viewport / Pointer
// source-trust rule); the old grid arm set
// `active_frontend` from it, letting a forged id
// paste into another frontend's active window.
//
// The paste is a non-command edit, so it breaks the
// source's command chain (Q#KR2), and it fires
// `buffer.after-edit` like any other edit (Q#KR10b)
// — previously it never did, so LSP missed pastes.
handle_inbound_paste(editor, source, claimed_fid, &data);
}
_ => {
let term_size = *term_sizes
.get(&source)
@ -1579,10 +1603,21 @@ fn handle_dispatcher_event(
last_dispatch_idle_sent.remove(&frontend_id);
last_active_buffer_sent.remove(&frontend_id);
session_registry.unregister_session(frontend_id);
editor
.core
.borrow_mut()
.unregister_frontend_view(frontend_id);
{
let mut core = editor.core.borrow_mut();
core.unregister_frontend_view(frontend_id);
// Kill ring Q#KR11: frontend ids are monotonic, so
// per-frontend state must not outlive the session.
core.command_history.remove(&frontend_id);
}
// Q#KR11: let Lua modules holding per-frontend tables
// (killring sessions / kill flags) drop this id's entries.
// The first frontend-lifecycle hook; carries the raw id.
let mut args = mlua::MultiValue::new();
args.push_back(mlua::Value::Integer(
i64::try_from(frontend_id.0).unwrap_or(i64::MAX),
));
editor.lua_host.run_hook("frontend.detached", args);
}
}
}
@ -1914,6 +1949,35 @@ fn validate_remote_crdt_op(
Ok(())
}
/// The unified inbound-paste route (kill ring Q#KR10a) — one handler
/// for grid *and* semantic sessions, keyed by the dispatcher's
/// authenticated `source`. `claimed` is the event payload's
/// client-supplied id: never trusted (a forged id must not paste into
/// another frontend's active window), only logged on mismatch. The
/// paste breaks the source's command chain (a non-command edit, Q#KR2)
/// and fires `buffer.after-edit` when the buffer changed (Q#KR10b).
fn handle_inbound_paste(
editor: &mut EditorState,
source: FrontendId,
claimed: FrontendId,
data: &[u8],
) {
if claimed != source {
eprintln!(
"pmacs daemon: Paste claimed {claimed:?} but came \
from {source:?}; using the authenticated source"
);
}
editor.core.borrow_mut().active_frontend = source;
editor.with_after_edit_check(|state| {
let mut core = state.core.borrow_mut();
core.break_command_chain(source);
if let Err(e) = core.paste_inbound(data) {
eprintln!("pmacs: inbound paste failed: {e}");
}
});
}
/// T M10.10 (post-audit) — apply a *pre-validated*
/// `FrontendEvent::CrdtOp`. Identity, capability, and scope checks
/// happen upstream in `validate_remote_crdt_op`; this function trusts
@ -1944,6 +2008,12 @@ fn handle_remote_crdt_op(
buffer_id: crate::buffer::BufferId,
op: crate::rope::CrdtOp,
) {
// Kill ring Q#KR2: an optimistic edit is non-command input — GPU
// typing, Enter/Tab, Backspace/Delete all arrive here without ever
// touching dispatch_key. It must break the source frontend's
// command chain, or `C-k x C-k` on the GPU would append across the
// typed character.
editor.core.borrow_mut().break_command_chain(source);
// Effect 1: apply to buffer's CRDT + rope. Capture the Edit
// (or `None` for an op that imported cleanly but produced no
// text delta — F17).
@ -2193,9 +2263,10 @@ fn build_presence_snapshot(editor: &EditorState, frontend_id: FrontendId) -> Pre
/// arms of [`apply_event`] but takes no `RenderState` — a semantic
/// frontend lays out locally, so the only state these events touch is
/// the editor core (cursor, buffer, commands), which `dispatch_key` /
/// `dispatch_mouse` operate on directly. `Resize` / `Paste` / `Focus`
/// have no grid-less effect yet and are dropped; `Viewport` / `CrdtOp`
/// are handled in their own dispatcher arms and never reach here.
/// `dispatch_mouse` operate on directly. `Resize` / `Focus` have no
/// grid-less effect yet and are dropped; `Viewport` / `CrdtOp` /
/// `Paste` (Q#KR10a) are handled in their own dispatcher arms and
/// never reach here.
#[allow(clippy::needless_pass_by_value)] // consumes the event, mirroring `apply_event`.
fn apply_semantic_input_event(editor: &mut EditorState, ev: FrontendEvent, term_size: CellSize) {
match ev {
@ -2238,20 +2309,13 @@ fn apply_event(
render_state.resize(size);
*term_size = size;
}
FrontendEvent::Paste { frontend_id, data } => {
// Q#CM6 — inbound OS paste (terminal bracketed paste, or
// GPU Ctrl-V reading `arboard`). Insert at the cursor of the
// originating frontend's active window, replacing any region,
// and refresh the clipboard slot so a later in-app paste
// repeats the same text. (Previously dropped: pmacs had
// never honored a paste.)
let mut core = editor.core.borrow_mut();
core.active_frontend = frontend_id;
if let Err(e) = core.paste_inbound(&data) {
eprintln!("pmacs: inbound paste failed: {e}");
}
}
FrontendEvent::FocusGained(_)
// Q#KR10a — Paste is handled in the dispatcher's own
// `FrontendEvent::Paste` arm (unified for grid and semantic
// sessions, keyed by the authenticated source), and never
// reaches here. Listed explicitly so a future reshuffle can't
// silently re-route it through this payload-trusting path.
FrontendEvent::Paste { .. }
| FrontendEvent::FocusGained(_)
| FrontendEvent::FocusLost(_)
// T M11.1: the semantic-frontend viewport declaration. Its
// consumer is the instance-side projection seam
@ -2447,6 +2511,188 @@ mod tests {
);
}
/// Kill ring Q#KR2 — an optimistic edit is non-command input: GPU
/// typing arrives here without touching dispatch_key, so it must
/// break the source frontend's command chain or `C-k x C-k` on the
/// GPU would append across the typed character.
#[cfg(feature = "crdt")]
#[test]
fn handle_remote_crdt_op_breaks_the_source_command_chain() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
let mut editor = EditorState::new();
let source = FrontendId(7);
// A live kill chain for the source frontend...
editor
.core
.borrow_mut()
.rotate_command(source, "edit.kill-line");
// ...and one for a bystander that must survive.
editor
.core
.borrow_mut()
.rotate_command(FrontendId::LOCAL, "edit.kill-line");
let buffer_id = editor.core.borrow().active_window().buffer_id;
{
let core = editor.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.get_mut(buffer_id)
.expect("active buffer")
.upgrade_to_crdt(2)
.expect("upgrade to crdt");
}
let snapshot_bytes = {
let core = editor.core.borrow();
let reg = core.registry.borrow();
reg.get(buffer_id)
.expect("buffer")
.crdt_state()
.expect("crdt-backed")
.export_snapshot()
.expect("export snapshot")
};
let peer = loro::LoroDoc::new();
peer.set_peer_id(7).expect("set peer id");
peer.import(&snapshot_bytes).expect("import snapshot");
let v_before = peer.oplog_vv();
peer.get_text("body").insert(0, "x").expect("peer insert");
let op_bytes = peer
.export(loro::ExportMode::updates(&v_before))
.expect("export op");
handle_remote_crdt_op(
&mut editor,
source,
buffer_id,
crate::rope::CrdtOp {
peer_id: 7,
bytes: op_bytes,
},
);
let core = editor.core.borrow();
assert!(
core.command_history
.get(&source)
.is_none_or(|b| b.this.is_none()),
"the optimistic edit must break the source's chain"
);
assert_eq!(
core.command_history
.get(&FrontendId::LOCAL)
.and_then(|b| b.this.as_deref()),
Some("edit.kill-line"),
"a bystander frontend's chain is untouched"
);
}
/// Kill ring Q#KR10a — the unified paste route trusts only the
/// dispatcher's authenticated source. A forged payload id must not
/// paste into another frontend's active window, and the paste
/// breaks the SOURCE's chain (not the claimed frontend's) and
/// fires `buffer.after-edit` exactly once.
#[test]
fn inbound_paste_uses_authenticated_source_not_the_claimed_id() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
use crate::text_view::TextView;
use crate::window::{FrontendView, Layout, Window, WindowId};
let mut editor = EditorState::new();
editor
.lua_host
.eval(
Some("test"),
r#"
_G.PASTE_AFTER_EDIT = 0
pmacs.hook.add("buffer.after-edit", function()
_G.PASTE_AFTER_EDIT = _G.PASTE_AFTER_EDIT + 1
end)
"#,
)
.expect("install after-edit hook");
// Give the attacker frontend its OWN view onto its own buffer,
// so "which window did the text land in" is observable.
let source = FrontendId(7);
let victim = FrontendId::LOCAL;
let attacker_buf = {
let core = editor.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.create("attacker-buffer")
};
{
let mut core = editor.core.borrow_mut();
let tv = {
let reg = core.registry.borrow();
TextView::new(reg.get(attacker_buf).expect("attacker buffer"))
};
let wid = WindowId::next();
core.windows.insert(wid, Window::new(wid, attacker_buf, tv));
core.register_frontend_view(
source,
FrontendView {
layout: Layout::single(wid),
active: wid,
},
);
}
let victim_buf = editor.core.borrow().active_window().buffer_id;
// Seed a live chain on the victim: the forged paste must not
// break it (only the authenticated source's chain breaks).
editor
.core
.borrow_mut()
.rotate_command(victim, "edit.kill-line");
// The payload CLAIMS to be the victim.
handle_inbound_paste(&mut editor, source, victim, b"FORGED");
let core = editor.core.borrow();
let text_of = |id| {
let reg = core.registry.borrow();
let buf = reg.get(id).expect("buffer");
let len = buf.len();
let mut out = vec![0u8; usize::try_from(len).unwrap_or(0)];
if len > 0 {
buf.snapshot_rope().slice(0, len, &mut out);
}
String::from_utf8_lossy(&out).into_owned()
};
assert!(
text_of(attacker_buf).contains("FORGED"),
"the paste lands in the AUTHENTICATED source's active window"
);
assert!(
!text_of(victim_buf).contains("FORGED"),
"a forged payload id must not paste into the claimed frontend's window"
);
assert!(
core.command_history
.get(&source)
.is_none_or(|b| b.this.is_none()),
"the paste breaks the source's chain"
);
assert_eq!(
core.command_history
.get(&victim)
.and_then(|b| b.this.as_deref()),
Some("edit.kill-line"),
"the claimed frontend's chain is untouched"
);
drop(core);
let count = editor
.lua_host
.eval(Some("test-readback"), "return _G.PASTE_AFTER_EDIT")
.expect("read counter");
assert!(
matches!(count, mlua::Value::Integer(1)),
"paste fires buffer.after-edit exactly once, got {count:?}"
);
}
/// v15 regression: an optimistic-path edit (the bulk of plain-char
/// typing from a semantic frontend) must clear the transient
/// status message, exactly as `dispatch_key`'s entry clear does

View File

@ -327,6 +327,12 @@ impl EditorState {
include_str!("../builtin/runtime/autosave.lua"),
)
.expect("load autosave builtin chunk");
lua_host
.eval(
Some("@pmacs/builtin/runtime/killring.lua"),
include_str!("../builtin/runtime/killring.lua"),
)
.expect("load killring builtin chunk");
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
// was loaded directly via `eval(include_str!(...))`; the
// M7.11 deliverable migrates it to the package system so it
@ -706,6 +712,10 @@ impl EditorState {
match action {
Action::Run { command, .. } => {
// Kill ring Q#KR2: record the command boundary before the
// body runs, so the body's own `ed.last_command()` reads
// its *predecessor* (Emacs `last-command` semantics).
self.core.borrow_mut().rotate_command(frontend_id, &command);
if let Err(e) = self
.lua_host
.invoke_command(&command, mlua::MultiValue::new())
@ -723,20 +733,28 @@ impl EditorState {
// shadow instead of the dispatcher.
self.core.borrow_mut().completion_popup_close();
}
Action::Unbound { sequence } => match printable_char(&sequence) {
Some(ch) => {
Action::Unbound { sequence } => {
if let Some(ch) = printable_char(&sequence) {
// Typing a character is a command too (Q#KR2): it
// must break a kill chain — `C-k x C-k` is two ring
// entries, not an append.
self.core
.borrow_mut()
.rotate_command(frontend_id, "buffer.self-insert");
let mut args = mlua::MultiValue::new();
args.push_back(mlua::Value::Integer(ch as i64));
if let Err(e) = self.lua_host.invoke_command("buffer.self-insert", args) {
self.core.borrow_mut().status =
format!("self-insert failed: {}", first_line(&e.to_string()));
}
}
None => {
} else {
// An unbound key still breaks the chain (Q#KR2) —
// Emacs's `undefined` runs as a command.
self.core.borrow_mut().break_command_chain(frontend_id);
self.core.borrow_mut().status =
format!("{}: not bound", display_sequence(&sequence));
}
},
}
}
let post_revision = self.active_buffer_revision();
@ -761,6 +779,32 @@ impl EditorState {
self.buffer_revision(id)
}
/// Run `f`, then fire `buffer.after-edit` if the active buffer's
/// revision changed — the same compare `dispatch_key` performs
/// after a keybound command (kill ring Q#KR10b).
///
/// For call sites that execute edits *outside* `dispatch_key`'s
/// post-command check: the minibuffer accept callback (`M-x`), the
/// menu invoke, and the unified paste route. Without it, those
/// edits are invisible to LSP `didChange`, the syntax reparse, and
/// autosave's observers.
///
/// Scope, honestly: the *active-buffer* before/after compare is
/// sound for these paths (all edit the active buffer and stay
/// there) but is not a general any-buffer guarantee — a callback
/// that edits buffer A then switches to B evades it. The general
/// fix is a buffer-aware edit epoch; deferred, named in the
/// kill-ring framing.
pub(crate) fn with_after_edit_check(&mut self, f: impl FnOnce(&mut Self)) {
let pre = self.active_buffer_revision();
f(self);
let post = self.active_buffer_revision();
if pre != post {
self.lua_host
.run_hook("buffer.after-edit", mlua::MultiValue::new());
}
}
/// Edit revision of a specific buffer, or `None` if the registry no
/// longer knows it. Used by the query-replace shadow to compare the
/// *edited* (origin) buffer, not whichever is active.
@ -946,13 +990,28 @@ impl EditorState {
fn menu_invoke_active(&mut self) {
let command = self.core.borrow().menu_active_command();
self.core.borrow_mut().menu_close();
if let Some(command) = command
&& let Err(e) = self
.lua_host
.invoke_command(&command, mlua::MultiValue::new())
{
self.core.borrow_mut().status =
format!("error in {command}: {}", first_line(&e.to_string()));
if let Some(command) = command {
// A menu item is an interactive command (kill ring Q#KR2):
// rotate the boundary so a menu Cut chains like a keybound
// one. The invoke below bypasses dispatch_key, which would
// otherwise leave the boundary stale.
{
let mut core = self.core.borrow_mut();
let fid = core.active_frontend;
core.rotate_command(fid, &command);
}
// Q#KR10b: menu invocation bypasses dispatch_key's
// revision check — a menu Cut's edit must still fire
// `buffer.after-edit`.
self.with_after_edit_check(|state| {
if let Err(e) = state
.lua_host
.invoke_command(&command, mlua::MultiValue::new())
{
state.core.borrow_mut().status =
format!("error in {command}: {}", first_line(&e.to_string()));
}
});
}
}
@ -1062,12 +1121,18 @@ impl EditorState {
.create_string(&contents)
.expect("Lua VM out of memory while building minibuffer callback args"),
));
if let Err(e) = on_accept.call::<mlua::MultiValue>(args) {
self.core.borrow_mut().status = format!(
"minibuffer on_accept failed: {}",
first_line(&e.to_string())
);
}
// Q#KR10b: the accept callback runs outside dispatch_key's
// post-command revision check (the minibuffer interception
// returns before it), so an M-x'd editing command would never
// fire `buffer.after-edit` without this wrapper.
self.with_after_edit_check(|state| {
if let Err(e) = on_accept.call::<mlua::MultiValue>(args) {
state.core.borrow_mut().status = format!(
"minibuffer on_accept failed: {}",
first_line(&e.to_string())
);
}
});
}
fn minibuffer_cancel(&mut self) {
@ -1159,6 +1224,12 @@ impl EditorState {
self.mouse_click = None;
return; // Mode-line click: reserved.
}
// Point moves: break the command chain (kill ring
// Q#KR2). Scroll arms below deliberately do NOT — a
// wheel that only moves the viewport preserves a kill
// chain, as in Emacs (`mwheel-scroll` vs
// `mouse-set-point`).
self.core.borrow_mut().break_command_chain(frontend_id);
let click_cell = CellCoord::new(cell_row, cell_col);
let is_double_click = self.is_double_click(frontend_id, win_id, click_cell);
self.activate_and_position(win_id, local_row, local_col);
@ -1181,10 +1252,12 @@ impl EditorState {
if local_row >= inner_rows {
return;
}
self.core.borrow_mut().break_command_chain(frontend_id);
self.activate_and_position(win_id, local_row, local_col);
}
MouseEventKind::Up(MouseButton::Left) => {
let mut core = self.core.borrow_mut();
core.break_command_chain(frontend_id);
if let Some(sel) = core.active_window().selection
&& sel.anchor == core.cursor()
{
@ -1197,6 +1270,8 @@ impl EditorState {
return; // Mode-line right-click: reserved.
}
self.mouse_click = None;
// Opening the menu is a pointer gesture too (Q#KR2).
self.core.borrow_mut().break_command_chain(frontend_id);
self.open_context_menu(win_id, local_row, local_col, (cell_row, cell_col));
}
MouseEventKind::ScrollUp => {
@ -1271,6 +1346,12 @@ impl EditorState {
return;
}
core.set_active_window_id(win_id);
// Every PointerKind moves point or changes the selection (the
// GPU scrolls locally via Viewport, which never reaches here),
// so any pointer gesture breaks the frontend's command chain
// (kill ring Q#KR2) — clicking away and killing again must not
// append, and M-y after a click must refuse.
core.break_command_chain(frontend_id);
let byte = {
let registry = core.registry.clone();
let reg = registry.borrow();
@ -1348,6 +1429,12 @@ impl EditorState {
return;
}
core.set_active_window_id(win_id);
// A context right-click is a pointer gesture (kill ring
// Q#KR2): it must break the chain like the grid path's
// right-click does. The semantic dispatcher routes
// PointerKind::Context here directly, bypassing
// dispatch_pointer's break.
core.break_command_chain(frontend_id);
if core.active_region().is_none() {
let snapped = {
let registry = core.registry.clone();

View File

@ -123,6 +123,25 @@ pub struct QueryReplaceSession {
found_any: bool,
}
/// One frontend's command boundary — Emacs's `this-command` /
/// `last-command` pair (kill ring, Q#KR2).
///
/// `this` is the command currently (or most recently) executing for the
/// frontend; `last` is the one before it. A chain-sensitive command
/// (kill append, `M-y`) reads `last` *while it runs* — its own rotation
/// already moved its predecessor there. `this = None` is a broken
/// chain: some non-command input (an optimistic CRDT edit, a pointer
/// gesture, a paste, an unbound key) intervened, so the next rotation
/// makes `last = None` and every chain check fails.
#[derive(Debug, Clone, Default)]
pub struct CommandBoundary {
/// The command executing now / most recently, or `None` after a
/// non-command input.
pub this: Option<String>,
/// The command before `this`.
pub last: Option<String>,
}
/// The world state mutated by editor commands.
pub struct EditorCore {
/// Shared buffer registry. The registry is the canonical owner
@ -217,6 +236,15 @@ pub struct EditorCore {
/// frontend, which writes the OS clipboard (OSC 52 in the TUI,
/// `arboard` in the GPU). Drained per-tick like `pending_crdt_ops`.
pending_clipboard: Option<(FrontendId, Vec<u8>)>,
/// Per-frontend command boundaries (kill ring, Q#KR2) — Emacs's
/// `this-command` / `last-command`, tracked **per frontend**: two
/// attached frontends interleave their own command streams, and a
/// kill chain or yank session on frontend A must not survive into
/// frontend B's checks. Every input path updates this — commands
/// rotate; non-command inputs (optimistic CRDT edits, pointer
/// gestures, pastes, unbound keys) break the chain. Entries are
/// pruned on `SessionDetached` (Q#KR11).
pub command_history: HashMap<FrontendId, CommandBoundary>,
/// Open context menu (Q#CM1), or `None` when closed. Shared
/// `Arc<Mutex>` so the TUI [`crate::menu::MenuView`] overlay renders
/// from the same state the dispatch path mutates — the menu twin of
@ -280,6 +308,7 @@ impl EditorCore {
search: None,
clipboard_slot: Vec::new(),
pending_clipboard: None,
command_history: HashMap::new(),
menu: crate::menu::make_shared_menu(),
completion_popup: crate::completion::make_shared_popup(),
round_trip_buffers: std::collections::HashSet::new(),
@ -2043,6 +2072,38 @@ impl EditorCore {
Some(out)
}
// ---- command boundaries (kill ring, Q#KR2) --------------------------
/// Record `name` as `fid`'s executing command: `last = this;
/// this = name`. Called once per interactive command dispatch —
/// keybound commands, the self-insert fallback, menu items, and
/// `pmacs.command.invoke_interactive` (`M-x`).
pub fn rotate_command(&mut self, fid: FrontendId, name: &str) {
let entry = self.command_history.entry(fid).or_default();
entry.last = entry.this.take();
entry.this = Some(name.to_owned());
}
/// Break `fid`'s command chain: a non-command input happened (an
/// optimistic CRDT edit, a point-moving pointer gesture, an inbound
/// paste, an unbound key). Sets `this = None`, so the next
/// rotation yields `last = None` and every chain-sensitive check
/// (kill append, `M-y`) fails.
pub fn break_command_chain(&mut self, fid: FrontendId) {
self.command_history.entry(fid).or_default().this = None;
}
/// The active frontend's previous command — Emacs's `last-command`
/// as observed *from inside* the currently-running command (its own
/// rotation already happened).
#[must_use]
pub fn last_command(&self) -> Option<&str> {
self.command_history
.get(&self.active_frontend)?
.last
.as_deref()
}
/// Copy the active region into the clipboard slot and queue an
/// outbound OS-clipboard publish to the originating frontend.
/// Returns `false` (a no-op) when there is no region.
@ -2069,6 +2130,28 @@ impl EditorCore {
Ok(true)
}
/// Set the clipboard slot to arbitrary bytes and queue the
/// OS-clipboard publish to the acting frontend (kill ring Q#KR1).
/// The ring's kills have no region for [`Self::clipboard_copy`] to
/// read (`C-k`'s killed line, an appended chain), so the Lua ring
/// pushes the exact bytes here.
pub fn clipboard_set(&mut self, bytes: Vec<u8>) {
self.clipboard_slot.clone_from(&bytes);
self.pending_clipboard = Some((self.active_frontend, bytes));
}
/// The clipboard slot's current bytes, or `None` when empty (kill
/// ring Q#KR6 — the yank-time "did external content arrive via a
/// paste since our last kill" check).
#[must_use]
pub fn clipboard_get(&self) -> Option<&[u8]> {
if self.clipboard_slot.is_empty() {
None
} else {
Some(&self.clipboard_slot)
}
}
/// Paste the clipboard slot at the cursor, replacing the active
/// region if one exists (one undo step, like CUA type-over).
/// Returns `false` when the slot is empty.

View File

@ -1198,7 +1198,7 @@ fn add_mutation_methods<M: UserDataMethods<BufferIdLua>>(methods: &mut M) {
bypass_intercept,
)?;
notify_buffer_edit_to_windows(lua, this.0, &edit);
Ok(())
effective_edit_triple(&edit)
},
);
@ -1209,7 +1209,7 @@ fn add_mutation_methods<M: UserDataMethods<BufferIdLua>>(methods: &mut M) {
let bypass_intercept = parse_bypass_intercept(opts.as_ref())?;
let edit = run_buffer_edit(lua, this.0, EditOp::Delete { range }, bypass_intercept)?;
notify_buffer_edit_to_windows(lua, this.0, &edit);
Ok(())
effective_edit_triple(&edit)
},
);
@ -1229,7 +1229,7 @@ fn add_mutation_methods<M: UserDataMethods<BufferIdLua>>(methods: &mut M) {
bypass_intercept,
)?;
notify_buffer_edit_to_windows(lua, this.0, &edit);
Ok(())
effective_edit_triple(&edit)
},
);
}
@ -1243,6 +1243,22 @@ fn parse_bypass_intercept(opts: Option<&Table>) -> mlua::Result<bool> {
})
}
/// The mutators' Lua return value: the **effective** edit after buffer
/// intercepts ran — `(start, end, inserted_len)` of the operation that
/// was actually applied (kill ring review round 4). An intercept may
/// legally rewrite an op's range; callers that must know exactly what
/// happened (killring's C-k / M-y) compare these against what they
/// requested instead of inferring from length deltas, which an
/// equal-length rewrite defeats.
fn effective_edit_triple(edit: &crate::rope::Edit) -> mlua::Result<(i64, i64, i64)> {
let cvt = |v: u64| i64::try_from(v).map_err(mlua::Error::external);
Ok((
cvt(edit.range.start)?,
cvt(edit.range.end)?,
cvt(edit.inserted_len)?,
))
}
fn run_buffer_edit(
lua: &Lua,
id: BufferId,
@ -4652,6 +4668,42 @@ fn install_command_module(lua: &Lua, commands: &SharedCommandRegistry) -> mlua::
)?;
}
{
// invoke_interactive(name, ...): like `invoke`, but records a
// command boundary first (kill ring Q#KR2) — `last = this;
// this = name` for the active frontend. Used by
// `editor.execute-command` (M-x) so the invoked command's
// chain semantics match Emacs's `execute-extended-command`
// (which sets `this-command`): `M-x edit.kill-line` then `C-k`
// appends, while `C-k` then `M-x edit.kill-line` does not.
//
// Plain `invoke` deliberately stamps NOTHING: it is a public
// programmatic API called from wrappers, hooks, and async
// callbacks, and must never pollute interactive command
// history.
let cmds = commands.clone();
command.set(
"invoke_interactive",
lua.create_function(move |lua, (name, args): (String, Variadic<Value>)| {
if let Some(core) = lua.app_data_ref::<SharedCore>() {
let mut core = core.borrow_mut();
let fid = core.active_frontend;
core.rotate_command(fid, &name);
}
let body = {
let r = cmds.borrow();
r.get(&name)
.ok_or_else(|| {
mlua::Error::external(CommandError::NotFound { name: name.clone() })
})?
.body
.clone()
};
body.call::<Variadic<Value>>(args)
})?,
)?;
}
{
let cmds = commands.clone();
command.set(
@ -10935,6 +10987,18 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
})?,
)?;
}
{
// last_command(): the active frontend's previous interactive
// command — Emacs's `last-command` as seen from inside the
// running command (kill ring Q#KR2). `nil` after a non-command
// input (optimistic edit, pointer gesture, paste, unbound key)
// broke the chain.
let cc = core.clone();
editor.set(
"last_command",
lua.create_function(move |_, ()| Ok(cc.borrow().last_command().map(str::to_owned)))?,
)?;
}
{
// view_top(): the active window's first visible source line.
// The saveplace getter (Arc 3) — pairs with set_view_top so a
@ -11107,6 +11171,35 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
})?,
)?;
}
{
// clipboard_set(bytes): set the slot + queue the OS publish to
// the acting frontend (kill ring Q#KR1). The ring's kills have
// no region for clipboard_copy to read.
let cc = core.clone();
editor.set(
"clipboard_set",
lua.create_function(move |_, bytes: mlua::String| {
cc.borrow_mut().clipboard_set(bytes.as_bytes().to_vec());
Ok(())
})?,
)?;
}
{
// clipboard_get() -> string?: the slot's bytes (kill ring
// Q#KR6 — yank's "did external content arrive via a paste
// since our last kill" check). nil when empty.
let cc = core.clone();
editor.set(
"clipboard_get",
lua.create_function(move |lua, ()| {
let cc = cc.borrow();
match cc.clipboard_get() {
Some(bytes) => Ok(Some(lua.create_string(bytes)?)),
None => Ok(None),
}
})?,
)?;
}
{
let cc = core.clone();
editor.set(

View File

@ -0,0 +1,857 @@
//! Kill-ring acceptance (Arc 2, docs/kill-ring-framing.md rev 3).
//!
//! Drives the real dispatch surfaces: `dispatch_key` for chords (both
//! "frontends" via distinct `FrontendId`s — an unregistered id falls
//! back to LOCAL's view, sharing the buffer while keeping its own
//! command boundary, which is exactly the shared-ring interleaving
//! shape), `dispatch_mouse` / `dispatch_pointer` for the pointer
//! boundary rows, and the real minibuffer for `M-x`.
//!
//! The daemon-side rows (optimistic CRDT edits, the unified
//! authenticated-source paste) are covered by unit tests next to
//! `handle_remote_crdt_op` / `handle_inbound_paste` in `src/daemon.rs`.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn ctrl(s: &mut EditorState, c: char) {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(c), KeyModifiers::CONTROL),
);
}
fn alt(s: &mut EditorState, c: char) {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT));
}
fn press(s: &mut EditorState, code: KeyCode) {
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
}
fn type_str(s: &mut EditorState, text: &str) {
for ch in text.chars() {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
fn ring(s: &EditorState) -> Vec<String> {
eval(s, "return pmacs.killring.list()")
}
fn buffer_text(s: &EditorState) -> String {
let b: mlua::String = eval(
s,
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
);
String::from_utf8_lossy(&b.as_bytes()).into_owned()
}
fn status(s: &EditorState) -> String {
s.core.borrow().status.clone()
}
/// Fresh editor whose scratch buffer holds `text`, cursor at 0.
fn editor_with(text: &str) -> EditorState {
let mut s = EditorState::new();
type_str(&mut s, text);
exec(&s, "pmacs.editor.goto_byte(0)");
s
}
// ---------------------------------------------------------------------------
// Chain mechanics
// ---------------------------------------------------------------------------
#[test]
fn kill_line_kills_to_eol_and_the_newline_separately() {
let mut s = editor_with("hello\nworld");
ctrl(&mut s, 'k'); // kills "hello"
assert_eq!(buffer_text(&s), "\nworld");
assert_eq!(ring(&s), vec!["hello"]);
// Cursor now sits at the newline: C-k kills the newline itself —
// and, being consecutive, APPENDS.
ctrl(&mut s, 'k');
assert_eq!(buffer_text(&s), "world");
assert_eq!(ring(&s), vec!["hello\n"], "consecutive C-k appends");
}
#[test]
fn consecutive_kills_build_one_entry_and_sync_the_clipboard() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one"
ctrl(&mut s, 'k'); // "\n"
ctrl(&mut s, 'k'); // "two"
assert_eq!(ring(&s), vec!["one\ntwo"]);
// OS slot mirrors the appended head (Q#KR4).
let slot: String = eval(&s, "return pmacs.editor.clipboard_get()");
assert_eq!(slot, "one\ntwo");
}
#[test]
fn movement_breaks_the_kill_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one"; buffer now "\ntwo\n"
press(&mut s, KeyCode::Down); // a cursor command: chain broken
exec(&s, "pmacs.editor.goto_byte(1)"); // start of "two"
ctrl(&mut s, 'k'); // "two"
assert_eq!(ring(&s), vec!["two", "one"], "two entries, no append");
}
#[test]
fn self_insert_breaks_the_kill_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one" (line now "\ntwo\n", cursor 0)
type_str(&mut s, "x"); // buffer "x\ntwo\n"
ctrl(&mut s, 'k'); // kills "" ... wait: cursor after 'x' is 1, at "\n"
// cursor sits at the newline → kills it.
assert_eq!(ring(&s), vec!["\n", "one"], "self-insert broke the chain");
}
#[test]
fn an_unbound_key_breaks_the_kill_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k');
press(&mut s, KeyCode::F(12)); // unbound, not printable
assert!(status(&s).contains("not bound"));
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k');
assert_eq!(ring(&s).len(), 2, "unbound key broke the chain");
}
#[test]
fn failed_kill_does_not_leave_an_appendable_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // ring: ["one"]
ctrl(&mut s, 'w'); // no region → fails, clears last_kill_id
assert!(status(&s).contains("no region"));
assert_eq!(ring(&s), vec!["one"], "failed kill pushed nothing");
ctrl(&mut s, 'k'); // kills "\n"
assert_eq!(
ring(&s),
vec!["\n", "one"],
"a kill after a FAILED kill pushes fresh (no stale append)"
);
}
// ---------------------------------------------------------------------------
// M-x semantics (the three-direction matrix, Q#KR2)
// ---------------------------------------------------------------------------
fn m_x(s: &mut EditorState, name: &str) {
alt(s, 'x');
type_str(s, name);
press(s, KeyCode::Enter);
}
#[test]
fn m_x_kill_after_keybound_kill_does_not_append() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one"
m_x(&mut s, "edit.kill-line"); // kills "\n" — but must NOT append
assert_eq!(
ring(&s),
vec!["\n", "one"],
"the minibuffer interaction breaks the chain (Emacs semantics)"
);
}
#[test]
fn keybound_kill_after_m_x_kill_appends() {
let mut s = editor_with("one\ntwo\nthree\n");
m_x(&mut s, "edit.kill-line"); // "one" — invoke_interactive stamps it
ctrl(&mut s, 'k'); // "\n" — last_command is edit.kill-line → append
assert_eq!(
ring(&s),
vec!["one\n"],
"execute-extended-command sets this-command (Emacs semantics)"
);
}
#[test]
fn m_x_kill_twice_does_not_append() {
let mut s = editor_with("one\ntwo\nthree\n");
m_x(&mut s, "edit.kill-line");
m_x(&mut s, "edit.kill-line");
assert_eq!(ring(&s).len(), 2, "each M-x interposes execute-command");
}
// ---------------------------------------------------------------------------
// Yank / yank-pop
// ---------------------------------------------------------------------------
#[test]
fn yank_inserts_the_head_and_yank_pop_cycles_and_wraps() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one"
press(&mut s, KeyCode::Down); // break chain
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k'); // rest of "two"-line from byte 1: "wo"... careful
// (buffer was "\ntwo\nthree\n"; byte 1 = 't'; kills "two"[1..] = "wo")
let r = ring(&s);
assert_eq!(r.len(), 2);
let newest = r[0].clone(); // "wo"
let oldest = r[1].clone(); // "one"
// Yank at the end of the buffer.
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y');
assert!(buffer_text(&s).ends_with(&newest), "C-y yanks the head");
let cursor_after_yank: i64 = eval(&s, "return pmacs.editor.cursor()");
assert_eq!(
cursor_after_yank,
len + i64::try_from(newest.len()).unwrap()
);
// M-y replaces with the older entry...
alt(&mut s, 'y');
assert!(buffer_text(&s).ends_with(&oldest), "M-y rotates to older");
let cursor: i64 = eval(&s, "return pmacs.editor.cursor()");
assert_eq!(
cursor,
len + i64::try_from(oldest.len()).unwrap(),
"cursor at end of rotation"
);
// ...and wraps back to the newest.
alt(&mut s, 'y');
assert!(buffer_text(&s).ends_with(&newest), "M-y wraps");
}
#[test]
fn yank_pop_without_a_yank_refuses_and_stays_refused() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k');
let before = buffer_text(&s);
alt(&mut s, 'y');
assert!(status(&s).contains("not a yank"));
assert_eq!(buffer_text(&s), before, "no edit on refusal");
// A second M-y must not ride the first one's name-stamp (Q#KR7):
// last_command IS edit.yank-pop now, but no session exists.
alt(&mut s, 'y');
assert!(status(&s).contains("not a yank"));
assert_eq!(buffer_text(&s), before);
}
#[test]
fn pointer_click_breaks_kill_chain_and_yank_session() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut s = editor_with("one\ntwo\nthree\n");
let term = pmacs::cell::CellSize::new(24, 80);
let click = |s: &mut EditorState, kind| {
s.dispatch_mouse(
FrontendId::LOCAL,
MouseEvent {
kind,
column: 1,
row: 1,
modifiers: KeyModifiers::NONE,
},
term,
);
};
// Kill side: C-k, click, C-k → two entries.
ctrl(&mut s, 'k');
click(&mut s, MouseEventKind::Down(MouseButton::Left));
click(&mut s, MouseEventKind::Up(MouseButton::Left));
ctrl(&mut s, 'k');
assert_eq!(ring(&s).len(), 2, "a click must break the kill chain");
// Yank side: C-y, click, M-y → refused.
ctrl(&mut s, 'y');
click(&mut s, MouseEventKind::Down(MouseButton::Left));
click(&mut s, MouseEventKind::Up(MouseButton::Left));
let before = buffer_text(&s);
alt(&mut s, 'y');
assert!(
status(&s).contains("not a yank"),
"click invalidates the yank"
);
assert_eq!(buffer_text(&s), before);
}
#[test]
fn wheel_scroll_does_not_break_the_kill_chain() {
use crossterm::event::{MouseEvent, MouseEventKind};
let term = pmacs::cell::CellSize::new(24, 80);
let scroll = |s: &mut EditorState, kind| {
s.dispatch_mouse(
FrontendId::LOCAL,
MouseEvent {
kind,
column: 1,
row: 1,
modifiers: KeyModifiers::NONE,
},
term,
);
};
// Case 1 — a boundary-clamped scroll (ScrollUp at the top) moves
// nothing at all: the chain holds and the next C-k appends in
// place.
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one"
scroll(&mut s, MouseEventKind::ScrollUp);
ctrl(&mut s, 'k'); // the newline
assert_eq!(ring(&s), vec!["one\n"], "no-op scroll preserves the chain");
// Case 2 — pmacs scrolling is cursor-follows-view, so a mid-buffer
// wheel moves point too. The chain STILL holds (Emacs: mwheel
// preserves last-command) and the next kill appends from the NEW
// point — one entry, not two.
let mut s = editor_with("one\ntwo\nthree\nfour\nfive\nsix\n");
ctrl(&mut s, 'k'); // "one"
scroll(&mut s, MouseEventKind::ScrollDown);
ctrl(&mut s, 'k');
assert_eq!(
ring(&s).len(),
1,
"a cursor-following scroll still preserves the chain: {:?}",
ring(&s)
);
}
#[test]
fn semantic_pointer_gesture_breaks_the_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k');
let buf_id = s.core.borrow().active_window().buffer_id;
s.dispatch_pointer(
FrontendId::LOCAL,
buf_id,
5,
pmacs::protocol::PointerKind::Down,
pmacs::protocol::Modifiers::NONE,
);
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k');
assert_eq!(
ring(&s).len(),
2,
"a semantic pointer Down breaks the chain"
);
}
// ---------------------------------------------------------------------------
// Region kills, selection yank
// ---------------------------------------------------------------------------
#[test]
fn cut_and_copy_feed_the_ring_and_yank_replaces_a_selection() {
let mut s = editor_with("alpha beta\n");
// Select "alpha" (bytes 0..5).
exec(
&s,
"pmacs.editor.goto_byte(5); pmacs.editor.begin_selection(0)",
);
alt(&mut s, 'w'); // copy
assert_eq!(ring(&s), vec!["alpha"]);
assert_eq!(buffer_text(&s), "alpha beta\n", "copy does not delete");
// Cut " beta" (bytes 5..10).
exec(
&s,
"pmacs.editor.goto_byte(10); pmacs.editor.begin_selection(5)",
);
ctrl(&mut s, 'w');
assert_eq!(buffer_text(&s), "alpha\n");
assert_eq!(ring(&s), vec![" beta", "alpha"]);
// Yank over a selection replaces it, and M-y rotates against the
// replaced range.
exec(
&s,
"pmacs.editor.goto_byte(5); pmacs.editor.begin_selection(0)",
);
ctrl(&mut s, 'y'); // "alpha" → " beta"
assert_eq!(buffer_text(&s), " beta\n");
alt(&mut s, 'y'); // rotate to "alpha"
assert_eq!(buffer_text(&s), "alpha\n");
}
// ---------------------------------------------------------------------------
// Shared-ring interleaving (two frontends)
// ---------------------------------------------------------------------------
/// A second "frontend": an unregistered id shares LOCAL's view (the
/// `active_view` fallback) but has its own command boundary and killring
/// session — the exact shape of the Q#KR4/KR7 interleaving blockers.
const B: FrontendId = FrontendId(9);
fn ctrl_as(s: &mut EditorState, fid: FrontendId, c: char) {
s.dispatch_key(fid, key(KeyCode::Char(c), KeyModifiers::CONTROL));
}
#[test]
fn interleaved_kills_never_append_across_frontends() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // A kills "one"
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl_as(&mut s, B, 'k'); // B kills "wo" — B's own first kill
exec(&s, "pmacs.editor.goto_byte(2)");
ctrl(&mut s, 'k'); // A again — A's last_kill is NOT the head (B's is)
let r = ring(&s);
assert_eq!(r.len(), 3, "A-kill/B-kill/A-kill = three entries");
assert_eq!(r[1], "two", "B's entry intact — never appended onto");
}
#[test]
fn yank_pop_rotates_from_the_sessions_own_entry_despite_other_pushes() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // A: ring ["one"]
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y'); // A yanks "one"; session → entry("one")
// B pushes a new head, shifting positions but not ids.
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl_as(&mut s, B, 'k'); // B kills "wo" → ring ["wo", "one"]
// A's M-y must rotate from A's OWN entry ("one" — now position 2),
// to the next-older-with-wrap = "wo". An index-based session would
// have mis-resolved after B's push.
// (B's kill edited the buffer, but before A's yanked range, so the
// session's slice-verify passes — the range shifted is upstream.)
// Note: B's kill removed bytes BEFORE the yank range, so the
// remembered {start,stop} no longer hold the yanked text → the
// invalidation guard fires instead. That IS the specified
// behavior: refuse rather than splice a shifted range.
alt(&mut s, 'y');
assert!(
status(&s).contains("changed since the yank"),
"a concurrent upstream edit invalidates rather than mis-splices: {:?}",
status(&s)
);
}
#[test]
fn yank_pop_uses_stable_ids_when_other_pushes_leave_the_range_intact() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // ring ["one"], A's chain live
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y'); // A yanks "one" at end; session entry = "one"
// B COPIES bytes 1..3 ("tw" of the post-kill buffer "\ntwo\none"):
// ring becomes ["tw", "one"], buffer untouched, so A's yanked
// range is still intact.
exec(
&s,
"pmacs.editor.begin_selection(1); pmacs.editor.goto_byte(3)",
);
s.dispatch_key(B, key(KeyCode::Char('w'), KeyModifiers::ALT));
assert_eq!(ring(&s), vec!["tw", "one"]);
// A's M-y: the session's entry ("one") sits at position 2 now;
// next-older-with-wrap is B's copy. An integer index recorded at
// yank time (position 1) would have rotated from the wrong place.
alt(&mut s, 'y');
assert!(
buffer_text(&s).ends_with("tw"),
"rotation follows the stable id, not a shifted index: {:?}",
buffer_text(&s)
);
}
#[test]
fn eviction_mid_session_invalidates_the_yank_pop() {
let mut s = editor_with("one\ntwo\nthree\nfour\n");
exec(&s, "pmacs.killring.max(2)");
ctrl(&mut s, 'k'); // ring ["one"]
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y'); // session → entry "one"
// Two copies (buffer untouched) evict "one" from a cap-2 ring.
exec(
&s,
"pmacs.editor.begin_selection(1); pmacs.editor.goto_byte(3)",
);
s.dispatch_key(B, key(KeyCode::Char('w'), KeyModifiers::ALT));
exec(
&s,
"pmacs.editor.begin_selection(5); pmacs.editor.goto_byte(8)",
);
s.dispatch_key(B, key(KeyCode::Char('w'), KeyModifiers::ALT));
let r = ring(&s);
assert_eq!(r.len(), 2);
assert!(!r.contains(&"one".to_string()), "'one' evicted");
alt(&mut s, 'y');
assert!(
status(&s).contains("expired"),
"an evicted session entry refuses cleanly: {:?}",
status(&s)
);
}
// ---------------------------------------------------------------------------
// External content, menu, hook delivery, cap
// ---------------------------------------------------------------------------
#[test]
fn externally_pasted_content_joins_the_ring_at_yank() {
let mut s = editor_with("");
ctrl(&mut s, 'k'); // fails (empty buffer) — ring stays empty
// An OS paste arrives (the daemon route sets the slot + inserts).
s.core.borrow_mut().paste_inbound(b"external").unwrap();
assert_eq!(buffer_text(&s), "external");
// The next yank notices slot ≠ head, pushes it, and yanks it.
ctrl(&mut s, 'y');
assert_eq!(ring(&s), vec!["external"]);
assert_eq!(buffer_text(&s), "externalexternal");
}
#[test]
fn menu_cut_feeds_the_ring_fires_after_edit_and_chains() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut s = editor_with("alpha beta\n");
exec(
&s,
r#"
_G.AE = 0
pmacs.hook.add("buffer.after-edit", function() _G.AE = _G.AE + 1 end)
"#,
);
// Select "alpha", open the context menu via right-click, invoke Cut.
exec(
&s,
"pmacs.editor.goto_byte(5); pmacs.editor.begin_selection(0)",
);
s.dispatch_mouse(
FrontendId::LOCAL,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: 2,
row: 0,
modifiers: KeyModifiers::NONE,
},
pmacs::cell::CellSize::new(24, 80),
);
assert!(
s.core.borrow().menu_is_open(),
"right-click opened the menu"
);
// Find the "edit.cut" row in the open menu's state.
let cut_index = {
let menu = s.core.borrow().menu.clone();
let guard = menu.lock().unwrap();
guard
.as_ref()
.and_then(|m| {
m.rows.iter().position(|r| {
matches!(r, pmacs::menu::MenuRow::Item { command, .. }
if command == "edit.cut")
})
})
.expect("menu has a Cut row")
};
s.dispatch_menu_pointer(
FrontendId::LOCAL,
Some(u32::try_from(cut_index).unwrap()),
true,
);
assert_eq!(ring(&s), vec!["alpha"], "menu Cut fed the ring");
assert_eq!(buffer_text(&s), " beta\n");
let fired: i64 = eval(&s, "return _G.AE");
assert_eq!(fired, 1, "menu Cut fires after-edit exactly once (Q#KR10b)");
// The menu rotation makes the cut chain like a keybound one:
// a following C-k appends.
ctrl(&mut s, 'k');
let r = ring(&s);
assert_eq!(r[0], "alpha beta", "menu Cut then C-k appends (rotate row)");
}
#[test]
fn m_x_kill_fires_after_edit_and_keybound_does_not_double_fire() {
let mut s = editor_with("one\ntwo\n");
exec(
&s,
r#"
_G.AE = 0
pmacs.hook.add("buffer.after-edit", function() _G.AE = _G.AE + 1 end)
"#,
);
ctrl(&mut s, 'k');
let after_keybound: i64 = eval(&s, "return _G.AE");
assert_eq!(after_keybound, 1, "keybound kill fires once, no double");
m_x(&mut s, "edit.kill-line");
let after_mx: i64 = eval(&s, "return _G.AE");
assert_eq!(after_mx, 2, "M-x kill fires after-edit too (Q#KR10b)");
}
#[test]
fn cap_is_validated_and_shrink_trims() {
let s = editor_with("");
let d: i64 = eval(&s, "return pmacs.killring.max()");
assert_eq!(d, 60);
for bad in ["0/0", "math.huge", "-math.huge", "0", "-3", "'ten'", "{}"] {
let ok: bool = eval(&s, &format!("return (pcall(pmacs.killring.max, {bad}))"));
assert!(!ok, "killring.max({bad}) must be rejected");
}
let set: i64 = eval(&s, "return pmacs.killring.max(2.9)");
assert_eq!(set, 2, "floored");
// Five distinct entries via COPY over a static buffer (copies never
// delete, so the byte offsets stay put; each text is distinct so
// duplicate-of-head collapse never fires).
let mut s2 = editor_with("aa bb cc dd ee\n");
exec(&s2, "pmacs.killring.max(10)");
for i in 0..5i64 {
let lo = i * 3;
exec(
&s2,
&format!(
"pmacs.editor.begin_selection({lo}); pmacs.editor.goto_byte({})",
lo + 2
),
);
alt(&mut s2, 'w');
}
assert_eq!(ring(&s2).len(), 5);
let trimmed: i64 = eval(&s2, "return pmacs.killring.max(3)");
assert_eq!(trimmed, 3);
assert_eq!(ring(&s2).len(), 3, "shrinking the cap trims immediately");
}
#[test]
fn semantic_context_right_click_breaks_the_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one" — chain live
// The semantic dispatcher routes PointerKind::Context straight to
// open_menu_at_byte, bypassing dispatch_pointer — the GPU
// right-click path.
let buf_id = s.core.borrow().active_window().buffer_id;
s.open_menu_at_byte(FrontendId::LOCAL, buf_id, 3);
// Dismiss the menu without invoking anything.
s.core.borrow_mut().menu_close();
ctrl(&mut s, 'k');
assert_eq!(
ring(&s).len(),
2,
"a semantic right-click must break the kill chain: {:?}",
ring(&s)
);
}
#[test]
fn rejecting_intercept_clears_the_kill_chain() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one" — chain live, ring ["one"]
// An intercept that rejects exactly the NEXT edit, then allows.
exec(
&s,
r#"
_G.reject_once = true
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
if _G.reject_once then
_G.reject_once = false
error("rejected by test intercept")
end
return nil
end)
"#,
);
ctrl(&mut s, 'k'); // rejected — must clear the chain, push nothing
assert!(
status(&s).contains("rejected"),
"rejection is reported: {:?}",
status(&s)
);
assert_eq!(ring(&s), vec!["one"], "a rejected kill feeds nothing");
ctrl(&mut s, 'k'); // allowed again — must push FRESH, not append
assert_eq!(
ring(&s),
vec!["\n", "one"],
"the chain did not survive the rejection"
);
}
#[test]
fn transforming_intercept_does_not_feed_the_ring() {
let mut s = editor_with("alpha\nbeta\n");
// An intercept that shrinks every delete to its first byte: the
// bytes actually removed are not what C-k sliced, so pushing the
// sliced text would put never-killed bytes on the ring.
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "delete" then
return { kind = "delete", start = op.start, ["end"] = op.start + 1 }
end
return nil
end)
"#,
);
ctrl(&mut s, 'k');
assert!(
status(&s).contains("altered"),
"transformation is reported: {:?}",
status(&s)
);
assert!(ring(&s).is_empty(), "a transformed kill feeds nothing");
// The interceptor's result stands (accepted post-hoc semantics).
assert_eq!(buffer_text(&s), "lpha\nbeta\n");
}
#[test]
fn equal_length_shifted_delete_does_not_feed_the_ring() {
let mut s = editor_with("abcdef\nghijkl\n");
// An intercept that SHIFTS every delete right by 2 bytes while
// keeping its length — a length-delta check cannot see this, and
// the ring would receive text that was never killed.
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "delete" then
return { kind = "delete", start = op.start + 2, ["end"] = op["end"] + 2 }
end
return nil
end)
"#,
);
ctrl(&mut s, 'k'); // wanted [0,6) "abcdef"; intercept deletes [2,8)
assert!(
status(&s).contains("altered"),
"an equal-length shifted delete is detected: {:?}",
status(&s)
);
assert!(
ring(&s).is_empty(),
"never-killed text must not reach the ring: {:?}",
ring(&s)
);
// The interceptor's result stands.
assert_eq!(buffer_text(&s), "abhijkl\n");
}
#[test]
fn stop_enlarging_replace_ends_the_yank_session() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one"
press(&mut s, KeyCode::Down);
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k'); // "two"
// Yank at the START so the buffer extends past the yanked range —
// an end+1 range at buffer end would fail validation ("rejected")
// instead of exercising the transform path.
exec(&s, "pmacs.editor.goto_byte(0)");
ctrl(&mut s, 'y'); // session live
// An intercept that enlarges every replace's end by ONE byte: the
// replacement text still lands at s.start, so a "text appears at
// start" verify passes — but one extra byte was silently deleted.
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "replace" then
return { kind = "replace", start = op.start, ["end"] = op["end"] + 1 }
end
return nil
end)
"#,
);
alt(&mut s, 'y');
assert!(
status(&s).contains("altered"),
"an end-enlarged replace is detected: {:?}",
status(&s)
);
// The session is dead: a further M-y refuses without editing.
let before = buffer_text(&s);
alt(&mut s, 'y');
assert!(status(&s).contains("not a yank"));
assert_eq!(buffer_text(&s), before);
}
#[test]
fn rejecting_intercept_ends_the_yank_session() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one"
press(&mut s, KeyCode::Down);
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k'); // "two"
assert_eq!(ring(&s).len(), 2);
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y'); // session live
// Reject the next edit (the M-y replace).
exec(
&s,
r#"
_G.reject_once = true
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
if _G.reject_once then
_G.reject_once = false
error("rejected by test intercept")
end
return nil
end)
"#,
);
alt(&mut s, 'y'); // rejected — must END the session, not throw through
assert!(
status(&s).contains("rejected"),
"rejection reported: {:?}",
status(&s)
);
// A second M-y must refuse on "no session", not reuse the dead one.
let before = buffer_text(&s);
alt(&mut s, 'y');
assert!(
status(&s).contains("not a yank"),
"the rejected session is gone: {:?}",
status(&s)
);
assert_eq!(buffer_text(&s), before, "no splice from a dead session");
}
#[test]
fn frontend_detached_drops_per_frontend_state() {
let mut s = editor_with("one\ntwo\n");
ctrl_as(&mut s, B, 'k'); // B kills → B has last_kill_id
let has: bool = eval(
&s,
"return pmacs.killring._debug_state(9).last_kill_id ~= nil",
);
assert!(has, "B has kill state");
// The daemon fires this on SessionDetached (Q#KR11); fire it the
// same way to exercise the Lua-side cleanup.
exec(&s, "pmacs.hook.run('frontend.detached', 9)");
let gone: bool = eval(
&s,
"local st = pmacs.killring._debug_state(9); \
return st.last_kill_id == nil and st.session == nil",
);
assert!(gone, "detach dropped B's killring state");
}