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:
parent
d856a1bdc4
commit
dc26c84b7c
|
|
@ -5201,6 +5201,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
|
|||
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
|
||||
InstanceMessage::DispatchIdle { .. } => "DispatchIdle",
|
||||
InstanceMessage::LineNumbers { .. } => "LineNumbers",
|
||||
InstanceMessage::CompletionPopup { .. } => "CompletionPopup",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 v1–v5 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue