From d1a7683c2b214772a10882c1f60ae14240621a46 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 20:08:43 -0400 Subject: [PATCH 1/4] docs: LSP panels framing (Arc 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q#P1-P7: panels are buffers (a shared listview runtime module generalizing the *buffer-list* idiom — zero protocol change, both frontends render them for free); switch-in-place presentation with q restore (the GPU cannot show splits); read-only via intercept with its limits recorded; the Q#P6 round-trip buffer seam so semantic frontends never optimistic-apply into panels (RET visits instead of inserting a newline); references list, outline, minibuffer code-action picker, hover-doc panel; byte==UTF-16 caveat inherited, not multiplied. Co-Authored-By: Claude Fable 5 --- docs/lsp-panels-framing.md | 187 +++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/lsp-panels-framing.md diff --git a/docs/lsp-panels-framing.md b/docs/lsp-panels-framing.md new file mode 100644 index 0000000..228c58b --- /dev/null +++ b/docs/lsp-panels-framing.md @@ -0,0 +1,187 @@ +# LSP panels — framing (Arc 1b) + +pmacs's LSP data layer answers references, document symbols, code +actions, and hover in full — and then renders one line in the +modeline. `lsp.find-references` reports "12 references" and throws +eleven away; `lsp.code-actions` applies `acts[1]` blind; +`lsp.document-symbols` prints a count; multi-line hover shows its +first line. Every one of these is flagged "future UX work" in +`builtin/runtime/lsp.lua`. This arc builds that UX — as **buffers**, +not new UI surfaces, so both frontends get it for free. + +Roadmap context: `docs/roadmap-2026-07.md` Arc 1b (also the panel +substrate DAP will reuse). Direct trigger: the PR #93 validation note +("we probably want a more fine grained UI for this sort of stuff and +error checking"). + +## What already exists (verified) + +- **The `*buffer-list*` idiom** (`builtin/commands/default.lua:320-545`) + is a working panel: a named persistent buffer, wholesale + re-rendered (`buf:delete` + `buf:insert`), buffer-local keymap + (`scope = "buffer"` binds, installed once, never torn down), a + `line_to_buffer` row→item map read via `pmacs.editor.cursor_line()`, + `prev_buffer_id` capture + `q` restore, and a refresh that manually + re-seats the cursor (because `switch_active_buffer` zeroes + cursor/overlays — `editor_core.rs:2052-2071`). Gaps: no read-only + enforcement, logic not reusable. +- **Read surfaces are uniform** `pmacs..{getter, clear}(sid, + uri)` over Rust stores: references = `pmacs.references.locations` + (rows `{uri, line, col}`, 0-based, the byte==UTF-16 v0.1 caveat); + outline = `pmacs.document_symbol.symbols` (**flat** rows with + `depth` + optional `container` — indent, don't recurse); actions = + `pmacs.code_action.actions` (the full ordered array; each item + carries its own `edit`/`command`, so applying by index is pure Lua); + hover = `pmacs.hover.current` (`contents` is the full multi-line + string). +- **The cross-file visit template** is `go_to_definition`'s SP-4 path + (`lsp.lua:1187-1213`): `push_jump` → `path_for_uri` → + `find_or_open` → `move_active_cursor_to`. +- **The picker substrate** is `pmacs.minibuffer.read { source = + function() return {...} end, on_accept }` — the + `window.set-line-numbers` shape (`default.lua:231-246`), already + dual-frontend since protocol v12. +- **Read-only is intercept-only** (no buffer flag exists): the REPL's + `add_intercept` + `error()` + self-write bypass + (`repl/init.lua:686-726`) is the reference implementation. + +## Decisions + +### Q#P1 — Panels are buffers; one shared `listview` runtime module + +`builtin/runtime/listview.lua`: + +```lua +listview.open { + name = "*references*", -- persistent buffer, found-or-created + header = "12 references to `foo` RET visit n/p move g refresh q quit", + rows = { { text = "...", item = }, ... }, + on_visit = function(item) ... end, -- RET/SPC + on_refresh = function() return rows end, -- g (optional) +} +``` + +It owns everything the buffer-list hand-rolls: ensure-buffer, +wholesale render, `line→item` map, buffer-local keymap (RET/SPC +visit, n/p + arrows move, g refresh, q quit), prev-buffer capture + +restore, cursor re-seat after render, and the read-only intercept +(Q#P3). Bindings install once per panel buffer and persist — the +buffer-list precedent; per-open teardown buys nothing for persistent +buffers. `*buffer-list*` itself is NOT migrated in this arc (working +code, separate risk); named as a follow-up. + +### Q#P2 — Presentation: switch-in-place, `q` restores + +Panels open in the current window via `pmacs.window.switch_buffer`; +`q` restores the saved previous buffer (scratch fallback if killed). +This is the only presentation that behaves identically in both +frontends today — the GPU renders exactly one buffer and cannot show +splits. TUI split placement (`display-buffer`-style) is a named +deferral, not a v1 behavior fork. + +### Q#P3 — Read-only via intercept; its honest limits + +Each panel buffer gets an `add_intercept` that `error("read-only +panel")`s every op; `listview`'s own renders write with +`{ bypass_intercept = true }`. Two recorded limits: (a) the intercept +guards the daemon's command/edit path — a CRDT-import write (a +semantic frontend's optimistic op) does not run the intercept chain; +Q#P6's round-trip seam is what actually keeps GPU typing out of +panels. (b) A real `read_only` buffer flag (enforced in both edit +paths, shipped to frontends) is deferred — it's the proper fix for +this AND the REPL's identical exposure, and deserves its own small +arc. + +### Q#P6 — Panel input routing: round-trip buffers (the load-bearing catch) + +RET on a panel row must *visit*, but in the GPU, Enter is +optimistic-eligible — it would locally insert `\n` into the panel +mirror and ship a CrdtOp, never reaching the buffer-local RET +binding. Same for plain chars (vandalizing the panel around the +intercept, per Q#P3a). + +Fix, wire-free: a core-side set of **round-trip buffers**. +`pmacs.buffer.set_round_trip_input(buf, true)` marks a buffer; +`EditorState::dispatch_idle()` returns `false` while the active +buffer is marked. `DispatchIdle { idle: false }` already gates every +semantic frontend's optimistic path (M11.6), so with a panel focused, +every GPU key round-trips: RET dispatches into the buffer-local +binding (visit works), typing dispatches to self-insert and the +intercept rejects it cleanly. No protocol change, no GPU change. +`listview` marks every panel it creates. (The REPL can adopt the same +flag later for its GPU story — named follow-up.) + +### Q#P4 — References list (`*references*`, the flagship) + +`lsp.find-references` (`M-?`) keeps its request/store flow, then +opens a panel: one row per location, `path:line+1:col+1` with the +path shortened relative to the project root when possible. RET = +the SP-4 visit template (jump ring included), so `M-,` returns. +Modeline summary stays (it's now the panel's byline). Row *snippets* +(the referenced line's text) require reading target files that may +not be open — deferred, named. + +### Q#P5 — Outline (`*outline*`) + code-action picker + hover doc + +- **Outline**: `lsp.document-symbols` (`C-c o`) → panel rows indented + `2 × depth` with a kind tag; RET restores the source buffer and + moves to the symbol (same-file visit: `switch_buffer(prev)` + + `move_active_cursor_to`). +- **Code actions**: no panel needed — `lsp.code-actions` (`C-c a`) + opens `pmacs.minibuffer.read` with `"N: title"` candidates (index + prefix disambiguates duplicate titles); `on_accept` applies the + *chosen* item's `edit`/`command` through the existing apply branch. + One action still auto-applies without prompting? No — always prompt + when more than one; a single action applies directly (today's + behavior, now correct instead of lucky). +- **Hover doc**: `lsp.hover` (`C-c h`) unchanged (first line in the + echo area). New `lsp.hover-doc` (`C-c H`) renders the full + `contents` into a `*lsp-help*` panel (rows non-visitable; q quits). + +### Q#P7 — Coordinates + +Panels inherit the v0.1 byte==UTF-16 assumption exactly as +`go_to_definition` does today (`lsp.lua:658-662`); the v0.2 +position-encoding hardening remains one deferral, not four new ones. + +## Phasing (each phase green + user-validated) + +1. **`listview` module + Q#P6 round-trip seam + references list.** + The seam is the only Rust change (core set + one binding + + `dispatch_idle` clause + tests). Validate in TUI *and* GPU — + phase 1's GPU validation is what scores bet #3. +2. **Outline + code-action picker + hover doc.** Pure Lua on the + phase-1 substrate. +3. **As-built notes** + deferral ledger update. + +Arc 2 interleave point (query-replace) after phase 2. + +## Categorical bets (score at close) + +1. **The listview module covers all three panels without per-panel + Rust.** The whole arc lands with one small core seam (Q#P6) and + zero protocol change. +2. **Cursor-zeroing bites once.** `switch_active_buffer` clears + cursor/overlays; some flow (refresh, visit-then-return) will land + the cursor somewhere surprising before the re-seat discipline is + applied everywhere. +3. **GPU panels render via the existing BufferSnapshot/F29 path with + no GPU changes.** The mechanism exists (mid-session CRDT upgrade + + snapshot push); a panel buffer exercising it from a `switch_buffer` + is the untested claim in this arc. +4. **Round-trip routing has one gap somewhere** — a key that neither + round-trips nor falls through correctly while a panel is focused + (the Q#C6-class finding of this arc). + +## Deferred (named, not silently dropped) + +- A real `read_only` buffer flag enforced on both edit paths and + shipped to frontends (fixes panels + REPL properly; Q#P3). +- Migrating `*buffer-list*` onto `listview`. +- TUI split placement for panels (`display-buffer`-style). +- Reference row snippets (needs off-buffer file reads). +- Fuzzy filtering inside panels (type-to-narrow). +- Peek/preview on n/p (echo the target line without visiting). +- Call-hierarchy / workspace-symbols panels (data partially present; + same idiom when wanted). +- REPL adopting the Q#P6 round-trip flag for its GPU story. From b25b47334d7046b71f4278f03927e516ac02e9c6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 20:15:46 -0400 Subject: [PATCH 2/4] feat(panels): listview module, Q#P6 round-trip seam, references panel Arc 1b phase 1 (framing: docs/lsp-panels-framing.md). Q#P6 (the one Rust change): EditorCore.round_trip_buffers + pmacs.buffer.set_round_trip_input(buf, on); dispatch_idle() reports false while a marked buffer is active, so semantic frontends' optimistic-apply stays off -- RET reaches a panel's buffer-local visit binding instead of locally inserting a newline, and typing dispatches into the edit path where the read-only intercept rejects it (a CRDT import would bypass the intercept chain entirely). Pruned on kill. Q#P1/P2/P3: builtin/runtime/listview.lua generalizes the *buffer-list* idiom -- pmacs.listview.open{name, header, rows, on_visit, on_refresh} owns ensure-buffer (recreates if user-killed), wholesale render with bypass_intercept, line->item map, buffer-local RET/SPC/n/p/g/q keymap, previous-buffer capture + q restore (never another panel; scratch fallback), cursor re-seat after render, the read-only intercept, and the Q#P6 mark. Panels are buffers: both frontends render them with zero protocol change. Q#P4: lsp.find-references (M-?) opens *references* -- one row per location, paths shortened against the project root, RET visits via the shared SP-4 template (jump ring, find_or_open, cursor walk; extracted as visit_location for the phase-2 outline to reuse). Acceptance: tests/listview_acceptance.rs -- open/seat/visit, header non-visitable, q restore, read-only rejection, dispatch_idle gate, refresh re-render + re-seat. Co-Authored-By: Claude Fable 5 --- builtin/runtime/listview.lua | 169 +++++++++++++++++++++++++++++++++++ builtin/runtime/lsp.lua | 61 +++++++++++-- src/editor.rs | 20 ++++- src/editor_core.rs | 31 +++++++ src/lua_bindings/mod.rs | 19 ++++ tests/listview_acceptance.rs | 138 ++++++++++++++++++++++++++++ 6 files changed, 430 insertions(+), 8 deletions(-) create mode 100644 builtin/runtime/listview.lua create mode 100644 tests/listview_acceptance.rs diff --git a/builtin/runtime/listview.lua b/builtin/runtime/listview.lua new file mode 100644 index 0000000..4be4faa --- /dev/null +++ b/builtin/runtime/listview.lua @@ -0,0 +1,169 @@ +-- listview.lua --- reusable read-only list panels (Arc 1b, Q#P1). +-- +-- Generalizes the *buffer-list* idiom (builtin/commands/default.lua) +-- into `pmacs.listview.open{...}`: a persistent named buffer, +-- wholesale re-render, buffer-local RET/n/p/g/q keymap, a +-- line->item map, previous-buffer capture + `q` restore, and the two +-- disciplines the hand-rolled original lacks --- a read-only +-- intercept (Q#P3; the panel's own renders write with +-- bypass_intercept) and the Q#P6 round-trip-input mark, so a +-- semantic frontend's RET dispatches into the visit binding instead +-- of optimistically inserting a newline. +-- +-- Panels are buffers, so both frontends render them with zero +-- protocol change (Q#P2: switch-in-place; the GPU cannot show +-- splits). Framing: docs/lsp-panels-framing.md. +-- +-- pmacs.listview.open { +-- name = "*references*", +-- header = "12 references RET visit n/p move g refresh q quit", +-- rows = { { text = "src/foo.rs:12:4", item = }, ... }, +-- on_visit = function(item) ... end, -- RET/SPC (optional) +-- on_refresh = function() return rows end, -- g (optional) +-- } + +pmacs.listview = pmacs.listview or {} + +-- name -> { buffer, prev, header, line_to_item, on_visit, on_refresh } +local panels = {} + +local function find_buffer_by_name(name) + for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == name then return id end + end + return nil +end + +-- The panel record whose buffer the active window shows, or nil. +local function panel_for_current_buffer() + local buf = pmacs.window.buffer() + if not buf then return nil end + local ok, d = pcall(pmacs.describe.buffer, buf) + if not (ok and d) then return nil end + return panels[d.name] +end + +-- Wholesale re-render: header + one line per row, rebuilding the +-- line->item map (data lines are 1-based; the header is line 0). +-- Panel writes bypass the read-only intercept. +local function render(p, rows) + local lines = { p.header } + p.line_to_item = {} + for _, row in ipairs(rows) do + lines[#lines + 1] = row.text + p.line_to_item[#lines - 1] = row.item + end + local body = table.concat(lines, "\n") + local buf = p.buffer + local len = buf:len() + if len > 0 then buf:delete(0, len, { bypass_intercept = true }) end + if #body > 0 then buf:insert(0, body, { bypass_intercept = true }) end +end + +-- Re-seat the cursor on data line `line` (1-based, clamped). +-- `switch_active_buffer` zeroes the window cursor, so a fresh switch +-- puts us on the header; walk down from there. +local function seat_cursor(p, line) + local count = #p.line_to_item + if count == 0 then return end + local target = math.max(1, math.min(line or 1, count)) + for _ = 1, target do + pmacs.editor.move_down() + end +end + +local function bind_local_keymap(buf) + local function bind(seq, command) + pmacs.keymap.bind { scope = "buffer", buffer = buf, sequence = seq, command = command } + end + bind("RET", "listview.visit") + bind("SPC", "listview.visit") + bind("n", "cursor.down") + bind("", "cursor.down") + bind("p", "cursor.up") + bind("", "cursor.up") + bind("g", "listview.refresh") + bind("q", "listview.quit") +end + +-- Build (or adopt) the persistent panel record for `name`. Handles a +-- user-killed panel buffer by recreating it. +local function ensure_panel(name) + local p = panels[name] + if p and p.buffer:is_valid() then return p end + local buf = find_buffer_by_name(name) or pmacs.buffer.create(name) + p = { buffer = buf, line_to_item = {} } + panels[name] = p + -- Read-only (Q#P3): every non-bypass edit is rejected. The + -- intercept lives as long as the buffer; no teardown (the + -- buffer-list precedent for its keymap). + pmacs.buffer.add_intercept(buf, function() + error(name .. " is read-only") + end) + -- Q#P6: semantic frontends must round-trip keys while this panel + -- is focused (RET = visit, not an optimistic newline). + pmacs.buffer.set_round_trip_input(buf, true) + bind_local_keymap(buf) + return p +end + +function pmacs.listview.open(spec) + assert(type(spec) == "table" and type(spec.name) == "string", + "listview.open: spec.name (string) required") + local p = ensure_panel(spec.name) + p.header = spec.header or spec.name + p.on_visit = spec.on_visit + p.on_refresh = spec.on_refresh + -- Remember where to return on `q` --- but never another panel + -- (chained panels would trap `q` in a loop; restore targets the + -- last real buffer). + local active = pmacs.window.buffer() + if active and not panel_for_current_buffer() then + p.prev = active + end + render(p, spec.rows or {}) + pmacs.window.switch_buffer(p.buffer) + seat_cursor(p, 1) +end + +pmacs.command.define { + name = "listview.visit", + description = "Visit the list-panel item under the cursor.", + fn = function() + local p = panel_for_current_buffer() + if not p then return end + local item = p.line_to_item[pmacs.editor.cursor_line()] + if item ~= nil and p.on_visit then p.on_visit(item) end + end, +} + +pmacs.command.define { + name = "listview.refresh", + description = "Re-run the list panel's data source and re-render.", + fn = function() + local p = panel_for_current_buffer() + if not (p and p.on_refresh) then return end + local saved = pmacs.editor.cursor_line() + local rows = p.on_refresh() or {} + render(p, rows) + -- The wholesale rewrite leaves the window cursor at a stale byte + -- offset; re-enter the buffer to reset, then re-seat. + pmacs.window.switch_buffer(p.buffer) + seat_cursor(p, saved) + end, +} + +pmacs.command.define { + name = "listview.quit", + description = "Leave the list panel, restoring the previous buffer.", + fn = function() + local p = panel_for_current_buffer() + if not p then return end + local target = p.prev + if not (target and target:is_valid()) then + target = find_buffer_by_name("*scratch*") or pmacs.buffer.create("*scratch*") + end + pmacs.window.switch_buffer(target) + end, +} diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index cf36fa8..2a11595 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -1214,6 +1214,41 @@ function pmacs.lsp.go_to_definition() end) end +-- Visit one LSP location (Arc 1b): the SP-4 cross-file template --- +-- jump ring, find-or-open, cursor walk. Same-buffer hits skip the +-- open. Shared by the references panel (and the outline in phase 2). +local function visit_location(loc) + local path = pmacs.lsp.path_for_uri(loc.uri) + if not path then + pmacs.editor.set_status("LSP: cannot decode target uri " .. tostring(loc.uri)) + return + end + pmacs.editor.push_jump() + local ok, err = pcall(pmacs.buffer.find_or_open, path) + if not ok then + -- Open failed: drop the origin we just pushed so M-, isn't left + -- pointing at a jump that never happened. + pmacs.editor.jump_back() + pmacs.editor.set_status("LSP: failed to open " .. path .. ": " .. tostring(err)) + return + end + move_active_cursor_to(loc.line, loc.col) +end + +-- Shorten `path` against the project root of `relative_to` (a path in +-- the same project) for panel display; falls back to the full path. +local function display_path(path, relative_to) + local ok, proj = pcall(pmacs.project.detect, relative_to or path) + if ok and proj and proj.root then + local root = proj.root + if root:sub(-1) ~= "/" then root = root .. "/" end + if path:sub(1, #root) == root then + return path:sub(#root + 1) + end + end + return path +end + function pmacs.lsp.find_references() local rec = attached_for_active() if not rec then @@ -1236,13 +1271,27 @@ function pmacs.lsp.find_references() pmacs.editor.set_status("LSP: no references found") return end - -- v1 surfaces a modeline summary (count + first hit); a - -- references list buffer is future UX work, like the hover panel. - local first = locs[1] + -- Arc 1b: a browsable *references* panel. RET visits (jump ring + -- included, so M-, returns); q restores this buffer. + local here = pmacs.lsp.path_for_uri(rec.uri) + local rows = {} + for _, loc in ipairs(locs) do + local path = pmacs.lsp.path_for_uri(loc.uri) or loc.uri + rows[#rows + 1] = { + text = string.format("%s:%d:%d", display_path(path, here), loc.line + 1, loc.col + 1), + item = loc, + } + end + pmacs.listview.open { + name = "*references*", + header = string.format( + "%d reference%s RET visit n/p move q quit", + #locs, (#locs == 1 and "" or "s")), + rows = rows, + on_visit = visit_location, + } pmacs.editor.set_status(string.format( - "LSP: %d reference%s; first at %s:%d:%d", - #locs, (#locs == 1 and "" or "s"), - first.uri, first.line + 1, first.col + 1)) + "LSP: %d reference%s", #locs, (#locs == 1 and "" or "s"))) end) end diff --git a/src/editor.rs b/src/editor.rs index 6fb720e..5df243d 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -276,6 +276,15 @@ impl EditorState { // auto-attach buffer hooks, key-bound commands. Loaded last so // every dependency table (`pmacs.lsp`, `pmacs.parse`, // `pmacs.window`, etc.) already exists. + // Arc 1b: the reusable list-panel module. Loaded before + // lsp.lua, whose panel commands (references, outline) call + // `pmacs.listview.open`. + lua_host + .eval( + Some("@pmacs/builtin/runtime/listview.lua"), + include_str!("../builtin/runtime/listview.lua"), + ) + .expect("load listview builtin chunk"); lua_host .eval( Some("@pmacs/builtin/runtime/lsp.lua"), @@ -508,8 +517,15 @@ impl EditorState { let core = self.core.borrow(); // A live context menu shadows the keymap too (Q#CM1): keys must // round-trip so the daemon's `dispatch_menu_key` drives the menu - // rather than the frontend self-inserting. - !core.minibuffer.is_active() && !core.search_active() && !core.menu_is_open() + // rather than the frontend self-inserting. A round-trip buffer + // (Arc 1b Q#P6 — a focused panel) is the buffer-shaped member of + // the same family: RET must reach its buffer-local bindings and + // typing must reach its read-only intercept, neither of which an + // optimistic local edit would do. + !core.minibuffer.is_active() + && !core.search_active() + && !core.menu_is_open() + && !core.active_buffer_round_trips() } /// `frontend_id` records which frontend produced the event. v0.1 diff --git a/src/editor_core.rs b/src/editor_core.rs index a394222..d1c4c59 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -193,6 +193,16 @@ pub struct EditorCore { /// same state the dispatch path navigates and the Lua driver /// publishes into — the completion twin of `menu`. pub completion_popup: crate::completion::SharedCompletionPopup, + /// Buffers whose input must round-trip (Arc 1b, Q#P6). While one + /// of these is the active buffer, + /// [`crate::editor::EditorState::dispatch_idle`] reports `false`, + /// so semantic frontends' optimistic-apply stays off: RET reaches + /// buffer-local bindings (a panel's visit) instead of locally + /// inserting `\n`, and plain typing dispatches into the edit path + /// where a read-only intercept can reject it — a CRDT-import + /// write would bypass the intercept chain entirely. Marked from + /// Lua via `pmacs.buffer.set_round_trip_input`; pruned on kill. + round_trip_buffers: std::collections::HashSet, } impl EditorCore { @@ -233,6 +243,7 @@ impl EditorCore { pending_clipboard: None, menu: crate::menu::make_shared_menu(), completion_popup: crate::completion::make_shared_popup(), + round_trip_buffers: std::collections::HashSet::new(), } } @@ -1980,6 +1991,25 @@ impl EditorCore { true } + // ---- round-trip input buffers (Arc 1b, Q#P6) ---------------------------- + + /// Mark (or unmark) `buffer_id` as requiring round-trip input. + /// See the field doc on `round_trip_buffers` for the semantics. + pub fn set_round_trip_input(&mut self, buffer_id: BufferId, on: bool) { + if on { + self.round_trip_buffers.insert(buffer_id); + } else { + self.round_trip_buffers.remove(&buffer_id); + } + } + + /// True while the active buffer requires round-trip input (a + /// panel or other buffer-local-keymap surface is focused). + #[must_use] + pub fn active_buffer_round_trips(&self) -> bool { + self.round_trip_buffers.contains(&self.active_buffer_id()) + } + /// Ensure the active window carries a /// [`crate::completion::CompletionView`] overlay (deduped by kind). /// The view reads the shared popup, so one instance suffices; it @@ -2012,6 +2042,7 @@ impl EditorCore { return Err("cannot kill the last remaining buffer".into()); } } + self.round_trip_buffers.remove(&buffer_id); let fallback = { let mut reg = self.registry.borrow_mut(); match reg.find_by_name("*scratch*") { diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 0961a9e..0cd09de 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -2366,6 +2366,25 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result() { + core.borrow_mut().set_round_trip_input(id.0, on); + } + Ok(()) + })?, + )?; + } + { // T M4.5 L1: find-or-open. If a buffer is already bound to // `path`, switch to it (preserving unsaved edits — no diff --git a/tests/listview_acceptance.rs b/tests/listview_acceptance.rs new file mode 100644 index 0000000..9bb0a45 --- /dev/null +++ b/tests/listview_acceptance.rs @@ -0,0 +1,138 @@ +//! List-panel acceptance (Arc 1b phase 1) --- the `pmacs.listview` +//! module end-to-end through `dispatch_key`: open/navigate/visit, +//! `q` restore, the Q#P3 read-only intercept, the Q#P6 round-trip +//! gate (`dispatch_idle` false while a panel is focused), and +//! refresh. The references panel itself needs a live LSP and is +//! validated manually / via the m4 harness; these tests drive the +//! substrate hermetically. +//! +//! Framing: docs/lsp-panels-framing.md. + +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::empty(), + } +} + +fn press(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); +} + +/// Open a three-row test panel whose visits record into `_G.VISITED`. +fn open_test_panel(s: &mut EditorState) { + s.lua_host + .lua() + .load( + r#" + _G.VISITED = nil + pmacs.listview.open { + name = "*test-panel*", + header = "3 items RET visit q quit", + rows = { + { text = "alpha", item = "A" }, + { text = "beta", item = "B" }, + { text = "gamma", item = "C" }, + }, + on_visit = function(item) _G.VISITED = item end, + on_refresh = function() + return { { text = "delta", item = "D" } } + end, + } + "#, + ) + .exec() + .expect("open test panel"); +} + +/// `(active buffer name, buffer text, cursor line, visited)` probed +/// through the Lua surface. +fn probe(s: &EditorState) -> (String, String, i64, Option) { + s.lua_host + .lua() + .load( + r" + local b = pmacs.window.buffer() + local d = pmacs.describe.buffer(b) + return d.name, b:slice(0, b:len()), pmacs.editor.cursor_line(), _G.VISITED + ", + ) + .eval() + .expect("probe panel state") +} + +#[test] +fn open_seats_cursor_and_ret_visits_the_row() { + let mut s = EditorState::new(); + open_test_panel(&mut s); + let (name, text, line, _) = probe(&s); + assert_eq!(name, "*test-panel*"); + assert!(text.starts_with("3 items"), "header renders first"); + assert_eq!(line, 1, "the cursor opens on the first data row"); + + press(&mut s, KeyCode::Char('n')); // buffer-local: cursor.down + press(&mut s, KeyCode::Enter); + let (_, _, _, visited) = probe(&s); + assert_eq!(visited.as_deref(), Some("B"), "RET visits the second row"); +} + +#[test] +fn header_row_is_not_visitable() { + let mut s = EditorState::new(); + open_test_panel(&mut s); + press(&mut s, KeyCode::Char('p')); // up onto the header + press(&mut s, KeyCode::Enter); + let (_, _, _, visited) = probe(&s); + assert_eq!(visited, None, "the header maps to no item"); +} + +#[test] +fn q_restores_the_previous_buffer() { + let mut s = EditorState::new(); + open_test_panel(&mut s); + press(&mut s, KeyCode::Char('q')); + let (name, _, _, _) = probe(&s); + assert_eq!(name, "*scratch*", "q returns to the buffer we came from"); +} + +#[test] +fn panel_rejects_typing() { + let mut s = EditorState::new(); + open_test_panel(&mut s); + let (_, before, _, _) = probe(&s); + press(&mut s, KeyCode::Char('z')); // unbound printable → self-insert → intercept rejects + let (_, after, _, _) = probe(&s); + assert_eq!(before, after, "the read-only intercept rejects self-insert"); +} + +#[test] +fn dispatch_idle_is_false_while_a_panel_is_focused() { + // Q#P6: while the panel is the active buffer, semantic frontends + // must round-trip every key (RET = visit, not an optimistic \n). + let mut s = EditorState::new(); + assert!(s.dispatch_idle(), "scratch buffer: idle"); + open_test_panel(&mut s); + assert!(!s.dispatch_idle(), "panel focused: keys must round-trip"); + press(&mut s, KeyCode::Char('q')); + assert!(s.dispatch_idle(), "restored buffer: idle again"); +} + +#[test] +fn refresh_reruns_the_source_and_reseats() { + let mut s = EditorState::new(); + open_test_panel(&mut s); + press(&mut s, KeyCode::Char('g')); + let (_, text, line, _) = probe(&s); + assert!(text.contains("delta"), "g re-renders from on_refresh"); + assert!(!text.contains("alpha"), "old rows are gone"); + assert_eq!(line, 1, "cursor re-seats on a data row after refresh"); + press(&mut s, KeyCode::Enter); + let (_, _, _, visited) = probe(&s); + assert_eq!(visited.as_deref(), Some("D"), "the refreshed row visits"); +} From 5c5caa94820357924b57222df0ed32f23133061d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 20:46:50 -0400 Subject: [PATCH 3/4] fix(panels): follow active buffer on semantic frontends; re-attach overlays on switch Two PR #94 validation findings. 1. (High, blocking) GPU stuck after leaving a panel: the GPU only swaps its displayed buffer on BufferSnapshot, and the daemon only sent one on the first CRDT upgrade (F29's ensure returns None for an already-backed buffer). A panel's q / RET switched the daemon's active buffer back to the already-known source and sent nothing -- the GPU kept rendering the panel while input targeted the source: a typing-into-a-buffer-you-can't-see hazard. Fix: the per-tick loop now FOLLOWS each replica frontend's own active buffer -- when it differs from the last snapshot sent to that frontend, ship that buffer's snapshot to that frontend only (the F29 broadcast records itself so the upgrade tick doesn't double-send). First-tick send also repairs the attach-time last-snapshot-wins ambiguity. Snapshot export extracted and shared with the F29 broadcast; per-fid state cleaned on both detach paths. 2. (High, wider than reported) 'LSP doesn't activate on navigate': switch_active_buffer clears the window's overlays, and the runtime dedup tables (highlighted_buffers, styled_buffers, diag_viewed_buffers) blocked re-attachment -- so EVERY buffer switch (plain C-x b included, long-latent) permanently stripped syntax color, LSP semantic style, and diagnostic underlines; verified: overlay kinds [syntax-highlight, lsp-style, diagnostic] -> [] after one away-and-back. Fix: a new additive buffer.after-switch hook, fired by the window.switch_buffer binding and find_or_open's existing-buffer branch; syntax.lua and lsp.lua subscribe and re-push their views (the just-cleared window makes that exactly-once per switch; fresh loads keep firing after-load). Regression: tests/overlay_reattach_acceptance.rs (double round-trip counts exactly one highlight overlay; panel q restores styling). The daemon follow path is validated live (daemon + GPU) -- its unit seam is the shared export helper. Co-Authored-By: Claude Fable 5 --- builtin/hooks/default.lua | 10 +++ builtin/runtime/lsp.lua | 20 +++++ builtin/runtime/syntax.lua | 19 +++++ src/daemon.rs | 107 +++++++++++++++++++---- src/lua_bindings/mod.rs | 16 +++- tests/overlay_reattach_acceptance.rs | 123 +++++++++++++++++++++++++++ 6 files changed, 277 insertions(+), 18 deletions(-) create mode 100644 tests/overlay_reattach_acceptance.rs diff --git a/builtin/hooks/default.lua b/builtin/hooks/default.lua index 5e48993..98d89e4 100644 --- a/builtin/hooks/default.lua +++ b/builtin/hooks/default.lua @@ -44,6 +44,16 @@ define { kind = "all-must-succeed", } +define { + name = "buffer.after-switch", + description = "Fired after the active window switches to a different, " .. + "already-open buffer (C-x b, panel visits, find_or_open of " .. + "an open file). Switching clears the window's overlays; " .. + "syntax/LSP subscribers re-attach theirs here. Fresh loads " .. + "fire buffer.after-load instead.", + kind = "all-must-succeed", +} + define { name = "buffer.after-save", description = "Fired after a successful save. LSP did_save and " .. diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 2a11595..d2b171b 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -569,6 +569,26 @@ pmacs.hook.add("buffer.after-load", function() pcall(attach_buffer, pmacs.window.buffer()) end) +pmacs.hook.add("buffer.after-switch", function() + -- Arc 1b: switching buffers clears the window's overlays, and + -- `attach_buffer` early-returns for a live attachment without + -- touching views — so a switch back to an attached buffer must + -- re-push the LSP style + diagnostic views itself. The just- + -- cleared window makes this exactly-once per switch; the dedup + -- tables keep gating the after-load path only. Without this, + -- navigating between attached buffers looked like "the LSP + -- deactivated" (no semantic color, no underlines). + local buf = pmacs.window.buffer() + if not buf then return end + local key = tostring(buf) + local rec = attachments[key] + if not rec then return end + local ok_s, attached_s = pcall(pmacs.lsp._attach_style, buf) + if ok_s and attached_s then styled_buffers[key] = true end + local ok_d, attached_d = pcall(pmacs.diag._attach_view, buf, rec.uri) + if ok_d and attached_d then diag_viewed_buffers[key] = true end +end) + pmacs.hook.add("buffer.after-edit", function() local buf = pmacs.window.buffer() if not buf then return end diff --git a/builtin/runtime/syntax.lua b/builtin/runtime/syntax.lua index f28e012..5f7fcb8 100644 --- a/builtin/runtime/syntax.lua +++ b/builtin/runtime/syntax.lua @@ -83,6 +83,25 @@ pmacs.hook.add("buffer.after-load", function() end end) +pmacs.hook.add("buffer.after-switch", function() + -- Arc 1b: switching buffers clears the window's overlays + -- (`switch_active_buffer` resets window view state), so the + -- highlight view must be re-pushed for the now-active buffer. + -- Dropping the `highlighted_buffers` entry first lets + -- `attach_for_active_buffer` re-attach; the just-cleared window + -- makes that exactly-once per switch. Without this, C-x b / + -- panel navigation permanently stripped syntax color. + local ok, err = pcall(function() + local buf = pmacs.window.buffer() + if not buf then return end + highlighted_buffers[tostring(buf)] = nil + attach_for_active_buffer() + end) + if not ok and pmacs.error then + pmacs.error("syntax.after-switch: " .. tostring(err)) + end +end) + local function reparse_active_buffer_after_edit() local buf = pmacs.window.buffer() if not buf then return end diff --git a/src/daemon.rs b/src/daemon.rs index c5616b2..d58013f 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -842,6 +842,16 @@ fn dispatcher_loop( // attach emits an initial `DispatchIdle` so the frontend starts // from a known idle state (its default is pessimistic-`false`). let mut last_dispatch_idle_sent: HashMap = HashMap::new(); + // Arc 1b — the buffer each replica frontend last received a + // `BufferSnapshot` for via the active-buffer-follow path. Absence + // means "never sent": the first tick after attach ships the + // frontend its own active buffer, which also repairs the + // attach-time last-snapshot-wins ambiguity (the initial + // `send_buffer_snapshots` sweep sends every buffer; the display + // follows whichever arrived last, not necessarily the active one). + // Declared for both flavors (the follow path is crdt-gated; the + // detach cleanup isn't). + let mut last_active_buffer_sent: HashMap = HashMap::new(); let mut session_registry = SessionRegistry::new(); // T M10.11 Q8 — jitter PRNG, seeded once so the // convergence-under-jitter scenario is deterministically @@ -966,6 +976,31 @@ fn dispatcher_loop( &session_registry, &mut streams, ); + // The broadcast just delivered this buffer to this + // frontend too; record it so the follow check below + // doesn't send a duplicate on the same tick. + last_active_buffer_sent.insert(*fid, upgraded); + } + + // Arc 1b — follow this frontend's active buffer. The + // F29 push above only fires on the *upgrade* tick; + // switching to an already-CRDT-backed buffer (a + // panel's `q`, `find_or_open` of an open file, plain + // `C-x b`) previously sent nothing, so a semantic + // frontend kept rendering the old buffer while + // daemon-side input targeted the new one — a + // typing-into-a-buffer-you-can't-see hazard. Ship the + // now-active buffer's snapshot to THIS frontend only + // (its own view changed; nobody else's did). + let active_now = { + let core = editor.core.borrow(); + core.active_window_for(*fid).map(|w| w.buffer_id) + }; + if let Some(active_now) = active_now + && last_active_buffer_sent.get(fid) != Some(&active_now) + { + send_buffer_snapshot_to_frontend(editor, active_now, *fid, &mut streams); + last_active_buffer_sent.insert(*fid, active_now); } } #[cfg(not(feature = "crdt"))] @@ -1181,6 +1216,7 @@ fn dispatcher_loop( semantic_states.remove(fid); term_sizes.remove(fid); last_dispatch_idle_sent.remove(fid); + last_active_buffer_sent.remove(fid); session_registry.unregister_session(*fid); editor.core.borrow_mut().unregister_frontend_view(*fid); } @@ -1221,6 +1257,7 @@ fn dispatcher_loop( &mut streams, &mut term_sizes, &mut last_dispatch_idle_sent, + &mut last_active_buffer_sent, &mut session_registry, ); // Drain a burst of immediately-available events to @@ -1236,6 +1273,7 @@ fn dispatcher_loop( &mut streams, &mut term_sizes, &mut last_dispatch_idle_sent, + &mut last_active_buffer_sent, &mut session_registry, ); } @@ -1341,6 +1379,7 @@ fn handle_dispatcher_event( streams: &mut HashMap, term_sizes: &mut HashMap, last_dispatch_idle_sent: &mut HashMap, + last_active_buffer_sent: &mut HashMap, session_registry: &mut SessionRegistry, ) { match event { @@ -1515,6 +1554,7 @@ fn handle_dispatcher_event( streams.remove(&frontend_id); term_sizes.remove(&frontend_id); last_dispatch_idle_sent.remove(&frontend_id); + last_active_buffer_sent.remove(&frontend_id); session_registry.unregister_session(frontend_id); editor .core @@ -1691,6 +1731,55 @@ fn ensure_active_buffer_crdt_backed( /// send is small (snapshot bytes for the upgrade-instant state, /// which is the empty / freshly-loaded buffer content the replica /// already has) and only fires on the actual upgrade tick. +/// Export `buffer_id`'s CRDT snapshot bytes, or `None` (logged) when +/// the buffer is missing, not CRDT-backed, or the export fails. +#[cfg(feature = "crdt")] +fn export_buffer_snapshot( + editor: &EditorState, + buffer_id: crate::buffer::BufferId, +) -> Option> { + let core = editor.core.borrow(); + let registry = core.registry.borrow(); + let buf = registry.get(buffer_id).ok()?; + let crdt = buf.crdt_state()?; + match crdt.export_snapshot() { + Ok(bytes) => Some(bytes), + Err(e) => { + eprintln!("pmacs: export_snapshot for {buffer_id:?} failed: {e:?}"); + None + } + } +} + +/// Arc 1b — send `buffer_id`'s snapshot to ONE frontend. The +/// active-buffer-follow path (see the per-tick loop) uses this when a +/// semantic frontend's own active buffer changes to an +/// already-CRDT-backed buffer: the F29 broadcast only fires on the +/// upgrade tick, so without this a frontend that switched *back* to a +/// known buffer (a panel's `q`, `find_or_open` of an open file) kept +/// displaying the old buffer while daemon-side input targeted the new +/// one. +#[cfg(feature = "crdt")] +fn send_buffer_snapshot_to_frontend( + editor: &EditorState, + buffer_id: crate::buffer::BufferId, + fid: FrontendId, + streams: &mut HashMap, +) { + let Some(snapshot_bytes) = export_buffer_snapshot(editor, buffer_id) else { + return; + }; + let msg = InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot: snapshot_bytes, + }; + if let Some(stream) = streams.get_mut(&fid) + && let Err(e) = write_message(stream, &msg) + { + eprintln!("pmacs: send BufferSnapshot for {buffer_id:?} to {fid:?} failed: {e}"); + } +} + #[cfg(feature = "crdt")] fn broadcast_buffer_snapshot_to_replicas( editor: &EditorState, @@ -1698,22 +1787,8 @@ fn broadcast_buffer_snapshot_to_replicas( session_registry: &SessionRegistry, streams: &mut HashMap, ) { - let snapshot_bytes = { - let core = editor.core.borrow(); - let registry = core.registry.borrow(); - let Ok(buf) = registry.get(buffer_id) else { - return; - }; - let Some(crdt) = buf.crdt_state() else { - return; - }; - match crdt.export_snapshot() { - Ok(bytes) => bytes, - Err(e) => { - eprintln!("pmacs: F29 export_snapshot for {buffer_id:?} failed: {e:?}"); - return; - } - } + let Some(snapshot_bytes) = export_buffer_snapshot(editor, buffer_id) else { + return; }; let msg = InstanceMessage::BufferSnapshot { buffer_id, diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 0cd09de..1f9f4a0 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -2402,6 +2402,11 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result mlua::Result { let cc = core.clone(); win.set( "switch_buffer", - lua.create_function(move |_, id: BufferIdLua| -> mlua::Result<()> { + lua.create_function(move |lua, id: BufferIdLua| -> mlua::Result<()> { cc.borrow_mut() .switch_active_buffer(id.0) - .map_err(mlua::Error::external) + .map_err(mlua::Error::external)?; + // Arc 1b: switching clears the window's overlays; + // subscribers (syntax highlight, LSP style/diag views) + // re-attach theirs here — without this, C-x b / panel + // navigation permanently stripped styling from the + // session. + run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); + Ok(()) })?, )?; } diff --git a/tests/overlay_reattach_acceptance.rs b/tests/overlay_reattach_acceptance.rs new file mode 100644 index 0000000..48eafbb --- /dev/null +++ b/tests/overlay_reattach_acceptance.rs @@ -0,0 +1,123 @@ +//! Overlay re-attach on buffer switch (Arc 1b, PR #94 validation +//! finding 2): `switch_active_buffer` clears the window's overlays, +//! and the runtime's dedup tables blocked re-attachment — so plain +//! `C-x b`, buffer-list visits, and panel navigation permanently +//! stripped syntax/LSP styling ("the LSP doesn't activate if I +//! navigate to a reference"). The `buffer.after-switch` hook now +//! re-pushes the views. +//! +//! Hermetic: only the tree-sitter `syntax-highlight` overlay is +//! asserted (always available for `.rs`); the LSP style/diag views +//! ride the same hook but need a live server. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; + +fn open_probe_file(_s: &EditorState) -> String { + let dir = std::env::temp_dir().join(format!("pmacs-ovl-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("mk tempdir"); + let file = dir.join("probe.rs"); + std::fs::write(&file, "fn main() {}\n").expect("write probe file"); + file.display().to_string() +} + +/// `(overlay kinds on the active window, active buffer name)`. +fn kinds(s: &EditorState) -> (Vec, String) { + s.lua_host + .lua() + .load( + r" + local d = pmacs.describe.buffer(pmacs.window.buffer()) + return pmacs.window._overlay_kinds(), d.name + ", + ) + .eval() + .expect("probe overlay kinds") +} + +fn count_of(kinds: &[String], kind: &str) -> usize { + kinds.iter().filter(|k| *k == kind).count() +} + +#[test] +fn switch_away_and_back_reattaches_syntax_overlay_exactly_once() { + let s = EditorState::new(); + let path = open_probe_file(&s); + s.lua_host + .lua() + .load(format!( + r#" + _G.TARGET = pmacs.buffer.find_or_open("{path}") + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == "*scratch*" then _G.SCRATCH = id end + end + "# + )) + .exec() + .expect("open probe + find scratch"); + let (before, _) = kinds(&s); + assert_eq!( + count_of(&before, "syntax-highlight"), + 1, + "fresh open attaches the highlight overlay once (got {before:?})" + ); + + // Away and back — twice, so stacking would show as a count > 1. + s.lua_host + .lua() + .load( + r" + pmacs.window.switch_buffer(_G.SCRATCH) + pmacs.window.switch_buffer(_G.TARGET) + pmacs.window.switch_buffer(_G.SCRATCH) + pmacs.window.switch_buffer(_G.TARGET) + ", + ) + .exec() + .expect("switch away and back twice"); + let (after, name) = kinds(&s); + assert!(name.ends_with("probe.rs"), "back on the probe file"); + assert_eq!( + count_of(&after, "syntax-highlight"), + 1, + "the switch re-attaches exactly one highlight overlay (got {after:?})" + ); +} + +#[test] +fn panel_quit_restores_overlays_on_the_source_buffer() { + let s = EditorState::new(); + let path = open_probe_file(&s); + s.lua_host + .lua() + .load(format!( + r#" + pmacs.buffer.find_or_open("{path}") + pmacs.listview.open {{ + name = "*ovl-panel*", + header = "h", + rows = {{ {{ text = "row", item = 1 }} }}, + }} + "# + )) + .exec() + .expect("open probe + panel"); + // q leaves the panel back to the source file. + let mut s = s; + s.dispatch_key( + pmacs::protocol::FrontendId::LOCAL, + KeyEvent { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + }, + ); + let (after, name) = kinds(&s); + assert!(name.ends_with("probe.rs"), "q restored the source buffer"); + assert_eq!( + count_of(&after, "syntax-highlight"), + 1, + "leaving a panel restores the source buffer's styling (got {after:?})" + ); +} From 157358c57e61199eca578f3851d79f749d488a07 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 21:11:32 -0400 Subject: [PATCH 4/4] fix(daemon): follow-active-buffer snapshots are semantic-sessions only Round-2 regression (TUI could not attach): the follow path fired for every crdt_replica session, and its first-tick send delivered a GUARANTEED duplicate BufferSnapshot right after the attach sweep. The grid TUI's BufferMirror is init-once -- the duplicate errored ('buffer already has a CRDT snapshot applied') on every attach, and every TUI buffer switch would have produced another. Display-follows-snapshot is a grid-less-frontend concept: the GPU rebuilds its replica wholesale on every snapshot and is the only consumer that needs the follow. The send is now gated on negotiated_capabilities.semantic_render -- TUI sessions get zero follow-sends, byte-identical wire behavior to main. Verified: PTY-doubled real-binary attach tests pass with the gate; the one failing PTY test (m10_11_doubled_pty_fixture_propagates_ keystrokes_from_both_sides, 'AXYB' interleaving) fails identically on main 3/3 -- pre-existing, tracked separately, not this branch. Co-Authored-By: Claude Fable 5 --- src/daemon.rs | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index d58013f..c025d43 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -992,15 +992,29 @@ fn dispatcher_loop( // typing-into-a-buffer-you-can't-see hazard. Ship the // now-active buffer's snapshot to THIS frontend only // (its own view changed; nobody else's did). - let active_now = { - let core = editor.core.borrow(); - core.active_window_for(*fid).map(|w| w.buffer_id) - }; - if let Some(active_now) = active_now - && last_active_buffer_sent.get(fid) != Some(&active_now) + // + // SEMANTIC sessions only: display-follows-snapshot is + // a grid-less-frontend concept, and the GPU rebuilds + // its replica wholesale on every snapshot. The grid + // TUI renders via CellDelta and its `BufferMirror` is + // init-once — a follow send there is a guaranteed + // duplicate that errors ("already has a CRDT snapshot + // applied") on every attach and every buffer switch + // (the PR #94 round-2 startup regression). + if session_registry + .session_state(*fid) + .is_some_and(|s| s.negotiated_capabilities.semantic_render) { - send_buffer_snapshot_to_frontend(editor, active_now, *fid, &mut streams); - last_active_buffer_sent.insert(*fid, active_now); + let active_now = { + let core = editor.core.borrow(); + core.active_window_for(*fid).map(|w| w.buffer_id) + }; + if let Some(active_now) = active_now + && last_active_buffer_sent.get(fid) != Some(&active_now) + { + send_buffer_snapshot_to_frontend(editor, active_now, *fid, &mut streams); + last_active_buffer_sent.insert(*fid, active_now); + } } } #[cfg(not(feature = "crdt"))]