From 5111ae82e7fd2758e560902576da68a96575ed4a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 11:26:25 -0400 Subject: [PATCH] search: GPU isearch surface (Q#SR5, protocol v9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings incremental search to pmacs-gpu, which has no minibuffer, by reusing the shared daemon-side search core from the previous commit. Key routing needs no new mechanism: `dispatch_idle` now also reports false while a search is running, so the GPU's existing M11.6 optimistic-apply gate round-trips every keystroke to the daemon — where `dispatch_search_key` extends the query / steps — instead of self-inserting it. The match highlights were already wired (commit 2's SearchMatch / SearchMatchActive decoration colors), so they light up live the moment keys round-trip. The one thing a semantic frontend can't derive locally is the query text, so a new additive `InstanceMessage::SearchPrompt { buffer_id, query, active, total }` carries it (protocol v9, SUPPORTED grows to [6,7,8,9]). The producer emits it cached-compare-suppressed like StatusFacts — `query: Some` while searching, `None` to clear on accept/cancel (matches keep highlighting via decorations), and stays silent on a fresh buffer that never searched. The daemon's per-session filter keeps the variant off wires negotiated < 9. The GPU mirrors it into the status band: while searching, the band's left side shows `I-search: (n/m)` (or `[no match]`) in place of the buffer name, returning to the name when the search ends. Tests: protocol version pin + SearchPrompt postcard round-trip (active / failing / cleared shapes); producer emit-on-change + suppress + clear-on-accept + first-sight silence; dispatch_idle flips false during search (the GPU round-trip contract). Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 57 ++++++++++++- pmacs-protocol/src/message.rs | 35 +++++++- src/daemon.rs | 10 +++ src/editor.rs | 27 ++++++- src/frontend.rs | 4 + src/protocol.rs | 54 +++++++++++-- src/semantic_render.rs | 145 +++++++++++++++++++++++++++++++++- 7 files changed, 315 insertions(+), 17 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index ccd3b8b..637ac51 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -529,6 +529,12 @@ struct State { status_left_text: String, /// Q#S1 — the wire-authoritative status facts (protocol v8). status_facts: Option, + /// Q#SR5 — the live incremental-search prompt (protocol v9), or + /// `None` when no search is running. While `Some`, the status + /// band's left side shows `I-search: (n/m)` in place of + /// the buffer name; the matches highlight via `SearchMatch` + /// decorations. + search_prompt: Option, /// Minimap vertex bytes cached by [`MinimapCacheKey`] — /// rebuilding rescanned every line shape per frame. minimap_cache: Option<(MinimapCacheKey, Vec)>, @@ -545,6 +551,16 @@ struct StatusFactsLocal { diag_warnings: u32, } +/// The live incremental-search prompt (Q#SR5, protocol v9), mirrored +/// from a `SearchPrompt` message whose `query` was `Some`. +#[derive(Clone, Debug, PartialEq, Eq)] +struct SearchPromptLocal { + buffer_id: BufferId, + query: String, + active: Option, + total: u32, +} + /// pmacs-gpu's own cursor position, mirrored from `CursorByte`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct OwnCursor { @@ -1417,6 +1433,7 @@ impl State { status_left_buffer, status_left_text: String::new(), status_facts: None, + search_prompt: None, minimap_cache: None, } } @@ -2010,6 +2027,27 @@ impl State { self.window.request_redraw(); None } + // Q#SR5 — the live isearch prompt (protocol v9). `query: + // None` clears the band (search ended); `Some` shows + // `I-search: (n/m)` on the band's left side. The + // matches themselves arrive as SearchMatch decorations and + // the keys round-trip via the DispatchIdle gate, so this + // handler only drives the prompt text. + InstanceMessage::SearchPrompt { + buffer_id, + query, + active, + total, + } => { + self.search_prompt = query.map(|q| SearchPromptLocal { + buffer_id, + query: q, + active, + total, + }); + self.window.request_redraw(); + None + } // Session 9.3 — peer presence. The editing frontend's // cursor + selection drive the `CurrentLine` / `Selection` // washes for this read-only mirror (finding QB1). Store @@ -2510,9 +2548,23 @@ impl State { spans } - /// The band's left side: buffer name + modified dot, from the - /// v8 `StatusFacts` (empty until the daemon ships them). + /// The band's left side. While an incremental search is running + /// (Q#SR5) it shows `I-search: (n/m)` — the prompt takes + /// over the band like Emacs's echo area, returning to the buffer + /// name + modified dot (v8 `StatusFacts`) when the search ends. fn compose_status_left(&self) -> String { + if let Some(sp) = self + .search_prompt + .as_ref() + .filter(|s| Some(s.buffer_id) == self.current_buffer_id) + { + let count = if sp.total == 0 { + " [no match]".to_string() + } else { + format!(" ({}/{})", sp.active.map_or(0, |a| a + 1), sp.total) + }; + return format!("I-search: {}{}", sp.query, count); + } match self .status_facts .as_ref() @@ -3885,6 +3937,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::InlineAdornments { .. } => "InlineAdornments", InstanceMessage::FileStyleSummary { .. } => "FileStyleSummary", InstanceMessage::StatusFacts { .. } => "StatusFacts", + InstanceMessage::SearchPrompt { .. } => "SearchPrompt", InstanceMessage::BlockAdornments { .. } => "BlockAdornments", InstanceMessage::FoldState { .. } => "FoldState", InstanceMessage::ResourceOffer { .. } => "ResourceOffer", diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index a041ce0..1504f54 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -814,6 +814,30 @@ pub enum InstanceMessage { /// frontend must round-trip via [`FrontendEvent::Key`]. idle: bool, }, + /// Q#SR5 (incremental search, protocol v9) — the live isearch + /// prompt for a semantic frontend that cannot host a minibuffer. + /// Carries the query as typed and the match readout so the + /// frontend can render an `I-search: (n/m)` band; the + /// matches themselves arrive as [`DecorationKind::SearchMatch`] / + /// [`DecorationKind::SearchMatchActive`] decorations. A `query` of + /// `None` means no search is running — the frontend hides the band. + /// + /// Emitted by the semantic producer when the search state changes + /// (cached-compare suppressed, like [`Self::StatusFacts`]). Kept + /// off wires negotiated `< 9` by the daemon's per-session filter + /// (additive variant — an older peer would hard-error decoding it). + SearchPrompt { + /// Buffer the search is anchored in (the active buffer). + buffer_id: crate::BufferId, + /// The query as typed so far, or `None` when no search runs. + /// `Some("")` is a freshly-started search with an empty query. + query: Option, + /// 0-based index of the active match, or `None` when the query + /// has no matches (a failing search). + active: Option, + /// Total number of matches for the current query. + total: u32, + }, } /// Flat selection state for the wire. @@ -1070,7 +1094,11 @@ pub enum ResourceBody { /// *daemon* this time (the variant travels instance→frontend): the /// per-session filter keeps it off wires negotiated `< 8`, the same /// shape as the `DispatchIdle` (v4) gate. -pub const PROTOCOL_VERSION: u32 = 8; +/// +/// Q#SR5 (incremental search): bumped from 8 to 9 for +/// [`InstanceMessage::SearchPrompt`]. Additive and daemon-gated per +/// session, identical shape to the `StatusFacts` (v8) bump. +pub const PROTOCOL_VERSION: u32 = 9; /// 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 @@ -1110,7 +1138,10 @@ pub const PROTOCOL_VERSION: u32 = 8; /// /// Q#S1: extended to `[6, 7, 8]`. `InstanceMessage::StatusFacts` is /// additive and daemon-gated per session. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8]; +/// +/// Q#SR5: extended to `[6, 7, 8, 9]`. `InstanceMessage::SearchPrompt` +/// is additive and daemon-gated per session. +pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. diff --git a/src/daemon.rs b/src/daemon.rs index 2365a36..84b2c2a 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1021,12 +1021,22 @@ fn dispatcher_loop( let peer_knows_status_facts = session_registry .session_state(*fid) .is_some_and(|s| s.negotiated_protocol_version >= 8); + // Q#SR5 — `SearchPrompt` is a v9 variant; gate it the + // same way so an < 9 peer never sees the new shape. + let peer_knows_search_prompt = session_registry + .session_state(*fid) + .is_some_and(|s| s.negotiated_protocol_version >= 9); for msg in &messages { if !peer_knows_status_facts && matches!(msg, InstanceMessage::StatusFacts { .. }) { continue; } + if !peer_knows_search_prompt + && matches!(msg, InstanceMessage::SearchPrompt { .. }) + { + continue; + } // T M10.10 Day 4 / M10.11 F2 — the criterion-1 // jitter site: render-write latency. // diff --git a/src/editor.rs b/src/editor.rs index 204325f..5adfade 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -450,7 +450,8 @@ impl EditorState { /// - the dispatcher holds a pending multi-key prefix (e.g. the /// user has typed `C-x` and the daemon is waiting for the next /// chord), or - /// - a minibuffer prompt is active and absorbing keys. + /// - a minibuffer prompt is active and absorbing keys, or + /// - an incremental search is running and absorbing keys (Q#SR5). /// /// Used by the daemon to drive the `InstanceMessage::DispatchIdle` /// wire signal that gates `crdt_replica` frontends' optimistic-apply @@ -458,13 +459,18 @@ impl EditorState { /// plain-char keystroke into the active document while the /// daemon's actual intent is to route the keystroke into the /// minibuffer prompt — the M10.10 "documented limitation" that - /// surfaced during session-5 manual validation. + /// surfaced during session-5 manual validation. Isearch reuses the + /// exact same gate: while a search runs every keystroke must + /// round-trip so the daemon's `dispatch_search_key` receives it + /// (extend the query / step) instead of the frontend self-inserting + /// it into the buffer. #[must_use] pub fn dispatch_idle(&self) -> bool { if !self.dispatcher.pending().is_empty() { return false; } - !self.core.borrow().minibuffer.is_active() + let core = self.core.borrow(); + !core.minibuffer.is_active() && !core.search_active() } /// `frontend_id` records which frontend produced the event. v0.1 @@ -2100,6 +2106,21 @@ mod tests { assert_eq!(s.core.borrow().search_query(), "foo"); } + #[test] + fn isearch_flips_dispatch_idle_so_gpu_round_trips() { + // The GPU's optimistic-apply gate (M11.6) keys off dispatch_idle. + // An active isearch must drive it false so the GPU round-trips + // keystrokes to the daemon's dispatch_search_key instead of + // self-inserting them — the shared-core contract for Q#SR5. + let mut s = fresh_with(b"foo foo"); + assert!(s.dispatch_idle(), "idle before any search"); + s.dispatch_key(FrontendId::LOCAL, ctrl('s')); + assert!(s.core.borrow().search_active()); + assert!(!s.dispatch_idle(), "search active ⇒ keys must round-trip"); + s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Enter)); // accept + assert!(s.dispatch_idle(), "search ended ⇒ optimistic apply resumes"); + } + // ---- T M11.6 — DispatchIdle --------------------------------------------- #[test] diff --git a/src/frontend.rs b/src/frontend.rs index ef40299..b5ceb1b 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -384,6 +384,10 @@ impl Frontend { | InstanceMessage::FoldState { .. } | InstanceMessage::FileStyleSummary { .. } | InstanceMessage::StatusFacts { .. } + // Q#SR5 — SearchPrompt is a semantic-frontend status-band + // family member; the cell-grid TUI never negotiates it and + // drops it silently if one arrives. + | InstanceMessage::SearchPrompt { .. } | 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 5bb172e..7d1a653 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_eight_for_status_facts() { + fn protocol_version_is_nine_for_search_prompt() { // 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 @@ -1694,8 +1694,9 @@ mod tests { // making v6 the ladder's encoding floor. Q#M4 bumped 6→7 // (`PointerKind::TripleDown`, additive + frontend-gated). // Q#S1 bumped 7→8 (`InstanceMessage::StatusFacts`, additive - // + daemon-gated per session). - assert_eq!(PROTOCOL_VERSION, 8); + // + daemon-gated per session). Q#SR5 bumped 8→9 + // (`InstanceMessage::SearchPrompt`, additive + daemon-gated). + assert_eq!(PROTOCOL_VERSION, 9); } #[test] @@ -1704,16 +1705,18 @@ 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: the ladder resumes above that floor — v7 - // (`TripleDown`, frontend-gated) and v8 (`StatusFacts`, - // daemon-gated) are additive, so v6 through v8 interoperate. + // Q#M4 / Q#S1 / Q#SR5: the ladder resumes above that floor — v7 + // (`TripleDown`, frontend-gated), v8 (`StatusFacts`) and v9 + // (`SearchPrompt`, both daemon-gated) are additive, so v6 + // through v9 interoperate. assert!(is_supported_protocol_version(6)); assert!(is_supported_protocol_version(7)); assert!(is_supported_protocol_version(8)); - for rejected in [0, 1, 2, 3, 4, 5, 9, u32::MAX] { + assert!(is_supported_protocol_version(9)); + for rejected in [0, 1, 2, 3, 4, 5, 10, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v8 binary" + "v{rejected} must be rejected by a v9 binary" ); } } @@ -1877,6 +1880,41 @@ mod tests { } } + #[test] + fn search_prompt_round_trips_through_postcard() { + // Q#SR5 — the v9 wire variant. Cover the three shapes the + // producer emits: an active search with matches, a failing + // search (active=None), and a cleared band (query=None). + let cases = [ + (Some("foo".to_owned()), Some(2u32), 5u32), + (Some("zzz".to_owned()), None, 0u32), + (None, None, 0u32), + ]; + for (query, active, total) in cases { + let msg = InstanceMessage::SearchPrompt { + buffer_id: crate::buffer::BufferId::next(), + query: query.clone(), + active, + total, + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + match decoded { + InstanceMessage::SearchPrompt { + query: q, + active: a, + total: t, + .. + } => { + assert_eq!(q, query); + assert_eq!(a, active); + assert_eq!(t, total); + } + other => panic!("expected SearchPrompt, got {other:?}"), + } + } + } + #[test] fn key_event_to_crossterm_round_trips() { // Build a protocol KeyEvent, translate to crossterm, translate diff --git a/src/semantic_render.rs b/src/semantic_render.rs index cbb0961..990baa5 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -121,6 +121,10 @@ pub struct SemanticRenderState { /// `(name, modified, diag_errors, diag_warnings)` last emitted as /// `StatusFacts` (Q#S1) — cached-compare suppression. last_status: HashMap, + /// `(query, active, total)` last emitted as `SearchPrompt` + /// (Q#SR5) — cached-compare suppression. A `None` query means the + /// last emission cleared the band (no active search). + last_search_prompt: HashMap, Option, u32)>, /// `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) @@ -189,6 +193,7 @@ impl SemanticRenderState { last_sent: HashMap::new(), last_decorations: HashMap::new(), last_adornments: HashMap::new(), + last_search_prompt: HashMap::new(), last_summary: HashMap::new(), last_status: HashMap::new(), last_style_gate: HashMap::new(), @@ -389,9 +394,73 @@ impl SemanticRenderState { out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation)); // --- StatusFacts (status band; Q#S1, protocol v8) --- out.extend(self.status_facts_msg(state, vp.buffer_id)); + // --- SearchPrompt (isearch band; Q#SR5, protocol v9) --- + out.extend(self.search_prompt_msg(state, vp.buffer_id)); out } + /// 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 + /// always runs in the active buffer, and emitting for that buffer's + /// viewport keeps the per-buffer cached-compare honest. When no + /// search runs the active buffer emits `query: None` once (to clear + /// the frontend's band), then stays silent. The daemon's write loop + /// keeps the variant off wires negotiated `< 9`. + fn search_prompt_msg( + &mut self, + state: &EditorState, + buffer_id: BufferId, + ) -> Option { + // Off-active-buffer viewports never touch the search band — the + // active buffer owns it. (Without this, switching buffers mid- + // session would let an inactive viewport clobber the cache.) + let facts = { + let core = state.core.borrow(); + if buffer_id != core.active_buffer_id() { + return None; + } + if core.search_active() { + let (active_idx, total) = core.search_match_summary(); + ( + Some(core.search_query().to_owned()), + active_idx.and_then(|i| u32::try_from(i).ok()), + u32::try_from(total).unwrap_or(u32::MAX), + ) + } else { + // No search → a cleared band. active/total are zeroed so + // the inactive state is one canonical tuple (the GPU only + // reads them when `query` is `Some`). The accepted matches + // keep highlighting via Decorations regardless. + (None, None, 0) + } + }; + if self.last_search_prompt.get(&buffer_id) == Some(&facts) { + return None; + } + let cached = self.last_search_prompt.get(&buffer_id); + if cached == Some(&facts) { + return None; + } + // First sight of this buffer with no active search: there is + // nothing to clear, so stay silent rather than ship an empty + // band on every fresh buffer. Record the baseline so a *later* + // search→clear transition still diffs. (Mirrors the inline- + // adornments "speak only if there's something to show" rule.) + if cached.is_none() && facts.0.is_none() { + self.last_search_prompt.insert(buffer_id, facts); + return None; + } + let msg = InstanceMessage::SearchPrompt { + buffer_id, + query: facts.0.clone(), + active: facts.1, + total: facts.2, + }; + self.last_search_prompt.insert(buffer_id, facts); + Some(msg) + } + /// The `StatusFacts` message for this frame, or `None` when /// nothing changed. Carries the facts a semantic frontend cannot /// derive locally: buffer name, modified flag, whole-file @@ -1462,8 +1531,8 @@ mod tests { /// All `InstanceMessage` variants the semantic projection may /// emit are `StyleSpans`, `Decorations`, `InlineAdornments`, - /// `FileStyleSummary`, or `StatusFacts` (Q#S1) — never - /// `CellDelta`, grid `Cursor`, or the still-unwired + /// `FileStyleSummary`, `StatusFacts` (Q#S1), or `SearchPrompt` + /// (Q#SR5) — never `CellDelta`, grid `Cursor`, or the still-unwired /// `BlockAdornments` / `FoldState` families. fn assert_semantic_only(msgs: &[InstanceMessage]) { for m in msgs { @@ -1475,6 +1544,7 @@ mod tests { | InstanceMessage::InlineAdornments { .. } | InstanceMessage::FileStyleSummary { .. } | InstanceMessage::StatusFacts { .. } + | InstanceMessage::SearchPrompt { .. } ), "semantic projection emitted an unexpected variant: {m:?}" ); @@ -2985,6 +3055,77 @@ mod tests { }) } + fn search_prompt_of(msgs: &[InstanceMessage]) -> Option<(Option, Option, u32)> { + msgs.iter().find_map(|m| match m { + InstanceMessage::SearchPrompt { + query, + active, + total, + .. + } => Some((query.clone(), *active, *total)), + _ => None, + }) + } + + #[test] + fn search_prompt_emits_on_change_and_clears_on_finish() { + let state = empty_state(); + let mut s = local(); + let bid = active_buffer(&state); + // Three "foo" matches. + { + let core = state.core.borrow(); + core.registry + .clone() + .borrow_mut() + .get_mut(bid) + .expect("active buffer") + .apply_edit(crate::buffer::EditOp::Insert { + pos: 0, + bytes: b"foo foo foo", + }) + .expect("seed"); + } + s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0); + + // No search yet: any prompt that ships carries a cleared query. + if let Some((q, _, _)) = search_prompt_of(&s.render_frame(&state)) { + assert!(q.is_none(), "no search ⇒ no live query"); + } + + // Begin + type "foo": the live query + active/total ship. + { + let mut core = state.core.borrow_mut(); + core.search_begin(true); + for ch in "foo".chars() { + core.search_input_char(ch); + } + } + assert_eq!( + search_prompt_of(&s.render_frame(&state)), + Some((Some("foo".to_owned()), Some(0), 3)), + "live isearch ships query + (active, total)" + ); + // Unchanged → suppressed (cached-compare). + assert!(search_prompt_of(&s.render_frame(&state)).is_none()); + + // Step: active index advances and re-emits. + state.core.borrow_mut().search_step(true); + assert_eq!( + search_prompt_of(&s.render_frame(&state)), + Some((Some("foo".to_owned()), Some(1), 3)) + ); + + // Accept: the prompt band clears (query None) even though the + // matches stay in the store for navigation + highlight. + state.core.borrow_mut().search_finish(true); + assert_eq!( + search_prompt_of(&s.render_frame(&state)), + Some((None, None, 0)), + "accept clears the prompt band" + ); + } + #[test] fn status_facts_emit_on_change_and_freeze_counts_while_stale() { let state = empty_state();