StatusFacts (protocol v8): name, modified, exact diag counts

The wire-authoritative half of the status band (Q#S1): an additive
InstanceMessage::StatusFacts { buffer_id, name, modified,
diag_errors, diag_warnings }, emitted by the semantic producer on
change (cached-compare). Counts freeze at their last value while
the diag store is stale — positions go wrong mid-edit but counts
merely lag, and flickering to zero per keystroke would be worse.
The daemon's write loop keeps the variant off wires negotiated
< 8, the DispatchIdle gate shape; SUPPORTED grows to [6, 7, 8].

GPU side: the band's left shows name + modified dot, the right
gains severity-colored E:n/W:n ahead of the local L:C/scroll
readout (rich-text spans, change-detected per side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-12 15:17:07 -04:00
parent a962d18d4d
commit 44430e8377
6 changed files with 347 additions and 46 deletions

View File

@ -474,11 +474,30 @@ struct State {
/// The string `status_buffer` currently holds, for change
/// detection.
status_text: String,
/// Q#S2 — the band's left side (buffer name + modified dot),
/// its own buffer so it left-aligns independently of the
/// right-aligned readout.
status_left_buffer: Buffer,
/// Change-detection twin of `status_text` for the left side.
status_left_text: String,
/// Q#S1 — the wire-authoritative status facts (protocol v8).
status_facts: Option<StatusFactsLocal>,
/// Minimap vertex bytes cached by [`MinimapCacheKey`] —
/// rebuilding rescanned every line shape per frame.
minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>,
}
/// The wire-authoritative status facts (Q#S1, protocol v8),
/// mirrored from `InstanceMessage::StatusFacts`.
#[derive(Clone, Debug, PartialEq, Eq)]
struct StatusFactsLocal {
buffer_id: BufferId,
name: String,
modified: bool,
diag_errors: u32,
diag_warnings: u32,
}
/// pmacs-gpu's own cursor position, mirrored from `CursorByte`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct OwnCursor {
@ -1208,6 +1227,15 @@ impl State {
Some(config.width as f32),
Some(STATUS_BAND_HEIGHT),
);
let mut status_left_buffer = Buffer::new(
&mut font_system,
Metrics::new(STATUS_FONT_SIZE, STATUS_LINE_HEIGHT),
);
status_left_buffer.set_size(
&mut font_system,
Some(config.width as f32),
Some(STATUS_BAND_HEIGHT),
);
buffer.set_text(
&mut font_system,
initial_text,
@ -1273,6 +1301,9 @@ impl State {
minimap_vertex_buffer: ReusableVertexBuffer::new(),
status_buffer,
status_text: String::new(),
status_left_buffer,
status_left_text: String::new(),
status_facts: None,
minimap_cache: None,
}
}
@ -1847,6 +1878,25 @@ impl State {
self.apply_file_style_summary(buffer_id, generation, lines);
None
}
// Q#S1 (protocol v8) — the wire-authoritative half of the
// status band: name, modified, whole-file diag counts.
InstanceMessage::StatusFacts {
buffer_id,
name,
modified,
diag_errors,
diag_warnings,
} => {
self.status_facts = Some(StatusFactsLocal {
buffer_id,
name,
modified,
diag_errors,
diag_warnings,
});
self.window.request_redraw();
None
}
// Session 9.3 — peer presence. The editing frontend's
// cursor + selection drive the `CurrentLine` / `Selection`
// washes for this read-only mirror (finding QB1). Store
@ -2290,14 +2340,32 @@ impl State {
self.window.request_redraw();
}
/// Compose the status-band readout from the locally fresh facts
/// (Q#S1): cursor L:C from the *optimistic* caret (so it tracks
/// typing bursts instead of lagging a round trip) and the
/// All/Top/Bot/NN% scroll indicator. The wire-authoritative
/// facts (name, modified star, diagnostic counts) join via the
/// v8 `StatusFacts` pass.
fn compose_status_line(&self) -> String {
let mut out = String::new();
/// Compose the status-band readout (Q#S1): diagnostic counts
/// (wire-authoritative, severity-colored, omitted when zero),
/// then cursor L:C from the *optimistic* caret (so it tracks
/// typing bursts instead of lagging a round trip), then the
/// All/Top/Bot/NN% scroll indicator. Returns the colored spans.
fn compose_status_spans(&self) -> Vec<(String, Option<Color>)> {
let mut spans: Vec<(String, Option<Color>)> = Vec::new();
if let Some(facts) = self
.status_facts
.as_ref()
.filter(|f| Some(f.buffer_id) == self.current_buffer_id)
{
if facts.diag_errors > 0 {
spans.push((
format!("E:{}", facts.diag_errors),
Some(Color::rgb(241, 76, 76)),
));
}
if facts.diag_warnings > 0 {
spans.push((
format!("W:{}", facts.diag_warnings),
Some(Color::rgb(245, 245, 67)),
));
}
}
let mut readout = String::new();
let mut cursor_row = self.scroll_top;
if let Some(own) = self.own_cursor
&& self.current_buffer_id == Some(own.buffer_id)
@ -2316,38 +2384,80 @@ impl State {
.current_text
.get(ls..byte)
.map_or(0, |s| s.chars().count());
out.push_str(&format!("L{}:C{}", line + 1, col + 1));
readout.push_str(&format!("L{}:C{}", line + 1, col + 1));
readout.push_str(" ");
}
let scroll = format_scroll_indicator(
readout.push_str(&format_scroll_indicator(
self.scroll_top,
estimated_visible_lines(self.config.height),
self.current_line_starts.len(),
cursor_row,
);
if !out.is_empty() {
out.push_str(" ");
}
out.push_str(&scroll);
out
));
spans.push((readout, None));
spans
}
/// Re-shape the status-band text iff the composed string changed
/// (one short line — shaping is trivial, but not free per frame).
fn refresh_status_line(&mut self) {
let composed = self.compose_status_line();
if composed == self.status_text {
return;
/// The band's left side: buffer name + modified dot, from the
/// v8 `StatusFacts` (empty until the daemon ships them).
fn compose_status_left(&self) -> String {
match self
.status_facts
.as_ref()
.filter(|f| Some(f.buffer_id) == self.current_buffer_id)
{
Some(facts) if facts.modified => format!("{}", facts.name),
Some(facts) => facts.name.clone(),
None => String::new(),
}
}
/// Re-shape the status-band text iff the composed content
/// changed (short lines — shaping is trivial, but not free per
/// frame).
fn refresh_status_line(&mut self) {
let spans = self.compose_status_spans();
let composed: String = spans
.iter()
.map(|(t, _)| t.as_str())
.collect::<Vec<_>>()
.join(" ");
let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono"));
if composed != self.status_text {
let mut rich: Vec<(&str, Attrs)> = Vec::new();
for (i, (t, c)) in spans.iter().enumerate() {
if i > 0 {
rich.push((" ", default_attrs.clone()));
}
let attrs = match c {
Some(color) => default_attrs.clone().color(*color),
None => default_attrs.clone(),
};
rich.push((t.as_str(), attrs));
}
self.status_buffer.set_rich_text(
&mut self.font_system,
rich,
&default_attrs,
Shaping::Advanced,
None,
);
self.status_buffer
.shape_until_scroll(&mut self.font_system, false);
self.status_text = composed;
}
let left = self.compose_status_left();
if left != self.status_left_text {
self.status_left_buffer.set_text(
&mut self.font_system,
&left,
&default_attrs,
Shaping::Advanced,
None,
);
self.status_left_buffer
.shape_until_scroll(&mut self.font_system, false);
self.status_left_text = left;
}
self.status_buffer.set_text(
&mut self.font_system,
&composed,
&Attrs::new().family(Family::Name("JetBrains Mono")),
Shaping::Advanced,
None,
);
self.status_buffer
.shape_until_scroll(&mut self.font_system, false);
self.status_text = composed;
}
/// The status band's background quad (Q#S2): a full-width strip
@ -2656,6 +2766,11 @@ impl State {
Some(width as f32),
Some(STATUS_BAND_HEIGHT),
);
self.status_left_buffer.set_size(
&mut self.font_system,
Some(width as f32),
Some(STATUS_BAND_HEIGHT),
);
// A taller/shorter window changes the visible line count, so the
// slice + scoped viewport change (session S1).
self.reshape();
@ -2790,6 +2905,21 @@ impl State {
default_color: Color::rgb(168, 168, 180),
custom_glyphs: &[],
},
TextArea {
buffer: &self.status_left_buffer,
left: STATUS_TEXT_PAD,
top: status_top,
scale: 1.0,
bounds: TextBounds {
left: 0,
top: text_area_bottom(self.config.height).round() as i32,
// Stop before the right-aligned readout.
right: (status_left - STATUS_TEXT_PAD).max(0.0).round() as i32,
bottom: self.config.height.cast_signed(),
},
default_color: Color::rgb(200, 200, 210),
custom_glyphs: &[],
},
],
&mut self.swash_cache,
)
@ -3593,6 +3723,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::Decorations { .. } => "Decorations",
InstanceMessage::InlineAdornments { .. } => "InlineAdornments",
InstanceMessage::FileStyleSummary { .. } => "FileStyleSummary",
InstanceMessage::StatusFacts { .. } => "StatusFacts",
InstanceMessage::BlockAdornments { .. } => "BlockAdornments",
InstanceMessage::FoldState { .. } => "FoldState",
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",

View File

@ -724,6 +724,26 @@ pub enum InstanceMessage {
/// from line 0. Empty when the buffer is empty.
lines: Vec<crate::cell::Style>,
},
/// Q#S1 (status band, protocol v8) — instance-authoritative
/// status facts a semantic frontend cannot derive locally:
/// buffer name, modified flag, whole-file diagnostic counts.
/// Cursor position and scroll stay frontend-derived (the
/// optimistic caret must not lag a round trip). Emitted by the
/// semantic producer when any fact changes; kept off wires
/// negotiated `< 8` (additive variant — an older peer would
/// hard-error decoding it).
StatusFacts {
/// Buffer these facts describe.
buffer_id: crate::BufferId,
/// Buffer display name.
name: String,
/// Unsaved-changes flag.
modified: bool,
/// Whole-file `Error`-severity diagnostic count.
diag_errors: u32,
/// Whole-file `Warning`-severity diagnostic count.
diag_warnings: u32,
},
/// T M11.1 — diff zones, folded-region placeholders, anything
/// occupying its own vertical band. Anchored to the offset of the
/// line it precedes or replaces; the frontend allocates the
@ -1044,7 +1064,13 @@ pub enum ResourceBody {
/// (sent only when the instance's `Hello.protocol_version >= 7`),
/// so the compat ladder restarts on the v6 encoding floor —
/// `SUPPORTED_PROTOCOL_VERSIONS` grows to `[6, 7]`.
pub const PROTOCOL_VERSION: u32 = 7;
///
/// Q#S1 (status band): bumped from 7 to 8 for
/// [`InstanceMessage::StatusFacts`]. Additive again, gated in the
/// *daemon* this time (the variant travels instance→frontend): the
/// per-session filter keeps it off wires negotiated `< 8`, the same
/// shape as the `DispatchIdle` (v4) gate.
pub const PROTOCOL_VERSION: u32 = 8;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
@ -1081,7 +1107,10 @@ pub const PROTOCOL_VERSION: u32 = 7;
/// and frontend-gated (like `Pointer` itself at v5), so the ladder
/// resumes: v6 and v7 binaries interoperate, with the new variant
/// kept off wires whose instance negotiated `< 7`.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7];
///
/// Q#S1: extended to `[6, 7, 8]`. `InstanceMessage::StatusFacts` is
/// additive and daemon-gated per session.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -1015,7 +1015,18 @@ fn dispatcher_loop(
if let Some(stream) = streams.get_mut(fid)
&& !write_failed
{
// Q#S1 — `StatusFacts` is a v8 variant; an older peer
// would hard-error decoding it. Same per-session gate
// shape as `DispatchIdle` (v4).
let peer_knows_status_facts = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 8);
for msg in &messages {
if !peer_knows_status_facts
&& matches!(msg, InstanceMessage::StatusFacts { .. })
{
continue;
}
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
// jitter site: render-write latency.
//

View File

@ -383,6 +383,7 @@ impl Frontend {
| InstanceMessage::BlockAdornments { .. }
| InstanceMessage::FoldState { .. }
| InstanceMessage::FileStyleSummary { .. }
| InstanceMessage::StatusFacts { .. }
| InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_seven_for_triple_click() {
fn protocol_version_is_eight_for_status_facts() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1693,7 +1693,9 @@ mod tests {
// bump that changed an existing struct's postcard encoding,
// making v6 the ladder's encoding floor. Q#M4 bumped 6→7
// (`PointerKind::TripleDown`, additive + frontend-gated).
assert_eq!(PROTOCOL_VERSION, 7);
// Q#S1 bumped 7→8 (`InstanceMessage::StatusFacts`, additive
// + daemon-gated per session).
assert_eq!(PROTOCOL_VERSION, 8);
}
#[test]
@ -1702,14 +1704,16 @@ mod tests {
// every cell-carrying message, ending the v1v5 ladder —
// pre-v6 peers are refused at the handshake (a clean
// VersionMismatch) rather than garbling postcard mid-session.
// Q#M4: the ladder resumes above that floor — v7 is additive
// (`TripleDown`, frontend-gated), so v6 and v7 interoperate.
// Q#M4 / Q#S1: the ladder resumes above that floor — v7
// (`TripleDown`, frontend-gated) and v8 (`StatusFacts`,
// daemon-gated) are additive, so v6 through v8 interoperate.
assert!(is_supported_protocol_version(6));
assert!(is_supported_protocol_version(7));
for rejected in [0, 1, 2, 3, 4, 5, 8, u32::MAX] {
assert!(is_supported_protocol_version(8));
for rejected in [0, 1, 2, 3, 4, 5, 9, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v7 binary"
"v{rejected} must be rejected by a v8 binary"
);
}
}

View File

@ -118,6 +118,9 @@ pub struct SemanticRenderState {
/// bump, so the epoch half catches republishes (minimap marks,
/// T M4.6 GPU parity).
last_summary: HashMap<BufferId, (u64, u64)>,
/// `(name, modified, diag_errors, diag_warnings)` last emitted as
/// `StatusFacts` (Q#S1) — cached-compare suppression.
last_status: HashMap<BufferId, (String, bool, u32, u32)>,
/// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs
/// the tree-sitter highlights query over the *whole declared
/// viewport* (which the GPU frontend sets to the entire buffer)
@ -187,6 +190,7 @@ impl SemanticRenderState {
last_decorations: HashMap::new(),
last_adornments: HashMap::new(),
last_summary: HashMap::new(),
last_status: HashMap::new(),
last_style_gate: HashMap::new(),
diag_line_cache: HashMap::new(),
}
@ -383,9 +387,70 @@ impl SemanticRenderState {
out.extend(self.inline_adornments_msg(state, &vp));
// --- FileStyleSummary (minimap producer; Open Q#2) ---
out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation));
// --- StatusFacts (status band; Q#S1, protocol v8) ---
out.extend(self.status_facts_msg(state, vp.buffer_id));
out
}
/// The `StatusFacts` message for this frame, or `None` when
/// nothing changed. Carries the facts a semantic frontend cannot
/// derive locally: buffer name, modified flag, whole-file
/// diagnostic counts (errors / warnings). Counts freeze at their
/// last value while the diag store is stale — mid-edit positions
/// are wrong but *counts* merely lag, and flickering to zero on
/// every keystroke would be worse. The daemon's write loop keeps
/// the variant off wires negotiated `< 8`.
fn status_facts_msg(
&mut self,
state: &EditorState,
buffer_id: BufferId,
) -> Option<InstanceMessage> {
let (name, modified) = {
let core = state.core.borrow();
let registry = core.registry.clone();
let reg = registry.borrow();
let buf = reg.get(buffer_id).ok()?;
(buf.name().to_owned(), buf.is_modified())
};
let counts = {
let core = state.core.borrow();
buffer_file_uri(&core, buffer_id).and_then(|uri| {
let store = state.lsp_manager.borrow().diag_store();
let guard = store.lock().expect("diag store mutex poisoned");
if guard.is_stale(&uri) {
None // keep the cached counts
} else {
let mut errors = 0u32;
let mut warnings = 0u32;
for d in guard.for_uri(&uri) {
match d.severity {
crate::diag::DiagnosticSeverity::Error => errors += 1,
crate::diag::DiagnosticSeverity::Warning => warnings += 1,
_ => {}
}
}
Some((errors, warnings))
}
})
};
let cached = self.last_status.get(&buffer_id);
let (diag_errors, diag_warnings) =
counts.unwrap_or_else(|| cached.map_or((0, 0), |c| (c.2, c.3)));
let facts = (name, modified, diag_errors, diag_warnings);
if cached == Some(&facts) {
return None;
}
let msg = InstanceMessage::StatusFacts {
buffer_id,
name: facts.0.clone(),
modified: facts.1,
diag_errors,
diag_warnings,
};
self.last_status.insert(buffer_id, facts);
Some(msg)
}
/// The `InlineAdornments` message for this frame, or `None` when
/// nothing should be sent. The wire variant has no
/// `generation`/`full`/`segments`, so this is M11.2-level
@ -1368,9 +1433,10 @@ mod tests {
}
/// All `InstanceMessage` variants the semantic projection may
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`, or
/// `FileStyleSummary` — never `CellDelta`, grid `Cursor`, or the
/// still-unwired `BlockAdornments` / `FoldState` families.
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`,
/// `FileStyleSummary`, or `StatusFacts` (Q#S1) — never
/// `CellDelta`, grid `Cursor`, or the still-unwired
/// `BlockAdornments` / `FoldState` families.
fn assert_semantic_only(msgs: &[InstanceMessage]) {
for m in msgs {
assert!(
@ -1380,6 +1446,7 @@ mod tests {
| InstanceMessage::Decorations { .. }
| InstanceMessage::InlineAdornments { .. }
| InstanceMessage::FileStyleSummary { .. }
| InstanceMessage::StatusFacts { .. }
),
"semantic projection emitted an unexpected variant: {m:?}"
);
@ -1478,12 +1545,13 @@ mod tests {
// the first frame is a `full` resync for both diffable families
// (the frontend clears its viewport), carrying empty segments.
// FileStyleSummary also emits on the first frame for this buffer
// (post-M11 minimap producer, generation-keyed).
// (post-M11 minimap producer, generation-keyed), as does
// StatusFacts (Q#S1, cached-compare).
let first = s.render_frame(&state);
assert_eq!(
first.len(),
3,
"first frame ships StyleSpans + Decorations + FileStyleSummary"
4,
"first frame ships StyleSpans + Decorations + FileStyleSummary + StatusFacts"
);
assert_semantic_only(&first);
let (style_full, _) = style_segments(&first).expect("StyleSpans present");
@ -2806,6 +2874,63 @@ mod tests {
assert_eq!(lines[3], Style::default(), "trailing empty line → default");
}
fn facts_of(msgs: &[InstanceMessage]) -> Option<(String, bool, u32, u32)> {
msgs.iter().find_map(|m| match m {
InstanceMessage::StatusFacts {
name,
modified,
diag_errors,
diag_warnings,
..
} => Some((name.clone(), *modified, *diag_errors, *diag_warnings)),
_ => None,
})
}
#[test]
fn status_facts_emit_on_change_and_freeze_counts_while_stale() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
// "abc\nde" + a Warning diagnostic + file path; the seeding
// edit flips `modified`.
seed_diagnostic(&state, bid);
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
let first = s.render_frame(&state);
let (_, modified, errors, warnings) = facts_of(&first).expect("first frame ships facts");
assert!(modified, "the seeding edit dirtied the buffer");
assert_eq!((errors, warnings), (0, 1));
// Nothing changed → suppressed.
assert!(facts_of(&s.render_frame(&state)).is_none());
// Republish as an Error → re-emit with new counts.
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs"));
let store = state.lsp_manager.borrow().diag_store();
store.lock().expect("diag store").set(
&uri,
vec![crate::diag::Diagnostic {
start_line: 0,
start_col: 0,
end_line: 0,
end_col: 3,
severity: crate::diag::DiagnosticSeverity::Error,
message: "boom".into(),
source: None,
code: None,
}],
);
let (_, _, errors, warnings) =
facts_of(&s.render_frame(&state)).expect("republish re-emits");
assert_eq!((errors, warnings), (1, 0));
// Stale store: counts freeze at the cached value instead of
// flickering to zero, so no re-emission either.
store.lock().expect("diag store").mark_stale(&uri);
assert!(facts_of(&s.render_frame(&state)).is_none());
}
#[test]
fn zero_width_diagnostics_widen_to_a_visible_byte() {
// "abc\nde" — line starts [0, 4], source_len 6; line 0