1use 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
31pub struct ModelSpec {
33 pub provider: &'static str,
34 pub id: &'static str,
35 pub name: &'static str,
36 pub ttft: &'static str,
38 pub tps: &'static str,
40 pub ctx: &'static str,
42 pub out: &'static str,
44 pub cost: &'static str,
46 pub reasoning: bool,
47 pub vision: bool,
48}
49
50pub 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
126struct Role {
129 name: &'static str,
130 model: usize,
131 color: Color,
132 thinking: Option<&'static str>,
134 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
182const FRAME_ROWS: u16 = 8;
185
186const LOGO_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/login");
190
191#[derive(Clone, Copy, PartialEq, Eq, Debug)]
193pub enum PickerEvent {
194 Consumed,
196 Close,
198 Pick(usize),
200}
201
202#[derive(Clone, Copy, PartialEq, Eq)]
204enum Mode {
205 Models,
206 Roles,
207}
208
209#[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
238pub struct ModelPicker {
241 ui: Ui,
242 mode: Mode,
243 tier: PerfTier,
244 current: usize,
245 ctx: UiContext,
246 options: OverlayOptions,
247 query: Str,
249 rows: u16,
251}
252
253impl ModelPicker {
254 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 pub fn handle_key(&mut self, key: Key) -> PickerEvent {
281 let event = self.ui.handle_key(key);
282 self.route(event)
283 }
284
285 pub fn handle_paste(&mut self, text: &str) -> PickerEvent {
287 let event = self.ui.handle_paste(text);
288 self.route(event)
289 }
290
291 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 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 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 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 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 Mode::Roles => ROLES.first().map(|role| role.model),
366 };
367 self.show_detail(initial);
368 }
369
370 fn show_detail(&mut self, model: Option<usize>) {
373 show_detail_on(&mut self.ui, model);
374 }
375}
376
377pub 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 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
393fn 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
417struct 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 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
484struct Chip {
486 text: Str,
487 color: Color,
488}
489
490fn 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#[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
528fn 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
541fn 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 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 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 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 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}