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<String> (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 <noreply@anthropic.com>
This commit is contained in:
parent
d20a97ca7b
commit
05c6519649
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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()
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
},
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -165,9 +165,10 @@ 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)>,
|
||||
/// `(name, modified, diag_errors, diag_warnings, message)` last
|
||||
/// emitted as `StatusFacts` (Q#S1; `message` since v15) —
|
||||
/// cached-compare suppression.
|
||||
last_status: HashMap<BufferId, (String, bool, u32, u32, Option<String>)>,
|
||||
/// 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<InstanceMessage> {
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue