Skip to main content

chat/
picker.rs

1//! Session model picker: pi's `Switch Model` overlay (`model-picker.ts` /
2//! `model-browser.ts`) rebuilt on core primitives.
3//!
4//! `Ctrl+P` opens it over the chat transcript. The list is a `<select
5//! filter>` whose options carry `<td>` cells, so the core widget owns the
6//! query editor (always-on typing, paste, `Ctrl+U`/`Ctrl+W`, the Esc cancel
7//! ladder, the hardware caret), fuzzy ranking, cursor movement (`↑`/`↓`
8//! wrap, `PageUp`/`PageDown`/`Home`/`End` clamp), windowed scrolling,
9//! hover, wheel, and click activation — the picker only routes the
10//! surfaced [`UiEvent`]s. Columns align across rows through the shared
11//! table solver; the name cell start-truncates (`truncate=start`) so the
12//! distinctive id tail survives narrow widths, and every glyph — the
13//! context-window icon, dots, cursor — resolves through the host's
14//! detected [`UiContext`] charset. A leading `@` swaps the catalog to
15//! quick roles; `Enter` (or a click) applies the selected model for this
16//! session only.
17
18use omp_core::{Str, StrMut, fmts};
19use omp_tui::{
20	Charset, Color, Component, Dim, Icon, IntoComponent as _, Key, Layer, Mouse, OverlayAnchor,
21	OverlayOptions, Prop, Size, Ui, UiContext, UiEvent, dom,
22};
23
24const GREEN: Color = Color::Rgb(81, 196, 112);
25const CYAN: Color = Color::Rgb(62, 190, 203);
26const PURPLE: Color = Color::Rgb(171, 119, 230);
27const GOLD: Color = Color::Rgb(210, 167, 86);
28const TEXT: Color = Color::Rgb(194, 198, 204);
29const DIM: Color = Color::Rgb(110, 116, 124);
30
31/// One catalog entry with the display stats pi renders per row.
32pub struct ModelSpec {
33	pub provider:  &'static str,
34	pub id:        &'static str,
35	pub name:      &'static str,
36	/// Time to first token, preformatted (`"4.5s"`).
37	pub ttft:      &'static str,
38	/// Throughput, preformatted (`"64t/s"`).
39	pub tps:       &'static str,
40	/// Context window (`"1m"`, `"272k"`).
41	pub ctx:       &'static str,
42	/// Output limit (`"128k"`).
43	pub out:       &'static str,
44	/// `$input/output` per M tokens, or `"free"`.
45	pub cost:      &'static str,
46	pub reasoning: bool,
47	pub vision:    bool,
48}
49
50/// The demo catalog, mirroring pi's switcher screenshot.
51pub const MODELS: [ModelSpec; 6] = [
52	ModelSpec {
53		provider:  "anthropic",
54		id:        "claude-fable-5",
55		name:      "Claude Fable 5",
56		ttft:      "4.5s",
57		tps:       "64t/s",
58		ctx:       "1m",
59		out:       "128k",
60		cost:      "$10/50",
61		reasoning: true,
62		vision:    true,
63	},
64	ModelSpec {
65		provider:  "google-antigravity",
66		id:        "gemini-3.6-flash",
67		name:      "Gemini 3.6 Flash",
68		ttft:      "2.6s",
69		tps:       "342t/s",
70		ctx:       "1m",
71		out:       "65k",
72		cost:      "$1.5/7.5",
73		reasoning: true,
74		vision:    true,
75	},
76	ModelSpec {
77		provider:  "openai-codex",
78		id:        "gpt-5.6-sol",
79		name:      "GPT 5.6 Sol",
80		ttft:      "1.7s",
81		tps:       "41t/s",
82		ctx:       "272k",
83		out:       "128k",
84		cost:      "$5/30",
85		reasoning: true,
86		vision:    false,
87	},
88	ModelSpec {
89		provider:  "google-antigravity",
90		id:        "gemini-3.1-pro",
91		name:      "Gemini 3.1 Pro",
92		ttft:      "3.3s",
93		tps:       "89t/s",
94		ctx:       "1m",
95		out:       "65k",
96		cost:      "$2/12",
97		reasoning: true,
98		vision:    true,
99	},
100	ModelSpec {
101		provider:  "ollama",
102		id:        "lfm2:2.6b",
103		name:      "LFM2 2.6B",
104		ttft:      "4.8s",
105		tps:       "0.4t/s",
106		ctx:       "128k",
107		out:       "32k",
108		cost:      "free",
109		reasoning: false,
110		vision:    false,
111	},
112	ModelSpec {
113		provider:  "anthropic",
114		id:        "claude-opus-5",
115		name:      "Claude Opus 5",
116		ttft:      "6.1s",
117		tps:       "44t/s",
118		ctx:       "1m",
119		out:       "64k",
120		cost:      "$5/25",
121		reasoning: true,
122		vision:    true,
123	},
124];
125
126/// A configured or auto-selected role, surfaced as `@role` quick items and as
127/// chips under the detail block (pi's `ResolvedRoleModel`).
128struct Role {
129	name:       &'static str,
130	model:      usize,
131	color:      Color,
132	/// Thinking-level glyph suffix shown after the chip and quick item.
133	thinking:   Option<&'static str>,
134	/// Configured roles render a solid `●` chip; auto-selected ones `○`.
135	configured: bool,
136}
137
138const ROLES: [Role; 5] = [
139	Role {
140		name:       "default",
141		model:      0,
142		color:      GREEN,
143		thinking:   None,
144		configured: true,
145	},
146	Role {
147		name:       "smol",
148		model:      1,
149		color:      CYAN,
150		thinking:   Some("◔"),
151		configured: true,
152	},
153	Role {
154		name:       "slow",
155		model:      5,
156		color:      PURPLE,
157		thinking:   Some("◕"),
158		configured: true,
159	},
160	Role {
161		name:       "plan",
162		model:      0,
163		color:      GOLD,
164		thinking:   Some("◑"),
165		configured: false,
166	},
167	Role {
168		name:       "fable",
169		model:      0,
170		color:      PURPLE,
171		thinking:   Some("◑"),
172		configured: false,
173	},
174];
175
176const STATUS_MODELS: &str = "Session-only switch — role models stay unchanged";
177const STATUS_ROLES: &str = "Quick role switch — applies its model and thinking for this session";
178const HINT_MODELS: &str =
179	"↑/↓ models · Enter use for this session · type to search · @ quick roles · Esc close";
180const HINT_ROLES: &str = "↑/↓ roles · Enter apply role model · type to search · Esc close";
181
182/// Rows the picker occupies beyond the list: box borders, status, the
183/// select's query row, a blank, facts, chips, and the hint bar.
184const FRAME_ROWS: u16 = 8;
185
186/// Vendored provider logo directory shared with the other examples; rows
187/// embed each logo as a small `<img w=2 h=1 trim/>` thumbnail — real pixels
188/// on the Kitty-placeholder tier, trimmed half-blocks everywhere else.
189const LOGO_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/login");
190
191/// What a routed input event did to the picker.
192#[derive(Clone, Copy, PartialEq, Eq, Debug)]
193pub enum PickerEvent {
194	/// The picker consumed the event (repaint and continue).
195	Consumed,
196	/// Close the overlay without switching.
197	Close,
198	/// Apply this catalog model for the session and close.
199	Pick(usize),
200}
201
202/// Which catalog the select currently lists.
203#[derive(Clone, Copy, PartialEq, Eq)]
204enum Mode {
205	Models,
206	Roles,
207}
208
209/// pi's responsive perf column: full `ttft tps` at 96 cells, `tps` at 76,
210/// gone below (model-browser.ts perf tiers).
211#[derive(Clone, Copy, PartialEq, Eq)]
212enum PerfTier {
213	Full,
214	Tps,
215	None,
216}
217
218impl PerfTier {
219	const fn of(width: u16) -> Self {
220		if width >= 96 {
221			Self::Full
222		} else if width >= 76 {
223			Self::Tps
224		} else {
225			Self::None
226		}
227	}
228
229	fn cell(self, model: &ModelSpec) -> Str {
230		match self {
231			Self::Full => fmts!("{} {}", model.ttft, model.tps),
232			Self::Tps => Str::new_static(model.tps),
233			Self::None => Str::new_static(""),
234		}
235	}
236}
237
238/// Retained picker overlay: one `Ui` per catalog mode, rebuilt only when
239/// the mode or the perf tier changes; everything else is core select state.
240pub struct ModelPicker {
241	ui:      Ui,
242	mode:    Mode,
243	tier:    PerfTier,
244	current: usize,
245	ctx:     UiContext,
246	options: OverlayOptions,
247	/// Query carried across catalog swaps (the `@` mode toggle).
248	query:   Str,
249	/// List rows granted by the last viewport.
250	rows:    u16,
251}
252
253impl ModelPicker {
254	/// Opens the picker with the session's current model preselected,
255	/// presenting through the host's detected context (charset, graphics,
256	/// theme).
257	pub fn open(current: usize, ctx: &UiContext) -> Self {
258		let options = OverlayOptions::default()
259			.anchor(OverlayAnchor::Bottom)
260			.width(Dim::Pct(100))
261			.z(10);
262		let mode = Mode::Models;
263		let tier = PerfTier::Full;
264		let ui = build(mode, tier, current, "", 5, 100, ctx);
265		let mut picker = Self {
266			ui,
267			mode,
268			tier,
269			current,
270			ctx: ctx.clone(),
271			options,
272			query: Str::default(),
273			rows: 5,
274		};
275		picker.show_detail(Some(current));
276		picker
277	}
278
279	/// Routes a key through the retained tree and maps the surfaced event.
280	pub fn handle_key(&mut self, key: Key) -> PickerEvent {
281		let event = self.ui.handle_key(key);
282		self.route(event)
283	}
284
285	/// Routes pasted text into the select's query editor.
286	pub fn handle_paste(&mut self, text: &str) -> PickerEvent {
287		let event = self.ui.handle_paste(text);
288		self.route(event)
289	}
290
291	/// Routes a mouse report through the compositor's own band: hover,
292	/// wheel, and clicks land in the select; a click outside the layer
293	/// closes the picker (pi's dismiss gesture).
294	pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> PickerEvent {
295		match self
296			.ui
297			.handle_mouse_as_layer(&self.options, viewport, col, row, kind)
298		{
299			Some(event) => self.route(event),
300			None if kind == Mouse::Click => PickerEvent::Close,
301			None => PickerEvent::Consumed,
302		}
303	}
304
305	/// The composited layer for this frame: bottom-anchored, full width,
306	/// at most 40% of the viewport tall.
307	pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
308		let rows = (viewport.height * 2 / 5).saturating_sub(FRAME_ROWS).max(5);
309		let tier = PerfTier::of(viewport.width);
310		if tier != self.tier {
311			self.tier = tier;
312			self.rebuild();
313		}
314		if rows != self.rows {
315			self.rows = rows;
316			// One query row plus the windowed list.
317			self.ui.set_prop("models", Prop::H, rows.saturating_add(1));
318		}
319		if self.ui.frame().size().width != viewport.width {
320			self.ui.resize(viewport.width);
321		}
322		Layer { frame: self.ui.frame(), options: &self.options, active: true }
323	}
324
325	/// Applies one surfaced [`UiEvent`] to picker state.
326	fn route(&mut self, event: UiEvent) -> PickerEvent {
327		match event {
328			UiEvent::Cancel => PickerEvent::Close,
329			UiEvent::Changed { value, .. } => value
330				.as_str()
331				.parse()
332				.map_or(PickerEvent::Consumed, PickerEvent::Pick),
333			UiEvent::Highlighted { value, .. } => {
334				self.show_detail(value.as_str().parse().ok());
335				PickerEvent::Consumed
336			},
337			UiEvent::Filtered { query, value, .. } => {
338				let wants_roles = query.starts_with('@');
339				self.query = query;
340				if wants_roles == (self.mode == Mode::Roles) {
341					self.show_detail(value.and_then(|value| value.as_str().parse().ok()));
342				} else {
343					self.mode = if wants_roles {
344						Mode::Roles
345					} else {
346						Mode::Models
347					};
348					self.rebuild();
349				}
350				PickerEvent::Consumed
351			},
352			UiEvent::None | UiEvent::Submit | UiEvent::Pressed(_) => PickerEvent::Consumed,
353		}
354	}
355
356	/// Rebuilds the retained tree for the current mode/tier, reseeding the
357	/// select's query so typing continuity survives the swap.
358	fn rebuild(&mut self) {
359		let width = self.ui.frame().size().width;
360		self.ui = build(self.mode, self.tier, self.current, &self.query, self.rows, width, &self.ctx);
361		let initial = match self.mode {
362			Mode::Models => Some(self.current),
363			// The role list keeps its configured order; detail follows the
364			// first row until the next cursor event.
365			Mode::Roles => ROLES.first().map(|role| role.model),
366		};
367		self.show_detail(initial);
368	}
369
370	/// Points the facts line and role chips at `model`; `None` (no match)
371	/// blanks the details while keeping the frame height stable.
372	fn show_detail(&mut self, model: Option<usize>) {
373		show_detail_on(&mut self.ui, model);
374	}
375}
376
377/// Points the facts line and role chips of any Ui hosting the picker tree
378/// at `model`; `None` blanks the details while keeping the height stable.
379pub fn show_detail_on(ui: &mut Ui, model: Option<usize>) {
380	let facts = model.map_or_else(|| Str::new_static(" "), |index| facts(&MODELS[index]));
381	ui.set_text("facts", facts);
382	// Hide before show: the document must never transiently exceed its
383	// steady height — a raw-frame layer's retained frame keeps the
384	// high-water mark, which would leave a stale extra row.
385	for index in (0..MODELS.len()).filter(|&index| model != Some(index)) {
386		ui.set_visible(&fmts!("chips-{index}"), false);
387	}
388	if let Some(index) = model {
389		ui.set_visible(&fmts!("chips-{index}"), true);
390	}
391}
392
393/// The facts line under the list (name, limits, price, capabilities).
394fn facts(model: &ModelSpec) -> Str {
395	fn part(facts: &mut StrMut, text: &str) {
396		if !facts.is_empty() {
397			facts.push_str(" · ");
398		}
399		facts.push_str(text);
400	}
401	let mut line = StrMut::with_capacity(96);
402	part(&mut line, model.name);
403	part(&mut line, &fmts!("{} ctx", model.ctx));
404	part(&mut line, &fmts!("{} out", model.out));
405	part(&mut line, &fmts!("{} per M", model.cost));
406	if model.reasoning {
407		part(&mut line, "reasoning");
408	}
409	if model.vision {
410		part(&mut line, "vision");
411	}
412	part(&mut line, &fmts!("~{}", model.tps));
413	part(&mut line, &fmts!("{} ttft", model.ttft));
414	line.freeze()
415}
416
417/// One option row's static content, shared by both catalogs.
418struct RowSpec {
419	value:       Str,
420	label:       Str,
421	logo:        Str,
422	prefix:      Str,
423	prefix_fg:   Color,
424	name:        Str,
425	name_fg:     Color,
426	current:     bool,
427	recommended: bool,
428	perf:        Str,
429	ctx:         Str,
430	cost:        Str,
431}
432
433fn model_rows(tier: PerfTier, current: usize, charset: Charset) -> Vec<RowSpec> {
434	MODELS
435		.iter()
436		.enumerate()
437		.map(|(index, model)| RowSpec {
438			value:       fmts!("{index}"),
439			label:       fmts!("{}/{}", model.provider, model.id),
440			logo:        fmts!("{LOGO_DIR}/{}.png", model.provider),
441			prefix:      fmts!("{}/", model.provider),
442			prefix_fg:   DIM,
443			name:        Str::new_static(model.id),
444			name_fg:     TEXT,
445			current:     index == current,
446			recommended: index == current,
447			perf:        tier.cell(model),
448			ctx:         fmts!("{} {}", model.ctx, charset.icon(Icon::Context)),
449			cost:        Str::new_static(model.cost),
450		})
451		.collect()
452}
453
454fn role_rows(tier: PerfTier, current: usize, charset: Charset) -> Vec<RowSpec> {
455	ROLES
456		.iter()
457		.enumerate()
458		.map(|(index, role)| {
459			let model = &MODELS[role.model];
460			let name = match role.thinking {
461				Some(glyph) => fmts!("{} {glyph}", role.name),
462				None => Str::new_static(role.name),
463			};
464			RowSpec {
465				value: fmts!("{}", role.model),
466				// The `@` stays in the haystack so `@arc` matches and the
467				// bare `@` keeps every role visible.
468				label: fmts!("@{}", role.name),
469				logo: fmts!("{LOGO_DIR}/{}.png", model.provider),
470				prefix: Str::default(),
471				prefix_fg: DIM,
472				name,
473				name_fg: role.color,
474				current: role.model == current,
475				recommended: index == 0,
476				perf: tier.cell(model),
477				ctx: fmts!("{} {}", model.ctx, charset.icon(Icon::Context)),
478				cost: Str::new_static(model.cost),
479			}
480		})
481		.collect()
482}
483
484/// One role chip (`● default`, `○ plan ◑`) under the facts line.
485struct Chip {
486	text:  Str,
487	color: Color,
488}
489
490/// The chip row for `model`: its current marker plus every role resolving
491/// to it. Dots resolve through the charset — solid (`enabled`) for
492/// configured roles, hollow (`shadowed`) for auto-selected ones.
493fn chips(model: usize, current: usize, charset: Charset) -> Vec<Chip> {
494	let mut chips = Vec::new();
495	if model == current {
496		chips.push(Chip { text: fmts!("{} current", charset.icon(Icon::Enabled)), color: GREEN });
497	}
498	for role in ROLES.iter().filter(|role| role.model == model) {
499		let dot = if role.configured {
500			charset.icon(Icon::Enabled)
501		} else {
502			charset.icon(Icon::Shadowed)
503		};
504		let color = if role.configured { role.color } else { DIM };
505		let mut text = StrMut::with_capacity(16);
506		text.push_str(dot);
507		text.push(' ');
508		text.push_str(role.name);
509		if let Some(glyph) = role.thinking {
510			text.push(' ');
511			text.push_str(glyph);
512		}
513		chips.push(Chip { text: text.freeze(), color });
514	}
515	if chips.is_empty() {
516		chips.push(Chip { text: Str::new_static(" "), color: DIM });
517	}
518	chips
519}
520
521/// The models-catalog picker pane at full perf tier for `width`: the tree
522/// behind [`ModelPicker`], reusable as inline content by other examples.
523#[allow(dead_code, reason = "consumed by the gallery example's #[path] include of this module")]
524pub fn models_pane(current: usize, rows: u16, width: u16, charset: Charset) -> Box<dyn Component> {
525	tree(Mode::Models, PerfTier::of(width), current, "", rows, charset)
526}
527
528/// Builds the retained overlay tree for one catalog mode.
529fn build(
530	mode: Mode,
531	tier: PerfTier,
532	current: usize,
533	query: &str,
534	rows: u16,
535	width: u16,
536	ctx: &UiContext,
537) -> Ui {
538	Ui::from_root(tree(mode, tier, current, query, rows, ctx.charset), width, ctx.clone())
539}
540
541/// The picker component tree for one catalog mode.
542fn tree(
543	mode: Mode,
544	tier: PerfTier,
545	current: usize,
546	query: &str,
547	rows: u16,
548	charset: Charset,
549) -> Box<dyn Component> {
550	let list = match mode {
551		Mode::Models => model_rows(tier, current, charset),
552		Mode::Roles => role_rows(tier, current, charset),
553	};
554	let status = match mode {
555		Mode::Models => STATUS_MODELS,
556		Mode::Roles => STATUS_ROLES,
557	};
558	let hint = match mode {
559		Mode::Models => HINT_MODELS,
560		Mode::Roles => HINT_ROLES,
561	};
562	let current_dot = fmts!(" {}", charset.icon(Icon::Enabled));
563	let seed = Str::from(query);
564	let height = rows.saturating_add(1);
565	dom! {
566			<box border=round title="Switch Model" pad-x=1>
567				<col>
568					<text fg=muted truncate>{status}</text>
569					<select id="models" filter={seed} h={height}>
570						for row in list {
571							<option value={row.value} label={row.label} recommended={row.recommended}>
572								<td><img src={row.logo} w=2 h=1 trim/></td>
573								<td truncate=start grow>
574									if !row.prefix.is_empty() {
575										<pre fg={row.prefix_fg}>{row.prefix}</pre>
576									}
577									<pre fg={row.name_fg}>{row.name}</pre>
578									if row.current {
579										<pre fg={GREEN}>{current_dot.clone()}</pre>
580									}
581								</td>
582								if tier != PerfTier::None {
583									<td align=end><pre fg={DIM}>{row.perf}</pre></td>
584								}
585								<td align=end><pre fg={DIM}>{row.ctx}</pre></td>
586								<td align=end><pre fg={DIM}>{row.cost}</pre></td>
587							</option>
588						}
589					</select>
590					<spacer h=1/>
591					<text id="facts" fg=muted truncate>{" "}</text>
592					for model in 0..MODELS.len() {
593						<row id={fmts!("chips-{model}")}>
594							for (index, chip) in chips(model, current, charset).into_iter().enumerate() {
595								if index > 0 {
596									<pre fg={DIM}>{" · "}</pre>
597								}
598								<pre fg={chip.color}>{chip.text}</pre>
599							}
600						</row>
601					}
602					<text dim truncate>{hint}</text>
603				</col>
604			</box>
605	}
606	.into_component()
607}
608
609#[cfg(test)]
610mod tests {
611	use super::{FRAME_ROWS, Key, MODELS, ModelPicker, Mouse, PickerEvent, Size, UiContext};
612
613	fn ctx() -> UiContext {
614		UiContext::default()
615	}
616
617	fn opened(current: usize) -> ModelPicker {
618		let mut picker = ModelPicker::open(current, &ctx());
619		// Establish geometry the way a presenting host does.
620		let _ = picker.layer(Size::new(120, 50));
621		picker
622	}
623
624	#[test]
625	fn typing_filters_and_enter_picks_the_ranked_model() {
626		let mut picker = opened(0);
627		for ch in "flash".chars() {
628			assert_eq!(picker.handle_key(Key::Char(ch)), PickerEvent::Consumed);
629		}
630		assert_eq!(picker.handle_key(Key::Enter), PickerEvent::Pick(1));
631	}
632
633	#[test]
634	fn escape_clears_the_query_before_closing() {
635		let mut picker = opened(0);
636		picker.handle_key(Key::Char('x'));
637		assert_eq!(picker.handle_key(Key::Esc), PickerEvent::Consumed);
638		assert_eq!(picker.handle_key(Key::Esc), PickerEvent::Close);
639	}
640
641	#[test]
642	fn at_prefix_switches_to_quick_roles() {
643		let mut picker = opened(0);
644		picker.handle_key(Key::Char('@'));
645		for ch in "slow".chars() {
646			picker.handle_key(Key::Char(ch));
647		}
648		assert_eq!(picker.handle_key(Key::Enter), PickerEvent::Pick(5));
649	}
650
651	#[test]
652	fn cursor_opens_on_the_current_model() {
653		let mut picker = opened(5);
654		assert_eq!(picker.handle_key(Key::Enter), PickerEvent::Pick(5));
655	}
656
657	#[test]
658	fn single_steps_wrap_while_jumps_clamp() {
659		let mut picker = opened(0);
660		picker.handle_key(Key::Up);
661		assert_eq!(picker.handle_key(Key::Enter), PickerEvent::Pick(MODELS.len() - 1));
662		picker.handle_key(Key::Down);
663		assert_eq!(picker.handle_key(Key::Enter), PickerEvent::Pick(0));
664
665		picker.handle_key(Key::PageDown);
666		picker.handle_key(Key::PageDown);
667		picker.handle_key(Key::PageDown);
668		assert_eq!(
669			picker.handle_key(Key::Enter),
670			PickerEvent::Pick(MODELS.len() - 1),
671			"page jumps clamp at the end instead of wrapping"
672		);
673		picker.handle_key(Key::Home);
674		assert_eq!(picker.handle_key(Key::Enter), PickerEvent::Pick(0));
675		picker.handle_key(Key::End);
676		assert_eq!(picker.handle_key(Key::Enter), PickerEvent::Pick(MODELS.len() - 1));
677	}
678
679	#[test]
680	fn paste_lands_in_the_query() {
681		let mut picker = opened(0);
682		picker.handle_paste("gpt-5.6");
683		assert_eq!(picker.handle_key(Key::Enter), PickerEvent::Pick(2));
684	}
685
686	#[test]
687	fn selection_survives_query_edits_min_clamped() {
688		let mut picker = opened(5);
689		picker.handle_key(Key::Char('a'));
690		// "a" matches fewer than six models; the index min-clamps instead of
691		// resetting to the top (pi #applyQuery).
692		let picked = picker.handle_key(Key::Enter);
693		assert!(matches!(picked, PickerEvent::Pick(_)));
694	}
695
696	#[test]
697	fn mouse_click_activates_the_hit_row_and_outside_closes() {
698		let viewport = Size::new(120, 50);
699		let mut picker = ModelPicker::open(0, &ctx());
700		let band = picker.layer(viewport).band(viewport);
701		// Rows begin under the border, status, and query lines.
702		let first_row = band.y + 3;
703		assert_eq!(
704			picker.handle_mouse(10, first_row + 2, Mouse::Click, viewport),
705			PickerEvent::Pick(2),
706			"click commits the row under the pointer"
707		);
708		assert_eq!(
709			picker.handle_mouse(10, 0, Mouse::Click, viewport),
710			PickerEvent::Close,
711			"click outside the layer dismisses"
712		);
713		assert_eq!(
714			picker.handle_mouse(10, 0, Mouse::Move, viewport),
715			PickerEvent::Consumed,
716			"motion outside only clears hover"
717		);
718	}
719
720	#[test]
721	fn wheel_moves_the_selection() {
722		let viewport = Size::new(120, 50);
723		let mut picker = ModelPicker::open(0, &ctx());
724		let band = picker.layer(viewport).band(viewport);
725		let inside = band.y + 4;
726		picker.handle_mouse(10, inside, Mouse::WheelDown, viewport);
727		assert_eq!(picker.handle_key(Key::Enter), PickerEvent::Pick(1));
728	}
729
730	#[test]
731	fn overlay_height_is_stable_at_forty_percent_regardless_of_filtering() {
732		let viewport = Size::new(120, 50);
733		// max(5, floor(50 * 0.4) - FRAME_ROWS) list rows plus the frame.
734		let expected = (50 * 2 / 5 - FRAME_ROWS).max(5) + FRAME_ROWS;
735
736		let mut picker = ModelPicker::open(0, &ctx());
737		let band = picker.layer(viewport).band(viewport);
738		assert_eq!(band.rows, expected, "short catalogs pad the list to max visible");
739		assert_eq!(band.y, 50 - band.rows, "anchored to the viewport bottom");
740
741		for ch in "flash".chars() {
742			picker.handle_key(Key::Char(ch));
743		}
744		let band = picker.layer(viewport).band(viewport);
745		assert_eq!(band.rows, expected, "filtering never shrinks the box");
746
747		for ch in "zzz".chars() {
748			picker.handle_key(Key::Char(ch));
749		}
750		let band = picker.layer(viewport).band(viewport);
751		assert_eq!(band.rows, expected, "empty results keep the frame");
752	}
753}