Merge pull request #93 from levineuwirth/session-completion-popup-p2

feat(completion): GPU dropdown over protocol v15 — Arc 1a phase 2
This commit is contained in:
Levi Neuwirth 2026-07-07 19:57:22 -04:00 committed by GitHub
commit bb82db32b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 898 additions and 50 deletions

View File

@ -313,6 +313,47 @@ Also caught by the new acceptance suite pre-validation:
`install_completion` rebuilt `pmacs.completion` and clobbered the
popup bindings — all `pmacs.completion` installers now merge.
## As-built notes — phase 2 (PR #93)
Protocol v15 + the GPU dropdown, close to the framing with four
divergences worth recording:
1. **Q#C6 landed much narrower than framed.** The full
`is_completion_control_key` predicate proved unnecessary:
C-n/C-p/C-g already round-trip as command chords and Up/Down as
forwarded motion keys, so the daemon's completion shadow handles
them with zero GPU changes. Only two GPU defaults are wrong under
a popup and got gated on `completion_open`: **Esc** (round-trips
to dismiss instead of the local quit) and **RET/TAB** (skip the
optimistic insert so they accept instead of typing `\n`/`\t`).
Bet #1 (the family generalizes) and this simplification both held;
bet #3's ESC prediction was exactly right.
2. **The producer needed a multi-frontend rule the family didn't
have**: the session is window-stamped and `SemanticRenderState` is
per-frontend, so `completion_popup_msg` emits the popup only to
the frontend whose own window owns the session — TUI typing never
raises the GPU dropdown, and vice versa.
3. **v15 also carries `StatusFacts.message`** (validation finding):
`pmacs.editor.set_status` output — "12 references", LSP errors —
was TUI-only (the grid ships the bottom row; the wire never
carried the message). The band shows it echo-area style; its gate
moved 8 → 15 (encoding change). Corollary fix: the optimistic
CrdtOp path (`handle_remote_crdt_op`) now clears `core.status`
like `dispatch_key`'s entry clear, else a message wedges through
ordinary GPU typing.
4. **Buffer switches need a local clear, not just wire closes**
(validation finding): the producer's first-sight-closed silence
means no close ever ships for a viewport that no longer exists, so
the `BufferSnapshot` arm clears the popup mirror, and
`CompletionLocal` carries its `buffer_id` with a shared
`completion_open_for_current_buffer()` predicate gating keys and
painting — a stale mirror can neither act nor draw against a
foreign buffer.
Phase-3 items absorbed along the way: `isIncomplete` re-query and
per-server trigger chars shipped with the phase-1 findings round;
these as-built notes close the docs item.
## Deferred (named, not silently dropped)
- Snippet tabstops/placeholders (v1 inserts bodies literally).

View File

@ -34,10 +34,10 @@ use glyphon::{
};
use loro::{ContainerTrait, ExportMode};
use pmacs_protocol::{
AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CrdtOp, Decoration, DecorationKind,
DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, InstanceSignal,
Key as ProtocolKey, LineNumberMode, MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot,
StyleSegment, StyleSpan,
AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CompletionPopupRow, CrdtOp,
Decoration, DecorationKind, DecorationSegment, FrontendId, InlineAdornment, InstanceMessage,
InstanceSignal, Key as ProtocolKey, LineNumberMode, MenuPromptRow, Modifiers, PointerKind,
SelectionSnapshot, StyleSegment, StyleSpan,
cell::{Color as CellColor, Style as CellStyle},
};
use wgpu::MultisampleState;
@ -652,6 +652,17 @@ struct State {
mb_text_renderer: TextRenderer,
/// Minibuffer dropdown background + selection quads (Q#MB1).
mb_bg_vertex_buffer: ReusableVertexBuffer,
/// Arc 1a Q#C5 — the live in-buffer completion popup (protocol
/// v15), or `None` when closed.
completion: Option<CompletionLocal>,
/// Shaped row text for the completion dropdown, one line per
/// candidate ("glyph label detail").
completion_buffer: Buffer,
/// Dedicated text renderer for the completion dropdown (its own
/// layer over the buffer, like the menu's / minibuffer's).
completion_text_renderer: TextRenderer,
/// Completion dropdown background + selection quads.
completion_bg_vertex_buffer: ReusableVertexBuffer,
/// Minimap vertex bytes cached by [`MinimapCacheKey`] —
/// rebuilding rescanned every line shape per frame.
minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>,
@ -668,8 +679,31 @@ struct State {
gutter_text_renderer: TextRenderer,
}
/// The wire-authoritative status facts (Q#S1, protocol v8),
/// mirrored from `InstanceMessage::StatusFacts`.
/// Kind-glyph column for a completion row: the LSP
/// `CompletionItemKind` numeric code → the single-char glyph the TUI
/// popup uses (`crate::completion::CompletionItemKind::glyph`'s
/// mapping, replicated — the GPU crate doesn't depend on `pmacs`).
/// Unknown codes fall back to the plain-text dot, per the LSP
/// "accept extended kinds gracefully" contract.
fn completion_kind_glyph(kind: u8) -> char {
match kind {
2..=4 => 'f', // method / function / constructor
5 | 10 => 'p', // field / property
6 | 21 => 'v', // variable / constant
7 | 22 => 'C', // class / struct
8 => 'I', // interface
9 => 'M', // module
13 | 20 => 'E', // enum / enum member
14 => 'k', // keyword
15 => 's', // snippet
25 => 't', // type parameter
17 | 19 => '/', // file / folder
_ => '.',
}
}
/// 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,
@ -677,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),
@ -705,6 +742,40 @@ struct MinibufferLocal {
total: u32,
}
/// The live in-buffer completion popup (Arc 1a Q#C5, protocol v15),
/// mirrored from a `CompletionPopup` whose `anchor` was `Some`. The
/// dropdown anchors at the glyph rect of `anchor` (a byte offset —
/// the caret mapping reused), one row per candidate; navigation and
/// accept round-trip into the daemon's completion shadow.
#[derive(Clone, Debug, PartialEq, Eq)]
struct CompletionLocal {
/// Buffer the popup targets. Rendering and the key gates check
/// this against `current_buffer_id` so a popup can never act
/// against a buffer it wasn't opened in (buffer switches also
/// clear the whole mirror at the `BufferSnapshot` arm; this is
/// the belt to that suspender).
buffer_id: BufferId,
/// Byte offset of the prefix start.
anchor: u64,
/// Bytes of typed prefix at `anchor` (reserved for a bolded-
/// prefix refinement; unused by the first render).
#[allow(
dead_code,
reason = "shipped on the wire for the bolded-prefix refinement"
)]
prefix_len: u32,
/// Windowed candidate rows (label / kind / detail), best-first.
rows: Vec<CompletionPopupRow>,
/// Highlighted row within `rows`.
selected: Option<u32>,
/// Total candidate count (reserved for an "i/total" hint).
#[allow(
dead_code,
reason = "shipped on the wire for the i/total hint refinement"
)]
total: u32,
}
/// The live context menu (Q#CM1, protocol v11), mirrored from a
/// `MenuPrompt` with non-empty rows. The popup draws at `anchor_px`
/// (the right-click pixel, remembered locally — the daemon never sees
@ -849,10 +920,26 @@ impl ApplicationHandler<AppEvent> for App {
.as_ref()
.is_some_and(State::daemon_intercepts_keys);
// Arc 1a Q#C6 — the completion popup is NON-modal, so it
// never flips the intercept gate (typing stays
// optimistic; the daemon's after-edit refresh re-ships
// the popup). Only the keys whose *default GPU handling
// is wrong under a popup* need this flag: Esc (below,
// else it's the local quit) and RET/TAB (the optimistic
// gate further down, else they'd insert instead of
// accept). C-n/C-p/C-g already round-trip as command
// chords, Up/Down as forwarded motion keys — the daemon's
// completion shadow handles all of them.
let completion_open = self
.state
.as_ref()
.is_some_and(State::completion_open_for_current_buffer);
// Escape cancels an active intercept (e.g. a running
// search); otherwise it stays the local quit.
// search) or dismisses the completion popup; otherwise it
// stays the local quit.
if matches!(key.logical_key, Key::Named(NamedKey::Escape)) {
if intercept {
if intercept || completion_open {
if let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE)
{
@ -956,11 +1043,21 @@ impl ApplicationHandler<AppEvent> for App {
return;
}
if let Some(op) = self.state.as_mut().and_then(|state| {
state
.optimistic_crdt_insert(pkey, pmods)
.or_else(|| state.optimistic_crdt_delete(pkey, pmods))
}) {
// Arc 1a Q#C6 — with the popup open, RET and TAB mean
// "accept", not "insert \n / \t": skip the optimistic
// path so they round-trip into the daemon's
// dispatch_completion_key. Everything else stays
// optimistic.
let completion_takes_key =
completion_open && matches!(pkey, ProtocolKey::Enter | ProtocolKey::Tab);
if !completion_takes_key
&& let Some(op) = self.state.as_mut().and_then(|state| {
state
.optimistic_crdt_insert(pkey, pmods)
.or_else(|| state.optimistic_crdt_delete(pkey, pmods))
})
{
if debug_input() {
eprintln!(
"pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B",
@ -1744,6 +1841,9 @@ impl State {
// Q#MB1 — a third renderer for the minibuffer dropdown layer.
let mb_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
// Arc 1a Q#C5 — a renderer for the completion dropdown layer.
let completion_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
// UX gutter — a renderer for the line-number layer.
let gutter_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
@ -1796,6 +1896,17 @@ impl State {
Some(MB_DROP_MAX_WIDTH),
Some(config.height as f32),
);
// Completion dropdown buffer (Arc 1a): the minibuffer
// dropdown's metrics, its own layer.
let mut completion_buffer = Buffer::new(
&mut font_system,
Metrics::new(MB_DROP_FONT_SIZE, MB_DROP_LINE_HEIGHT),
);
completion_buffer.set_size(
&mut font_system,
Some(MB_DROP_MAX_WIDTH),
Some(config.height as f32),
);
// Line-number gutter buffer (UX gutter arc): same font size + line
// height as the code buffer so its rows align one-for-one.
let mut gutter_buffer = Buffer::new(
@ -1888,6 +1999,10 @@ impl State {
mb_buffer,
mb_text_renderer,
mb_bg_vertex_buffer: ReusableVertexBuffer::new(),
completion: None,
completion_buffer,
completion_text_renderer,
completion_bg_vertex_buffer: ReusableVertexBuffer::new(),
minimap_cache: None,
line_numbers: LineNumberMode::Off,
gutter_buffer,
@ -2319,6 +2434,14 @@ impl State {
// next PresenceUpdate / CursorByte arrives.
self.peer_presences.clear();
self.own_cursor = None;
// The completion popup too (Arc 1a): its anchor is a
// byte in the prior buffer, and the producer never
// ships a close for a viewport that no longer exists
// (first-sight of the new buffer stays silent) — a
// retained popup would render against the new rope AND
// keep hijacking Esc/RET/TAB. The daemon-side session
// was already invalidated by the switch.
self.completion = None;
self.cursor_fresh = false;
self.optimistic_cursor_floor = None;
self.optimistic_floor_set_at = None;
@ -2520,14 +2643,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,
@ -2535,6 +2661,7 @@ impl State {
modified,
diag_errors,
diag_warnings,
message,
});
self.request_redraw();
None
@ -2728,10 +2855,53 @@ impl State {
self.request_redraw();
None
}
// Arc 1a Q#C5/Q#C6 — the in-buffer completion dropdown.
// A close (`anchor: None`) always applies — the daemon may
// ship it carrying a buffer this window just switched away
// from, and dropping it would wedge a stale popup. An OPEN
// for a buffer this window isn't showing is dropped (the
// CrdtOp rule).
InstanceMessage::CompletionPopup {
buffer_id,
anchor,
prefix_len,
rows,
selected,
total,
} => {
let Some(anchor) = anchor else {
self.completion = None;
self.request_redraw();
return None;
};
if self.current_buffer_id != Some(buffer_id) {
return None;
}
self.completion = Some(CompletionLocal {
buffer_id,
anchor,
prefix_len,
rows,
selected,
total,
});
self.request_redraw();
None
}
_ => None,
}
}
/// True while the completion popup is open **for the buffer this
/// window currently shows** — the predicate the key gates (Esc,
/// RET/TAB) and the render path share, so a stale mirror can
/// never act against a foreign buffer.
fn completion_open_for_current_buffer(&self) -> bool {
self.completion
.as_ref()
.is_some_and(|c| Some(c.buffer_id) == self.current_buffer_id)
}
/// A `ViewportSend` for the current `view_range` if it differs from
/// the last one declared, else `None` (Q#S5 coalescing). `generation`
/// is 0 — the producer's full-resync triggers on the visible-range
@ -3288,6 +3458,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()
@ -3510,6 +3693,170 @@ impl State {
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
/// Re-shape the completion dropdown rows (Arc 1a Q#C5), one line
/// per candidate: kind glyph, label, then the dimmable detail.
/// Empty when the popup is closed.
fn refresh_completion_buffer(&mut self) {
let text = self.completion.as_ref().map_or_else(String::new, |comp| {
comp.rows
.iter()
.map(|row| {
let glyph = completion_kind_glyph(row.kind);
match row.detail.as_deref() {
Some(detail) => format!("{glyph} {} {detail}", row.label),
None => format!("{glyph} {}", row.label),
}
})
.collect::<Vec<_>>()
.join("\n")
});
self.completion_buffer.set_text(
&mut self.font_system,
&text,
&Attrs::new().family(Family::Name("JetBrains Mono")),
Shaping::Advanced,
None,
);
self.completion_buffer
.shape_until_scroll(&mut self.font_system, false);
}
/// The pixel position of the completion popup's byte anchor:
/// `(x, line_top_y, line_height)` of the glyph the anchor sits
/// before — the caret mapping (`caret_rect`) reused for a second
/// byte. `None` when the popup is closed or the anchor is
/// scrolled out of the visible slice (the popup then simply
/// doesn't draw this frame; scrolling back restores it).
fn completion_anchor_px(&self) -> Option<(f32, f32, f32)> {
if !self.completion_open_for_current_buffer() {
return None; // never paint against a foreign buffer's rope
}
let comp = self.completion.as_ref()?;
let (vstart, vend) = self.view_range;
if vend <= vstart {
return None;
}
let anchor = comp.anchor;
if anchor < vstart || anchor > vend {
return None;
}
let slice = &self.current_text[vstart as usize..vend as usize];
let line_offsets = line_byte_offsets(slice);
let slice_anchor = anchor - vstart;
let (line_lo, _) = source_line_range(slice, slice_anchor);
let text_left = self.text_left();
for run in self.buffer.layout_runs() {
if line_offsets.get(run.line_i).copied().unwrap_or(0) != line_lo {
continue;
}
let mut x = text_left;
for glyph in run.glyphs {
if line_lo + glyph.start as u64 >= slice_anchor {
x = text_left + glyph.x;
break;
}
// Anchor is past this glyph; track its right edge so an
// anchor at line end lands after the final glyph.
x = text_left + glyph.x + glyph.w;
}
return Some((x, TEXT_TOP + run.line_top, run.line_height));
}
None
}
/// Layout of the completion dropdown: `(first_row, row_count,
/// left_x, top_y)`. Anchored on the row *below* the anchor's line
/// (growing downward toward the status band); flips above when
/// nothing fits below — the TUI overlay's placement rule. The
/// visible slice windows around the selection so it stays on
/// screen when fewer rows fit than the wire shipped (the F-007
/// discipline).
fn completion_dropdown_layout(&self) -> Option<(usize, usize, f32, f32)> {
let comp = self.completion.as_ref()?;
let n = comp.rows.len();
if n == 0 {
return None;
}
let (ax, line_top, line_h) = self.completion_anchor_px()?;
let band_top = text_area_bottom(self.config.height);
let below_px = band_top - (line_top + line_h);
let above_px = line_top - TEXT_TOP;
let max_below = (below_px / MB_DROP_ROW_HEIGHT).floor() as usize;
let max_above = (above_px / MB_DROP_ROW_HEIGHT).floor() as usize;
let (avail, below) = if max_below >= 1 {
(max_below, true)
} else {
(max_above, false)
};
if avail == 0 {
return None;
}
let count = n.min(avail);
let sel = comp.selected.map_or(0, |s| s as usize);
let first = if n <= count {
0
} else {
sel.saturating_sub(count / 2).min(n - count)
};
let top_y = if below {
line_top + line_h
} else {
line_top - count as f32 * MB_DROP_ROW_HEIGHT
};
Some((first, count, ax, top_y))
}
/// Dropdown geometry `(left, top_y, width)`: as wide as the widest
/// row (clamped, the minibuffer bounds), left edge at the anchor
/// column shifted back from the window's right margin.
/// `refresh_completion_buffer` must have run so the width
/// measurement is current.
fn completion_dropdown_rect(&self) -> Option<(f32, f32, f32)> {
let (_first, _count, ax, top_y) = self.completion_dropdown_layout()?;
let widest = self
.completion_buffer
.layout_runs()
.map(|r| r.line_w)
.fold(0.0_f32, f32::max);
let width = (widest + 2.0 * MB_DROP_PAD_X).clamp(MB_DROP_MIN_WIDTH, MB_DROP_MAX_WIDTH);
let left = ax.min((self.config.width as f32 - width).max(0.0));
Some((left, top_y, width))
}
/// Completion dropdown background + selection-highlight quads.
/// Empty when closed or the anchor is off-screen.
fn completion_dropdown_vertex_bytes(&self) -> Vec<u8> {
let Some(comp) = self.completion.as_ref() else {
return Vec::new();
};
let Some((first, count, _ax, _ty)) = self.completion_dropdown_layout() else {
return Vec::new();
};
let Some((x, top_y, width)) = self.completion_dropdown_rect() else {
return Vec::new();
};
let mut rects = vec![MinimapRect {
x,
y: top_y,
w: width,
h: count as f32 * MB_DROP_ROW_HEIGHT,
color: MENU_BG,
}];
if let Some(sel) = comp.selected.map(|s| s as usize)
&& sel >= first
&& sel < first + count
{
rects.push(MinimapRect {
x,
y: top_y + (sel - first) as f32 * MB_DROP_ROW_HEIGHT,
w: width,
h: MB_DROP_ROW_HEIGHT,
color: MENU_SELECTED_BG,
});
}
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
/// Bookkeeping for an outgoing Pointer event: it supersedes any
/// unconfirmed optimistic-cursor prediction (the daemon's answer
/// will be the click position, not the typing prediction), and
@ -3988,6 +4335,23 @@ impl State {
&mb_vertices,
)
.cloned();
// Arc 1a Q#C5 — the completion dropdown quads (bg + selection),
// a layer over the code anchored at the popup's byte anchor.
// `refresh_completion_buffer` first so the width measurement in
// `completion_dropdown_vertex_bytes` is current.
self.refresh_completion_buffer();
let completion_vertices = self.completion_dropdown_vertex_bytes();
let completion_vertex_count =
(completion_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32;
let completion_bg_buffer = self
.completion_bg_vertex_buffer
.upload(
&self.device,
&self.queue,
"pmacs-gpu completion dropdown",
&completion_vertices,
)
.cloned();
// The band's strip rides the bg quad batch so it draws under
// the band text (text renders after the first quad draw).
let mut bg_vertices = self.decoration_background_vertex_bytes();
@ -4244,6 +4608,42 @@ impl State {
)
.expect("minibuffer text_renderer prepare");
// Arc 1a Q#C5 — prepare the completion dropdown glyphs in their
// layer. The buffer is shaped with *all* wire rows; the layout
// scrolls it up by `first` rows so row `first` lands at `top_y`,
// and `bounds` clips the rows outside the visible window (the
// minibuffer dropdown's F-007 shape).
let completion_areas: Vec<TextArea> = self
.completion_dropdown_layout()
.zip(self.completion_dropdown_rect())
.map(|((first, count, _ax, _ty), (x, top_y, width))| TextArea {
buffer: &self.completion_buffer,
left: x + MB_DROP_PAD_X,
top: top_y - first as f32 * MB_DROP_ROW_HEIGHT,
scale: 1.0,
bounds: TextBounds {
left: x as i32,
top: top_y as i32,
right: (x + width).round() as i32,
bottom: (top_y + count as f32 * MB_DROP_ROW_HEIGHT).round() as i32,
},
default_color: Color::rgb(232, 232, 238),
custom_glyphs: &[],
})
.into_iter()
.collect();
self.completion_text_renderer
.prepare(
&self.device,
&self.queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
completion_areas,
&mut self.swash_cache,
)
.expect("completion text_renderer prepare");
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
@ -4307,6 +4707,17 @@ impl State {
self.mb_text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("minibuffer text_renderer render");
// Arc 1a Q#C5 — the completion dropdown floats over the
// code at its byte anchor: bg + selection quads, then its
// row glyphs on top (under the context menu, which stays
// the topmost surface).
if let Some(vertex_buffer) = completion_bg_buffer.as_ref() {
self.quad_renderer
.render(&mut pass, vertex_buffer, completion_vertex_count);
}
self.completion_text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("completion text_renderer render");
// Q#CM1 — the context menu draws last: its bg/highlight quads
// occlude everything beneath, then its glyphs on top.
if let Some(vertex_buffer) = menu_bg_buffer.as_ref() {
@ -5201,6 +5612,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
InstanceMessage::DispatchIdle { .. } => "DispatchIdle",
InstanceMessage::LineNumbers { .. } => "LineNumbers",
InstanceMessage::CompletionPopup { .. } => "CompletionPopup",
}
}
@ -7438,6 +7850,50 @@ mod tests {
);
}
#[test]
fn completion_popup_is_scoped_to_its_buffer() {
// Buffer-switch regression (PR #93 validation finding 1): a
// retained popup mirror must be inert — no key gating, no
// anchor mapping — the moment `current_buffer_id` differs
// from the popup's buffer.
let Some(mut state) = headless_or_skip(320, 240, "hello_world he") else {
return;
};
let own = BufferId::next();
let other = BufferId::next();
state.current_buffer_id = Some(own);
// Headless states never declare a viewport; anchor mapping
// reads `view_range`, so pin it to the whole text.
state.view_range = (0, state.current_text.len() as u64);
state.completion = Some(CompletionLocal {
buffer_id: own,
anchor: 12,
prefix_len: 2,
rows: vec![CompletionPopupRow {
label: "hello_world".into(),
kind: 3,
detail: None,
}],
selected: Some(0),
total: 1,
});
assert!(state.completion_open_for_current_buffer());
assert!(
state.completion_anchor_px().is_some(),
"the popup anchors in its own buffer"
);
// The window switches buffers; the mirror is stale.
state.current_buffer_id = Some(other);
assert!(
!state.completion_open_for_current_buffer(),
"a foreign-buffer popup must not gate keys"
);
assert!(
state.completion_anchor_px().is_none(),
"a foreign-buffer popup must not paint"
);
}
#[test]
fn headless_text_changes_the_rendered_frame() {
let Some(mut empty) = headless_or_skip(320, 240, "") else {

View File

@ -46,12 +46,12 @@ pub use cell::{
pub use crdt::CrdtOp;
pub use ids::{BufferId, ByteRange, FrontendId, Position};
pub use message::{
AdornmentContent, AdornmentPlacement, AttachRequest, BlockAdornment, CursorState, Decoration,
DecorationKind, DecorationSegment, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello,
InlineAdornment, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key,
KeyEvent, LineNumberMode, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind,
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan,
AdornmentContent, AdornmentPlacement, AttachRequest, BlockAdornment, CompletionPopupRow,
CursorState, Decoration, DecorationKind, DecorationSegment, FrontendCapabilities,
FrontendEvent, GoodbyeReason, Hello, InlineAdornment, InstanceCapabilities, InstanceIdentity,
InstanceMessage, InstanceSignal, Key, KeyEvent, LineNumberMode, MenuPromptRow, Modifiers,
MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind,
ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan,
is_supported_protocol_version, negotiate_capabilities,
};
pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message};

View File

@ -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
@ -922,6 +932,32 @@ pub enum InstanceMessage {
/// The line-number gutter mode for that window.
mode: LineNumberMode,
},
/// In-buffer completion popup state for a semantic frontend
/// (Arc 1a Q#C5, protocol v15). Unlike the band-anchored
/// [`Self::MinibufferPrompt`], the popup is anchored *at a byte*
/// (the typed prefix's start) — the frontend maps byte → glyph
/// rect locally, exactly as it does for the caret, so the
/// instance never learns a pixel. Rows are display-only: accept
/// is a daemon round-trip (`dispatch_completion_key`), so insert
/// text never ships. `anchor: None` clears the popup.
/// Cached-compare suppressed like `SearchPrompt`; daemon-gated
/// `>= 15`.
CompletionPopup {
/// Buffer the popup targets.
buffer_id: crate::BufferId,
/// Byte offset of the prefix start, or `None` when closed.
anchor: Option<u64>,
/// Bytes of typed prefix at `anchor` (a frontend may embolden
/// the matched prefix within each label).
prefix_len: u32,
/// A windowed slice of the candidates (best-first, already
/// scored/filtered by the core), `<= POPUP_VISIBLE`.
rows: Vec<CompletionPopupRow>,
/// Highlighted row *within* `rows`, or `None`.
selected: Option<u32>,
/// Total candidate count (the window is a slice of this).
total: u32,
},
}
/// Line-number gutter mode for a window (UX gutter arc). Shared across the
@ -976,6 +1012,21 @@ pub struct MenuPromptRow {
pub separator: bool,
}
/// One row of the in-buffer completion popup on the wire
/// ([`InstanceMessage::CompletionPopup`]). Display fields only ---
/// accept resolves daemon-side against the core session, so the
/// insert text stays off the wire.
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct CompletionPopupRow {
/// Display label.
pub label: String,
/// LSP `CompletionItemKind` numeric code (1..=25; frontends map
/// unknown codes to a plain-text glyph, per the LSP contract).
pub kind: u8,
/// Optional one-line detail rendered after the label.
pub detail: Option<String>,
}
/// Flat selection state for the wire.
///
/// Mirrors [`crate::window::Selection`] but as a self-contained pair
@ -1252,7 +1303,18 @@ pub enum ResourceBody {
/// message. Encoding change to that variant; daemon-gated `< 14` (a v13
/// peer negotiates v13 and receives no `LineNumbers` rather than
/// mis-decoding the wider shape), same shape as the v10 `SearchPrompt` bump.
pub const PROTOCOL_VERSION: u32 = 14;
///
/// Completion popup (Arc 1a Q#C5): bumped 14 → 15 for
/// [`InstanceMessage::CompletionPopup`] — a new additive variant
/// carrying the byte-anchored in-buffer completion dropdown.
/// 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
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
@ -1309,7 +1371,10 @@ pub const PROTOCOL_VERSION: u32 = 14;
/// Q#MB1: extended to `[6, 7, 8, 9, 10, 11, 12]`.
/// `InstanceMessage::MinibufferPrompt` is additive and daemon-gated per
/// session, so the ladder resumes again.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14];
///
/// Q#C5: extended to `[6, ..., 15]`. `InstanceMessage::CompletionPopup`
/// is additive and daemon-gated per session.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -582,8 +582,9 @@ const DEFAULT_POPUP_WIDTH: u32 = 40;
/// Rows the popup shows at once; when more candidates are live the
/// visible slice windows around the selection (mirroring the
/// minibuffer dropdown's `MB_VISIBLE` cap).
const POPUP_MAX_ROWS: u32 = 10;
/// minibuffer dropdown's `MB_VISIBLE` cap). Shared with the semantic
/// producer so the wire window matches the TUI overlay's.
pub(crate) const POPUP_MAX_ROWS: u32 = 10;
/// Minimum popup width in cells (glyph column + a readable label).
const POPUP_MIN_WIDTH: u32 = 12;

View File

@ -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
@ -1056,6 +1061,12 @@ fn dispatcher_loop(
let peer_knows_line_numbers = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 14);
// Arc 1a Q#C5 — CompletionPopup gated at v15; a v14 peer
// still completes via the daemon-side session + key
// round-trip, it just gets no GPU dropdown.
let peer_knows_completion_popup = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 15);
for msg in &messages {
if !peer_knows_status_facts
&& matches!(msg, InstanceMessage::StatusFacts { .. })
@ -1086,6 +1097,11 @@ fn dispatcher_loop(
{
continue;
}
if !peer_knows_completion_popup
&& matches!(msg, InstanceMessage::CompletionPopup { .. })
{
continue;
}
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
// jitter site: render-write latency.
//
@ -1861,6 +1877,13 @@ fn handle_remote_crdt_op(
// notify but the op still needs broadcasting (F17).
if let Some(edit) = edit_opt.as_ref() {
let mut core = editor.core.borrow_mut();
// Transient status messages clear on user input. The Key path
// gets this from `dispatch_key`'s entry clear; the optimistic
// path routes plain typing here instead, and since v15 ships
// `core.status` over `StatusFacts`, a stale "12 references"
// would otherwise stay wedged in a semantic frontend's band
// through ordinary typing.
core.status.clear();
let post_edit_cursor = edit.range.start + edit.inserted_len;
// Identify source's active window id (so we can skip it
@ -2326,6 +2349,63 @@ mod tests {
);
}
/// v15 regression: an optimistic-path edit (the bulk of plain-char
/// typing from a semantic frontend) must clear the transient
/// status message, exactly as `dispatch_key`'s entry clear does
/// for round-tripped keys — otherwise "12 references" stays
/// wedged in the GPU band (which renders `StatusFacts.message`)
/// through ordinary typing.
#[cfg(feature = "crdt")]
#[test]
fn handle_remote_crdt_op_clears_the_transient_status() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
let mut editor = EditorState::new();
let buffer_id = editor.core.borrow().active_window().buffer_id;
{
let core = editor.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.get_mut(buffer_id)
.expect("active buffer")
.upgrade_to_crdt(2)
.expect("upgrade to crdt");
}
let snapshot_bytes = {
let core = editor.core.borrow();
let reg = core.registry.borrow();
reg.get(buffer_id)
.expect("active buffer")
.crdt_state()
.expect("crdt-backed")
.export_snapshot()
.expect("export snapshot")
};
let peer = loro::LoroDoc::new();
peer.set_peer_id(99).expect("set peer id");
peer.import(&snapshot_bytes).expect("import snapshot");
let v_before = peer.oplog_vv();
peer.get_text("body").insert(0, "x").expect("peer insert");
let op_bytes = peer
.export(loro::ExportMode::updates(&v_before))
.expect("export op");
editor.core.borrow_mut().status = "12 references".to_owned();
super::handle_remote_crdt_op(
&mut editor,
FrontendId(99),
buffer_id,
crate::rope::CrdtOp {
peer_id: 99,
bytes: op_bytes,
},
);
assert!(
editor.core.borrow().status.is_empty(),
"an optimistic-path edit must clear the transient status"
);
}
/// Session B1 regression: a `Key` event from a *semantic*
/// (grid-less) frontend must reach the editor core. Before B1 the
/// dispatcher's catch-all only called `apply_event` when the

View File

@ -409,6 +409,10 @@ impl Frontend {
// toggle; the cell-grid TUI reads its window's mode directly,
// so it drops this silently like the other semantic families.
| InstanceMessage::LineNumbers { .. }
// Arc 1a Q#C5 — CompletionPopup is the semantic-frontend
// completion dropdown; the TUI paints the popup via its
// CompletionView cell overlay, so it drops this silently.
| InstanceMessage::CompletionPopup { .. }
| 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_fourteen_for_line_number_modes() {
fn protocol_version_is_fifteen_for_completion_popup() {
// 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
@ -1705,7 +1705,69 @@ mod tests {
// (`InstanceMessage::LineNumbers`, additive + daemon-gated). UX
// gutter modes bumped 13→14 (`LineNumbers` swapped `enabled: bool`
// for a `LineNumberMode` enum — encoding change, still daemon-gated).
assert_eq!(PROTOCOL_VERSION, 14);
// Arc 1a Q#C5 bumped 14→15 (`InstanceMessage::CompletionPopup`,
// additive + daemon-gated).
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.
// Pin both the open and the closed shapes.
let bid = crate::buffer::BufferId::next();
for msg in [
InstanceMessage::CompletionPopup {
buffer_id: bid,
anchor: Some(4_096),
prefix_len: 2,
rows: vec![
CompletionPopupRow {
label: "hello_world".into(),
kind: 3,
detail: Some("fn() -> ()".into()),
},
CompletionPopupRow {
label: "help".into(),
kind: 14,
detail: None,
},
],
selected: Some(1),
total: 42,
},
InstanceMessage::CompletionPopup {
buffer_id: bid,
anchor: None,
prefix_len: 0,
rows: Vec::new(),
selected: None,
total: 0,
},
] {
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(msg, decoded);
}
}
#[test]
@ -1714,24 +1776,22 @@ 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 / Q#S1 / Q#SR5 / Q#RX6 / Q#CM1 / Q#MB1 / UX gutter: the
// ladder resumes above that floor — v7 (`TripleDown`), v8
// (`StatusFacts`), v9 + v10 (`SearchPrompt` + regex/invalid), v11
// (the context menu), v12 (the GUI minibuffer), v13 (`LineNumbers`),
// v14 (`LineNumberMode`) all interoperate, so v6 through v14 talk.
assert!(is_supported_protocol_version(6));
assert!(is_supported_protocol_version(7));
assert!(is_supported_protocol_version(8));
assert!(is_supported_protocol_version(9));
assert!(is_supported_protocol_version(10));
assert!(is_supported_protocol_version(11));
assert!(is_supported_protocol_version(12));
assert!(is_supported_protocol_version(13));
assert!(is_supported_protocol_version(14));
for rejected in [0, 1, 2, 3, 4, 5, 15, u32::MAX] {
// Q#M4 / Q#S1 / Q#SR5 / Q#RX6 / Q#CM1 / Q#MB1 / UX gutter /
// Arc 1a: the ladder resumes above that floor — v7
// (`TripleDown`), v8 (`StatusFacts`), v9 + v10 (`SearchPrompt` +
// regex/invalid), v11 (the context menu), v12 (the GUI
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
// (`CompletionPopup`) all interoperate, so v6 through v15 talk.
for accepted in 6..=15 {
assert!(
is_supported_protocol_version(accepted),
"v{accepted} must be accepted"
);
}
for rejected in [0, 1, 2, 3, 4, 5, 16, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v14 binary"
"v{rejected} must be rejected by a v15 binary"
);
}
}

View File

@ -91,6 +91,17 @@ type MenuPromptFacts = (Vec<MenuPromptRow>, Option<u32>);
/// A `None` prompt means the minibuffer is closed.
type MinibufferFacts = (Option<String>, String, u32, Vec<String>, Option<u32>, u32);
/// Cached `CompletionPopup` payload for cached-compare suppression
/// (Arc 1a Q#C5): `(anchor, prefix_len, rows-window, selected, total)`.
/// A `None` anchor means the popup is closed.
type CompletionPopupFacts = (
Option<u64>,
u32,
Vec<crate::protocol::CompletionPopupRow>,
Option<u32>,
u32,
);
/// How many completion candidates the minibuffer ships per frame — a
/// scrolled window around the selection, not the full (≤1024) list.
const MB_VISIBLE: usize = 10;
@ -154,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),
@ -172,6 +184,10 @@ pub struct SemanticRenderState {
/// not per-buffer, because the minibuffer is one global core
/// instance.
last_minibuffer: Option<MinibufferFacts>,
/// Last emitted `CompletionPopup` payload per buffer (Arc 1a
/// Q#C5), for cached-compare suppression (see
/// [`CompletionPopupFacts`]).
last_completion_popup: HashMap<BufferId, CompletionPopupFacts>,
/// `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)
@ -243,6 +259,7 @@ impl SemanticRenderState {
last_search_prompt: HashMap::new(),
last_menu_prompt: HashMap::new(),
last_minibuffer: None,
last_completion_popup: HashMap::new(),
last_summary: HashMap::new(),
last_status: HashMap::new(),
// Seed to the frontend's default (gutter off): a plain default
@ -456,9 +473,90 @@ impl SemanticRenderState {
out.extend(self.menu_prompt_msg(state, vp.buffer_id));
// --- MinibufferPrompt (Q#MB1, protocol v12) ---
out.extend(self.minibuffer_prompt_msg(state, vp.buffer_id));
// --- CompletionPopup (Arc 1a Q#C5, protocol v15) ---
out.extend(self.completion_popup_msg(state, vp.buffer_id));
out
}
/// The `CompletionPopup` message for this frame, or `None` when the
/// popup state for `buffer_id` is unchanged (Arc 1a Q#C5). Only the
/// active buffer carries a live popup, and — the multi-frontend
/// rule — only the frontend whose *own window* owns the session
/// sees it open: the session is window-stamped at open
/// (`completion_popup_open`), and this producer state is
/// per-frontend, so a popup opened by TUI typing never renders in
/// an attached GPU and vice versa. Closed = `anchor: None`; first
/// sight of a buffer with no popup stays silent (like
/// `search_prompt_msg`). The daemon keeps the variant off wires
/// negotiated `< 15`.
fn completion_popup_msg(
&mut self,
state: &EditorState,
buffer_id: BufferId,
) -> Option<InstanceMessage> {
let facts: CompletionPopupFacts = {
let core = state.core.borrow();
if buffer_id != core.active_buffer_id() {
return None;
}
let own_window = core.views.get(&self.frontend_id).map(|v| v.active);
let guard = core
.completion_popup
.lock()
.expect("completion popup poisoned");
match guard.as_ref() {
Some(p)
if p.buffer_id == buffer_id
&& p.window_id.is_some()
&& p.window_id == own_window =>
{
let (start, len) = crate::completion::popup_window(
p.candidates.len(),
p.selected,
crate::completion::POPUP_MAX_ROWS as usize,
);
let rows: Vec<crate::protocol::CompletionPopupRow> = p.candidates
[start..start + len]
.iter()
.map(|c| crate::protocol::CompletionPopupRow {
label: c.label.clone(),
kind: c.kind as u8,
detail: c.detail.clone(),
})
.collect();
(
Some(p.anchor),
u32::try_from(p.prefix.len()).unwrap_or(u32::MAX),
rows,
u32::try_from(p.selected - start).ok(),
u32::try_from(p.total).unwrap_or(u32::MAX),
)
}
_ => (None, 0, Vec::new(), None, 0),
}
};
let cached = self.last_completion_popup.get(&buffer_id);
if cached == Some(&facts) {
return None;
}
// First sight of this buffer with no popup: nothing to clear,
// stay silent (the search-prompt rule).
if cached.is_none() && facts.0.is_none() {
self.last_completion_popup.insert(buffer_id, facts);
return None;
}
let msg = InstanceMessage::CompletionPopup {
buffer_id,
anchor: facts.0,
prefix_len: facts.1,
rows: facts.2.clone(),
selected: facts.3,
total: facts.4,
};
self.last_completion_popup.insert(buffer_id, facts);
Some(msg)
}
/// The `SearchPrompt` message for this frame, or `None` when the
/// search state for `buffer_id` is unchanged. Only the active
/// buffer carries a live prompt: a search shadows dispatch, so it
@ -651,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();
@ -682,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;
}
@ -692,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)
@ -3491,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