search: GPU isearch surface (Q#SR5, protocol v9)

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: <query> (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) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-27 11:26:25 -04:00
parent 58b68f6ac0
commit 5111ae82e7
7 changed files with 315 additions and 17 deletions

View File

@ -529,6 +529,12 @@ struct State {
status_left_text: String,
/// Q#S1 — the wire-authoritative status facts (protocol v8).
status_facts: Option<StatusFactsLocal>,
/// 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: <query> (n/m)` in place of
/// the buffer name; the matches highlight via `SearchMatch`
/// decorations.
search_prompt: Option<SearchPromptLocal>,
/// Minimap vertex bytes cached by [`MinimapCacheKey`] —
/// rebuilding rescanned every line shape per frame.
minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>,
@ -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<u32>,
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: <query> (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: <query> (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",

View File

@ -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: <query> (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<String>,
/// 0-based index of the active match, or `None` when the query
/// has no matches (a failing search).
active: Option<u32>,
/// 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`].

View File

@ -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.
//

View File

@ -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]

View File

@ -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

View File

@ -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 v1v5 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

View File

@ -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<BufferId, (String, bool, u32, u32)>,
/// `(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<BufferId, (Option<String>, Option<u32>, 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<InstanceMessage> {
// 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<String>, Option<u32>, 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();