Skip to main content

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}