Skip to main content

companies/
companies.rs

1//! Pi `/login` provider roster with vendored logos and live paint statistics.
2//!
3//! ```sh
4//! cargo run -p omp-tui --example companies
5//! ```
6
7use std::{collections::HashMap, io, time::Duration};
8
9use omp_tui::{
10	App, AppEvent, AppOptions, Cached, Elements, Graphics, Prop, Props, Size, TerminalCaps, Ui,
11	UiContext, components::Img, dom,
12};
13
14const SCROLL_ID: &str = "login-providers";
15const HUD_ID: &str = "paint-hud";
16const CHROME_ROWS: u16 = 5;
17const ASSET_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/login");
18/// Card cell: rounded border (2) + 1-cell side padding around a 12-wide
19/// body that fits the 4-cell logo and every shortened name.
20///
21/// Cards live in a `wrap` row: the layout engine packs as many per line as
22/// the width allows and re-centers each line, so the grid reflows on every
23/// resize without rebuilding the tree.
24const CARD_W: u16 = 16;
25
26struct Provider {
27	id:   &'static str,
28	/// Short display name sized for one card-grid cell.
29	name: &'static str,
30}
31
32// Literal getOAuthProviders() order from @omp/ai's provider
33// registry; names shortened to fit one grid card.
34const PROVIDERS: &[Provider] = &[
35	Provider { id: "openai-codex", name: "Codex" },
36	Provider { id: "anthropic", name: "Claude" },
37	Provider { id: "zai", name: "Z.AI" },
38	Provider { id: "zai-coding-plan", name: "Z.AI GLM" },
39	Provider { id: "kimi-code", name: "Kimi Code" },
40	Provider { id: "openrouter", name: "OpenRouter" },
41	Provider { id: "github-copilot", name: "Copilot" },
42	Provider { id: "cursor", name: "Cursor" },
43	Provider { id: "devin", name: "Devin" },
44	Provider { id: "google-antigravity", name: "Antigravity" },
45	Provider { id: "google-gemini-cli", name: "Gemini CLI" },
46	Provider { id: "openai-codex-device", name: "Codex CLI" },
47	Provider { id: "xai", name: "xAI" },
48	Provider { id: "xai-oauth", name: "Grok" },
49	Provider { id: "gitlab-duo", name: "GitLab Duo" },
50	Provider { id: "gitlab-duo-agent", name: "Duo Agent" },
51	Provider { id: "alibaba-coding-plan", name: "Alibaba" },
52	Provider { id: "alibaba-token-plan", name: "QwenCloud" },
53	Provider { id: "aiand", name: "ai&" },
54	Provider { id: "zhipu-coding-plan", name: "Zhipu" },
55	Provider { id: "umans", name: "Umans" },
56	Provider { id: "qwen-portal", name: "Qwen" },
57	Provider { id: "sakana", name: "Sakana" },
58	Provider { id: "minimax-code", name: "MiniMax" },
59	Provider { id: "minimax-code-cn", name: "MiniMax CN" },
60	Provider { id: "xiaomi", name: "MiMo" },
61	Provider { id: "xiaomi-token-plan-sgp", name: "MiMo SGP" },
62	Provider { id: "xiaomi-token-plan-ams", name: "MiMo EU" },
63	Provider { id: "xiaomi-token-plan-cn", name: "MiMo CN" },
64	Provider { id: "firepass", name: "Fire Pass" },
65	Provider { id: "deepseek", name: "DeepSeek" },
66	Provider { id: "meta", name: "Meta" },
67	Provider { id: "moonshot", name: "Moonshot" },
68	Provider { id: "cerebras", name: "Cerebras" },
69	Provider { id: "baseten", name: "Baseten" },
70	Provider { id: "fireworks", name: "Fireworks" },
71	Provider { id: "together", name: "Together" },
72	Provider { id: "nvidia", name: "NVIDIA" },
73	Provider { id: "novita", name: "Novita" },
74	Provider { id: "huggingface", name: "HuggingFace" },
75	Provider { id: "perplexity", name: "Perplexity" },
76	Provider { id: "qianfan", name: "Qianfan" },
77	Provider { id: "venice", name: "Venice" },
78	Provider { id: "siliconflow", name: "SiliconFlow" },
79	Provider { id: "siliconflow-cn", name: "SF China" },
80	Provider { id: "synthetic", name: "Synthetic" },
81	Provider { id: "nanogpt", name: "NanoGPT" },
82	Provider { id: "wafer-serverless", name: "Wafer" },
83	Provider { id: "coreweave", name: "CoreWeave" },
84	Provider { id: "vercel-ai-gateway", name: "Vercel AI" },
85	Provider { id: "cloudflare-ai-gateway", name: "Cloudflare" },
86	Provider { id: "litellm", name: "LiteLLM" },
87	Provider { id: "kilo", name: "Kilo" },
88	Provider { id: "zenmux", name: "ZenMux" },
89	Provider { id: "opencode-zen", name: "OpenCode Zen" },
90	Provider { id: "opencode-go", name: "OpenCode Go" },
91	Provider { id: "tavily", name: "Tavily" },
92	Provider { id: "kagi", name: "Kagi" },
93	Provider { id: "exa", name: "Exa" },
94	Provider { id: "parallel", name: "Parallel" },
95	Provider { id: "ollama", name: "Ollama" },
96	Provider { id: "ollama-cloud", name: "Ollama Cloud" },
97	Provider { id: "lm-studio", name: "LM Studio" },
98	Provider { id: "llama.cpp", name: "llama.cpp" },
99	Provider { id: "vllm", name: "vLLM" },
100	Provider { id: "gmi-cloud", name: "GMI Cloud" },
101];
102
103fn scroll_height(viewport: Size) -> u16 {
104	viewport.height.saturating_sub(CHROME_ROWS).max(3)
105}
106
107fn build_ui(viewport: Size, context: UiContext) -> Ui {
108	let ids = PROVIDERS
109		.iter()
110		.enumerate()
111		.map(|(index, provider)| {
112			(format!("{ASSET_DIR}/{}.png", provider.id), u32::try_from(index + 1).unwrap())
113		})
114		.collect::<HashMap<_, _>>();
115	let elements = Elements::builder()
116		.with("logo", move |_: &str, props: Props, _: Vec<Cached>| {
117			let source = props.str_of(Prop::Src).map_or("", |value| value.as_str());
118			let id = ids.get(source).copied().unwrap_or(1);
119			Box::new(
120				Img::new()
121					.with_str(Prop::Src, source)
122					.with(Prop::W, 4_u16)
123					.kitty(id, 2, 4),
124			) as Box<dyn omp_tui::Component>
125		})
126		.build();
127	let root = dom! {
128		<col gap=1>
129			<row gap=1>
130				<i:log-in/>
131				<text bold fg="accent..info">{"Choose a provider"}</text>
132				<text dim>{format!("{} providers", PROVIDERS.len())}</text>
133			</row>
134			<scroll id={SCROLL_ID} h={scroll_height(viewport)}>
135				<row wrap gap=1 justify=center>
136					for provider in PROVIDERS.iter() {
137						<box focus id={provider.id} w={CARD_W} border=round bc="muted..muted"
138							hover="#38bdf8..#c084fc" lift=1 anim=220 ease=in-out
139							align=center pad-x=1>
140							<logo src={format!("{ASSET_DIR}/{}.png", provider.id)}/>
141							<text bold truncate align=center>{provider.name}</text>
142						</box>
143					}
144				</row>
145			</scroll>
146			<row gap=2>
147				<text dim>{"↹/←→/↑↓ pick · ↵ login · wheel scroll · Ctrl-C quit"}</text>
148				<text id={HUD_ID} dim>{"repaint: 0 cells"}</text>
149			</row>
150		</col>
151	};
152	let context = UiContext { elements, ..context };
153	Ui::from_root(root, viewport.width, context)
154}
155
156fn show_stats(app: &mut App, chosen: Option<&str>) {
157	let caps = app.caps();
158	let stats = app.last_stats();
159	let pixels = caps.cell_px.map_or_else(
160		|| "cell-px ?".to_owned(),
161		|(width, height)| format!("cell-px {width}×{height}"),
162	);
163	let login = chosen.map_or(String::new(), |id| format!("login: {id} · "));
164	app.ui_mut().set_text(
165		HUD_ID,
166		format!(
167			"{login}{} · {} · {} · repaint: {} cells",
168			graphics_label(caps.graphics),
169			caps.id,
170			pixels,
171			stats.changed_cells,
172		),
173	);
174}
175
176fn forced_from_args(caps: &TerminalCaps) -> Option<Graphics> {
177	let mut forced = None;
178	for argument in std::env::args().skip(1) {
179		forced = match argument.as_str() {
180			"--cells" => Some(Graphics::Cells),
181			"--kitty" if caps.kitty_placeholders => Some(Graphics::KittyPlaceholders),
182			"--kitty" => Some(Graphics::KittyDirect),
183			"--kitty-placeholders" => Some(Graphics::KittyPlaceholders),
184			"--sixel" => Some(Graphics::Sixel),
185			_ => forced,
186		};
187	}
188	forced
189}
190
191#[tokio::main]
192async fn main() -> io::Result<()> {
193	let mut app = AppOptions::new()
194		.mouse()
195		.probe(Duration::from_millis(150))
196		.graphics_with(forced_from_args)
197		.start(|env| build_ui(env.viewport, env.ctx))
198		.await?;
199	if app.caps().graphics != Graphics::Cells {
200		for (index, provider) in PROVIDERS.iter().enumerate() {
201			let png = tokio::fs::read(format!("{ASSET_DIR}/{}.png", provider.id)).await?;
202			app.renderer_mut().register_image(
203				u32::try_from(index + 1).expect("provider count fits image IDs"),
204				png,
205			)?;
206		}
207		app.ui_mut().invalidate(SCROLL_ID);
208	}
209	show_stats(&mut app, None);
210	let mut chosen: Option<String> = None;
211	while let Some(event) = app.next().await? {
212		match event {
213			AppEvent::Resized(viewport) => {
214				app.ui_mut().set_height(SCROLL_ID, scroll_height(viewport));
215			},
216			AppEvent::Pressed(id) => chosen = Some(id.to_string()),
217			_ => {},
218		}
219		show_stats(&mut app, chosen.as_deref());
220	}
221	Ok(())
222}
223
224const fn graphics_label(graphics: Graphics) -> &'static str {
225	match graphics {
226		Graphics::Cells => "cells",
227		Graphics::Sixel => "sixel",
228		Graphics::KittyDirect => "kitty-direct",
229		Graphics::KittyPlaceholders => "kitty-placeholders",
230		Graphics::Iterm2 => "iterm2",
231	}
232}
233
234#[cfg(test)]
235mod tests {
236	use omp_tui::{Key, Mouse, Renderer, Size, test_support::TerminalModel};
237
238	use super::{CARD_W, PROVIDERS, build_ui, scroll_height};
239
240	fn replay(renderer: &mut Renderer<Vec<u8>>, terminal: &mut TerminalModel) {
241		let output =
242			String::from_utf8(std::mem::take(renderer.writer_mut())).expect("renderer emits UTF-8");
243		terminal.apply(&output);
244	}
245
246	/// Renders `ui` at `width`×18 and returns the first row containing a
247	/// card's top border.
248	fn first_card_line(ui: &omp_tui::Ui, width: u16) -> String {
249		let mut renderer = Renderer::new(Vec::new());
250		let mut terminal = TerminalModel::new(width.into(), 18);
251		renderer
252			.rebuild(ui.frame().clone(), 18, 0, "")
253			.expect("paint succeeds");
254		replay(&mut renderer, &mut terminal);
255		terminal
256			.visible_rows()
257			.iter()
258			.find(|row| row.contains('╭'))
259			.cloned()
260			.expect("a card border row is visible")
261	}
262
263	#[test]
264	fn grid_reflows_columns_and_recenters_on_resize() {
265		let mut context = omp_tui::UiContext::default();
266		context.graphics = omp_tui::Graphics::Cells;
267		let mut ui = build_ui(Size::new(100, 18), context);
268
269		// n cards fit when n*CARD_W + (n-1) gaps ≤ width - 1 (scrollbar
270		// column), i.e. n = width / (CARD_W + 1).
271		let columns = |width: u16| usize::from(width / (CARD_W + 1));
272		let centered = |row: &str, inner: usize| {
273			let cells: Vec<char> = row.chars().collect();
274			let left = cells.iter().position(|&ch| ch == '╭').unwrap();
275			let last = cells.iter().rposition(|&ch| ch == '╮').unwrap();
276			let right = inner - 1 - last;
277			assert!(
278				left.abs_diff(right) <= 1,
279				"line not centered: left pad {left}, right pad {right}: {row}"
280			);
281		};
282
283		let narrow = first_card_line(&ui, 100);
284		assert_eq!(narrow.matches('╭').count(), columns(100));
285		centered(&narrow, 99);
286
287		ui.resize(150);
288		let wide = first_card_line(&ui, 150);
289		assert_eq!(wide.matches('╭').count(), columns(150));
290		assert!(wide.matches('╭').count() > narrow.matches('╭').count());
291		centered(&wide, 149);
292	}
293
294	#[test]
295	fn scrolling_keeps_damage_inside_viewport_and_decodes_vendored_logos() {
296		let viewport = Size::new(100, 18);
297		let mut context = omp_tui::UiContext::default();
298		context.graphics = omp_tui::Graphics::Cells;
299		let mut ui = build_ui(viewport, context);
300		let mut renderer = Renderer::new(Vec::new());
301		let mut terminal = TerminalModel::new(viewport.width.into(), viewport.height.into());
302		renderer
303			.rebuild(ui.frame().clone(), viewport.height, 0, "")
304			.expect("initial paint succeeds");
305		replay(&mut renderer, &mut terminal);
306
307		let initial = terminal.visible_rows().join("\n");
308		assert!(initial.contains(PROVIDERS[0].name));
309		assert!(initial.contains(PROVIDERS[1].name));
310		assert!(initial.contains('▀'), "decoded image must paint half-block cells:\n{initial}");
311		assert!(!initial.contains("[img:"), "vendored image unexpectedly failed to decode");
312
313		for (step, key) in [Key::Down, Key::PageDown, Key::Down, Key::PageDown]
314			.into_iter()
315			.enumerate()
316		{
317			ui.handle_key(key);
318			let stats = ui
319				.present(&mut renderer, viewport.height, 0)
320				.expect("scroll paint succeeds");
321			replay(&mut renderer, &mut terminal);
322			let scroll_area = usize::from(viewport.width) * usize::from(scroll_height(viewport));
323			assert!(
324				stats.changed_cells <= scroll_area,
325				"step {step} repainted {} cells outside {scroll_area}-cell scroll viewport",
326				stats.changed_cells,
327			);
328		}
329
330		ui.handle_mouse(1, 3, Mouse::WheelDown);
331		let stats = ui
332			.present(&mut renderer, viewport.height, 0)
333			.expect("wheel paint succeeds");
334		let scroll_area = usize::from(viewport.width) * usize::from(scroll_height(viewport));
335		assert!(stats.changed_cells <= scroll_area);
336	}
337
338	#[test]
339	fn kitty_mode_uploads_and_materializes_placeholders() {
340		let viewport = Size::new(100, 18);
341		let mut context = omp_tui::UiContext::default();
342		context.graphics = omp_tui::Graphics::KittyPlaceholders;
343		let ui = build_ui(viewport, context);
344		let mut renderer = Renderer::new(Vec::new());
345		for (index, provider) in PROVIDERS.iter().enumerate() {
346			let path = format!("{}/{}.png", super::ASSET_DIR, provider.id);
347			renderer
348				.register_image(
349					u32::try_from(index + 1).expect("provider count fits image IDs"),
350					std::fs::read(path).unwrap(),
351				)
352				.unwrap();
353		}
354		renderer
355			.rebuild(ui.frame().clone(), viewport.height, 0, "")
356			.expect("Kitty initial paint succeeds");
357		let output = String::from_utf8(renderer.into_inner()).unwrap();
358		assert!(output.starts_with("\x1b_Gf=100,t=d,a=t,i=1,q=2,"));
359		// Placement id rides `p=`: rows<<9 | cols for a 2×4 logo box.
360		assert!(output.contains("\x1b_Ga=p,U=1,i=1,p=1028,r=2,c=4,q=2\x1b\\"));
361		assert!(output.contains("\u{10eeee}\u{0305}\u{0305}"));
362		// Image ID in the fg, placement ID (1028 = 0:4:4) in the underline
363		// color of every placeholder cell.
364		assert!(output.contains("38;2;0;0;1m"));
365		assert!(output.contains("58:2::0:4:4"));
366	}
367
368	#[test]
369	fn hovering_a_card_lifts_it_and_recolors_its_ring() {
370		let mut context = omp_tui::UiContext::default();
371		context.graphics = omp_tui::Graphics::Cells;
372		let mut ui = build_ui(Size::new(100, 18), context);
373		let rows = |ui: &omp_tui::Ui| -> Vec<String> {
374			(0..ui.frame().size().height)
375				.map(|y| omp_tui::test_support::frame_row_text(ui.frame(), y))
376				.collect()
377		};
378		let resting = rows(&ui);
379		let rest_top = resting
380			.iter()
381			.position(|row| row.contains('╭'))
382			.expect("cards render a border row");
383		let column = resting[rest_top]
384			.chars()
385			.position(|glyph| glyph == '╭')
386			.unwrap() as u16;
387
388		// Enter the first card, then advance the clock past the 220ms ease.
389		ui.handle_mouse(column + 2, rest_top as u16 + 2, Mouse::Move);
390		ui.tick(std::time::Duration::from_millis(400));
391		let lifted = rows(&ui);
392		let lifted_top = lifted.iter().position(|row| row.contains('╭')).unwrap();
393		assert_eq!(lifted_top + 1, rest_top, "the hovered card rises into its headroom");
394		let ring = omp_tui::test_support::frame_cell_style(ui.frame(), column + 2, lifted_top as u16)
395			.foreground_color();
396		assert_ne!(
397			ring,
398			omp_tui::Theme::default().muted,
399			"the border cell over the pointer carries the glow"
400		);
401		assert!(lifted[rest_top + 4].contains('▀'), "the vacated row carries the shadow");
402	}
403
404	#[test]
405	fn keyboard_focus_lifts_a_card_and_enter_presses_it() {
406		let mut context = omp_tui::UiContext::default();
407		context.graphics = omp_tui::Graphics::Cells;
408		let mut ui = build_ui(Size::new(100, 18), context);
409		let rows = |ui: &omp_tui::Ui| -> Vec<String> {
410			(0..ui.frame().size().height)
411				.map(|y| omp_tui::test_support::frame_row_text(ui.frame(), y))
412				.collect()
413		};
414		let rest_top = rows(&ui)
415			.iter()
416			.position(|row| row.contains('╭'))
417			.expect("cards render a border row");
418
419		// Initial focus sits on the scroll pane; one Tab reaches the first
420		// card.
421		ui.handle_key(Key::Tab);
422		ui.tick(std::time::Duration::from_millis(400));
423		let focused_top = rows(&ui).iter().position(|row| row.contains('╭')).unwrap();
424		assert_eq!(focused_top + 1, rest_top, "keyboard focus lifts the card like hover");
425		assert_eq!(
426			ui.handle_key(Key::Enter),
427			omp_tui::UiEvent::Pressed(PROVIDERS[0].id.into()),
428			"enter presses the focused card"
429		);
430	}
431
432	#[test]
433	fn arrow_navigation_stays_anchored_and_presses_the_selected_card() {
434		let mut context = omp_tui::UiContext::default();
435		context.graphics = omp_tui::Graphics::Cells;
436		let mut ui = build_ui(Size::new(100, 18), context);
437		let rows = |ui: &omp_tui::Ui| -> Vec<String> {
438			(0..ui.frame().size().height)
439				.map(|y| omp_tui::test_support::frame_row_text(ui.frame(), y))
440				.collect()
441		};
442
443		// Tab enters the grid, Right steps to the second card. The viewport
444		// must stay on the first providers instead of chasing the scrollbar
445		// to the end of the roster.
446		ui.handle_key(Key::Tab);
447		ui.handle_key(Key::Right);
448		ui.tick(std::time::Duration::from_millis(400));
449		let grid = rows(&ui);
450		assert!(
451			grid.iter().any(|row| row.contains(PROVIDERS[0].name)),
452			"arrow navigation keeps the viewport anchored at the top"
453		);
454		assert_eq!(
455			ui.handle_key(Key::Enter),
456			omp_tui::UiEvent::Pressed(PROVIDERS[1].id.into()),
457			"enter presses the arrow-selected card"
458		);
459	}
460
461	#[test]
462	fn arrow_down_reaches_the_card_below() {
463		let mut context = omp_tui::UiContext::default();
464		context.graphics = omp_tui::Graphics::Cells;
465		let mut ui = build_ui(Size::new(100, 18), context);
466		// Tab reaches the first card; Down must land on the card directly
467		// beneath it — one full 5-column row ahead in the roster — rather
468		// than the next ring neighbor.
469		ui.handle_key(Key::Tab);
470		ui.handle_key(Key::Down);
471		assert_eq!(
472			ui.handle_key(Key::Enter),
473			omp_tui::UiEvent::Pressed(PROVIDERS[5].id.into()),
474			"down picks the card in the row below"
475		);
476	}
477}