From dc26c84b7cfa2b82f22ed314f52812bd6df436be Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 17:10:31 -0400 Subject: [PATCH] feat(protocol): v15 CompletionPopup message + producer + daemon gate (Q#C5) InstanceMessage::CompletionPopup {buffer_id, anchor: Option, prefix_len, rows: Vec, 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 --- pmacs-gpu/src/main.rs | 1 + pmacs-protocol/src/lib.rs | 12 ++--- pmacs-protocol/src/message.rs | 55 +++++++++++++++++++- src/completion.rs | 5 +- src/daemon.rs | 11 ++++ src/frontend.rs | 4 ++ src/protocol.rs | 76 ++++++++++++++++++++------- src/semantic_render.rs | 97 +++++++++++++++++++++++++++++++++++ 8 files changed, 233 insertions(+), 28 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 711cfba..a4c037f 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -5201,6 +5201,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::ResourceOffer { .. } => "ResourceOffer", InstanceMessage::DispatchIdle { .. } => "DispatchIdle", InstanceMessage::LineNumbers { .. } => "LineNumbers", + InstanceMessage::CompletionPopup { .. } => "CompletionPopup", } } diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index fec34e3..bcb1000 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -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}; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 7ec8eb8..c9cacef 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -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, + /// 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, + /// Highlighted row *within* `rows`, or `None`. + selected: Option, + /// 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, +} + /// 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`]. diff --git a/src/completion.rs b/src/completion.rs index ee152e8..d7c4492 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -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; diff --git a/src/daemon.rs b/src/daemon.rs index 8efa647..0433a32 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -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. // diff --git a/src/frontend.rs b/src/frontend.rs index 7ed5cc8..2595ebd 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -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 diff --git a/src/protocol.rs b/src/protocol.rs index eaa0610..ea84848 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -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" ); } } diff --git a/src/semantic_render.rs b/src/semantic_render.rs index b99e331..c2a7abb 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -91,6 +91,17 @@ type MenuPromptFacts = (Vec, Option); /// A `None` prompt means the minibuffer is closed. type MinibufferFacts = (Option, String, u32, Vec, Option, 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, + u32, + Vec, + Option, + 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, + /// Last emitted `CompletionPopup` payload per buffer (Arc 1a + /// Q#C5), for cached-compare suppression (see + /// [`CompletionPopupFacts`]). + last_completion_popup: HashMap, /// `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 { + 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 = 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