From 05c6519649130d210195122ff9125a0b9f7623c7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 17:36:02 -0400 Subject: [PATCH] feat(status): ship the transient status message to semantic frontends Validation finding: LSP command summaries ('12 references', hover first-lines, error reports -- everything pmacs.editor.set_status writes) showed in the TUI's bottom bar but never in the GPU band, regardless of which frontend initiated. The attached TUI gets the message for free through the rendered cell grid's bottom row; a semantic frontend only sees the wire, and StatusFacts never carried the message. Fix inside the still-unreleased v15: StatusFacts gains message: Option (encoding change to that variant; its daemon gate moves 8 -> 15, the v10 SearchPrompt / v14 LineNumbers shape -- an old peer's band goes dark rather than mis-decoding). Producer reads core.status into the cached-compare facts; the GPU band shows the message echo-area style (under the minibuffer and search prompts, over the buffer name), returning to the name when the daemon's next keypress clears it. Producer + postcard round-trip tests added. The finer-grained results UI (references list, panels, error surfaces) is Arc 1b on the roadmap; this closes the parity gap until then. Co-Authored-By: Claude Fable 5 --- pmacs-gpu/src/main.rs | 28 +++++++++++++++--- pmacs-protocol/src/message.rs | 14 +++++++++ src/daemon.rs | 7 ++++- src/protocol.rs | 20 +++++++++++++ src/semantic_render.rs | 56 +++++++++++++++++++++++++++++++---- 5 files changed, 114 insertions(+), 11 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index fbddf90..bcaba5c 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -702,8 +702,8 @@ fn completion_kind_glyph(kind: u8) -> char { } } -/// The wire-authoritative status facts (Q#S1, protocol v8), -/// mirrored from `InstanceMessage::StatusFacts`. +/// The wire-authoritative status facts (Q#S1, protocol v8; `message` +/// since v15), mirrored from `InstanceMessage::StatusFacts`. #[derive(Clone, Debug, PartialEq, Eq)] struct StatusFactsLocal { buffer_id: BufferId, @@ -711,6 +711,9 @@ struct StatusFactsLocal { modified: bool, diag_errors: u32, diag_warnings: u32, + /// The core's transient status message (`pmacs.editor.set_status` + /// — "12 references", LSP errors, ...), or `None` when clear. + message: Option, } /// The live incremental-search prompt (Q#SR5/Q#RX6, protocol v10), @@ -2626,14 +2629,17 @@ 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. + // Q#S1 (protocol v8; `message` since v15) — the + // wire-authoritative half of the status band: name, + // modified, whole-file diag counts, and the transient + // status message (LSP command summaries). InstanceMessage::StatusFacts { buffer_id, name, modified, diag_errors, diag_warnings, + message, } => { self.status_facts = Some(StatusFactsLocal { buffer_id, @@ -2641,6 +2647,7 @@ impl State { modified, diag_errors, diag_warnings, + message, }); self.request_redraw(); None @@ -3426,6 +3433,19 @@ impl State { }; return format!("{}{}{}", label, sp.query, count); } + // A transient status message (v15 `StatusFacts.message` — LSP + // command summaries like "12 references", error reports) takes + // the band over echo-area style; the daemon clears it on the + // next keypress, which ships a fresh `StatusFacts` and returns + // the band to the buffer name. + if let Some(msg) = self + .status_facts + .as_ref() + .filter(|f| Some(f.buffer_id) == self.current_buffer_id) + .and_then(|f| f.message.as_deref()) + { + return msg.to_owned(); + } match self .status_facts .as_ref() diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index c9cacef..29499ca 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -764,6 +764,16 @@ pub enum InstanceMessage { diag_errors: u32, /// Whole-file `Warning`-severity diagnostic count. diag_warnings: u32, + /// The core's transient status message (`pmacs.editor. + /// set_status` — LSP command summaries like "12 references", + /// error reports, ...), or `None` when clear. Added in v15: + /// the attached TUI gets the message for free through the + /// rendered cell grid's bottom row, but a semantic frontend + /// only sees what's on this wire — without it every modeline + /// summary was TUI-only. Encoding change to this variant; its + /// daemon gate moved `>= 8` → `>= 15` (the v10 `SearchPrompt` + /// / v14 `LineNumbers` precedent). + message: Option, }, /// T M11.1 — diff zones, folded-region placeholders, anything /// occupying its own vertical band. Anchored to the offset of the @@ -1300,6 +1310,10 @@ pub enum ResourceBody { /// Daemon-gated `< 15`; a v14 peer negotiates v14 and simply receives /// no `CompletionPopup` (completion still works via the daemon's TUI /// rendering and the key round-trip), like every prior additive bump. +/// v15 also widened `StatusFacts` with the transient status `message` +/// (encoding change to that variant; its gate moved `>= 8` → `>= 15`, +/// so a v14 peer's status band goes dark rather than mis-decoding — +/// the v10 `SearchPrompt` / v14 `LineNumbers` shape). pub const PROTOCOL_VERSION: u32 = 15; /// T M10.5: the set of protocol versions a v1.0 binary accepts on diff --git a/src/daemon.rs b/src/daemon.rs index 0433a32..156de13 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1034,9 +1034,14 @@ fn dispatcher_loop( // Q#S1 — `StatusFacts` is a v8 variant; an older peer // would hard-error decoding it. Same per-session gate // shape as `DispatchIdle` (v4). + // `StatusFacts` gained the transient status `message` + // in v15 (encoding change to the variant), so the gate + // moved 8 → 15: an older peer's band goes dark rather + // than mis-decoding the wider shape (the v10 + // SearchPrompt / v14 LineNumbers precedent). let peer_knows_status_facts = session_registry .session_state(*fid) - .is_some_and(|s| s.negotiated_protocol_version >= 8); + .is_some_and(|s| s.negotiated_protocol_version >= 15); // Q#SR5 / Q#RX6 — `SearchPrompt` gained regex/invalid // fields in v10 (encoding change); gate at >= 10 so a v9 // peer is sent no SearchPrompt rather than the wider diff --git a/src/protocol.rs b/src/protocol.rs index ea84848..43c361d 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1710,6 +1710,26 @@ mod tests { assert_eq!(PROTOCOL_VERSION, 15); } + #[test] + fn status_facts_round_trip_with_and_without_message() { + // v15 widened `StatusFacts` with the transient status message + // (its daemon gate moved 8 → 15). Pin both shapes. + let bid = crate::buffer::BufferId::next(); + for message in [None, Some("12 references".to_owned())] { + let msg = InstanceMessage::StatusFacts { + buffer_id: bid, + name: "main.rs".into(), + modified: true, + diag_errors: 1, + diag_warnings: 2, + message, + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + assert_eq!(msg, decoded); + } + } + #[test] fn completion_popup_round_trips_through_postcard() { // Arc 1a Q#C5 (v15): the byte-anchored completion dropdown. diff --git a/src/semantic_render.rs b/src/semantic_render.rs index c2a7abb..216cb85 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -165,9 +165,10 @@ pub struct SemanticRenderState { /// bump, so the epoch half catches republishes (minimap marks, /// T M4.6 GPU parity). last_summary: HashMap, - /// `(name, modified, diag_errors, diag_warnings)` last emitted as - /// `StatusFacts` (Q#S1) — cached-compare suppression. - last_status: HashMap, + /// `(name, modified, diag_errors, diag_warnings, message)` last + /// emitted as `StatusFacts` (Q#S1; `message` since v15) — + /// cached-compare suppression. + last_status: HashMap)>, /// Last-emitted line-number gutter mode (UX gutter arc, protocol v14) — /// cached-compare suppression. Seeded to `Some(Off)` (the frontend's /// default) so an off gutter never emits. Per-frontend (one value), @@ -748,12 +749,17 @@ impl SemanticRenderState { state: &EditorState, buffer_id: BufferId, ) -> Option { - let (name, modified) = { + let (name, modified, message) = { let core = state.core.borrow(); + // The transient status message (`pmacs.editor.set_status` + // — LSP command summaries, error reports). The attached + // TUI reads it off the rendered bottom row; a semantic + // frontend only sees this wire (v15). + let message = (!core.status.is_empty()).then(|| core.status.clone()); let registry = core.registry.clone(); let reg = registry.borrow(); let buf = reg.get(buffer_id).ok()?; - (buf.name().to_owned(), buf.is_modified()) + (buf.name().to_owned(), buf.is_modified(), message) }; let counts = { let core = state.core.borrow(); @@ -779,7 +785,7 @@ impl SemanticRenderState { 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); + let facts = (name, modified, diag_errors, diag_warnings, message); if cached == Some(&facts) { return None; } @@ -789,6 +795,7 @@ impl SemanticRenderState { modified: facts.1, diag_errors, diag_warnings, + message: facts.4.clone(), }; self.last_status.insert(buffer_id, facts); Some(msg) @@ -3588,6 +3595,43 @@ mod tests { assert!(facts_of(&s.render_frame(&state)).is_none()); } + #[test] + fn status_facts_carry_the_transient_message() { + // v15: `pmacs.editor.set_status` output must reach semantic + // frontends — the "12 references" class of LSP summaries was + // TUI-only before (the grid renders the bottom row; the wire + // never carried the message). + let state = empty_state(); + let mut s = local(); + let bid = active_buffer(&state); + s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0); + let _ = s.render_frame(&state); // baseline facts + + let message_of = |frame: &[InstanceMessage]| { + frame.iter().find_map(|m| match m { + InstanceMessage::StatusFacts { message, .. } => Some(message.clone()), + _ => None, + }) + }; + + state.core.borrow_mut().status = "12 references".to_owned(); + assert_eq!( + message_of(&s.render_frame(&state)), + Some(Some("12 references".into())), + "a fresh status message re-ships the facts" + ); + // Unchanged → suppressed. + assert_eq!(message_of(&s.render_frame(&state)), None); + // Cleared → re-ships with None so the frontend's band returns + // to the buffer name. + state.core.borrow_mut().status.clear(); + assert_eq!( + message_of(&s.render_frame(&state)), + Some(None), + "clearing the message re-ships the facts" + ); + } + #[test] fn zero_width_diagnostics_widen_to_a_visible_byte() { // "abc\nde" — line starts [0, 4], source_len 6; line 0