lattice_picker/lib.rs
1//! Vertico-style picker (DESIGN.md §5.9.7, §5.9.10).
2//!
3//! Generalises the completion popup's three-stage shape (raw
4//! candidates -> filter -> render) into a reusable host for any
5//! "type to drill down, Enter to act" UI: buffer switcher, LSP
6//! instance picker, future fuzzy-finder, command palette,
7//! diagnostics list, register / mark history.
8//!
9//! ## Architecture
10//!
11//! - [`Picker`] owns the live state: a query buffer, a cursor on
12//! that query, the unfiltered raw candidate list, the filtered
13//! rendered list, and a selection cursor on the rendered list.
14//! - [`PickerSource`] tags how the raw candidate list was built so
15//! refresh paths know which generator to re-run.
16//! - [`PickerAction`] tags what to do when the user accepts a row.
17//! The dispatch happens on the host side; Picker is dumb about
18//! side effects.
19//!
20//! Filtering today is **case-insensitive substring** (cheap, easy
21//! to reason about). The pipeline-driven path (`lattice-completion`
22//! crate's full vertico stack: matcher / ranker / annotators) takes
23//! over once we lift `CommandLineSlot` out of the slot detector --
24//! same data shape, richer scoring. Substring is enough to ship a
25//! useful buffer switcher and stays cheap when the candidate set is
26//! small (typical: <50 buffers, <10 LSP instances).
27//!
28//! ## Renderer-agnostic by design
29//!
30//! This crate is the **data model** for pickers; it owns no
31//! rendering code and no host-specific imports beyond
32//! `lattice-completion`'s candidate shape. Hosts (the TUI
33//! renderer today; the GPUI / web renderers later) read picker
34//! state and paint it however they like.
35//!
36//! The buffer-source candidate builder lives in the TUI host
37//! (`lattice-ui-tui::app::raw_buffer_candidates`) because it
38//! walks the host's `BufferRegistry`. LSP-instance candidates
39//! arrive via [`Picker::set_lsp_instances`] which takes a
40//! `Vec<LspInstanceRow>` of pure-data rows the host snapshots
41//! from the supervisor. Both paths feed [`Picker::set_raw_candidates`]
42//! (the only entry point that mutates `raw`).
43//!
44//! See `docs/dev/architecture/picker.md` for the trait-surface
45//! design that the registry, source generators, and MRU
46//! pipeline will land on top of this data model.
47
48pub mod context;
49pub mod events;
50pub mod mru;
51pub mod outcome;
52pub mod picker_sources;
53pub mod source;
54pub mod transient;
55
56pub use context::{
57 ActiveBufferSnapshot, BufferEntry, PaneHistoryRow, PickerContext, PositionEntry, PositionSource,
58};
59pub use mru::{
60 DEFAULT_CAP_PER_NAMESPACE, DEFAULT_HALF_LIFE, MruEntry, MruKey, MruPersistError,
61 PickerMruIndex, bonus_of, default_persist_path, routing_identity,
62};
63pub use outcome::{FillTarget, OpenTarget, PickerAcceptOutcome, PickerPreviewOutcome};
64pub use picker_sources::{DIR_PICK_SOURCE, FILE_PICK_SOURCE, YANK_RING_SOURCE};
65pub use source::{
66 AcceptFuture, CandidateBatch, CandidateFuture, CandidateStream, PickerInitResult,
67 PickerRegistry, PickerRegistryHandle, PickerSourceGenerator, PickerSourceSpec, SourceResult,
68};
69pub use transient::{
70 KeyResolution, TransientArgSource, TransientBuild, TransientBuildFuture, TransientContext,
71 TransientGroup, TransientItem, TransientItemKind, TransientSourceRegistry,
72 TransientSourceRegistryHandle, TransientSpec, TransientState, TransientValue,
73 confirm_transient_spec, transient_initial_state,
74};
75
76use std::path::PathBuf;
77
78use lattice_completion::{
79 CandidateData, CandidateKind, CompletionPipeline, FuzzyDisplayMatcher, MatchScore, MruRanker,
80 OrderlessDisplayMatcher, RawCandidate, RenderedCandidate,
81};
82
83/// `CandidateData::Extension { kind_id }` value the picker stamps
84/// on every candidate it builds. Each candidate's payload bytes
85/// are a `u32` LE index into the picker's `routing_meta` sidecar
86/// vec; the sidecar holds the typed [`RoutingPayload`] enum the
87/// accept dispatch matches on.
88///
89/// Same shape used by the insert-completion crate's snippet +
90/// LSP-completion sidecars (see `app::SNIPPET_COMPLETION_KIND_ID`
91/// / `LSP_COMPLETION_KIND_ID`); a distinct id keeps picker
92/// candidates from colliding with insert-completion ones if the
93/// surfaces ever share a list (they don't today, but the type
94/// system stays honest).
95pub const PICKER_ROUTING_KIND_ID: u32 = 200;
96
97/// OR.5: `CandidateData::Extension { kind_id }` the picker stamps on its
98/// synthetic **create** row.
99///
100/// A distinct id rather than an index into `routing_meta`, because the create
101/// row's payload is the *live query* — it changes on every keystroke, while
102/// `routing_meta` is written once per seat. Giving it its own kind keeps
103/// [`Picker::routing_for`] a lookup rather than a special case threaded through
104/// the sidecar.
105pub const PICKER_CREATE_KIND_ID: u32 = 201;
106
107/// Typed payload the picker accept dispatch reads to figure out
108/// which side effect to run. Replaces the tab-encoded string
109/// payloads stuffed into `RawCandidate.text` in earlier phases
110/// (Phase 4.2.g.7 polish).
111///
112/// One variant per [`PickerAction`]; the variant naturally
113/// communicates the dispatch path AND carries the typed data the
114/// handler needs (no string parsing, no fragility around `\t` in
115/// paths). The `PickerAction` tag stays for now -- the App's
116/// dispatch matches on it first to choose the code path; future
117/// cleanup could pivot to matching on the payload variant alone
118/// since the two always agree, but the redundancy is harmless.
119#[derive(Debug, Clone)]
120pub enum RoutingPayload {
121 /// `PickerAction::SwitchToBuffer` -- the host's buffer id
122 /// (newtype-wrapped `u32` host-side; we hold the raw value
123 /// to keep the picker module renderer-agnostic).
124 Buffer { id: u32 },
125 /// PBH.5: one entry in the ACTIVE pane's buffer trail, identified by
126 /// its **index** rather than its buffer id — the same buffer can
127 /// appear at several points in a trail, and picking the third stop
128 /// must land on the third stop.
129 ///
130 /// Accepting moves the walk cursor rather than pushing a visit: the
131 /// picker is random access over the existing trail, not a new
132 /// navigation. Pushing would append a duplicate and make `<C-7>`
133 /// unreachable, exactly as an unsuppressed walk would.
134 PaneHistoryEntry { index: u32 },
135 /// Resolve a specific pending diff review by its primary buffer id with
136 /// Accept (`accept = true`) or Reject. Emitted by the diff-review picker
137 /// (`:diff-accept` / `:diff-reject` with >1 pending review) so the user
138 /// chooses WHICH diff to resolve. `primary` is the raw host `BufferId`.
139 ResolveDiff { primary: u32, accept: bool },
140 /// `PickerAction::OpenLspLog` / `OpenLspTraceLog` -- the
141 /// supervisor key. `workspace` rides for completeness but
142 /// today's handlers only use `server_id`.
143 LspInstance {
144 server_id: String,
145 workspace: PathBuf,
146 },
147 /// `PickerAction::OpenAiLog` -- the AI session key (provider +
148 /// per-provider index) the host reconstructs into a
149 /// `SessionKey` to open `*ai:<provider>:<index>*`. Ephemeral
150 /// (sessions come and go), so `routing_identity` returns
151 /// `None` — no MRU recency.
152 AiSession { provider: String, index: u32 },
153 /// `PickerAction::JumpToLspLocation` -- canonical `(path,
154 /// line, col)` the host's `jump_to_file_line_col` consumes.
155 /// LSP 0-based line + utf-8 byte column; the host already
156 /// converted these utf-8 host-side at ingestion time.
157 LspLocation { path: PathBuf, line: u32, col: u32 },
158 /// `PickerAction::AcceptLspCompletion` -- numeric index into
159 /// the host's `pending_completion_items` snapshot.
160 LspCompletion { index: u32 },
161 /// `PickerAction::AcceptLspCodeAction` -- numeric index into
162 /// the host's `pending_code_action_items` snapshot.
163 LspCodeAction { index: u32 },
164 /// `PickerAction::OpenFile` -- canonical filesystem path the
165 /// accept dispatch hands to `App::do_edit(Some(path),
166 /// false)`. Used by the file picker (`:files`) and the
167 /// recent-files picker; directories defer to oil/file-tree
168 /// the same way `:e DIR` does.
169 OpenFile { path: PathBuf },
170 /// `PickerAction::JumpInBuffer` -- jump to `(line, col)` in
171 /// an already-registered buffer. Captured at picker-open
172 /// time so the destination is stable even if the user
173 /// arrowed through another picker's hover-preview.
174 /// Emitted by `:picker lines`, `:picker jumps` (for entries
175 /// pointing at currently-open buffers), and -- once
176 /// migrated -- `:picker marks`. MRU returns `None` for this
177 /// variant because `(line, col)` drift as the buffer is
178 /// edited; a stale identity would mis-rank candidates.
179 JumpInBuffer { buffer_id: u32, line: u32, col: u32 },
180 /// Invoke an ex-command by stable id. Emitted by the
181 /// command palette (`:picker commands`); the host resolves
182 /// `id` against its `CommandRegistry`, builds a
183 /// `CommandInvocation`, and routes through the same
184 /// dispatcher the `:` line uses. `args` carries any
185 /// pre-supplied positional arguments (the command palette
186 /// today emits `Args::None`; future "pick a thing, then
187 /// run a command on it" flows will carry richer values).
188 InvokeCommand {
189 id: String,
190 args: lattice_grammar::args::Args,
191 },
192 /// Paste a named register's contents at the cursor.
193 /// Emitted by `:picker registers`; `name` is the single-
194 /// char register identifier (`a`..`z`, `0`..`9`, `+`,
195 /// `"`, etc.). The host's `apply_picker_outcome::PasteRegister`
196 /// arm sets the pending register and runs the normal paste
197 /// path so charwise / linewise / blockwise distinction is
198 /// honored.
199 PasteRegister { name: char },
200 /// Jump to a named mark (`mX`). Emitted by `:picker marks`.
201 /// The host resolves the mark to a position via its
202 /// existing `do_jump_mark` path so the cursor placement +
203 /// position-history push match the keyboard-driven
204 /// behavior. `name` carries a stable identity, so MRU
205 /// will record `mark:<name>` once slice 14 lands.
206 JumpToMark { name: char },
207 /// Expand a snippet by name at the cursor. Emitted by
208 /// `:picker snippets`. The host resolves the body through
209 /// `SnippetRegistry::by_name` and routes through the
210 /// existing `:snippet-expand` path. MRU keys on
211 /// `snip:<name>`.
212 ExpandSnippet { id: String },
213 /// Accept one action from a server-initiated
214 /// `window/showMessageRequest`. `request_id` keys into the
215 /// App's `lsp_pending_show_message_requests` map (the slot
216 /// that holds the inbound oneshot); `action_index` selects
217 /// which `MessageActionItem` from the request's actions
218 /// vec ferries back to the server. Dismiss (Esc) routes
219 /// through `do_picker_dismiss` which replies `null` (i.e.
220 /// the user closed the prompt without picking).
221 AcceptShowMessageAction { request_id: u32, action_index: u32 },
222 /// 4.5.d -- `PickerAction::AcceptLspCodeLens`. Numeric
223 /// index into the host's `pending_code_lens_items`
224 /// snapshot (a clone of the active buffer's code-lens
225 /// cache at picker-open time). The accept dispatch
226 /// resolves the lens (if it arrived without a `command`)
227 /// and routes the resulting `command` through
228 /// `workspace/executeCommand` on the originating server.
229 LspCodeLens { index: u32 },
230 /// 4.5.e -- `PickerAction::AcceptColorPresentation`.
231 /// Numeric index into the host's
232 /// `pending_color_presentations` snapshot. Accept splices
233 /// the chosen `ColorPresentation.text_edit` (or `label`
234 /// fallback) into the buffer at the cached color range.
235 ColorPresentation { index: u32 },
236 /// T.12: the theme name a colorscheme-picker candidate carries.
237 /// Both accept and live-preview resolve the name against the
238 /// `ThemeRegistry` catalog and swap the active theme.
239 Colorscheme { name: String },
240 /// MB.3: the past command a history-picker candidate carries.
241 /// Accept loads `text` into the editable `:` line via the
242 /// host's `open_command_line` seam — it does **not** execute
243 /// (the user tweaks / `<C-x><C-e>`s it, then `<CR>`s). Emitted
244 /// by the `history` picker source (`q:` / `:history`).
245 /// Ephemeral by nature — `routing_identity` returns `None`, so
246 /// no MRU recency (the history ring is already recency-ordered).
247 LoadCommandLine { text: String },
248 /// MB.5: load `text` into the editable `/` search line. Emitted
249 /// by the `search-history` picker source (`q/` / `q?` /
250 /// `:history search`). The host opens the `*search-line*` buffer
251 /// with Forward direction and seeds the pattern; the user tweaks
252 /// it, then `<CR>` to execute. Ephemeral — no MRU recency.
253 LoadSearchLine { text: String },
254 /// The branch name a base-branch-picker candidate carries.
255 /// Emitted by magit's branch-create wizard (`c` in
256 /// `magit-branch-mode`): the user picks an existing branch as the
257 /// base, then accept opens a follow-up text prompt for the new
258 /// branch's name (`PickerAcceptOutcome::OpenPrompt`, stashing
259 /// this name in the prompt buffer's synthetic name).
260 BranchBase { name: String },
261 /// MG.53.e: a plain value the picked candidate stands for, for a
262 /// source that answers a question rather than performing an action.
263 ///
264 /// Carried by `file-pick` and consumed by whatever parked itself
265 /// waiting for a value — today a picker-backed transient argument.
266 /// The source does not know what the value is for, which is the
267 /// point: one registered file listing serves every argument that
268 /// names a file.
269 SuppliedValue { value: String },
270 /// OR.6: a place in a file on disk — `(path, line, col)`.
271 ///
272 /// The peer `PickerAcceptOutcome::JumpToLocation` already had and the
273 /// routing side lacked. Without it a source whose rows stand for positions
274 /// in files can only carry [`Self::OpenFile`], which drops the line — so a
275 /// row for a heading halfway down a file lands at the top of it. Org-roam's
276 /// headline nodes are 19% of its corpus and every one of them lands wrong
277 /// without this.
278 ///
279 /// Distinct from [`Self::LspLocation`], which is the same shape under a
280 /// name that says where it came from. Reusing it would have worked and read
281 /// as a lie in every source that is not an LSP result.
282 ///
283 /// No MRU identity: a location is not an identity — the same line means
284 /// something different after an edit above it.
285 FileLocation { path: PathBuf, line: u32, col: u32 },
286 /// OR.5: the query the user typed, carried by the picker's synthetic
287 /// **create** row — the offer to make the thing they were looking for and
288 /// did not find.
289 ///
290 /// Emitted by the picker itself rather than by a source: a source declares
291 /// `create_label` on its spec and the picker appends the row, so every
292 /// source gets the behaviour from one declaration and none of them
293 /// re-implements it. `accept` routes back to the source, which decides what
294 /// creation means — org-roam mints a node, another source might make a file
295 /// or a branch. The picker never knows.
296 ///
297 /// `query` is verbatim, spaces and non-ASCII included. The source is
298 /// creating something the *user named*, so trimming or normalising here
299 /// would be the picker having an opinion about a namespace it does not own.
300 Create { query: String },
301}
302
303/// Resolves a picker-relative buffer id to its on-disk path, for the one
304/// routing variant ([`RoutingPayload::JumpInBuffer`]) whose location is
305/// buffer-relative rather than a filesystem path.
306///
307/// The host supplies it — it owns the buffer↔path map — so `lattice-picker`
308/// stays free of any dependency on the editor (the off-thread guarantee is
309/// structural, not by discipline). A buffer with no path (a synthetic
310/// `*scratch*`, an unsaved buffer) yields `None`, and the entry is skipped:
311/// there is no file the error list could jump to.
312pub trait BufferPathResolver {
313 fn path_for_buffer(&self, buffer_id: u32) -> Option<PathBuf>;
314}
315
316/// A place in a file the error list can navigate to: `(path, line, col)`,
317/// line and column 0-based to match `lattice_protocol::error_list::ErrorEntry`.
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct ErrorLocation {
320 pub path: PathBuf,
321 pub line: u32,
322 pub col: u32,
323}
324
325impl RoutingPayload {
326 /// Where this entry points, when it points into a file on disk — the
327 /// picker's own translation of a row into an error-list location.
328 ///
329 /// This is what makes `<C-q>` (send-to-error-list) **generic over every
330 /// picker**: the host no longer decides which payloads have a location,
331 /// the payload does. A plugin picker that emits [`Self::FileLocation`]
332 /// across the WIT boundary gets error-list support for free, without the
333 /// host learning about it.
334 ///
335 /// **Exhaustive by construction — there is deliberately no `_` arm.** Every
336 /// variant decides here: a location (and how), or `None` (a register, a
337 /// command, a buffer id — nothing to jump to). A new payload variant will
338 /// not compile until it declares its mapping. The earlier host-side match
339 /// carried a wildcard and silently dropped [`Self::FileLocation`], so a
340 /// plugin picker's file rows sent nothing; folding coverage into the type
341 /// turns that silent gap into a compile error.
342 pub fn error_location(&self, resolver: &dyn BufferPathResolver) -> Option<ErrorLocation> {
343 match self {
344 // Already a filesystem `(path, line, col)`.
345 RoutingPayload::LspLocation { path, line, col }
346 | RoutingPayload::FileLocation { path, line, col } => Some(ErrorLocation {
347 path: path.clone(),
348 line: *line,
349 col: *col,
350 }),
351 // A file with no recorded position lands at its top, matching
352 // `:edit` and the single-accept `OpenFile` path.
353 RoutingPayload::OpenFile { path } => Some(ErrorLocation {
354 path: path.clone(),
355 line: 0,
356 col: 0,
357 }),
358 // Buffer-relative: navigable only if the buffer is file-backed.
359 // Synthetic / unsaved buffers resolve to `None` and are skipped.
360 RoutingPayload::JumpInBuffer {
361 buffer_id,
362 line,
363 col,
364 } => resolver
365 .path_for_buffer(*buffer_id)
366 .map(|path| ErrorLocation {
367 path,
368 line: *line,
369 col: *col,
370 }),
371 // Everything else stands for an action, a value, or an ephemeral
372 // key — not a place in a file. Listed explicitly so the compiler
373 // forces a decision when a variant is added.
374 RoutingPayload::Buffer { .. }
375 | RoutingPayload::PaneHistoryEntry { .. }
376 | RoutingPayload::ResolveDiff { .. }
377 | RoutingPayload::LspInstance { .. }
378 | RoutingPayload::AiSession { .. }
379 | RoutingPayload::LspCompletion { .. }
380 | RoutingPayload::LspCodeAction { .. }
381 | RoutingPayload::InvokeCommand { .. }
382 | RoutingPayload::PasteRegister { .. }
383 | RoutingPayload::JumpToMark { .. }
384 | RoutingPayload::ExpandSnippet { .. }
385 | RoutingPayload::AcceptShowMessageAction { .. }
386 | RoutingPayload::LspCodeLens { .. }
387 | RoutingPayload::ColorPresentation { .. }
388 | RoutingPayload::Colorscheme { .. }
389 | RoutingPayload::LoadCommandLine { .. }
390 | RoutingPayload::LoadSearchLine { .. }
391 | RoutingPayload::BranchBase { .. }
392 | RoutingPayload::SuppliedValue { .. }
393 | RoutingPayload::Create { .. } => None,
394 }
395 }
396}
397
398/// Where a picker pulls its raw candidates from. The App resolves
399/// this on `populate` / `refresh` and walks the appropriate source.
400/// One enum variant per first-party source so the App stays
401/// decoupled from generator implementations; plugin-provided
402/// pickers will arrive as a separate `Plugin(GeneratorId)`
403/// variant once the WASM host is online.
404#[derive(Debug, Clone)]
405pub enum PickerSource {
406 /// Walk every entry in `BufferRegistry` -- the buffer
407 /// switcher (`:b` with no arg, future `<C-x>b`).
408 Buffers,
409 /// Walk the LSP supervisor's running actor table, one
410 /// candidate per `(workspace_root, server_id)` pair, with
411 /// workspace path + buffer count + capability summary as
412 /// marginalia. Used by `:lsp-log` / `:lsp-server-log` /
413 /// `:lsp-trace-log`. The `prefilter` carries an optional
414 /// `server_id` so `:lsp-log rust` shows only rust-* rows;
415 /// the picker still appears so the user can disambiguate
416 /// when multiple workspaces have a rust server.
417 LspInstances { prefilter: Option<String> },
418 /// Static location list -- multi-result LSP navigation
419 /// (`gd` / `gD` / `gy` / `gI`), `gr` references, and the
420 /// `:diagnostics` workspace list. Each row encodes one
421 /// `file:line:col` target; the typed
422 /// [`RoutingPayload::LspLocation`] carries `(path, line,
423 /// col)` to the accept dispatch. Display is
424 /// `<rel-path>:<line>:<col> <line preview>` so the user
425 /// sees a ripgrep-style row.
426 LspLocations,
427 /// Workspace file walk -- `:files` and `:recent`. Each row
428 /// is one filesystem path; accept hands it to
429 /// `App::do_edit`. Caller seeds the candidate list (the
430 /// picker stays renderer-agnostic).
431 Files,
432 /// Server-initiated `window/showMessageRequest`. Each row
433 /// is one `MessageActionItem` title from the request's
434 /// `actions` vec. `request_id` keys into the App's pending-
435 /// SMR map so the accept arm (and the dismiss path) can
436 /// locate the inbound oneshot and reply. `server_id` rides
437 /// for display + log breadcrumbs.
438 LspShowMessageRequest { request_id: u32, server_id: String },
439 /// Walk the AI subsystem's known agent sessions, one
440 /// candidate per `SessionKey` (provider + per-provider
441 /// index). Used by `:ai-log`. Mirrors [`Self::LspInstances`]
442 /// one level simpler — the `prefilter` carries an optional
443 /// provider name so `:ai-log opencode` shows only opencode
444 /// rows; the picker still appears so the user can
445 /// disambiguate when multiple indices exist for a provider.
446 AiSessions { prefilter: Option<String> },
447}
448
449impl PickerSource {
450 /// PH.1: the help page for a picker seated WITHOUT a registry id — the
451 /// peer of the `picker-<id>` convention for the imperative pickers, which
452 /// have no id to form it from.
453 ///
454 /// `Buffers` without an id is `:buffers` / `:b` — the transient menu seats
455 /// on `Buffers` too, but `do_picker_help` answers transients before it
456 /// gets here — so it shares `:picker buffers`' page. `Files` answers
457 /// `None`: only the trait path seats on it, and that path always carries
458 /// an id.
459 pub fn help_topic(&self) -> Option<&'static str> {
460 match self {
461 Self::Buffers => Some("picker-buffers"),
462 Self::LspLocations => Some("picker-lsp-locations"),
463 Self::LspInstances { .. } => Some("picker-lsp-instances"),
464 Self::AiSessions { .. } => Some("picker-ai-sessions"),
465 Self::LspShowMessageRequest { .. } => Some("picker-lsp-message-request"),
466 Self::Files => None,
467 }
468 }
469}
470
471/// What `<CR>` does to the selected candidate. Variants stay
472/// dumb data; the App's `App::accept_picker`
473/// dispatcher pattern-matches and calls the right method.
474#[derive(Debug, Clone, Copy)]
475pub enum PickerAction {
476 /// Selected candidate's `text` is `"#<id>"`; activate that
477 /// buffer in the current pane.
478 SwitchToBuffer,
479 /// Selected candidate's `text` is `"<server_id>\t<workspace>"`;
480 /// open `*lsp:<server_id>*` (the per-server log) in the
481 /// current pane via `App::open_help_in_pane`.
482 OpenLspLog,
483 /// Same encoding as `OpenLspLog`; opens
484 /// `*lsp:<server_id>:trace*` -- the trace ring view --
485 /// without flipping the trace toggle. Pair with `:lsp-trace
486 /// <server>` to actually start tracing.
487 OpenLspTraceLog,
488 /// Selected candidate carries [`RoutingPayload::AiSession`];
489 /// open `*ai:<provider>:<index>*` (the per-session AI log) in
490 /// the current pane via the host's
491 /// `ensure_named_synthetic_document` + `AiLogMode`. Emitted by
492 /// the `:ai-log` picker.
493 OpenAiLog,
494 /// Selected candidate's `text` is
495 /// `"<path>\t<line>\t<col>"` (LSP 0-based line + utf-8
496 /// byte column); jump to that location via the same
497 /// `jump_to_file_line` path the help-link click uses.
498 /// Used by multi-result `gd` / `gD` / `gy` / `gI`, by
499 /// `gr` references, and by `:diagnostics`.
500 JumpToLspLocation,
501 /// Selected candidate's `text` is `"#<idx>"` -- a numeric
502 /// index into the host's `pending_completion_items`
503 /// snapshot. The accept handler reads the item by index
504 /// and splices it into the buffer at the captured replace
505 /// range. Used by `:complete` (Phase 4.2.g).
506 AcceptLspCompletion,
507 /// Selected candidate's `text` is `"#<idx>"` -- a numeric
508 /// index into the host's `pending_code_action_items`
509 /// snapshot. Accept resolves the action (when needed)
510 /// then applies its WorkspaceEdit / executeCommand.
511 /// Used by `:code-actions` (Phase 4.3).
512 AcceptLspCodeAction,
513 /// Accept the focused row's
514 /// [`RoutingPayload::OpenFile`] -- pass the path to
515 /// `App::do_edit(Some(path), false)`. File picker
516 /// (`:files`) and recent-files picker share this action.
517 OpenFile,
518 /// Accept one `MessageActionItem` from a server-initiated
519 /// `window/showMessageRequest`. Routing payload is
520 /// [`RoutingPayload::AcceptShowMessageAction`]; dismiss
521 /// (Esc) replies `null` via the SMR-specific arm in
522 /// `do_picker_dismiss`.
523 AcceptShowMessageAction,
524 /// 4.5.d: accept one code lens from the
525 /// `:lsp-code-lens` picker. Routing payload is
526 /// [`RoutingPayload::LspCodeLens`]; the host's accept
527 /// dispatch resolves the lens (when needed) and routes
528 /// its `command` through `workspace/executeCommand` on
529 /// the originating server.
530 AcceptLspCodeLens,
531 /// 4.5.e: accept one color presentation from the
532 /// `:lsp-color-presentation` picker. Routing payload is
533 /// [`RoutingPayload::ColorPresentation`]; the host
534 /// splices the chosen alternative into the buffer.
535 AcceptColorPresentation,
536}
537
538/// Source of [`Picker::revision`] stamps.
539///
540/// Process-wide and monotonic so that two DIFFERENT pickers — which is what a
541/// live re-query produces, since seating builds a fresh one rather than
542/// mutating the open one — can never carry the same stamp. `Relaxed` is
543/// sufficient: nothing orders on this value, the host only ever asks "is it
544/// the same number as last publish", and both reads happen on the actor
545/// thread.
546fn next_picker_revision() -> u64 {
547 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
548 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
549}
550
551/// One open vertico-style picker. Lives on `App.picker` while
552/// active; the input and render layers route to / from it via
553/// the `Action::Picker*` family.
554#[derive(Debug, Clone)]
555pub struct Picker {
556 pub title: String,
557 /// PP.2: the root these results are scoped to, ready to display —
558 /// home-contracted (`~/src/lattice`) by whoever set it, because the
559 /// renderers have one line and the home prefix is the least informative
560 /// part of a path.
561 ///
562 /// `None` for every picker whose results are not root-scoped, which is
563 /// most of them: `buffers` spans every project you have open, `commands`
564 /// is registry-wide, `lines` is one buffer. A root on those is noise on
565 /// the one line the user reads to know what they are looking at.
566 ///
567 /// Set at seat time, from the seating source's
568 /// [`PickerSourceSpec::rooted`](source::PickerSourceSpec::rooted) — or
569 /// directly, for the LSP pickers, which are seated by hand and never had
570 /// a spec.
571 pub root_label: Option<String>,
572 pub query: String,
573 /// Byte offset within `query` where the cursor sits. Today
574 /// the picker only appends / backspaces at end-of-query so
575 /// this equals `query.len()`; reserved for future left/right
576 /// editing.
577 pub query_cursor: usize,
578 /// Candidates that pass the current query filter. Re-built
579 /// on every `refilter` call.
580 pub candidates: Vec<RenderedCandidate>,
581 /// Index into `candidates`. Clamped to `0..candidates.len()`
582 /// on every refilter.
583 pub selected: usize,
584 pub source: PickerSource,
585 pub on_accept: PickerAction,
586 /// Unfiltered candidate list snapshot. `refilter` walks this
587 /// against `query`; the host rebuilds it via
588 /// [`Self::set_raw_candidates`] (or
589 /// [`Self::set_lsp_instances`] for the LSP shape).
590 raw: Vec<RawCandidate>,
591 /// Typed routing payloads keyed by the candidate's
592 /// `Extension { kind_id, payload }` u32 LE index (Phase
593 /// 4.2.g.7 polish). Indexed lookup at accept time replaces
594 /// the prior tab-encoded string-parsing dispatch. Built
595 /// alongside `raw` by [`Self::set_raw_candidates_with_routing`];
596 /// the `text`-only constructor leaves it empty (legacy
597 /// pickers without typed routing fall back to the
598 /// `Plain`-data path -- not used today).
599 routing_meta: Vec<RoutingPayload>,
600 /// Picker-registry source id that seated this picker. `Some`
601 /// when the picker was seated via the trait-driven path
602 /// (`:picker <source>`); `None` for legacy imperative
603 /// pickers (`:b`, `:lsp-log`, multi-result LSP locations).
604 /// `do_picker_accept` reads this to decide whether to
605 /// delegate accept to the source's `PickerSourceGenerator`
606 /// or fall back to the legacy per-routing dispatch.
607 pub source_id: Option<String>,
608 /// Frecency bonus per candidate, parallel to
609 /// `routing_meta`. Snapshotted host-side at picker-open by
610 /// looking each candidate's `routing_identity` up in the
611 /// `PickerMruIndex`; refilter combines `match_score + bonus`
612 /// when ranking. Empty (slice 12 / non-trait pickers) means
613 /// "no MRU contribution"; the combine path treats it as 0.0
614 /// for every candidate.
615 mru_bonuses: Vec<f64>,
616 /// True when the seating source's `spec().live` is true
617 /// (`:picker grep` today). Live sources own their own
618 /// filtering -- the external program (grep, future LSP
619 /// workspace-symbols) IS the filter -- so [`Self::refilter`]
620 /// bypasses fuzzy matching and renders `raw` 1:1 in
621 /// insertion order. The host sets this via
622 /// [`Self::set_live_source_mode`] right after opening the
623 /// picker, before the first batch lands.
624 live_source_mode: bool,
625 /// Whether the query is read as a set of whitespace-separated
626 /// orderless components (`picker.orderless`, default on) or as a
627 /// single token. Mirrors the option at picker-open rather than
628 /// being read per keystroke: `lattice-picker` deliberately carries
629 /// no config dependency (the crate stays free of host types so the
630 /// off-thread guarantee is structural), and the value cannot change
631 /// under a picker that is already on screen.
632 orderless: bool,
633 /// True while an async fetch for this picker is in flight
634 /// (the initial grep on `:picker grep <pat>`, or a live
635 /// re-query after a keystroke). Both renderers surface it as
636 /// a `searching…` indicator in the prompt so a slow grep
637 /// reads as "working", not "nothing happened". The host sets
638 /// it when it spawns a fetch future and clears it when
639 /// results seat (a fresh `Picker` defaults to `false`) or the
640 /// fetch errors. See `seat_picker_from_pairs` /
641 /// `open_picker` / `fire_live_picker_query_changed`.
642 pub loading: bool,
643 /// Globally monotonic stamp, re-taken by [`Self::refilter`] — i.e. by every
644 /// change to `candidates`, because `refilter` is the only thing that writes
645 /// them and every `raw` assignment calls it.
646 ///
647 /// Exists for the host's paint gate. `compute_paint_revision` used to fold
648 /// in `picker.is_some()` and nothing else, on a comment that read "async
649 /// result GROWTH inside an open picker still rides a keystroke today; if
650 /// that changes, fold a content count here." PC.10's `<C-l>` / `<Tab>`
651 /// descend is when that changed: it re-queries the source off-keystroke, so
652 /// the new listing replaced `candidates` while the gate reported nothing
653 /// moved, `paint_request` never fired, and the rows on screen stayed the
654 /// ones from before the descend until the user typed.
655 ///
656 /// A stamp rather than hashing the rows: the gate runs on every publish and
657 /// a picker may hold tens of thousands of candidates (paramount #1). A
658 /// stamp rather than `candidates.len()`: a re-query returning the same
659 /// NUMBER of different rows is exactly what a length cannot see.
660 ///
661 /// **GLOBAL rather than per-picker**, which is the whole of why the first
662 /// attempt at this did not work. A live re-query does not mutate the open
663 /// picker — `seat_picker_from_pairs` builds a FRESH [`Picker`] and swaps it
664 /// in. A per-instance counter therefore read 1 both before and after the
665 /// descend, the hash did not move, and the gate was as blind as before. A
666 /// process-wide counter is unrepeatable by construction, so re-seating
667 /// cannot alias.
668 pub revision: u64,
669 /// Active transient-mode specification + live state. When
670 /// `Some`, the renderer switches to grouped section layout
671 /// with single-key chord dispatch, and the input layer routes
672 /// keystrokes through transient chord matching rather than
673 /// the query → filter path. See `transient.rs`.
674 pub transient: Option<std::sync::Arc<TransientSpec>>,
675 pub transient_state: TransientState,
676 /// Stack of parent transient specs for `BS`/`DEL` back
677 /// navigation through nested submenus. The `usize` is the
678 /// parent's `transient_selected` at the moment its submenu opened —
679 /// popping restores it instead of leaving the parent's selection
680 /// wherever the submenu happened to leave the shared field.
681 pub transient_stack: Vec<(std::sync::Arc<TransientSpec>, TransientState, usize)>,
682 /// Which of the transient's items `<C-n>` / `<C-p>` have walked to
683 /// — an index over [`TransientSpec::selectable_count`], NOT a
684 /// scroll offset.
685 ///
686 /// The distinction is the whole fix: the host can bound an item
687 /// index from the spec alone, whereas a scroll offset's true
688 /// maximum depends on a viewport height only the renderer knows.
689 /// The offset version grew unbounded and each renderer clamped it
690 /// privately at paint time, so the stored value drifted tens of
691 /// rows past anything renderable and `<C-p>` did nothing visible
692 /// until the overshoot had been walked back off. Renderers now
693 /// derive their scroll from this every frame
694 /// ([`TransientSpec::scroll_for`]), leaving no scroll state to
695 /// drift.
696 pub transient_selected: usize,
697 /// Keys typed at this level that begin some row's key but do not
698 /// complete one yet — magit's `, k` / `, r` / `= f` rows.
699 ///
700 /// Empty whenever no multi-key row is part-way typed, which is
701 /// almost always. The host used to compare a single typed `char`
702 /// against each row's key string, so a multi-key row rendered,
703 /// could be walked to with `<C-n>` and fired with `<CR>`, and did
704 /// nothing at all when its own keys were pressed.
705 pub transient_prefix: String,
706 /// MG.54: when this picker's DEFERRED preview is due.
707 ///
708 /// Set only for a source that declares
709 /// [`PickerSourceGenerator::preview_debounce`](source::PickerSourceGenerator::preview_debounce)
710 /// — a source whose preview costs real work, which the settle window
711 /// lets the host skip entirely while the selection is moving.
712 ///
713 /// **It lives on the picker, not on the host.** A deadline is only
714 /// ever meaningful for the selection that armed it, so tying its
715 /// lifetime to the picker's makes "a settle fired into the picker
716 /// that replaced mine" unrepresentable rather than something two
717 /// host-side clear sites have to remember. Dismiss drops the picker
718 /// and the deadline goes with it.
719 preview_settle_until: Option<std::time::Instant>,
720 /// OR.5: the label of this picker's synthetic **create** row, copied from
721 /// the seating source's `PickerSourceSpec::create_label`. `None` for every
722 /// source that declares none, which is every source but roam's — and that
723 /// `None` is the regression guard: no label, no row, nothing about the
724 /// existing pickers changes.
725 ///
726 /// `%s` in the label is replaced by the query when the row is rendered.
727 create_label: Option<String>,
728 /// OR.5: the routing payload for the create row, rebuilt on every
729 /// [`Self::refilter`] because it carries the live query.
730 ///
731 /// Held rather than derived in [`Self::routing_for`] because that returns a
732 /// borrow, and a payload built on the fly has nothing to borrow from.
733 create_routing: Option<RoutingPayload>,
734}
735
736impl Picker {
737 pub fn new(title: impl Into<String>, source: PickerSource, on_accept: PickerAction) -> Self {
738 Self {
739 title: title.into(),
740 root_label: None,
741 query: String::new(),
742 query_cursor: 0,
743 candidates: Vec::new(),
744 selected: 0,
745 source,
746 on_accept,
747 raw: Vec::new(),
748 routing_meta: Vec::new(),
749 source_id: None,
750 mru_bonuses: Vec::new(),
751 loading: false,
752 revision: next_picker_revision(),
753 live_source_mode: false,
754 orderless: true,
755 transient: None,
756 transient_state: TransientState::new(),
757 transient_stack: Vec::new(),
758 transient_selected: 0,
759 transient_prefix: String::new(),
760 preview_settle_until: None,
761 create_label: None,
762 create_routing: None,
763 }
764 }
765
766 /// OR.5: declare that this picker offers to create what the query names.
767 ///
768 /// Called by the host at seat time from the seating source's
769 /// `PickerSourceSpec::create_label`. `%s` in `label` is replaced by the
770 /// query on every render.
771 pub fn set_create_label(&mut self, label: Option<String>) {
772 self.create_label = label;
773 self.refilter();
774 }
775
776 /// OR.5: this picker's create label, if it has one.
777 pub fn create_label(&self) -> Option<&str> {
778 self.create_label.as_deref()
779 }
780
781 /// MG.54: (re)start this picker's preview settle window.
782 ///
783 /// Called on every selection move for a source that declares a
784 /// window. Each call pushes the deadline out, so a burst of moves
785 /// leaves exactly one due time — the moment the user stopped.
786 ///
787 /// The host still schedules the wake (it owns the runtime), but the
788 /// policy and the state are the picker's, so every picker — first
789 /// party, plugin, one written next year — gets the same behaviour
790 /// from declaring the window alone.
791 pub fn arm_preview_settle(&mut self, delay: std::time::Duration) {
792 self.preview_settle_until = Some(std::time::Instant::now() + delay);
793 }
794
795 /// MG.54: has the settle window elapsed? Consumes the deadline when
796 /// it has, so the preview runs once per settle no matter how many
797 /// wakes arrive — a burst of N moves schedules N wakes, and the N-1
798 /// superseded ones find the deadline still in the future.
799 pub fn take_due_preview_settle(&mut self, now: std::time::Instant) -> bool {
800 match self.preview_settle_until {
801 Some(deadline) if now >= deadline => {
802 self.preview_settle_until = None;
803 true
804 }
805 _ => false,
806 }
807 }
808
809 /// MG.54: whether a deferred preview is waiting on this picker.
810 /// Read by tests and by the host's "is anything pending" checks; the
811 /// deadline itself is deliberately not exposed.
812 pub fn preview_settle_pending(&self) -> bool {
813 self.preview_settle_until.is_some()
814 }
815
816 /// Unwind one level of transient state: a half-typed multi-key row
817 /// first, then one submenu off the stack.
818 ///
819 /// Returns `false` when there was nothing to unwind — the caller
820 /// (`<Esc>` / `BS`) then means "close", because this is the root
821 /// menu with no pending keys.
822 ///
823 /// **The precedence is the point.** A part-typed row (`,` waiting
824 /// for `k`) is what the key most likely means to undo, so it goes
825 /// first; only once nothing is pending does the same key leave the
826 /// menu you are in. Vim gives `<Esc>` the same precedence over a
827 /// partial chord.
828 ///
829 /// Lives here rather than in the host's dispatch arm because `BS`
830 /// and `<Esc>` both need it, and two copies of a precedence rule
831 /// drift.
832 /// LR.5: the routing payloads of the candidates that survived the
833 /// current query — the FILTERED set, which is what `<C-q>` means.
834 /// Sending the unfiltered list would discard the work the user just
835 /// did typing a query (telescope's `send_to_qflist` semantics).
836 pub fn filtered_routing(&self) -> Vec<&RoutingPayload> {
837 self.candidates
838 .iter()
839 .filter_map(|c| self.routing_for(c))
840 .collect()
841 }
842
843 /// PE.2: the filtered candidates paired with their routing payload AND
844 /// the visible row text (`display`) — the peer of
845 /// [`Self::filtered_routing`] for a send that wants to carry the matched
846 /// line as the error-list entry's message, not just its coordinates.
847 ///
848 /// `<C-q>`'s result should read the way the rows looked in the picker
849 /// (the grep match text, the symbol name, the reference preview), so the
850 /// error list and `*problems*` are a column of meaningful lines rather
851 /// than bare `file:line`s the user must jump to one by one to tell apart.
852 pub fn filtered_entries(&self) -> Vec<(&str, &RoutingPayload)> {
853 self.candidates
854 .iter()
855 .filter_map(|c| self.routing_for(c).map(|r| (c.raw.display.as_str(), r)))
856 .collect()
857 }
858
859 pub fn transient_unwind(&mut self) -> bool {
860 if self.transient.is_none() {
861 return false;
862 }
863 if !self.transient_prefix.is_empty() {
864 self.transient_prefix.clear();
865 return true;
866 }
867 match self.transient_stack.pop() {
868 Some((parent_spec, parent_state, parent_selected)) => {
869 self.transient = Some(parent_spec);
870 self.transient_state = parent_state;
871 self.transient_selected = parent_selected;
872 true
873 }
874 None => false,
875 }
876 }
877
878 /// Walk the transient's selection one item forward, wrapping at the
879 /// end — the same wrap [`Self::select_next`] gives the candidate
880 /// list, and the reason `<C-n>` can no longer overshoot: the index
881 /// is taken modulo the item count, so there is no out-of-range
882 /// value to represent.
883 ///
884 /// No-op when no transient is open or it has no items.
885 pub fn transient_select_next(&mut self) {
886 if let Some(count) = self.transient_item_count() {
887 self.transient_selected = (self.transient_selected + 1) % count;
888 }
889 }
890
891 /// The peer of [`Self::transient_select_next`], wrapping the other
892 /// way.
893 pub fn transient_select_prev(&mut self) {
894 if let Some(count) = self.transient_item_count() {
895 self.transient_selected = if self.transient_selected == 0 {
896 count - 1
897 } else {
898 self.transient_selected - 1
899 };
900 }
901 }
902
903 /// The open transient's item count, or `None` when there is no
904 /// transient or it is empty — the guard both walkers share so
905 /// neither can divide by zero or index an empty menu.
906 fn transient_item_count(&self) -> Option<usize> {
907 self.transient
908 .as_ref()
909 .map(|spec| spec.selectable_count())
910 .filter(|c| *c > 0)
911 }
912
913 /// The item `<CR>` would fire — the selection resolved against the
914 /// open transient. `None` when there is no transient, it is empty,
915 /// or the selection is somehow past its end.
916 pub fn transient_selected_item(&self) -> Option<&TransientItem> {
917 self.transient.as_ref()?.item_at(self.transient_selected)
918 }
919
920 /// Toggle live-source mode (`spec().live == true`). When
921 /// on, [`Self::refilter`] renders `raw` verbatim instead
922 /// of running fuzzy matching. The host calls this once
923 /// at picker-open time after consulting the source's
924 /// spec; the flag stays on for the picker's lifetime.
925 pub fn set_live_source_mode(&mut self, live: bool) {
926 self.live_source_mode = live;
927 }
928
929 /// Query accessor for the host (and tests) -- mirrors the
930 /// other state predicates.
931 pub fn is_live_source_mode(&self) -> bool {
932 self.live_source_mode
933 }
934
935 /// Mirror the `picker.orderless` option onto this picker. The host
936 /// calls this once at picker-open time, alongside
937 /// [`Self::set_live_source_mode`]; the flag stays put for the
938 /// picker's lifetime so the result list cannot change semantics
939 /// underneath a user who is mid-query.
940 pub fn set_orderless(&mut self, orderless: bool) {
941 self.orderless = orderless;
942 }
943
944 /// Whether this picker splits its query into orderless components.
945 pub fn is_orderless(&self) -> bool {
946 self.orderless
947 }
948
949 /// Stamp the parallel `mru_bonuses` vec the matcher reads
950 /// during refilter. Must be called after
951 /// [`Self::set_raw_candidates_with_routing`] and must match
952 /// `routing_meta.len()` in length; mismatched lengths reset
953 /// to zero-bonus so the picker stays in a sane state if a
954 /// caller miscounts (mostly a guardrail for tests).
955 ///
956 /// Callers that already have both pairs + bonuses in hand
957 /// should prefer
958 /// [`Self::set_raw_candidates_with_routing_and_bonuses`] --
959 /// it sets all three vecs and refilters once, vs. this
960 /// path which leaves a wasted refilter behind from the
961 /// preceding `set_raw_candidates_with_routing` call.
962 pub fn set_mru_bonuses(&mut self, bonuses: Vec<f64>) {
963 if bonuses.len() != self.routing_meta.len() {
964 self.mru_bonuses = vec![0.0; self.routing_meta.len()];
965 } else {
966 self.mru_bonuses = bonuses;
967 }
968 self.refilter();
969 }
970
971 /// Single-pass seat: mutate `raw` + `routing_meta` +
972 /// `mru_bonuses` and refilter exactly once. The
973 /// fast-path replacement for the
974 /// `set_raw_candidates_with_routing` + `set_mru_bonuses`
975 /// pair the host's trait-driven seat path used before
976 /// this method existed -- which refiltered twice, with
977 /// the first pass entirely wasted because the bonuses
978 /// were about to replace the same data.
979 ///
980 /// Mismatched-length bonuses zero out (same guardrail as
981 /// [`Self::set_mru_bonuses`]). Legacy callers that don't
982 /// have bonuses yet keep using `set_raw_candidates_with_routing`
983 /// + `set_mru_bonuses`; the new method is opt-in.
984 pub fn set_raw_candidates_with_routing_and_bonuses(
985 &mut self,
986 items: Vec<(RawCandidate, RoutingPayload)>,
987 bonuses: Vec<f64>,
988 ) {
989 let mut raw: Vec<RawCandidate> = Vec::with_capacity(items.len());
990 let mut routing: Vec<RoutingPayload> = Vec::with_capacity(items.len());
991 for (mut cand, payload) in items {
992 let idx = routing.len() as u32;
993 cand.data = CandidateData::Extension {
994 kind_id: PICKER_ROUTING_KIND_ID,
995 payload: idx.to_le_bytes().to_vec(),
996 };
997 raw.push(cand);
998 routing.push(payload);
999 }
1000 let bonuses = if bonuses.len() == raw.len() {
1001 bonuses
1002 } else {
1003 vec![0.0; raw.len()]
1004 };
1005 self.raw = raw;
1006 self.routing_meta = routing;
1007 self.mru_bonuses = bonuses;
1008 self.refilter();
1009 }
1010
1011 /// Borrow the candidate's MRU bonus by its routing-payload
1012 /// index. Returns 0.0 for candidates without a registered
1013 /// bonus (slice 12 pickers, legacy LSP pickers, anything
1014 /// pre-MRU-snapshot). Public so the refilter path can read
1015 /// it; not intended for downstream callers.
1016 pub fn mru_bonus_for(&self, candidate: &RenderedCandidate) -> f64 {
1017 let CandidateData::Extension { kind_id, payload } = &candidate.raw.data else {
1018 return 0.0;
1019 };
1020 if *kind_id != PICKER_ROUTING_KIND_ID {
1021 return 0.0;
1022 }
1023 if payload.len() != 4 {
1024 return 0.0;
1025 }
1026 let idx = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
1027 self.mru_bonuses.get(idx).copied().unwrap_or(0.0)
1028 }
1029
1030 /// Replace the raw candidate list. Host-built (e.g. the TUI
1031 /// host walks `BufferRegistry` for the buffer switcher);
1032 /// picker just stores + refilters. The single mutation entry
1033 /// point: every other "set the candidates" helper (e.g.
1034 /// [`Self::set_lsp_instances`]) routes through this.
1035 pub fn set_raw_candidates(&mut self, raw: Vec<RawCandidate>) {
1036 self.raw = raw;
1037 self.routing_meta.clear();
1038 self.refilter();
1039 }
1040
1041 /// Replace the raw candidate list AND the typed routing
1042 /// sidecar (Phase 4.2.g.7 polish). Each input pair is a
1043 /// `(RawCandidate, RoutingPayload)` -- the picker stores
1044 /// the payload at index `i` in `routing_meta` and stamps
1045 /// the candidate's `data` with `Extension { kind_id:
1046 /// PICKER_ROUTING_KIND_ID, payload: i.to_le_bytes() }`. The
1047 /// accept dispatch reads the index back, indexes the
1048 /// sidecar, and matches on the typed enum variant. Replaces
1049 /// the prior `text`-tab-encoded string-parsing path.
1050 pub fn set_raw_candidates_with_routing(&mut self, items: Vec<(RawCandidate, RoutingPayload)>) {
1051 let mut raw: Vec<RawCandidate> = Vec::with_capacity(items.len());
1052 let mut routing: Vec<RoutingPayload> = Vec::with_capacity(items.len());
1053 for (mut cand, payload) in items {
1054 let idx = routing.len() as u32;
1055 cand.data = CandidateData::Extension {
1056 kind_id: PICKER_ROUTING_KIND_ID,
1057 payload: idx.to_le_bytes().to_vec(),
1058 };
1059 raw.push(cand);
1060 routing.push(payload);
1061 }
1062 self.raw = raw;
1063 self.routing_meta = routing;
1064 self.refilter();
1065 }
1066
1067 /// Look up the routing payload for `candidate` -- returns
1068 /// `None` for candidates that don't carry a picker-routing
1069 /// `Extension` payload (defensive; the picker only ever
1070 /// builds candidates through [`Self::set_raw_candidates_with_routing`]
1071 /// in the new world). Used by the accept dispatch.
1072 pub fn routing_for(&self, candidate: &RenderedCandidate) -> Option<&RoutingPayload> {
1073 let CandidateData::Extension { kind_id, payload } = &candidate.raw.data else {
1074 return None;
1075 };
1076 // OR.5: the create row carries the LIVE query rather than an index into
1077 // the seat-time sidecar, so it resolves from its own slot.
1078 if *kind_id == PICKER_CREATE_KIND_ID {
1079 return self.create_routing.as_ref();
1080 }
1081 if *kind_id != PICKER_ROUTING_KIND_ID {
1082 return None;
1083 }
1084 if payload.len() != 4 {
1085 return None;
1086 }
1087 let idx = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
1088 self.routing_meta.get(idx)
1089 }
1090
1091 /// Replace the raw candidate list with externally-built LSP
1092 /// location rows -- multi-result navigation, references,
1093 /// diagnostics. Caller builds + sorts + dedups the `Vec`
1094 /// host-side; the picker just stores + refilters.
1095 pub fn set_lsp_locations(&mut self, rows: Vec<LspLocationRow>) {
1096 let items: Vec<(RawCandidate, RoutingPayload)> = rows
1097 .into_iter()
1098 .map(|r| r.into_candidate_with_routing())
1099 .collect();
1100 self.set_raw_candidates_with_routing(items);
1101 }
1102
1103 /// Replace the raw candidate list with externally-built LSP
1104 /// instance rows. Caller (`App::open_lsp_picker`) snapshots the
1105 /// supervisor under its lock and hands the resulting tuples
1106 /// here. Refreshes the filter.
1107 ///
1108 /// Slice 15: stamps each candidate's `accept_action` based
1109 /// on the picker's `on_accept` (OpenLspLog vs
1110 /// OpenLspTraceLog) so the typed dispatch (7d.0) fires
1111 /// instead of the legacy PickerAction match.
1112 pub fn set_lsp_instances(&mut self, rows: Vec<LspInstanceRow>) {
1113 let prefilter = match &self.source {
1114 PickerSource::LspInstances { prefilter } => prefilter.clone(),
1115 _ => None,
1116 };
1117 let on_accept = self.on_accept;
1118 let items: Vec<(RawCandidate, RoutingPayload)> = rows
1119 .into_iter()
1120 .filter(|r| match &prefilter {
1121 Some(want) => r.server_id == *want,
1122 None => true,
1123 })
1124 .map(|r| {
1125 let (mut raw, routing) = r.into_candidate_with_routing();
1126 if let RoutingPayload::LspInstance {
1127 ref server_id,
1128 ref workspace,
1129 } = routing
1130 {
1131 raw.accept_action = match on_accept {
1132 PickerAction::OpenLspLog => {
1133 Some(Box::new(lattice_completion::AcceptAction::OpenLspLog {
1134 server_id: server_id.clone(),
1135 workspace: workspace.clone(),
1136 }))
1137 }
1138 PickerAction::OpenLspTraceLog => Some(Box::new(
1139 lattice_completion::AcceptAction::OpenLspTraceLog {
1140 server_id: server_id.clone(),
1141 workspace: workspace.clone(),
1142 },
1143 )),
1144 _ => None,
1145 };
1146 }
1147 (raw, routing)
1148 })
1149 .collect();
1150 self.set_raw_candidates_with_routing(items);
1151 }
1152
1153 /// Replace the raw candidate list with externally-built AI
1154 /// session rows. Caller (`App::do_open_ai_log`) snapshots the
1155 /// `AiLogger` service's `known_sessions()` and hands the rows
1156 /// here. Honors the source's optional `provider` prefilter.
1157 /// Mirrors [`Self::set_lsp_instances`], minus the
1158 /// `accept_action` stamping (the AI picker rides the legacy
1159 /// `RoutingPayload::AiSession` accept dispatch).
1160 pub fn set_ai_sessions(&mut self, rows: Vec<AiSessionRow>) {
1161 let prefilter = match &self.source {
1162 PickerSource::AiSessions { prefilter } => prefilter.clone(),
1163 _ => None,
1164 };
1165 let items: Vec<(RawCandidate, RoutingPayload)> = rows
1166 .into_iter()
1167 .filter(|r| match &prefilter {
1168 Some(want) => r.provider == *want,
1169 None => true,
1170 })
1171 .map(|r| r.into_candidate_with_routing())
1172 .collect();
1173 self.set_raw_candidates_with_routing(items);
1174 }
1175
1176 /// Filter `raw` against the current `query` and write the
1177 /// matches into `candidates`. Routes through
1178 /// [`lattice_completion::fuzzy_match`] -- the same 5-tier
1179 /// algorithm Insert-mode completion uses (exact / prefix /
1180 /// word-boundary / substring / subsequence). Picker rows
1181 /// match against `display` because their `text` field
1182 /// often carries a routing payload (e.g.
1183 /// `"<server_id>\t<workspace>"`) the user never sees.
1184 /// Empty query yields a uniform score so every candidate
1185 /// passes through; the rust stdlib's stable sort preserves
1186 /// the host-supplied insertion order on ties (callers like
1187 /// the buffer switcher depend on this -- alternate-buffer
1188 /// floats to the top via insertion order).
1189 pub fn refilter(&mut self) {
1190 // Re-stamped HERE, at the one entry point, rather than beside each of
1191 // the three `candidates` writes below: `refilter` has an early return,
1192 // so per-write stamps would be three chances to add a fourth write and
1193 // forget. The consumer is the host's paint gate — see
1194 // [`Self::revision`].
1195 self.revision = next_picker_revision();
1196 self.refilter_rows();
1197 }
1198
1199 /// The filtering itself. Split from [`Self::refilter`] so the revision bump
1200 /// cannot be bypassed by an early return inside it.
1201 fn refilter_rows(&mut self) {
1202 // Live-source bypass: the seating source's external
1203 // engine (grep, future LSP workspace-symbols) IS the
1204 // filter -- it returned exactly the rows that match
1205 // the user's query, in the order it wants them. Fuzzy-
1206 // matching on top would re-rank or drop rows, defeating
1207 // the point. Render `raw` 1:1, score = 0, no match
1208 // ranges (the renderer can highlight via a future
1209 // source-supplied annotation channel; not in v1).
1210 if self.live_source_mode {
1211 self.candidates = self
1212 .raw
1213 .iter()
1214 .cloned()
1215 .map(|raw| RenderedCandidate {
1216 raw,
1217 score: MatchScore(0),
1218 match_ranges: Vec::new(),
1219 annotations: Vec::new(),
1220 })
1221 .collect();
1222 self.push_create_row();
1223 if self.selected >= self.candidates.len() {
1224 self.selected = self.candidates.len().saturating_sub(1);
1225 }
1226 return;
1227 }
1228 // Slice `3c.unify.picker-via-pipeline`: picker filter +
1229 // rank now flows through `CompletionPipeline::match_and_rank`,
1230 // the shared match+rank entry point in `lattice-completion`.
1231 //
1232 // Picker-specific pipeline shape:
1233 // - matcher: `OrderlessDisplayMatcher` (or
1234 // `FuzzyDisplayMatcher` when `picker.orderless=false`) —
1235 // picker rows match on `display` (user-visible label), not
1236 // `text` (which carries the routing payload). The two
1237 // agree exactly on a query with no whitespace; orderless
1238 // only diverges once the user types a space, so the
1239 // option's cost is confined to multi-word queries.
1240 // - rankers: `MruRanker` capturing the picker's bonus
1241 // map via the same index-in-CandidateData encoding the
1242 // prior inline path used. The ranker subsumes the
1243 // "sort by `score + bonus` descending" logic.
1244 // - generators: empty (picker pre-supplies `raw`).
1245 // - annotators: empty (picker has no annotations today;
1246 // slice 6 plumbs marginalia in).
1247 //
1248 // Score = match (0..1000) + mru_bonus (0..~110 typical).
1249 // Bonus sits below the tier delta between match tiers
1250 // (200 between FUZZY_LOW and SUBSTRING) so it functions
1251 // as a within-tier tie-breaker rather than a tier
1252 // override.
1253 let bonuses = self.mru_bonuses.clone();
1254 let matcher: std::sync::Arc<dyn lattice_completion::CandidateMatcher> = if self.orderless {
1255 std::sync::Arc::new(OrderlessDisplayMatcher)
1256 } else {
1257 std::sync::Arc::new(FuzzyDisplayMatcher)
1258 };
1259 let pipeline = CompletionPipeline {
1260 generators: Vec::new(),
1261 matcher,
1262 rankers: vec![std::sync::Arc::new(MruRanker::new(move |raw| {
1263 bonus_for_raw(raw, &bonuses)
1264 }))],
1265 annotators: Vec::new(),
1266 };
1267 self.candidates = pipeline.match_and_rank(&self.query, &self.raw);
1268 self.push_create_row();
1269 if self.selected >= self.candidates.len() {
1270 self.selected = self.candidates.len().saturating_sub(1);
1271 }
1272 }
1273
1274 /// OR.5: append the synthetic **create** row, if this picker has one and the
1275 /// query is non-empty.
1276 ///
1277 /// Two decisions here, both load-bearing and neither obvious.
1278 ///
1279 /// **Present whenever the query is non-empty, not only when nothing
1280 /// matches.** Offering it only on zero matches makes it impossible to create
1281 /// a note called *Rust* while a note called *Rust Async* exists — which is
1282 /// precisely when you most want to, because the general note is the one you
1283 /// write after the specific one.
1284 ///
1285 /// **Pinned last, never ranked.** It is pushed AFTER `match_and_rank` and
1286 /// never enters the pipeline, so no scoring accident can float it above a
1287 /// real match. If it could sort above one, `<CR>` on a query that has a
1288 /// match would sometimes create a duplicate — a destructive outcome caused
1289 /// by ranking noise. Pinned last means creating is always a deliberate
1290 /// `<C-n>` past the real answers.
1291 fn push_create_row(&mut self) {
1292 let Some(label) = self.create_label.clone() else {
1293 self.create_routing = None;
1294 return;
1295 };
1296 if self.query.is_empty() {
1297 self.create_routing = None;
1298 return;
1299 }
1300 let display = label.replace("%s", &self.query);
1301 let mut raw = RawCandidate::plain(self.query.clone(), CandidateKind::Plain);
1302 raw.display = display;
1303 raw.data = CandidateData::Extension {
1304 kind_id: PICKER_CREATE_KIND_ID,
1305 // No index: the payload is the live query, held in `create_routing`
1306 // rather than in the seat-time sidecar.
1307 payload: Vec::new(),
1308 };
1309 self.candidates.push(RenderedCandidate {
1310 raw,
1311 // Zero rather than a high score, because the row's position comes
1312 // from being pushed last and not from outranking anything. A score
1313 // that competed would be a second, contradictory answer to "where
1314 // does this go".
1315 score: MatchScore(0),
1316 match_ranges: Vec::new(),
1317 annotations: Vec::new(),
1318 });
1319 self.create_routing = Some(RoutingPayload::Create {
1320 query: self.query.clone(),
1321 });
1322 }
1323
1324 pub fn append_query(&mut self, c: char) {
1325 self.query.push(c);
1326 self.query_cursor = self.query.len();
1327 self.selected = 0;
1328 self.refilter();
1329 }
1330
1331 /// Append a whole pasted burst to the query.
1332 ///
1333 /// **Newlines are flattened to spaces rather than dropped or
1334 /// honoured.** The query is a single line, so a multi-line paste has
1335 /// to become one — and joining with nothing would weld the last word
1336 /// of each line to the first of the next (`foo.rs` + `bar.rs` →
1337 /// `foo.rsbar.rs`), which matches nothing and looks like the paste
1338 /// was corrupted. Other control characters are dropped: they cannot
1339 /// be typed into the query, so they cannot be intended in it, and a
1340 /// stray `\t` or `\r` from a terminal round-trip would silently make
1341 /// the filter match nothing.
1342 ///
1343 /// Returns `false` when the burst contributes nothing, so the caller
1344 /// can skip the refilter and the preview.
1345 pub fn paste_query(&mut self, text: &str) -> bool {
1346 let cleaned: String = text
1347 .chars()
1348 .filter_map(|c| match c {
1349 '\n' | '\r' | '\t' => Some(' '),
1350 c if c.is_control() => None,
1351 c => Some(c),
1352 })
1353 .collect();
1354 if cleaned.is_empty() {
1355 return false;
1356 }
1357 self.query.push_str(&cleaned);
1358 self.query_cursor = self.query.len();
1359 self.selected = 0;
1360 self.refilter();
1361 true
1362 }
1363
1364 pub fn backspace_query(&mut self) {
1365 if let Some(last) = self.query.chars().last() {
1366 let new_len = self.query.len() - last.len_utf8();
1367 self.query.truncate(new_len);
1368 self.query_cursor = self.query.len();
1369 self.selected = 0;
1370 self.refilter();
1371 }
1372 }
1373
1374 /// PH.1: `<C-w>` — vim's `c_CTRL-W`. Drop trailing whitespace, then the
1375 /// run of characters of the class before it: a word (alphanumerics and
1376 /// `_`) or a run of punctuation. `foo bar` → `foo `; `src/lib` → `src/`.
1377 ///
1378 /// Returns whether anything was deleted, so the host can skip the
1379 /// re-query tail on an empty query.
1380 pub fn delete_word_backward(&mut self) -> bool {
1381 let is_word = |c: char| c.is_alphanumeric() || c == '_';
1382 let trimmed = self.query.trim_end_matches(char::is_whitespace);
1383 let cut = match trimmed.chars().last() {
1384 None => 0,
1385 Some(last) => {
1386 let class = is_word(last);
1387 trimmed
1388 .char_indices()
1389 .rev()
1390 .find(|&(_, c)| c.is_whitespace() || is_word(c) != class)
1391 .map_or(0, |(i, c)| i + c.len_utf8())
1392 }
1393 };
1394 if cut == self.query.len() {
1395 return false;
1396 }
1397 self.query.truncate(cut);
1398 self.query_cursor = self.query.len();
1399 self.selected = 0;
1400 self.refilter();
1401 true
1402 }
1403
1404 pub fn clear_query(&mut self) {
1405 self.query.clear();
1406 self.query_cursor = 0;
1407 self.selected = 0;
1408 self.refilter();
1409 }
1410
1411 pub fn select_next(&mut self) {
1412 if !self.candidates.is_empty() {
1413 self.selected = (self.selected + 1) % self.candidates.len();
1414 }
1415 }
1416
1417 pub fn select_prev(&mut self) {
1418 if !self.candidates.is_empty() {
1419 self.selected = if self.selected == 0 {
1420 self.candidates.len() - 1
1421 } else {
1422 self.selected - 1
1423 };
1424 }
1425 }
1426
1427 pub fn selected_candidate(&self) -> Option<&RenderedCandidate> {
1428 self.candidates.get(self.selected)
1429 }
1430}
1431
1432/// Resolve the MRU bonus stamped on `raw` against the parallel
1433/// `bonuses` slice. Returns 0.0 for candidates without an MRU
1434/// routing payload (non-picker `RawCandidate`s, or pickers
1435/// seated before the bonuses vec was populated). Pure helper
1436/// so the hot-path refilter inlines it.
1437fn bonus_for_raw(raw: &RawCandidate, bonuses: &[f64]) -> f64 {
1438 let CandidateData::Extension { kind_id, payload } = &raw.data else {
1439 return 0.0;
1440 };
1441 if *kind_id != PICKER_ROUTING_KIND_ID {
1442 return 0.0;
1443 }
1444 if payload.len() != 4 {
1445 return 0.0;
1446 }
1447 let idx = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
1448 bonuses.get(idx).copied().unwrap_or(0.0)
1449}
1450
1451/// One row of the LSP-instance source. The picker host (App)
1452/// snapshots this from `LspSupervisor::running_actors()` under
1453/// the supervisor lock, then drops the lock before handing the
1454/// vec to the picker. Decouples the picker module from the
1455/// supervisor's async `Mutex`.
1456#[derive(Debug, Clone)]
1457pub struct LspInstanceRow {
1458 pub workspace: PathBuf,
1459 pub server_id: String,
1460 pub buffer_count: usize,
1461 /// One-line capability summary -- `hover def refs comp`-style
1462 /// glyph cluster. The host (e.g. `lattice-ui-tui`'s
1463 /// `summarise_capabilities`) builds this; we just hold a
1464 /// string.
1465 pub cap_summary: String,
1466}
1467
1468impl LspInstanceRow {
1469 /// Render this row as a `(RawCandidate, RoutingPayload)`
1470 /// pair (Phase 4.2.g.7 polish). The candidate's `text`
1471 /// holds the user-facing server id (the matcher matches
1472 /// against `display`, so `text`'s value is observational
1473 /// only); the routing payload carries the typed
1474 /// `(server_id, workspace)` pair the accept dispatch reads.
1475 pub fn into_candidate_with_routing(self) -> (RawCandidate, RoutingPayload) {
1476 let workspace_str = self.workspace.display().to_string();
1477 let mut raw = RawCandidate::plain(
1478 self.server_id.clone(),
1479 lattice_completion::CandidateKind::Plain,
1480 );
1481 let marginalia = format!(
1482 "{} buf{} {}",
1483 self.buffer_count,
1484 if self.buffer_count == 1 { "" } else { "s" },
1485 self.cap_summary,
1486 );
1487 let body = format!("{:<20} {workspace_str}", self.server_id);
1488 raw.display = format!("{body:<70} {marginalia}");
1489 let routing = RoutingPayload::LspInstance {
1490 server_id: self.server_id,
1491 workspace: self.workspace,
1492 };
1493 (raw, routing)
1494 }
1495}
1496
1497/// One row of the AI-session source. The picker host (App)
1498/// snapshots this from the `AiLogger` service's `known_sessions()`
1499/// (no async lock — the logger's ring map is a sync `Mutex`), then
1500/// hands the vec to the picker. Mirrors [`LspInstanceRow`], one
1501/// level simpler: a session has no workspace / buffer-count /
1502/// capability marginalia, only its `(provider, index)` key.
1503#[derive(Debug, Clone)]
1504pub struct AiSessionRow {
1505 pub provider: String,
1506 pub index: u32,
1507}
1508
1509impl AiSessionRow {
1510 /// Render this row as a `(RawCandidate, RoutingPayload)` pair.
1511 /// The candidate's `display` is the user-facing
1512 /// `<provider>:<index>` (matching the `*ai:<provider>:<index>*`
1513 /// buffer name body); the typed [`RoutingPayload::AiSession`]
1514 /// carries the `(provider, index)` key the accept dispatch
1515 /// reconstructs into a `SessionKey`.
1516 pub fn into_candidate_with_routing(self) -> (RawCandidate, RoutingPayload) {
1517 let label = format!("{}:{}", self.provider, self.index);
1518 let mut raw = RawCandidate::plain(label.clone(), lattice_completion::CandidateKind::Plain);
1519 raw.display = label;
1520 let routing = RoutingPayload::AiSession {
1521 provider: self.provider,
1522 index: self.index,
1523 };
1524 (raw, routing)
1525 }
1526}
1527
1528/// One row of an LSP-location source -- multi-result navigation,
1529/// references, diagnostics. Carries the canonical
1530/// `(path, line, col)` triple the host needs to jump (LSP 0-based
1531/// line, utf-8 byte column already converted host-side) plus the
1532/// presentation pieces.
1533///
1534/// `display` is what the picker paints (e.g.
1535/// `src/foo.rs:42:7 let bar = ...`); the typed
1536/// [`RoutingPayload::LspLocation`] carries the `(path, line,
1537/// col)` triple to the accept dispatch. `marginalia` is
1538/// optional and renders right-aligned (e.g. severity `[E]` for
1539/// diagnostics, kind `[fn]` for symbols once we add
1540/// documentSymbol picker).
1541#[derive(Debug, Clone)]
1542pub struct LspLocationRow {
1543 pub path: PathBuf,
1544 /// LSP 0-based line.
1545 pub line: u32,
1546 /// utf-8 byte column.
1547 pub col: u32,
1548 /// Optional preview text (e.g. the line content from the file)
1549 /// to append after the `path:line:col` prefix. Empty string
1550 /// is fine -- only the prefix renders.
1551 pub preview: String,
1552 /// Right-aligned annotation. Empty string skips the column.
1553 pub marginalia: String,
1554 /// Syntax-highlight spans for `preview.trim_start()`, i.e. relative to
1555 /// the preview text as it appears at the tail of the rendered `display`
1556 /// (NOT yet offset by the `path:line:col` prefix). The host fills these
1557 /// grep-style (grammar by file extension); `into_candidate_with_routing`
1558 /// shifts them by the prefix length. Empty = plain preview. PH.2/PH.3
1559 /// mechanism, extended to LSP location pickers.
1560 pub display_spans: Vec<lattice_completion::DisplaySpan>,
1561}
1562
1563impl LspLocationRow {
1564 /// Build a row from a fully-resolved location triple. Reads
1565 /// the line text from disk best-effort (callers may also
1566 /// pre-populate `preview`).
1567 pub fn from_path_line_col(path: impl Into<PathBuf>, line: u32, col: u32) -> Self {
1568 Self {
1569 path: path.into(),
1570 line,
1571 col,
1572 preview: String::new(),
1573 marginalia: String::new(),
1574 display_spans: Vec::new(),
1575 }
1576 }
1577
1578 /// Render as a `(RawCandidate, RoutingPayload)` pair (Phase
1579 /// 4.2.g.7 polish). `text` carries the user-visible
1580 /// `path:line:col` form (matcher matches on `display`, so
1581 /// `text` is observational); the routing payload carries
1582 /// the typed `(path, line, col)` triple the jump dispatch
1583 /// consumes.
1584 pub fn into_candidate_with_routing(self) -> (RawCandidate, RoutingPayload) {
1585 let path_str = self.path.display().to_string();
1586 // 1-based line / col in the display; LSP 0-based line +
1587 // utf-8 byte column ride in the routing payload.
1588 let display = if self.preview.is_empty() && self.marginalia.is_empty() {
1589 format!("{path_str}:{}:{}", self.line + 1, self.col + 1)
1590 } else if self.marginalia.is_empty() {
1591 format!(
1592 "{path_str}:{}:{} {}",
1593 self.line + 1,
1594 self.col + 1,
1595 self.preview.trim_start()
1596 )
1597 } else {
1598 format!(
1599 "{} {path_str}:{}:{} {}",
1600 self.marginalia,
1601 self.line + 1,
1602 self.col + 1,
1603 self.preview.trim_start()
1604 )
1605 };
1606 let mut raw = RawCandidate::plain(
1607 format!("{path_str}:{}:{}", self.line + 1, self.col + 1),
1608 lattice_completion::CandidateKind::Plain,
1609 );
1610 raw.display = display;
1611 // PH.2/PH.3: the host highlighted `preview.trim_start()` grep-style
1612 // (grammar by file extension). Those spans are relative to the
1613 // preview; shift them by the length of the rendered prefix
1614 // (`[marginalia ]path:line:col `) so they land on the preview run
1615 // inside `display`, then attach. Empty when unhighlighted (no
1616 // grammar / no preview) → plain row, exactly as before.
1617 if !self.display_spans.is_empty() {
1618 let prefix_len = raw
1619 .display
1620 .len()
1621 .saturating_sub(self.preview.trim_start().len());
1622 raw.display_spans = self
1623 .display_spans
1624 .iter()
1625 .map(|s| lattice_completion::DisplaySpan {
1626 range: (s.range.start + prefix_len)..(s.range.end + prefix_len),
1627 style: s.style,
1628 })
1629 .collect();
1630 }
1631 // Slice 10: typed accept_action so LSP locations
1632 // (references / definitions / declaration / type-defs /
1633 // implementations / diagnostics) flow through 7d.0's
1634 // DefaultAcceptHandler dispatch + 7g's typed preview.
1635 // Closes the LSP-references-preview gap the user flagged
1636 // (2026-05-21) — every LSP location picker now previews
1637 // the file at the reference's line, not buffer-switcher
1638 // only.
1639 raw.accept_action = Some(Box::new(
1640 lattice_completion::AcceptAction::JumpToFileLocation {
1641 path: self.path.clone(),
1642 line: self.line,
1643 col: self.col,
1644 },
1645 ));
1646 let routing = RoutingPayload::LspLocation {
1647 path: self.path,
1648 line: self.line,
1649 col: self.col,
1650 };
1651 (raw, routing)
1652 }
1653}
1654
1655#[cfg(test)]
1656mod tests {
1657
1658 #![allow(clippy::unwrap_used, clippy::panic)]
1659
1660 use super::*;
1661 use lattice_completion::CandidateKind;
1662
1663 /// A `BufferPathResolver` that answers every buffer with a fixed path
1664 /// (or `None`, standing for an unsaved / synthetic buffer).
1665 struct FixedResolver(Option<PathBuf>);
1666 impl BufferPathResolver for FixedResolver {
1667 fn path_for_buffer(&self, _buffer_id: u32) -> Option<PathBuf> {
1668 self.0.clone()
1669 }
1670 }
1671
1672 /// The regression that motivated payload-owned translation: a
1673 /// `FileLocation` (what plugin pickers emit) used to fall through the
1674 /// host's wildcard and send nothing. It must now yield its location.
1675 #[test]
1676 fn error_location_maps_file_location() {
1677 let none = FixedResolver(None);
1678 let loc = RoutingPayload::FileLocation {
1679 path: PathBuf::from("/tmp/a.rs"),
1680 line: 7,
1681 col: 3,
1682 }
1683 .error_location(&none);
1684 assert_eq!(
1685 loc,
1686 Some(ErrorLocation {
1687 path: PathBuf::from("/tmp/a.rs"),
1688 line: 7,
1689 col: 3,
1690 })
1691 );
1692 }
1693
1694 #[test]
1695 fn error_location_maps_lsp_and_open_file() {
1696 let none = FixedResolver(None);
1697 assert_eq!(
1698 RoutingPayload::LspLocation {
1699 path: PathBuf::from("/tmp/b.rs"),
1700 line: 4,
1701 col: 1,
1702 }
1703 .error_location(&none),
1704 Some(ErrorLocation {
1705 path: PathBuf::from("/tmp/b.rs"),
1706 line: 4,
1707 col: 1,
1708 })
1709 );
1710 // A path with no recorded position lands at the file's top.
1711 assert_eq!(
1712 RoutingPayload::OpenFile {
1713 path: PathBuf::from("/tmp/c.rs"),
1714 }
1715 .error_location(&none),
1716 Some(ErrorLocation {
1717 path: PathBuf::from("/tmp/c.rs"),
1718 line: 0,
1719 col: 0,
1720 })
1721 );
1722 }
1723
1724 /// The one buffer-relative variant leans on the host resolver: a
1725 /// file-backed buffer yields a location, an unsaved buffer yields
1726 /// `None` so it is skipped rather than pointed at a phantom path.
1727 #[test]
1728 fn error_location_resolves_jump_in_buffer_via_resolver() {
1729 let backed = FixedResolver(Some(PathBuf::from("/tmp/d.rs")));
1730 let unsaved = FixedResolver(None);
1731 let row = || RoutingPayload::JumpInBuffer {
1732 buffer_id: 42,
1733 line: 9,
1734 col: 5,
1735 };
1736 assert_eq!(
1737 row().error_location(&backed),
1738 Some(ErrorLocation {
1739 path: PathBuf::from("/tmp/d.rs"),
1740 line: 9,
1741 col: 5,
1742 })
1743 );
1744 assert_eq!(row().error_location(&unsaved), None);
1745 }
1746
1747 /// Rows that stand for an action or a value, not a place, have no
1748 /// location — `<C-q>` skips them.
1749 #[test]
1750 fn error_location_is_none_for_non_locations() {
1751 let none = FixedResolver(None);
1752 assert_eq!(
1753 RoutingPayload::Colorscheme {
1754 name: "dawn".into(),
1755 }
1756 .error_location(&none),
1757 None
1758 );
1759 assert_eq!(
1760 RoutingPayload::PasteRegister { name: 'a' }.error_location(&none),
1761 None
1762 );
1763 assert_eq!(RoutingPayload::Buffer { id: 3 }.error_location(&none), None);
1764 }
1765
1766 fn unwind_spec(title: &str) -> std::sync::Arc<TransientSpec> {
1767 std::sync::Arc::new(TransientSpec {
1768 title: title.into(),
1769 groups: Vec::new(),
1770 preview: None,
1771 footer: None,
1772 })
1773 }
1774
1775 fn unwind_picker() -> Picker {
1776 let mut p = Picker::new("t", PickerSource::Buffers, PickerAction::SwitchToBuffer);
1777 p.transient = Some(unwind_spec("child"));
1778 p
1779 }
1780
1781 /// MG.29: `<Esc>` in a submenu goes back to its parent, and only
1782 /// closes once there is no parent left. Exiting all the way out on
1783 /// the first press punishes the ordinary mistake — opening the
1784 /// wrong submenu.
1785 #[test]
1786 fn unwinding_pops_one_level_at_a_time() {
1787 let mut p = unwind_picker();
1788 p.transient_stack
1789 .push((unwind_spec("root"), TransientState::new(), 3));
1790
1791 assert!(p.transient_unwind(), "there is a parent to go back to");
1792 assert_eq!(
1793 p.transient.as_ref().map(|s| s.title.clone()),
1794 Some("root".into())
1795 );
1796 assert_eq!(
1797 p.transient_selected, 3,
1798 "the parent's selection comes back where the user left it"
1799 );
1800
1801 assert!(
1802 !p.transient_unwind(),
1803 "at the root there is nothing left to unwind — the caller closes"
1804 );
1805 }
1806
1807 /// A half-typed multi-key row is what the key most likely means to
1808 /// undo, so it goes first — the same precedence vim gives `<Esc>`
1809 /// over a partial chord.
1810 #[test]
1811 fn a_pending_prefix_is_undone_before_the_stack() {
1812 let mut p = unwind_picker();
1813 p.transient_stack
1814 .push((unwind_spec("root"), TransientState::new(), 0));
1815 p.transient_prefix = ",".into();
1816
1817 assert!(p.transient_unwind());
1818 assert!(p.transient_prefix.is_empty(), "the prefix cleared");
1819 assert_eq!(
1820 p.transient.as_ref().map(|s| s.title.clone()),
1821 Some("child".into()),
1822 "and the submenu is still open — one key, one undo"
1823 );
1824
1825 assert!(p.transient_unwind(), "the next press pops the stack");
1826 assert_eq!(
1827 p.transient.as_ref().map(|s| s.title.clone()),
1828 Some("root".into())
1829 );
1830 }
1831
1832 #[test]
1833 fn a_picker_with_no_transient_never_claims_to_unwind() {
1834 let mut p = Picker::new("t", PickerSource::Buffers, PickerAction::SwitchToBuffer);
1835 assert!(!p.transient_unwind());
1836 }
1837
1838 /// LSP-picker highlighting: the host highlights the *trimmed* preview
1839 /// grep-style, so spans are preview-relative; `into_candidate_with_routing`
1840 /// must shift them past the `path:line:col` prefix so they land on the
1841 /// preview run within the rendered `display`.
1842 #[test]
1843 fn lsp_location_preview_spans_shift_past_prefix() {
1844 let mut row = LspLocationRow::from_path_line_col("src/main.rs", 4, 8);
1845 row.preview = " let x = 1;".to_string(); // leading ws → trim_start drops it
1846 let trimmed = "let x = 1;";
1847 row.display_spans = vec![lattice_completion::DisplaySpan {
1848 range: 0..3, // "let" within the trimmed preview
1849 style: lattice_cells::style::Style::Keyword,
1850 }];
1851
1852 let (raw, _routing) = row.into_candidate_with_routing();
1853
1854 assert!(
1855 raw.display.ends_with(trimmed),
1856 "display = {:?}",
1857 raw.display
1858 );
1859 let prefix = raw.display.len() - trimmed.len();
1860 assert_eq!(raw.display_spans.len(), 1);
1861 let span = &raw.display_spans[0];
1862 assert_eq!(span.range, prefix..prefix + 3, "span shifted by the prefix");
1863 assert_eq!(
1864 &raw.display[span.range.clone()],
1865 "let",
1866 "the shifted span must land exactly on the keyword in `display`"
1867 );
1868 assert_eq!(span.style, lattice_cells::style::Style::Keyword);
1869 }
1870
1871 /// A row the host didn't highlight (no grammar / message preview / symbol
1872 /// label) renders exactly as before — no spans, plain candidate.
1873 #[test]
1874 fn lsp_location_without_spans_stays_plain() {
1875 let mut row = LspLocationRow::from_path_line_col("notes.txt", 0, 0);
1876 row.preview = "just prose".to_string();
1877 let (raw, _routing) = row.into_candidate_with_routing();
1878 assert!(
1879 raw.display_spans.is_empty(),
1880 "unhighlighted row stays plain"
1881 );
1882 }
1883
1884 /// Build a buffer-source-shaped raw candidate by hand. Mirrors
1885 /// the host's `raw_buffer_candidates` shape (`text = "#<id>"`,
1886 /// display ends with the kind label) without depending on the
1887 /// host's `BufferRegistry`.
1888 fn buffer_candidate(id: u32, label: &str, kind: &str, current: bool) -> RawCandidate {
1889 let active_marker = if current { " (current)" } else { "" };
1890 let mut raw = RawCandidate::plain(format!("#{id}"), CandidateKind::Buffer);
1891 raw.display = format!("#{id:<3} {label:<55} {kind}{active_marker}");
1892 raw
1893 }
1894
1895 fn buffer_fixture() -> Vec<RawCandidate> {
1896 vec![
1897 buffer_candidate(1, "lsp:rust", "help", false),
1898 buffer_candidate(2, "describe-command write", "help", false),
1899 ]
1900 }
1901
1902 #[test]
1903 fn live_source_mode_bypasses_fuzzy_refilter() {
1904 // Slice 1: when the seating source's spec sets
1905 // `live = true`, the host calls `set_live_source_mode`
1906 // and the picker renders `raw` verbatim regardless of
1907 // the query. The source (grep, future live LSP) IS
1908 // the filter -- fuzzy-matching on top would re-rank or
1909 // drop rows the source said matched.
1910 let mut p = Picker::new("grep", PickerSource::Files, PickerAction::OpenFile);
1911 p.set_live_source_mode(true);
1912 p.set_raw_candidates(buffer_fixture());
1913 // Two candidates, neither matches the query, but both
1914 // render anyway (bypass).
1915 p.append_query('z');
1916 p.append_query('z');
1917 assert_eq!(p.candidates.len(), 2, "live mode must not drop rows");
1918 assert_eq!(
1919 p.candidates[0].raw.display,
1920 buffer_fixture()[0].display,
1921 "live mode must preserve source order"
1922 );
1923 }
1924
1925 #[test]
1926 fn live_source_mode_off_keeps_existing_fuzzy_behaviour() {
1927 // Regression for non-live sources: same setup minus the
1928 // live flag must still filter by query (the existing
1929 // path through `typing_query_filters_to_substring_matches`,
1930 // pinned again here as a paired counter-example to the
1931 // live-mode test above).
1932 let mut p = Picker::new(
1933 "buffers",
1934 PickerSource::Buffers,
1935 PickerAction::SwitchToBuffer,
1936 );
1937 assert!(!p.is_live_source_mode());
1938 p.set_raw_candidates(buffer_fixture());
1939 p.append_query('z');
1940 p.append_query('z');
1941 assert_eq!(p.candidates.len(), 0, "non-live mode must filter");
1942 }
1943
1944 // ---- orderless query matching ----
1945
1946 /// File-shaped fixture: the orderless case that matters is a path
1947 /// whose memorable fragments sit in the "wrong" order relative to
1948 /// how the user recalls them.
1949 fn path_fixture() -> Vec<RawCandidate> {
1950 [
1951 "crates/lattice-picker/src/refilter.rs",
1952 "crates/lattice-completion/src/orderless.rs",
1953 "crates/lattice-picker/tests/refilter_test.rs",
1954 "docs/dev/architecture/picker.md",
1955 ]
1956 .into_iter()
1957 .map(|p| {
1958 let mut raw = RawCandidate::plain(p, CandidateKind::Plain);
1959 raw.display = p.to_string();
1960 raw
1961 })
1962 .collect()
1963 }
1964
1965 fn path_picker(query: &str) -> Picker {
1966 let mut p = Picker::new("files", PickerSource::Files, PickerAction::OpenFile);
1967 p.set_raw_candidates(path_fixture());
1968 for c in query.chars() {
1969 p.append_query(c);
1970 }
1971 p
1972 }
1973
1974 fn displays(p: &Picker) -> Vec<&str> {
1975 p.candidates
1976 .iter()
1977 .map(|c| c.raw.display.as_str())
1978 .collect()
1979 }
1980
1981 /// The headline behaviour: two fragments, either order, matching a
1982 /// path that contains neither as a contiguous run.
1983 #[test]
1984 fn a_space_separated_query_matches_components_in_any_order() {
1985 for query in ["pick refil", "refil pick"] {
1986 let p = path_picker(query);
1987 assert!(
1988 displays(&p).contains(&"crates/lattice-picker/src/refilter.rs"),
1989 "query {query:?} should reach refilter.rs, got {:?}",
1990 displays(&p)
1991 );
1992 }
1993 }
1994
1995 /// Before orderless the same query matched nothing, because the
1996 /// whole string had to appear contiguously. This is the regression
1997 /// that would silently undo the feature.
1998 #[test]
1999 fn a_space_separated_query_is_not_matched_as_one_literal_token() {
2000 let mut p = Picker::new("files", PickerSource::Files, PickerAction::OpenFile);
2001 p.set_orderless(false);
2002 p.set_raw_candidates(path_fixture());
2003 for c in "pick refil".chars() {
2004 p.append_query(c);
2005 }
2006 assert!(
2007 p.candidates.is_empty(),
2008 "with orderless off the query is one token and matches nothing"
2009 );
2010 }
2011
2012 #[test]
2013 fn a_negated_component_excludes_matching_rows() {
2014 let p = path_picker("refilter !test");
2015 assert_eq!(displays(&p), vec!["crates/lattice-picker/src/refilter.rs"]);
2016 }
2017
2018 /// A single-token query must behave exactly as it did before
2019 /// orderless landed — same rows, same order. Orderless is only
2020 /// allowed to change what happens after a space is typed.
2021 #[test]
2022 fn a_single_token_query_is_unchanged_by_orderless() {
2023 let with = path_picker("picker");
2024 let mut without = Picker::new("files", PickerSource::Files, PickerAction::OpenFile);
2025 without.set_orderless(false);
2026 without.set_raw_candidates(path_fixture());
2027 for c in "picker".chars() {
2028 without.append_query(c);
2029 }
2030 assert_eq!(displays(&with), displays(&without));
2031 }
2032
2033 /// A trailing space is what the user has typed halfway through a
2034 /// second component. The list must not blank out under them.
2035 #[test]
2036 fn a_trailing_space_does_not_empty_the_list() {
2037 let p = path_picker("picker ");
2038 assert!(
2039 !p.candidates.is_empty(),
2040 "mid-typing whitespace must be inert, not a filter"
2041 );
2042 }
2043
2044 #[test]
2045 fn empty_query_returns_all_candidates_in_source_order() {
2046 let mut p = Picker::new(
2047 "buffers",
2048 PickerSource::Buffers,
2049 PickerAction::SwitchToBuffer,
2050 );
2051 p.set_raw_candidates(buffer_fixture());
2052 assert_eq!(p.candidates.len(), 2);
2053 }
2054
2055 #[test]
2056 fn typing_query_filters_to_substring_matches() {
2057 let mut p = Picker::new(
2058 "buffers",
2059 PickerSource::Buffers,
2060 PickerAction::SwitchToBuffer,
2061 );
2062 p.set_raw_candidates(buffer_fixture());
2063 p.append_query('r');
2064 p.append_query('u');
2065 p.append_query('s');
2066 p.append_query('t');
2067 // Only the lsp:rust buffer matches "rust".
2068 assert_eq!(p.candidates.len(), 1);
2069 assert!(p.candidates[0].raw.display.contains("lsp:rust"));
2070 }
2071
2072 #[test]
2073 fn case_insensitive_substring_match() {
2074 let mut p = Picker::new(
2075 "buffers",
2076 PickerSource::Buffers,
2077 PickerAction::SwitchToBuffer,
2078 );
2079 p.set_raw_candidates(buffer_fixture());
2080 p.append_query('R');
2081 p.append_query('U');
2082 p.append_query('S');
2083 p.append_query('T');
2084 assert_eq!(p.candidates.len(), 1);
2085 }
2086
2087 #[test]
2088 fn selection_wraps_at_boundaries() {
2089 let mut p = Picker::new(
2090 "buffers",
2091 PickerSource::Buffers,
2092 PickerAction::SwitchToBuffer,
2093 );
2094 p.set_raw_candidates(buffer_fixture());
2095 p.select_prev(); // wraps to last
2096 assert_eq!(p.selected, 1);
2097 p.select_next(); // wraps back to 0
2098 assert_eq!(p.selected, 0);
2099 }
2100
2101 /// PH.1: `c_CTRL-W`'s classes, checked against vim 9.2 on the `:` line.
2102 #[test]
2103 fn delete_word_backward_follows_vim_ctrl_w() {
2104 let mut p = Picker::new("t", PickerSource::Buffers, PickerAction::SwitchToBuffer);
2105 let mut after = |q: &str| {
2106 p.query = q.to_string();
2107 let deleted = p.delete_word_backward();
2108 (p.query.clone(), deleted)
2109 };
2110 assert_eq!(after("foo bar"), ("foo ".into(), true));
2111 assert_eq!(
2112 after("foo bar "),
2113 ("foo ".into(), true),
2114 "trailing blanks go with the word"
2115 );
2116 assert_eq!(
2117 after("src/lib"),
2118 ("src/".into(), true),
2119 "a word stops at punctuation"
2120 );
2121 assert_eq!(
2122 after("src/"),
2123 ("src".into(), true),
2124 "a punctuation run is its own word"
2125 );
2126 assert_eq!(after("a::b::"), ("a::b".into(), true));
2127 assert_eq!(
2128 after("héllo wörld"),
2129 ("héllo ".into(), true),
2130 "non-ASCII letters are word chars"
2131 );
2132 assert_eq!(after("snake_case"), ("".into(), true), "`_` is a word char");
2133 assert_eq!(after(" "), ("".into(), true));
2134 assert_eq!(
2135 after(""),
2136 ("".into(), false),
2137 "nothing to delete reports it"
2138 );
2139 }
2140
2141 #[test]
2142 fn backspace_repopulates_filter_results() {
2143 let mut p = Picker::new(
2144 "buffers",
2145 PickerSource::Buffers,
2146 PickerAction::SwitchToBuffer,
2147 );
2148 p.set_raw_candidates(buffer_fixture());
2149 p.append_query('r');
2150 p.append_query('u');
2151 assert_eq!(p.candidates.len(), 1);
2152 p.backspace_query();
2153 p.backspace_query();
2154 assert_eq!(p.candidates.len(), 2);
2155 }
2156
2157 #[test]
2158 fn routing_for_buffer_returns_typed_payload() {
2159 // Build the candidates with typed routing the same way
2160 // the App's `raw_buffer_candidates_with_routing` does:
2161 // each (RawCandidate, RoutingPayload::Buffer { id }) pair.
2162 let pairs: Vec<(RawCandidate, RoutingPayload)> = vec![
2163 (
2164 buffer_candidate(1, "lsp:rust", "help", false),
2165 RoutingPayload::Buffer { id: 1 },
2166 ),
2167 (
2168 buffer_candidate(2, "describe-command write", "help", false),
2169 RoutingPayload::Buffer { id: 2 },
2170 ),
2171 ];
2172 let mut p = Picker::new(
2173 "buffers",
2174 PickerSource::Buffers,
2175 PickerAction::SwitchToBuffer,
2176 );
2177 p.set_raw_candidates_with_routing(pairs);
2178 let c = p.selected_candidate().expect("first candidate");
2179 match p.routing_for(c) {
2180 Some(RoutingPayload::Buffer { id }) => assert_eq!(*id, 1),
2181 other => panic!("expected Buffer routing, got {other:?}"),
2182 }
2183 }
2184
2185 // ---- OR.5: the create row ----
2186
2187 /// A picker seeded with two candidates and a create label.
2188 fn create_picker() -> Picker {
2189 let pairs: Vec<(RawCandidate, RoutingPayload)> = vec![
2190 (
2191 RawCandidate::plain("Rust Async", CandidateKind::Plain),
2192 RoutingPayload::Buffer { id: 1 },
2193 ),
2194 (
2195 RawCandidate::plain("Rust Macros", CandidateKind::Plain),
2196 RoutingPayload::Buffer { id: 2 },
2197 ),
2198 ];
2199 let mut p = Picker::new("nodes", PickerSource::Buffers, PickerAction::SwitchToBuffer);
2200 p.set_raw_candidates_with_routing(pairs);
2201 p.set_create_label(Some("Create note: %s".to_string()));
2202 p
2203 }
2204
2205 fn create_rows(p: &Picker) -> Vec<String> {
2206 p.candidates.iter().map(|c| c.raw.display.clone()).collect()
2207 }
2208
2209 /// An empty query has nothing to create, so there is no row to offer.
2210 #[test]
2211 fn no_create_row_on_an_empty_query() {
2212 let p = create_picker();
2213 assert_eq!(create_rows(&p), vec!["Rust Async", "Rust Macros"]);
2214 }
2215
2216 /// **Present WITH matches, not only on zero matches.** Offering it only when
2217 /// nothing matched makes it impossible to create a note called *Rust* while
2218 /// *Rust Async* exists — which is precisely when you most want to, because
2219 /// the general note is the one you write after the specific one.
2220 #[test]
2221 fn the_create_row_is_present_even_when_the_query_matches() {
2222 let mut p = create_picker();
2223 for c in "Rust".chars() {
2224 p.append_query(c);
2225 }
2226 let rows = create_rows(&p);
2227 assert!(
2228 rows.len() > 1,
2229 "real matches survived alongside the offer: {rows:?}"
2230 );
2231 assert!(
2232 rows.contains(&"Create note: Rust".to_string()),
2233 "the offer is there anyway: {rows:?}"
2234 );
2235 }
2236
2237 #[test]
2238 fn the_create_row_is_present_when_nothing_matches() {
2239 let mut p = create_picker();
2240 for c in "Zettelkasten".chars() {
2241 p.append_query(c);
2242 }
2243 assert_eq!(create_rows(&p), vec!["Create note: Zettelkasten"]);
2244 }
2245
2246 /// **Pinned last, whatever the query and whatever matched.** If it could
2247 /// sort above a real match, `<CR>` on a query that HAS a match would
2248 /// sometimes create a duplicate — a destructive outcome caused by ranking
2249 /// noise rather than by anything the user did.
2250 #[test]
2251 fn the_create_row_is_always_last() {
2252 for query in ["R", "Rust", "Rust A", "Rust Async", "zzz", " "] {
2253 let mut p = create_picker();
2254 for c in query.chars() {
2255 p.append_query(c);
2256 }
2257 let rows = create_rows(&p);
2258 assert_eq!(
2259 rows.last().map(|s| s.as_str()),
2260 Some(format!("Create note: {query}").as_str()),
2261 "create is last for query {query:?}: {rows:?}"
2262 );
2263 }
2264 }
2265
2266 /// An exact match on the top candidate is the hardest case for "pinned
2267 /// last": the create row and a perfect match are both maximally relevant by
2268 /// any scoring story, and only the pin decides.
2269 #[test]
2270 fn an_exact_match_still_outranks_the_create_row() {
2271 let mut p = create_picker();
2272 for c in "Rust Async".chars() {
2273 p.append_query(c);
2274 }
2275 let rows = create_rows(&p);
2276 assert_eq!(rows.first().map(|s| s.as_str()), Some("Rust Async"));
2277 assert_eq!(
2278 rows.last().map(|s| s.as_str()),
2279 Some("Create note: Rust Async")
2280 );
2281 }
2282
2283 /// The query crosses verbatim — spaces and non-ASCII included. The source
2284 /// is creating something the USER named, so the picker must not have an
2285 /// opinion about a namespace it does not own.
2286 #[test]
2287 fn the_query_reaches_the_routing_payload_verbatim() {
2288 let mut p = create_picker();
2289 for c in " Ünïcode note ".chars() {
2290 p.append_query(c);
2291 }
2292 let row = p.candidates.last().expect("the create row");
2293 match p.routing_for(row) {
2294 Some(RoutingPayload::Create { query }) => {
2295 assert_eq!(query, " Ünïcode note ");
2296 }
2297 other => panic!("expected Create routing, got {other:?}"),
2298 }
2299 }
2300
2301 /// **The regression guard for every picker that existed before this slice.**
2302 /// A source that declares no label behaves exactly as it did.
2303 #[test]
2304 fn a_source_with_no_label_is_unchanged() {
2305 let pairs: Vec<(RawCandidate, RoutingPayload)> = vec![(
2306 RawCandidate::plain("Rust Async", CandidateKind::Plain),
2307 RoutingPayload::Buffer { id: 1 },
2308 )];
2309 let mut p = Picker::new("nodes", PickerSource::Buffers, PickerAction::SwitchToBuffer);
2310 p.set_raw_candidates_with_routing(pairs);
2311 for c in "Rust".chars() {
2312 p.append_query(c);
2313 }
2314 assert_eq!(create_rows(&p), vec!["Rust Async"]);
2315 for c in "zzz".chars() {
2316 p.append_query(c);
2317 }
2318 assert!(
2319 create_rows(&p).is_empty(),
2320 "and no rows when nothing matches"
2321 );
2322 }
2323
2324 /// Backspacing back to an empty query retracts the offer, rather than
2325 /// leaving a stale row offering to create the empty string.
2326 #[test]
2327 fn the_create_row_retracts_when_the_query_is_cleared() {
2328 let mut p = create_picker();
2329 for c in "Ru".chars() {
2330 p.append_query(c);
2331 }
2332 assert!(
2333 create_rows(&p)
2334 .iter()
2335 .any(|r| r.starts_with("Create note:"))
2336 );
2337 p.backspace_query();
2338 p.backspace_query();
2339 assert_eq!(create_rows(&p), vec!["Rust Async", "Rust Macros"]);
2340 assert!(
2341 p.routing_for(p.candidates.last().unwrap()).is_some(),
2342 "and the remaining rows still resolve their own routing"
2343 );
2344 }
2345
2346 #[test]
2347 fn lsp_instances_source_filters_to_named_server_when_prefilter_set() {
2348 let rows = vec![
2349 LspInstanceRow {
2350 workspace: PathBuf::from("/proj/a"),
2351 server_id: "rust".into(),
2352 buffer_count: 2,
2353 cap_summary: "hover def".into(),
2354 },
2355 LspInstanceRow {
2356 workspace: PathBuf::from("/proj/b"),
2357 server_id: "rust".into(),
2358 buffer_count: 1,
2359 cap_summary: "hover def refs".into(),
2360 },
2361 LspInstanceRow {
2362 workspace: PathBuf::from("/proj/c"),
2363 server_id: "pyright".into(),
2364 buffer_count: 1,
2365 cap_summary: "hover".into(),
2366 },
2367 ];
2368 let mut p = Picker::new(
2369 "lsp",
2370 PickerSource::LspInstances {
2371 prefilter: Some("rust".into()),
2372 },
2373 PickerAction::OpenLspLog,
2374 );
2375 p.set_lsp_instances(rows);
2376 // Only the two rust rows survive the prefilter.
2377 assert_eq!(p.candidates.len(), 2);
2378 for c in &p.candidates {
2379 match p.routing_for(c) {
2380 Some(RoutingPayload::LspInstance { server_id, .. }) => {
2381 assert_eq!(server_id, "rust");
2382 }
2383 other => panic!("expected LspInstance routing, got {other:?}"),
2384 }
2385 }
2386 }
2387
2388 #[test]
2389 fn lsp_instances_source_no_prefilter_includes_all() {
2390 let rows = vec![
2391 LspInstanceRow {
2392 workspace: PathBuf::from("/proj/a"),
2393 server_id: "rust".into(),
2394 buffer_count: 2,
2395 cap_summary: "hover".into(),
2396 },
2397 LspInstanceRow {
2398 workspace: PathBuf::from("/proj/b"),
2399 server_id: "pyright".into(),
2400 buffer_count: 1,
2401 cap_summary: "hover".into(),
2402 },
2403 ];
2404 let mut p = Picker::new(
2405 "lsp",
2406 PickerSource::LspInstances { prefilter: None },
2407 PickerAction::OpenLspLog,
2408 );
2409 p.set_lsp_instances(rows);
2410 assert_eq!(p.candidates.len(), 2);
2411 }
2412
2413 #[test]
2414 fn routing_for_lsp_instance_returns_typed_payload() {
2415 let rows = vec![LspInstanceRow {
2416 workspace: PathBuf::from("/proj/example"),
2417 server_id: "rust".into(),
2418 buffer_count: 1,
2419 cap_summary: "hover".into(),
2420 }];
2421 let mut p = Picker::new(
2422 "lsp",
2423 PickerSource::LspInstances { prefilter: None },
2424 PickerAction::OpenLspLog,
2425 );
2426 p.set_lsp_instances(rows);
2427 let c = p.selected_candidate().expect("first candidate");
2428 match p.routing_for(c) {
2429 Some(RoutingPayload::LspInstance {
2430 server_id,
2431 workspace,
2432 }) => {
2433 assert_eq!(server_id, "rust");
2434 assert_eq!(*workspace, PathBuf::from("/proj/example"));
2435 }
2436 other => panic!("expected LspInstance routing, got {other:?}"),
2437 }
2438 }
2439
2440 #[test]
2441 fn routing_for_ai_session_returns_typed_payload() {
2442 let rows = vec![
2443 AiSessionRow {
2444 provider: "opencode".into(),
2445 index: 1,
2446 },
2447 AiSessionRow {
2448 provider: "opencode".into(),
2449 index: 2,
2450 },
2451 ];
2452 let mut p = Picker::new(
2453 "ai-log",
2454 PickerSource::AiSessions { prefilter: None },
2455 PickerAction::OpenAiLog,
2456 );
2457 p.set_ai_sessions(rows);
2458 assert_eq!(p.candidates.len(), 2);
2459 let c = p.selected_candidate().expect("first candidate");
2460 match p.routing_for(c) {
2461 Some(RoutingPayload::AiSession { provider, index }) => {
2462 assert_eq!(provider, "opencode");
2463 assert_eq!(*index, 1);
2464 }
2465 other => panic!("expected AiSession routing, got {other:?}"),
2466 }
2467 }
2468
2469 #[test]
2470 fn ai_session_prefilter_narrows_by_provider() {
2471 let rows = vec![
2472 AiSessionRow {
2473 provider: "opencode".into(),
2474 index: 1,
2475 },
2476 AiSessionRow {
2477 provider: "claude".into(),
2478 index: 1,
2479 },
2480 ];
2481 let mut p = Picker::new(
2482 "ai-log",
2483 PickerSource::AiSessions {
2484 prefilter: Some("opencode".to_string()),
2485 },
2486 PickerAction::OpenAiLog,
2487 );
2488 p.set_ai_sessions(rows);
2489 assert_eq!(p.candidates.len(), 1);
2490 }
2491
2492 #[test]
2493 fn selected_candidate_is_none_when_filter_empties_list() {
2494 let mut p = Picker::new(
2495 "buffers",
2496 PickerSource::Buffers,
2497 PickerAction::SwitchToBuffer,
2498 );
2499 p.set_raw_candidates(buffer_fixture());
2500 p.append_query('z'); // matches nothing
2501 p.append_query('z');
2502 p.append_query('z');
2503 assert!(p.candidates.is_empty());
2504 assert!(p.selected_candidate().is_none());
2505 }
2506
2507 /// Slice 14a: MRU bonuses tilt ranking within a match
2508 /// tier. Two candidates with equal match scores -- the one
2509 /// with the higher bonus floats to the top.
2510 #[test]
2511 fn mru_bonus_breaks_match_score_ties() {
2512 let pairs = vec![
2513 (
2514 {
2515 let mut r = RawCandidate::plain(String::from("alpha"), CandidateKind::Plain);
2516 r.display = "alpha-file".into();
2517 r
2518 },
2519 RoutingPayload::OpenFile {
2520 path: PathBuf::from("/tmp/alpha"),
2521 },
2522 ),
2523 (
2524 {
2525 let mut r = RawCandidate::plain(String::from("beta"), CandidateKind::Plain);
2526 r.display = "beta-file".into();
2527 r
2528 },
2529 RoutingPayload::OpenFile {
2530 path: PathBuf::from("/tmp/beta"),
2531 },
2532 ),
2533 ];
2534 let mut p = Picker::new("files", PickerSource::Files, PickerAction::OpenFile);
2535 p.set_raw_candidates_with_routing(pairs);
2536 // Empty-query match scores are uniform so the tie-
2537 // breaker is the MRU bonus alone.
2538 p.set_mru_bonuses(vec![0.0, 50.0]);
2539 // beta gets the higher bonus and floats above alpha.
2540 assert_eq!(p.candidates.len(), 2);
2541 assert!(p.candidates[0].raw.display.contains("beta"));
2542 assert!(p.candidates[1].raw.display.contains("alpha"));
2543 }
2544
2545 /// Slice 14a: mismatched-length bonuses are clamped to all-
2546 /// zeros rather than panicking on out-of-range access.
2547 /// Defensive guard for tests / future async-init paths
2548 /// that might race the bonus snapshot.
2549 #[test]
2550 fn mru_bonus_length_mismatch_zeroes_out() {
2551 let pairs = vec![(
2552 {
2553 let mut r = RawCandidate::plain(String::from("only"), CandidateKind::Plain);
2554 r.display = "only-file".into();
2555 r
2556 },
2557 RoutingPayload::OpenFile {
2558 path: PathBuf::from("/tmp/only"),
2559 },
2560 )];
2561 let mut p = Picker::new("files", PickerSource::Files, PickerAction::OpenFile);
2562 p.set_raw_candidates_with_routing(pairs);
2563 // 2 bonuses for 1 candidate -- mismatch.
2564 p.set_mru_bonuses(vec![10.0, 20.0]);
2565 // Refilter still produces the one candidate, with 0.0
2566 // bonus (since the mismatch reset to zeros).
2567 assert_eq!(p.candidates.len(), 1);
2568 assert_eq!(p.mru_bonus_for(&p.candidates[0]), 0.0);
2569 }
2570}