omp-tui 0.1.0

Retained-mode terminal UI components, rendering, input, and terminal integration for omp
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
//! Pi `/login` provider roster with vendored logos and live paint statistics.
//!
//! ```sh
//! cargo run -p omp-tui --example companies
//! ```

use std::{collections::HashMap, io, time::Duration};

use omp_tui::{
	App, AppEvent, AppOptions, Cached, Elements, Graphics, Prop, Props, Size, TerminalCaps, Ui,
	UiContext, components::Img, dom,
};

const SCROLL_ID: &str = "login-providers";
const HUD_ID: &str = "paint-hud";
const CHROME_ROWS: u16 = 5;
const ASSET_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/login");
/// Card cell: rounded border (2) + 1-cell side padding around a 12-wide
/// body that fits the 4-cell logo and every shortened name.
///
/// Cards live in a `wrap` row: the layout engine packs as many per line as
/// the width allows and re-centers each line, so the grid reflows on every
/// resize without rebuilding the tree.
const CARD_W: u16 = 16;

struct Provider {
	id:   &'static str,
	/// Short display name sized for one card-grid cell.
	name: &'static str,
}

// Literal getOAuthProviders() order from @omp/ai's provider
// registry; names shortened to fit one grid card.
const PROVIDERS: &[Provider] = &[
	Provider { id: "openai-codex", name: "Codex" },
	Provider { id: "anthropic", name: "Claude" },
	Provider { id: "zai", name: "Z.AI" },
	Provider { id: "zai-coding-plan", name: "Z.AI GLM" },
	Provider { id: "kimi-code", name: "Kimi Code" },
	Provider { id: "openrouter", name: "OpenRouter" },
	Provider { id: "github-copilot", name: "Copilot" },
	Provider { id: "cursor", name: "Cursor" },
	Provider { id: "devin", name: "Devin" },
	Provider { id: "google-antigravity", name: "Antigravity" },
	Provider { id: "google-gemini-cli", name: "Gemini CLI" },
	Provider { id: "openai-codex-device", name: "Codex CLI" },
	Provider { id: "xai", name: "xAI" },
	Provider { id: "xai-oauth", name: "Grok" },
	Provider { id: "gitlab-duo", name: "GitLab Duo" },
	Provider { id: "gitlab-duo-agent", name: "Duo Agent" },
	Provider { id: "alibaba-coding-plan", name: "Alibaba" },
	Provider { id: "alibaba-token-plan", name: "QwenCloud" },
	Provider { id: "aiand", name: "ai&" },
	Provider { id: "zhipu-coding-plan", name: "Zhipu" },
	Provider { id: "umans", name: "Umans" },
	Provider { id: "qwen-portal", name: "Qwen" },
	Provider { id: "sakana", name: "Sakana" },
	Provider { id: "minimax-code", name: "MiniMax" },
	Provider { id: "minimax-code-cn", name: "MiniMax CN" },
	Provider { id: "xiaomi", name: "MiMo" },
	Provider { id: "xiaomi-token-plan-sgp", name: "MiMo SGP" },
	Provider { id: "xiaomi-token-plan-ams", name: "MiMo EU" },
	Provider { id: "xiaomi-token-plan-cn", name: "MiMo CN" },
	Provider { id: "firepass", name: "Fire Pass" },
	Provider { id: "deepseek", name: "DeepSeek" },
	Provider { id: "meta", name: "Meta" },
	Provider { id: "moonshot", name: "Moonshot" },
	Provider { id: "cerebras", name: "Cerebras" },
	Provider { id: "baseten", name: "Baseten" },
	Provider { id: "fireworks", name: "Fireworks" },
	Provider { id: "together", name: "Together" },
	Provider { id: "nvidia", name: "NVIDIA" },
	Provider { id: "novita", name: "Novita" },
	Provider { id: "huggingface", name: "HuggingFace" },
	Provider { id: "perplexity", name: "Perplexity" },
	Provider { id: "qianfan", name: "Qianfan" },
	Provider { id: "venice", name: "Venice" },
	Provider { id: "siliconflow", name: "SiliconFlow" },
	Provider { id: "siliconflow-cn", name: "SF China" },
	Provider { id: "synthetic", name: "Synthetic" },
	Provider { id: "nanogpt", name: "NanoGPT" },
	Provider { id: "wafer-serverless", name: "Wafer" },
	Provider { id: "coreweave", name: "CoreWeave" },
	Provider { id: "vercel-ai-gateway", name: "Vercel AI" },
	Provider { id: "cloudflare-ai-gateway", name: "Cloudflare" },
	Provider { id: "litellm", name: "LiteLLM" },
	Provider { id: "kilo", name: "Kilo" },
	Provider { id: "zenmux", name: "ZenMux" },
	Provider { id: "opencode-zen", name: "OpenCode Zen" },
	Provider { id: "opencode-go", name: "OpenCode Go" },
	Provider { id: "tavily", name: "Tavily" },
	Provider { id: "kagi", name: "Kagi" },
	Provider { id: "exa", name: "Exa" },
	Provider { id: "parallel", name: "Parallel" },
	Provider { id: "ollama", name: "Ollama" },
	Provider { id: "ollama-cloud", name: "Ollama Cloud" },
	Provider { id: "lm-studio", name: "LM Studio" },
	Provider { id: "llama.cpp", name: "llama.cpp" },
	Provider { id: "vllm", name: "vLLM" },
	Provider { id: "gmi-cloud", name: "GMI Cloud" },
];

fn scroll_height(viewport: Size) -> u16 {
	viewport.height.saturating_sub(CHROME_ROWS).max(3)
}

fn build_ui(viewport: Size, context: UiContext) -> Ui {
	let ids = PROVIDERS
		.iter()
		.enumerate()
		.map(|(index, provider)| {
			(format!("{ASSET_DIR}/{}.png", provider.id), u32::try_from(index + 1).unwrap())
		})
		.collect::<HashMap<_, _>>();
	let elements = Elements::builder()
		.with("logo", move |_: &str, props: Props, _: Vec<Cached>| {
			let source = props.str_of(Prop::Src).map_or("", |value| value.as_str());
			let id = ids.get(source).copied().unwrap_or(1);
			Box::new(
				Img::new()
					.with_str(Prop::Src, source)
					.with(Prop::W, 4_u16)
					.kitty(id, 2, 4),
			) as Box<dyn omp_tui::Component>
		})
		.build();
	let root = dom! {
		<col gap=1>
			<row gap=1>
				<i:log-in/>
				<text bold fg="accent..info">{"Choose a provider"}</text>
				<text dim>{format!("{} providers", PROVIDERS.len())}</text>
			</row>
			<scroll id={SCROLL_ID} h={scroll_height(viewport)}>
				<row wrap gap=1 justify=center>
					for provider in PROVIDERS.iter() {
						<box focus id={provider.id} w={CARD_W} border=round bc="muted..muted"
							hover="#38bdf8..#c084fc" lift=1 anim=220 ease=in-out
							align=center pad-x=1>
							<logo src={format!("{ASSET_DIR}/{}.png", provider.id)}/>
							<text bold truncate align=center>{provider.name}</text>
						</box>
					}
				</row>
			</scroll>
			<row gap=2>
				<text dim>{"↹/←→/↑↓ pick · ↵ login · wheel scroll · Ctrl-C quit"}</text>
				<text id={HUD_ID} dim>{"repaint: 0 cells"}</text>
			</row>
		</col>
	};
	let context = UiContext { elements, ..context };
	Ui::from_root(root, viewport.width, context)
}

fn show_stats(app: &mut App, chosen: Option<&str>) {
	let caps = app.caps();
	let stats = app.last_stats();
	let pixels = caps.cell_px.map_or_else(
		|| "cell-px ?".to_owned(),
		|(width, height)| format!("cell-px {width}×{height}"),
	);
	let login = chosen.map_or(String::new(), |id| format!("login: {id} · "));
	app.ui_mut().set_text(
		HUD_ID,
		format!(
			"{login}{} · {} · {} · repaint: {} cells",
			graphics_label(caps.graphics),
			caps.id,
			pixels,
			stats.changed_cells,
		),
	);
}

fn forced_from_args(caps: &TerminalCaps) -> Option<Graphics> {
	let mut forced = None;
	for argument in std::env::args().skip(1) {
		forced = match argument.as_str() {
			"--cells" => Some(Graphics::Cells),
			"--kitty" if caps.kitty_placeholders => Some(Graphics::KittyPlaceholders),
			"--kitty" => Some(Graphics::KittyDirect),
			"--kitty-placeholders" => Some(Graphics::KittyPlaceholders),
			"--sixel" => Some(Graphics::Sixel),
			_ => forced,
		};
	}
	forced
}

#[tokio::main]
async fn main() -> io::Result<()> {
	let mut app = AppOptions::new()
		.mouse()
		.probe(Duration::from_millis(150))
		.graphics_with(forced_from_args)
		.start(|env| build_ui(env.viewport, env.ctx))
		.await?;
	if app.caps().graphics != Graphics::Cells {
		for (index, provider) in PROVIDERS.iter().enumerate() {
			let png = tokio::fs::read(format!("{ASSET_DIR}/{}.png", provider.id)).await?;
			app.renderer_mut().register_image(
				u32::try_from(index + 1).expect("provider count fits image IDs"),
				png,
			)?;
		}
		app.ui_mut().invalidate(SCROLL_ID);
	}
	show_stats(&mut app, None);
	let mut chosen: Option<String> = None;
	while let Some(event) = app.next().await? {
		match event {
			AppEvent::Resized(viewport) => {
				app.ui_mut().set_height(SCROLL_ID, scroll_height(viewport));
			},
			AppEvent::Pressed(id) => chosen = Some(id.to_string()),
			_ => {},
		}
		show_stats(&mut app, chosen.as_deref());
	}
	Ok(())
}

const fn graphics_label(graphics: Graphics) -> &'static str {
	match graphics {
		Graphics::Cells => "cells",
		Graphics::Sixel => "sixel",
		Graphics::KittyDirect => "kitty-direct",
		Graphics::KittyPlaceholders => "kitty-placeholders",
		Graphics::Iterm2 => "iterm2",
	}
}

#[cfg(test)]
mod tests {
	use omp_tui::{Key, Mouse, Renderer, Size, test_support::TerminalModel};

	use super::{CARD_W, PROVIDERS, build_ui, scroll_height};

	fn replay(renderer: &mut Renderer<Vec<u8>>, terminal: &mut TerminalModel) {
		let output =
			String::from_utf8(std::mem::take(renderer.writer_mut())).expect("renderer emits UTF-8");
		terminal.apply(&output);
	}

	/// Renders `ui` at `width`×18 and returns the first row containing a
	/// card's top border.
	fn first_card_line(ui: &omp_tui::Ui, width: u16) -> String {
		let mut renderer = Renderer::new(Vec::new());
		let mut terminal = TerminalModel::new(width.into(), 18);
		renderer
			.rebuild(ui.frame().clone(), 18, 0, "")
			.expect("paint succeeds");
		replay(&mut renderer, &mut terminal);
		terminal
			.visible_rows()
			.iter()
			.find(|row| row.contains(''))
			.cloned()
			.expect("a card border row is visible")
	}

	#[test]
	fn grid_reflows_columns_and_recenters_on_resize() {
		let mut context = omp_tui::UiContext::default();
		context.graphics = omp_tui::Graphics::Cells;
		let mut ui = build_ui(Size::new(100, 18), context);

		// n cards fit when n*CARD_W + (n-1) gaps ≤ width - 1 (scrollbar
		// column), i.e. n = width / (CARD_W + 1).
		let columns = |width: u16| usize::from(width / (CARD_W + 1));
		let centered = |row: &str, inner: usize| {
			let cells: Vec<char> = row.chars().collect();
			let left = cells.iter().position(|&ch| ch == '').unwrap();
			let last = cells.iter().rposition(|&ch| ch == '').unwrap();
			let right = inner - 1 - last;
			assert!(
				left.abs_diff(right) <= 1,
				"line not centered: left pad {left}, right pad {right}: {row}"
			);
		};

		let narrow = first_card_line(&ui, 100);
		assert_eq!(narrow.matches('').count(), columns(100));
		centered(&narrow, 99);

		ui.resize(150);
		let wide = first_card_line(&ui, 150);
		assert_eq!(wide.matches('').count(), columns(150));
		assert!(wide.matches('').count() > narrow.matches('').count());
		centered(&wide, 149);
	}

	#[test]
	fn scrolling_keeps_damage_inside_viewport_and_decodes_vendored_logos() {
		let viewport = Size::new(100, 18);
		let mut context = omp_tui::UiContext::default();
		context.graphics = omp_tui::Graphics::Cells;
		let mut ui = build_ui(viewport, context);
		let mut renderer = Renderer::new(Vec::new());
		let mut terminal = TerminalModel::new(viewport.width.into(), viewport.height.into());
		renderer
			.rebuild(ui.frame().clone(), viewport.height, 0, "")
			.expect("initial paint succeeds");
		replay(&mut renderer, &mut terminal);

		let initial = terminal.visible_rows().join("\n");
		assert!(initial.contains(PROVIDERS[0].name));
		assert!(initial.contains(PROVIDERS[1].name));
		assert!(initial.contains(''), "decoded image must paint half-block cells:\n{initial}");
		assert!(!initial.contains("[img:"), "vendored image unexpectedly failed to decode");

		for (step, key) in [Key::Down, Key::PageDown, Key::Down, Key::PageDown]
			.into_iter()
			.enumerate()
		{
			ui.handle_key(key);
			let stats = ui
				.present(&mut renderer, viewport.height, 0)
				.expect("scroll paint succeeds");
			replay(&mut renderer, &mut terminal);
			let scroll_area = usize::from(viewport.width) * usize::from(scroll_height(viewport));
			assert!(
				stats.changed_cells <= scroll_area,
				"step {step} repainted {} cells outside {scroll_area}-cell scroll viewport",
				stats.changed_cells,
			);
		}

		ui.handle_mouse(1, 3, Mouse::WheelDown);
		let stats = ui
			.present(&mut renderer, viewport.height, 0)
			.expect("wheel paint succeeds");
		let scroll_area = usize::from(viewport.width) * usize::from(scroll_height(viewport));
		assert!(stats.changed_cells <= scroll_area);
	}

	#[test]
	fn kitty_mode_uploads_and_materializes_placeholders() {
		let viewport = Size::new(100, 18);
		let mut context = omp_tui::UiContext::default();
		context.graphics = omp_tui::Graphics::KittyPlaceholders;
		let ui = build_ui(viewport, context);
		let mut renderer = Renderer::new(Vec::new());
		for (index, provider) in PROVIDERS.iter().enumerate() {
			let path = format!("{}/{}.png", super::ASSET_DIR, provider.id);
			renderer
				.register_image(
					u32::try_from(index + 1).expect("provider count fits image IDs"),
					std::fs::read(path).unwrap(),
				)
				.unwrap();
		}
		renderer
			.rebuild(ui.frame().clone(), viewport.height, 0, "")
			.expect("Kitty initial paint succeeds");
		let output = String::from_utf8(renderer.into_inner()).unwrap();
		assert!(output.starts_with("\x1b_Gf=100,t=d,a=t,i=1,q=2,"));
		// Placement id rides `p=`: rows<<9 | cols for a 2×4 logo box.
		assert!(output.contains("\x1b_Ga=p,U=1,i=1,p=1028,r=2,c=4,q=2\x1b\\"));
		assert!(output.contains("\u{10eeee}\u{0305}\u{0305}"));
		// Image ID in the fg, placement ID (1028 = 0:4:4) in the underline
		// color of every placeholder cell.
		assert!(output.contains("38;2;0;0;1m"));
		assert!(output.contains("58:2::0:4:4"));
	}

	#[test]
	fn hovering_a_card_lifts_it_and_recolors_its_ring() {
		let mut context = omp_tui::UiContext::default();
		context.graphics = omp_tui::Graphics::Cells;
		let mut ui = build_ui(Size::new(100, 18), context);
		let rows = |ui: &omp_tui::Ui| -> Vec<String> {
			(0..ui.frame().size().height)
				.map(|y| omp_tui::test_support::frame_row_text(ui.frame(), y))
				.collect()
		};
		let resting = rows(&ui);
		let rest_top = resting
			.iter()
			.position(|row| row.contains(''))
			.expect("cards render a border row");
		let column = resting[rest_top]
			.chars()
			.position(|glyph| glyph == '')
			.unwrap() as u16;

		// Enter the first card, then advance the clock past the 220ms ease.
		ui.handle_mouse(column + 2, rest_top as u16 + 2, Mouse::Move);
		ui.tick(std::time::Duration::from_millis(400));
		let lifted = rows(&ui);
		let lifted_top = lifted.iter().position(|row| row.contains('')).unwrap();
		assert_eq!(lifted_top + 1, rest_top, "the hovered card rises into its headroom");
		let ring = omp_tui::test_support::frame_cell_style(ui.frame(), column + 2, lifted_top as u16)
			.foreground_color();
		assert_ne!(
			ring,
			omp_tui::Theme::default().muted,
			"the border cell over the pointer carries the glow"
		);
		assert!(lifted[rest_top + 4].contains(''), "the vacated row carries the shadow");
	}

	#[test]
	fn keyboard_focus_lifts_a_card_and_enter_presses_it() {
		let mut context = omp_tui::UiContext::default();
		context.graphics = omp_tui::Graphics::Cells;
		let mut ui = build_ui(Size::new(100, 18), context);
		let rows = |ui: &omp_tui::Ui| -> Vec<String> {
			(0..ui.frame().size().height)
				.map(|y| omp_tui::test_support::frame_row_text(ui.frame(), y))
				.collect()
		};
		let rest_top = rows(&ui)
			.iter()
			.position(|row| row.contains(''))
			.expect("cards render a border row");

		// Initial focus sits on the scroll pane; one Tab reaches the first
		// card.
		ui.handle_key(Key::Tab);
		ui.tick(std::time::Duration::from_millis(400));
		let focused_top = rows(&ui).iter().position(|row| row.contains('')).unwrap();
		assert_eq!(focused_top + 1, rest_top, "keyboard focus lifts the card like hover");
		assert_eq!(
			ui.handle_key(Key::Enter),
			omp_tui::UiEvent::Pressed(PROVIDERS[0].id.into()),
			"enter presses the focused card"
		);
	}

	#[test]
	fn arrow_navigation_stays_anchored_and_presses_the_selected_card() {
		let mut context = omp_tui::UiContext::default();
		context.graphics = omp_tui::Graphics::Cells;
		let mut ui = build_ui(Size::new(100, 18), context);
		let rows = |ui: &omp_tui::Ui| -> Vec<String> {
			(0..ui.frame().size().height)
				.map(|y| omp_tui::test_support::frame_row_text(ui.frame(), y))
				.collect()
		};

		// Tab enters the grid, Right steps to the second card. The viewport
		// must stay on the first providers instead of chasing the scrollbar
		// to the end of the roster.
		ui.handle_key(Key::Tab);
		ui.handle_key(Key::Right);
		ui.tick(std::time::Duration::from_millis(400));
		let grid = rows(&ui);
		assert!(
			grid.iter().any(|row| row.contains(PROVIDERS[0].name)),
			"arrow navigation keeps the viewport anchored at the top"
		);
		assert_eq!(
			ui.handle_key(Key::Enter),
			omp_tui::UiEvent::Pressed(PROVIDERS[1].id.into()),
			"enter presses the arrow-selected card"
		);
	}

	#[test]
	fn arrow_down_reaches_the_card_below() {
		let mut context = omp_tui::UiContext::default();
		context.graphics = omp_tui::Graphics::Cells;
		let mut ui = build_ui(Size::new(100, 18), context);
		// Tab reaches the first card; Down must land on the card directly
		// beneath it — one full 5-column row ahead in the roster — rather
		// than the next ring neighbor.
		ui.handle_key(Key::Tab);
		ui.handle_key(Key::Down);
		assert_eq!(
			ui.handle_key(Key::Enter),
			omp_tui::UiEvent::Pressed(PROVIDERS[5].id.into()),
			"down picks the card in the row below"
		);
	}
}