feat(protocol): v15 CompletionPopup message + producer + daemon gate (Q#C5)

InstanceMessage::CompletionPopup {buffer_id, anchor: Option<u64>,
prefix_len, rows: Vec<CompletionPopupRow{label, kind, detail}>,
selected, total} -- the first byte-anchored popup on the wire: the
frontend maps byte -> glyph rect locally (the caret precedent), so the
instance never learns a pixel. Rows are display-only; accept resolves
daemon-side via dispatch_completion_key, so insert text never ships.
PROTOCOL_VERSION 14 -> 15, SUPPORTED extended; postcard round-trip
(open + closed shapes) and version-pin/ladder tests updated.

Producer: semantic_render::completion_popup_msg, the family pattern
(per-buffer cached-compare, active-buffer only, first-sight-closed
stays silent) with one new rule -- the session is WINDOW-stamped and
this state is per-frontend, so only the frontend whose own window
owns the session sees it open: a popup opened by TUI typing never
renders in an attached GPU and vice versa. Windowed rows share the
TUI overlay's POPUP_MAX_ROWS. Daemon-gated >= 15 (a v14 peer still
completes via the key round-trip, it just gets no GPU dropdown).

GPU consumption follows in this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-07 17:10:31 -04:00
parent d856a1bdc4
commit dc26c84b7c
8 changed files with 233 additions and 28 deletions

View File

@ -5201,6 +5201,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
InstanceMessage::DispatchIdle { .. } => "DispatchIdle",
InstanceMessage::LineNumbers { .. } => "LineNumbers",
InstanceMessage::CompletionPopup { .. } => "CompletionPopup",
}
}

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

@ -922,6 +922,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 +1002,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 +1293,14 @@ 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.
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 +1357,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

@ -1056,6 +1056,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 +1092,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.
//

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,49 @@ 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 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 +1756,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;
@ -172,6 +183,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 +258,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 +472,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