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