Surfaced during session-5 manual validation as the final iteration
of bet #1 from the framing pass: edits that shift byte positions
left old diagnostic colors painted over post-edit text in both
`pmacs-gpu` and (now-visible) the TUI. Persisted for the full LSP
re-analysis window (100ms–5s).
PR #47 fixed the StyleSpans side via generation-tracked full=true
emission, but Decorations remained vulnerable: the producer's diff
shipped old diagnostics from the diag store, whose entries were
indexed at pre-edit byte positions until clangd republished.
Fix: a per-URI `stale_uris` flag in `DiagnosticStore`. The LSP
layer's `did_change_full` marks the URI stale right after sending
the notification; the next `publishDiagnostics` absorb path's
`set` clears it. The `semantic_render` producer reads `is_stale`
and skips diagnostic emission entirely while stale.
Effect: between an edit and clangd's next publish, the producer
ships zero diagnostic decorations. Frontend's replace/merge clears
old positions cleanly. Brief uncolored window (≤ LSP re-analysis
latency) replaces the previous wrong-position-color persistence.
The correct visual tradeoff: honest emptiness over deceptive
staleness.
Files changed:
- `src/diag.rs` — `DiagnosticStore` gains `stale_uris: HashSet<String>`;
new `mark_stale` / `is_stale` API; `set` and `clear` reset the
flag on the assumption that absorption / explicit removal mean
the LSP has caught up.
- `src/lsp.rs` — `LspManager::did_change_full` calls
`diag_store.lock().mark_stale(uri)` after `send_notification`.
- `src/semantic_render.rs` — `scoped_decorations` reads `is_stale`
alongside `for_uri`; when stale, suppresses the diagnostic
loop (selection and other non-diagnostic kinds still emit).
Tests (all crdt-gated where they reference semantic_render):
- `diag::tests::stale_flag_default_false`
- `diag::tests::mark_stale_sets_flag` (per-URI scoping)
- `diag::tests::set_clears_stale_flag`
- `diag::tests::empty_set_clears_stale_flag_too`
- `diag::tests::clear_drops_stale_flag`
- `semantic_render::tests::diagnostics_suppressed_while_diag_store_stale`
— assert no diagnostic kinds emit while stale; assert they
re-emit after a fresh `set` clears the flag.
Gates: cargo fmt + clippy (workspace, with/without `crdt`) clean;
lib 1482 (+6) with crdt; 1319 (+6) without; m4 83; m11_5 2.
This is approach (A) from the session-5 ask: track per-URI freshness
relative to buffer edits, suppress emission until LSP catches up.
Approach (B) — clangd's didChange/publishDiagnostics version
matching — would be more precise but requires plumbing version
tracking through the LspManager's document state, which is a
larger change deferred. The stale-flag captures the same semantic
("any edit since last publish ⇒ stale") at a cheaper cost.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Surfaced during session-5 manual validation (probe #3, the bet-#1
shape from the framing pass): editing at a diagnostic boundary in
the TUI left stale color fragments visible in pmacs-gpu against
shifted text.
Root cause: SemanticRenderState ships `full=true` styling only on
viewport-region changes, not on text-edit transitions. When the
buffer's CRDT generation advances (an edit) but the viewport stays
the same, the producer ships an incremental — but the frontend's
cached spans + decorations are indexed at *pre-edit* byte
positions. The incremental only ships dirty-range items, expecting
the frontend to retain everything else; combined with shifted
positions, the result is wrong-position color persisting until
the next viewport change.
Fix: track `generation` per buffer in `LastFrame`. Force `full=true`
when generation differs from the last-shipped value, so the
frontend's next `replace_*` operation rebuilds the cache wholesale
at the new positions.
Tradeoff: one extra full-viewport ship per edit. Negligible over
the local Unix socket; bounded by viewport size; and exactly what
the contract requires after position shifts.
Affects both diff-shaped families:
- `StyleSpans` — tree-sitter / LSP semantic tokens
- `Decorations` — diagnostics + selection
`InlineAdornments` uses whole-set replacement (M11.2-level
suppression), not dirty-segment diff, so doesn't have the same
issue. Tracking `generation` in its `LastFrame` for struct
uniformity; predicate unchanged.
Regression test: `full_resync_on_generation_transition` upgrades a
buffer to CRDT-backed, lands an initial full frame, edits the
buffer to bump `version_scalar`, asserts the next StyleSpans +
Decorations both ship `full=true`.
Gates: cargo fmt + clippy (workspace, with/without `crdt`) clean;
lib 1476 (+1) with crdt; 1313 unchanged without (new test is
crdt-gated); m4 83; m11_5 2.
This closes the bet-#1 surface for the consumer side. A separate
follow-up to session-5 PR #43 will document the resolution.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Surfaced during session-5 manual validation: pmacs-gpu opened a file
that had 8 LSP diagnostics in the shared store (verified via the
debug.diag-status command on the TUI side), yet pmacs-gpu's
`Decorations` message arrived empty.
Root cause: three sites in `semantic_render.rs` resolved the lookup
URI from `core.active_buffer_path()` — the *editor's* active buffer.
In a multi-frontend setup (TUI + pmacs-gpu attached at once), the
daemon's per-tick render loop temporarily flips `active_frontend`
to each fid before that frontend's frame. Each frontend has its own
`FrontendView` with its own active window; pmacs-gpu's was
registered against a fresh scratch buffer at attach time (the v0.1
default in `handle_session_established`). So when the producer
ran for pmacs-gpu, `active_buffer_path()` returned `None` — the
scratch has no file path — and the diag / inlay / LSP-semantic-token
lookups all came back empty.
Fix: route the URI through `vp.buffer_id` via a new
`buffer_file_uri(core, buffer_id)` helper. Single-frontend case is
unchanged (the active buffer equals the projected buffer); multi-
frontend now finds the right URI.
Three sites updated:
- `scoped_decorations` (line 366) — diagnostics
- `inline_adornments_msg` (line 425) — inlay hints
- `lsp_scoped_style_spans` (line 701) — LSP semantic tokens for
grammar-less languages
Regression test: `decorations_use_vp_buffer_not_active_buffer`
constructs the multi-frontend shape (LOCAL's active is scratch; a
second buffer with a file path holds a seeded diagnostic; the
viewport projects the second buffer) and asserts the decoration
surfaces.
Separately surfaced (not fixed here, documented in task #23):
`DiagnosticView` is defined in `diag.rs` but never attached to any
buffer. The TUI grid path has no diagnostic underline rendering as
a result — an unrelated M4.6 incompleteness from v0.1's initial
commit. Tracked as its own thread; will need a framing pass for
scope (view attach? navigation bindings? statusline summary?
gutter signs?).
Gates: cargo fmt + clippy (workspace, with/without `crdt`) clean;
lib 1475 (+1) with crdt; 1313 (+1) without; m4 83; m11_5 2.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
New InstanceMessage::FileStyleSummary { buffer_id, generation, lines:
Vec<Style> }: a coarse whole-file styling summary for a Zed/VSCode-
style minimap, resolving the design note's Open Q#2. One dominant
Style per source line (by byte count across the producer's current
spans); the frontend maps minimap rows to one or more lines.
Producer scoped_file_summary reuses scoped_style_spans with a whole-
buffer synthetic viewport, so policy A's authority pick (tree-sitter
for grammar-backed languages, LSP semantic tokens otherwise) is
inherited automatically — no separate styling path. file_style_summary_msg
is keyed on the buffer's CRDT generation: an idle buffer at the same
generation pays nothing (the whole-file summary is the expensive bit
on large files, so re-emit only after edits). First frame for a
buffer always emits; the existing first-frame test updated to expect
3 messages (StyleSpans + Decorations + FileStyleSummary).
Per-line dominant style is the v1 representation. Future refinements
(fixed-N bands; whole-file RLE style runs) are recorded in the design
note as straightforward extensions if a real frontend prefers them.
Structural gating same as the other semantic families: the daemon
only constructs a SemanticRenderState for sessions that negotiated
semantic_render, so non-semantic sessions never receive it. Grid TUI
adds the variant to its ignore list. Round-trip fixture covers it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scoped_inline_adornments (free fn, mirrors scoped_style_spans) reads
the inlay-hint store via for_uri and maps each InlayHint to an
InlineAdornment { at, AtOffset, Text{padded label, default style} },
clipped to the declared viewport (anchor in [vis_start, vis_end)).
Step 0 established inlay columns are already byte offsets by the time
they reach the store (inbound_converted rewrites the Position-shaped
InlayHint.position), so line_col_to_byte is exact with no per-server
encoding — unlike semantic-token styling.
inline_adornments_msg does the suppression: the InlineAdornments wire
variant has no generation/full/segments, so this is M11.2-level only
(whole-set re-send on any change, nothing when byte-identical, and
never an empty frame when there is nothing to say — no spam).
Tests: clip-to-viewport + padding + AtOffset, suppress-then-resync,
no-emit-without-hints; the old never-emitted invariant is split into
block_adornments_and_fold_state_still_never_emitted (Block/Fold are
still unwired) plus inline_adornments_not_emitted_without_hints.
assert_semantic_only now admits InlineAdornments.
Step 5 (folded): docs/semantic-frontend-protocol.md moves StyleSpans
(policy A) + InlineAdornments out of "declared, not wired", and adds
two deferred Open questions — per-byte tree-sitter/LSP blend, and
multiple-servers-one-URI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Languages with no bundled tree-sitter grammar (C/C++, …) had empty
StyleSpans — the visible "no C++ syntax coloring" gap. scoped_style_spans
now applies per-language styling authority (policy A): a grammar-backed
language stays tree-sitter-only (unchanged); a grammar-less buffer falls
through to lsp_scoped_style_spans, which reads the semantic-token store
(for_uri), resolves the owning server's encoding + legend via
LspManager::semantic_style_context, converts UTF-16 start/length to byte
per line with char_to_byte (now pub(crate); semantic-token data is NOT
byte-rewritten upstream, unlike inlay hints — see Step 0), names the
token via the legend, maps through the existing Theme::lookup, and drops
default-style spans. Output is shape-identical to the tree-sitter path,
so the M11.4 diff pipeline consumes it unchanged. Never two authorities
on one buffer: a still-parsing grammar-backed buffer returns empty
rather than briefly borrowing LSP styling.
Step 4 folded in: golden tests in the semantic_render module —
cpp_style_comes_from_lsp_when_no_tree_sitter_grammar (headline),
suppression (M11.4 reuse), incremental-on-token-change, and honest
empty-without-tokens. A #[cfg(test)] LspManager::insert_initialized_
test_client supplies a synthetic Initialized client (legend caps +
encoding) with no process, so the producer path is exercised without
a live server.
Per-byte tree-sitter/LSP blend and multi-server-same-uri merge remain
deferred open questions (recorded in the design note by Step 5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lays the groundwork for WorkspaceEdit/rename (L2+) by making
navigation cross-file-correct.
- Relocate file_path/file_meta from the EditorCore global onto
Buffer itself, so each buffer keeps its own filesystem identity
across cross-file navigation. Accessors + registry/editor/lua/
semantic_render call sites migrated; zero behavioural change for
single-file flows.
- uri->path: project_index::uri_to_path made pub; pmacs.lsp.path_for_uri.
- find-or-open: BufferRegistry::find_by_path + pmacs.buffer.find_or_open
dedups an already-open file instead of spawning a duplicate buffer
(SP-4 Gap A).
- Bounded jump ring on EditorCore (cap 64, oldest-evict, stale-buffer
skip): push_jump/jump_back + pmacs.editor.* bindings + lsp.jump-back
command bound to M-,.
- pmacs.lsp.go_to_definition cross-file branch: decode URI ->
push_jump -> find_or_open -> reposition, with a failure path that
unwinds the pushed origin. ensure_server now passes cfg.env through.
Tests: 5 jump-ring unit tests; m4_12_cross_file_go_to_definition_and_
jump_back end-to-end via a new `defenv` fake-LSP mode. All gates green
(lib 1262/0, m4 67/0, m8_1/m8_9/m8_10, m9_1, m11_5 --features crdt 2/0).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Completes the read-only LSP feature set (everything except edits).
Same request→store→Lua pattern as the nav batch; these three need
new parsing (new response shapes), so they were deliberately split
from the Location-family PR (#16).
- src/symbol.rs: one flat Symbol type for both symbol requests.
from_lsp_value handles BOTH LSP shapes — hierarchical
DocumentSymbol[] (flattened with depth + parent chain) and flat
SymbolInformation[]/WorkspaceSymbol[] (location.uri, range
optional for WorkspaceSymbol). Scope-keyed (Document(uri) vs
Workspace(query)) so an outline and a query don't collide.
- src/document_highlight.rs: range + DocumentHighlightKind (absent
defaults to Text=1 per spec), (server,uri)-keyed.
- lsp.rs: three ResponseRoute variants + absorb arms + request
methods. documentSymbol/documentHighlight ranges convert via the
requested-doc codec; workspace/symbol results are cross-file →
route uri "" → non-destructive passthrough (same rule as
cross-file definition).
- Lua: raw bindings + pmacs.document_symbol / .workspace_symbol /
.document_highlight read surfaces (the new LSP Symbol is aliased
to avoid the pre-existing project_index::Symbol name clash);
lsp.lua wrappers + an lsp.document-symbols command on C-c o
(modeline summary; outline buffer is future UX).
- Tests: 6 parser unit tests (hierarchical depth/parent, flat
SymbolInformation, range-less WorkspaceSymbol, highlight kind
default, scope non-collision) + an e2e driving all three through
the async bridge asserting shape correctness.
Also includes a pre-existing rustfmt normalization of the #15
semantic-frontend files (protocol.rs / semantic_client.rs /
semantic_render.rs) — main was not rustfmt-clean there after the #15
merge; bundled here per operator decision so the fmt gate is green.
Gate: fmt clean; clippy --all-targets -D warnings clean; lib
1257/0; m4_acceptance 66/0; m9_1 18/0; m8_1/m8_9/m8_10 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the M11.2/M11.3 coarse whole-payload suppression with a
CellDelta-style diff lifted from positional cells to byte-anchored
ranges.
- protocol.rs: StyleSpans/Decorations refined to
{ buffer_id, generation, full: bool, segments: Vec<...Segment> }.
New StyleSegment{range,spans} / DecorationSegment{range,decorations}.
full=true → frontend discards prior state for the buffer; full=false
→ replace styling only within each segment's range, bytes in no
segment keep prior state. Decorations gains generation for parity.
Each segment carries ALL current items intersecting its range
(clipped), so an unchanged span overlapping a dirty range is
reconstructed. ResourceOffer stays an honest stub (no producer).
- semantic_render.rs: LastFrame baseline per buffer (viewport region
+ full item set). full on first frame / viewport-region change;
else symmetric-difference the ordered sets, coalesce changed ranges
into maximal disjoint dirty intervals, emit one segment per
interval with current items clipped to it; suppress when no dirty
interval. Independent baselines for styling vs decorations.
Byte offsets cascade on edits (an insert shifts later spans), so an
incremental post-edit frame dirties [edit, viewport_end) — bounded;
no-edit frames (cursor/scroll/selection-only) still cost nothing.
10 semantic_render tests (full-on-first/viewport-change, incremental
dirty intervals, independent suppression, unchanged-overlapping
reconstruction) + updated protocol round-trip. Lib (1397 crdt / 1242
non-crdt) + integration green both flavors; clippy -D warnings clean
both.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SemanticRenderState now also projects InstanceMessage::Decorations
from the instance-side state pmacs actually has.
- Selection: per-window byte-native state via active_window_for(fid)
(SemanticRenderState now carries the session FrontendId), gated to
the declared buffer and clipped to the viewport →
DecorationKind::Selection.
- Diagnostics: the shared DiagnosticStore keyed by file URI. Made
lsp::path_to_file_uri pub(crate) (byte-identical to the Lua
file_uri_for) so the projection reproduces the exact store key from
core.file_path. LSP (line,col) -> byte via a line-start scan
against the buffer source; severity -> DiagnosticError/Warning/
Info/Hint. Clipped to the viewport.
- StyleSpans and Decorations suppress unchanged frames independently
(separate last_* maps): a selection move doesn't force a styling
re-send and vice versa.
- Deliberately NOT emitted: SearchMatch/SearchMatchActive (no
instance search-hit store), CurrentLine (frontend derives from
CursorByte; emitting it would breach the contract boundary).
- InlineAdornments/BlockAdornments/FoldState remain unproduced by
design — no inlay/blame/lens/fold/diff source exists in pmacs yet.
Honest stubs (the M11.1 "declared, not yet wired" discipline), not
empty messages every frame.
Dispatcher updated for SemanticRenderState::new(frontend_id). Lib
(1394 crdt / 1239 non-crdt) + integration green on both feature
flavors; clippy -D warnings clean on both.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first real producer of the semantic-frontend arc. The instance
projects syntax styling to semantic_render sessions without
rasterizing to a cell grid.
- src/semantic_render.rs: SemanticRenderState, sibling of
instance_render::RenderState. Reads the same EditorState, emits
InstanceMessage::StyleSpans (tree-sitter spans via the active
Theme), scoped + clipped to the FrontendEvent::Viewport byte range.
Emits nothing until a viewport is declared; suppresses
byte-identical frames (per-span delta encoding deferred to M11.4).
- CrdtState::version_scalar(): oplog version vector summed to a
monotonic non-decreasing u64 — the StyleSpans.generation anchor.
- daemon dispatcher: semantic_states map parallel to render_states;
projection selected per session. Semantic sessions get StyleSpans +
CursorByte + BufferSnapshot + CrdtOp + presence, never CellDelta /
grid Cursor. FrontendEvent::Viewport consumed (routed by
authenticated source). SessionEstablished body extracted to
handle_session_established (clippy 100-line ceiling). Grid-less
sessions no longer panic the _ => apply_event arm.
- InstanceCapabilities default semantic_render flipped to
cfg!(feature = "crdt") — the "M11.2 enables semantic" moment,
analogous to the M10.8 Day-4 flip. M11.1 negotiation test comment
updated for the flip (frontend-side default still false keeps M10.7
outcomes unperturbed).
Lib (1392 crdt / 1237 non-crdt) + integration suites green on both
feature flavors; clippy -D warnings clean on both.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>