Skip to main content

chat/
demo.rs

1use std::{
2	cell::RefCell,
3	fmt::Write as _,
4	rc::Rc,
5	time::{Duration, Instant},
6};
7
8use omp_core::{Str, StrMut, fmts};
9use omp_tui::{
10	Border, Charset, Color, Command, Component, EditOutcome, Editor, EditorOptions, EventCtx, Flow,
11	Frame, Hit, HitTag, Icon, Key, Mouse, MouseReport, PaintCtx, Prop, Props, Rect, Size,
12	SlashCommands, Slot, Style, SuggestionDisplay, Theme, Ui, UiContext,
13	anim::{Easing, Shimmer, Tween},
14	components::{
15		Attachment, Attachments, EditorPane, Segment, Status, attachment_color, chip_label,
16	},
17	next_slot,
18	syntax::{SyntaxRun, highlight_xml},
19};
20use smallvec::SmallVec;
21
22/// Only panel boxes paint a background; the rest of the chrome is
23/// transparent so the terminal's own backdrop shows through.
24const PANEL: Color = Color::Rgb(12, 15, 18);
25const TEXT: Color = Color::Rgb(194, 198, 204);
26const MUTED: Color = Color::Rgb(110, 116, 124);
27const FAINT: Color = Color::Rgb(72, 78, 86);
28const GREEN: Color = Color::Rgb(81, 196, 112);
29const CYAN: Color = Color::Rgb(62, 190, 203);
30const PURPLE: Color = Color::Rgb(171, 119, 230);
31const GOLD: Color = Color::Rgb(210, 167, 86);
32
33const EDIT_BOX_HEIGHT: u16 = 4;
34const MESSAGE_INTERVAL: Duration = Duration::from_millis(430);
35const EMIT_INTERVAL: Duration = Duration::from_millis(700);
36const LIVE_SHARD_ROWS: u16 = 12;
37/// One crest sweep over the working line's ~57-cell padded track,
38/// matching the classic 30 cells/second pace.
39const SHIMMER_PERIOD: Duration = Duration::from_millis(1900);
40const WORKING_MESSAGE: &str = "Implementing immutable seam commits";
41/// Mock session title: the named task — distinct from the working
42/// narration — resting right-aligned in the air row above the band.
43const SESSION_TITLE: &str = "Immutable Seam Commits & Status Bar Rework";
44/// Nerd-tier cancel hint; tests assert against this exact resolution.
45#[cfg(test)]
46const CANCEL_HINT: &str = Charset::NerdFont.icon(Icon::Cancellable);
47/// Nerd/Unicode-tier composer prompt; tests assert this exact shape.
48#[cfg(test)]
49const INPUT_PROMPT: &str = "╰─";
50/// How long the brand segment fades between the spinner and the omp brand.
51const BRAND_FADE: Duration = Duration::from_millis(450);
52/// Repaint cadence while the brand fade is in flight.
53const FADE_FRAME: Duration = Duration::from_millis(40);
54const STATUS_ID: &str = "status";
55
56#[derive(Clone, Copy)]
57struct Span<'a> {
58	text:  &'a str,
59	style: Style,
60}
61
62impl<'a> Span<'a> {
63	const fn new(text: &'a str, style: Style) -> Self {
64		Self { text, style }
65	}
66}
67
68/// Previous mutable-row text and placement, retained for byte-run updates.
69struct LiveRowCache {
70	label:          StrMut,
71	label_x:        u16,
72	label_shard:    u16,
73	label_progress: u64,
74	label_valid:    bool,
75	prefix_shard:   u16,
76	prefix_phase:   u8,
77	prefix_valid:   bool,
78}
79impl LiveRowCache {
80	fn new() -> Self {
81		Self {
82			label:          StrMut::with_capacity(40),
83			label_x:        0,
84			label_shard:    0,
85			label_progress: 0,
86			label_valid:    false,
87			prefix_shard:   0,
88			prefix_phase:   0,
89			prefix_valid:   false,
90		}
91	}
92}
93
94/// Paints `text` under the working-line crest, advancing `column`.
95/// `start` anchors cell zero so every segment rides one sweep.
96#[allow(clippy::too_many_arguments, reason = "immediate-mode painter threading frame state")]
97fn draw_shimmer(
98	frame: &mut Frame,
99	column: &mut u16,
100	start: u16,
101	y: u16,
102	right: u16,
103	text: &str,
104	shimmer: Shimmer,
105	high: Style,
106) {
107	for grapheme in xutf::graphemes_str(text) {
108		if *column >= right {
109			return;
110		}
111		let style = shimmer.pick(*column - start, ink(FAINT), ink(MUTED), high);
112		let next = frame.put(*column, y, grapheme, style);
113		if next == *column {
114			return;
115		}
116		*column = next;
117	}
118}
119
120fn elapsed_label(elapsed: Duration) -> Str {
121	let seconds = elapsed.as_secs();
122	if seconds < 60 {
123		fmts!("{seconds}s")
124	} else if seconds < 3_600 {
125		fmts!("{}m", seconds / 60)
126	} else {
127		fmts!("{}h", (seconds / 3_600).min(99))
128	}
129}
130
131/// The chat demo's slash-command palette. Editor completion is generic;
132/// this list is the demo application's own — the `Ctrl+K` command palette
133/// surfaces the same entries.
134pub fn demo_commands() -> Vec<Command> {
135	vec![
136		Command::new(
137			"security",
138			"Plan, run, inspect, import, and compare OMP-native security scans",
139			&[],
140		)
141		.with_args(&[
142			("plan", "Draft a scan plan for this workspace", "[focus]"),
143			("run", "Execute the current scan plan", ""),
144			("inspect", "Browse findings from the last scan", "[finding-id]"),
145			("import", "Import an external scan report", "<path>"),
146			("compare", "Diff two scan runs", "<run-a> <run-b>"),
147		])
148		.with_hint("plan|run|inspect|import|compare"),
149		Command::new("attach", "Stage an image attachment on the composer", &[]).with_hint("<path>"),
150		Command::new("settings", "Open settings menu", &[]),
151		Command::new("setup", "Open provider setup", &["providers"]).with_hint("[provider]"),
152		Command::new("plan", "Toggle plan mode (agent plans before executing)", &[]),
153		Command::new("plan-review", "Re-open the plan review for the latest plan", &[]),
154		Command::new("vibe", "Toggle persistent fast worker sessions", &[]),
155		Command::new("goal", "Toggle an autonomous objective for this session", &[])
156			.with_hint("<objective>"),
157		Command::new("guided-goal", "Interview you in chat, then set up goal mode", &[]),
158		Command::new("queue", "Queue a message for after the agent yields", &[]),
159		Command::new("switch", "Switch model for this session (same as alt+p)", &[]),
160		Command::new("fast", "Toggle priority service tier", &[]),
161		Command::new("computer", "Toggle the native computer-use tool", &[]),
162		Command::new("vision", "Control inspect_image vision delegation", &[]),
163		Command::new("prewalk", "Switch to a fast model at the next action", &[]),
164		Command::new("advisor", "Toggle the second-model advisor", &[]),
165		Command::new("export", "Export session to an HTML file", &[]),
166		Command::new("dump", "Copy the session transcript to clipboard", &[]),
167		Command::new("share", "Share session via an encrypted link", &[]),
168		Command::new("collab", "Share this session live via a relay", &[]),
169		Command::new("join", "Join a shared collab session", &[]),
170		Command::new("leave", "Leave the collab session", &[]),
171		Command::new("browser", "Toggle browser headless vs visible mode", &[]),
172		Command::new("copy", "Pick conversation text or code to copy", &[]),
173		Command::new("todo", "View or modify the agent's todo list", &[]),
174		Command::new("session", "Session management commands", &[]),
175		Command::new("jobs", "Show async background jobs status", &[]),
176		Command::new("usage", "Show provider usage and limits", &[]),
177		Command::new("stats", "Launch the local stats dashboard", &[]),
178		Command::new("changelog", "Show changelog entries", &[]),
179		Command::new("hotkeys", "Show all keyboard shortcuts", &[]),
180		Command::new("tools", "Show tools currently visible to the agent", &[]),
181		Command::new("context", "Show estimated context usage breakdown", &[]),
182		Command::new("agents", "Open Agent Control Center dashboard", &[]),
183		Command::new("branch", "Create a new branch from a previous message", &[]),
184		Command::new("fork", "Create a new fork from a previous message", &[]),
185		Command::new("tree", "Navigate the session tree", &[]),
186		Command::new("login", "Login with an OAuth provider", &[]),
187		Command::new("logout", "Logout from an OAuth provider", &[]),
188		Command::new("mcp", "Manage MCP servers", &[]),
189		Command::new("ssh", "Manage SSH hosts", &[]),
190		Command::new("new", "Start a new session", &[]),
191		Command::new("fresh", "Reset provider state without changing the transcript", &[]),
192		Command::new("clear", "Clear conversation context, keeping the session", &[]),
193		Command::new("drop", "Delete the current session and start a new one", &[]),
194		Command::new("compact", "Manually compact the session context", &[]),
195		Command::new("shake", "Drop heavy content from context", &[]),
196		Command::new("handoff", "Hand off context to a new session", &[]),
197		Command::new("resume", "Resume a different session", &[]),
198		Command::new("btw", "Ask an ephemeral side question", &[]),
199		Command::new("tan", "Run a background agent on tangential work", &[]),
200		Command::new("omfg", "Forge a rule from a recurring complaint", &[]),
201		Command::new("retry", "Retry the last failed agent turn", &[]),
202		Command::new("debug", "Open the debug tools selector", &[]),
203		Command::new("memory", "Inspect and operate memory maintenance", &[]),
204		Command::new("rename", "Rename the current session", &[]),
205		Command::new("move", "Move the session to a different directory", &[]),
206		Command::new("add-dir", "Add a workspace directory", &[]),
207		Command::new("remove-dir", "Remove a workspace directory", &[]),
208		Command::new("dirs", "List this session's workspace directories", &[]),
209		Command::new("marketplace", "Manage marketplace plugins", &[]),
210		Command::new("plugins", "View and manage installed plugins", &[]),
211		Command::new("reload-plugins", "Reload skills, commands, hooks, tools, and agents", &[]),
212		Command::new("force", "Force the next turn to use a specific tool", &["force:"]),
213		Command::new("live", "Start Codex-backed realtime voice mode", &[]),
214		Command::new("pause", "Freeze all agents until resumed", &[]),
215		Command::new("quit", "Quit the application", &["q"]),
216	]
217}
218
219/// A submitted message rendered as Markdown (with embedded markup), cached
220/// until the text or the content width changes.
221struct Submission {
222	text:  String,
223	width: u16,
224	/// `None` when the text can't be a markdown document (a literal
225	/// `</md>`, or embedded interactive markup) — painted verbatim instead.
226	view:  Option<Ui>,
227}
228
229impl Submission {
230	fn new(text: String, width: u16, ctx: &UiContext) -> Self {
231		let view = Self::view(&text, width, ctx);
232		Self { text, width, view }
233	}
234
235	fn view(text: &str, width: u16, ctx: &UiContext) -> Option<Ui> {
236		(!text.contains("</md>") && next_ref_tag(text).is_none())
237			.then(|| Ui::from_markup(format!("<md>{text}</md>"), width, ctx.clone()).ok())
238			.flatten()
239	}
240
241	fn resize(&mut self, width: u16, ctx: &UiContext) {
242		if self.width != width {
243			self.width = width;
244			self.view = Self::view(&self.text, width, ctx);
245		}
246	}
247
248	/// Rendered row count, including the fallback's own line count.
249	fn height(&self) -> u16 {
250		self
251			.view
252			.as_ref()
253			.map_or_else(|| explicit_line_count(&self.text), Ui::height)
254	}
255}
256
257/// One append-only transcript entry. The log is retained so a geometry
258/// rebuild can replay every entry at the new width; between rebuilds each
259/// entry is measured and painted exactly once, then never touched again.
260enum Entry {
261	/// The closed command box that opens the session.
262	Command,
263	/// The n-th scripted narration message.
264	Message(usize),
265	/// A finished shard's permanent result line.
266	ShardDone(u16),
267	/// A message submitted through the composer.
268	Submitted(Box<Submission>),
269}
270
271/// Demo-specific focused editor leaf with completion and syntax rendering.
272struct DemoInput {
273	props:   Props,
274	slot:    Slot,
275	editor:  Rc<RefCell<Editor>>,
276	outcome: Rc<RefCell<Option<EditOutcome>>>,
277}
278
279impl DemoInput {
280	fn new(editor: Rc<RefCell<Editor>>, outcome: Rc<RefCell<Option<EditOutcome>>>) -> Self {
281		Self { props: Props::new(), slot: next_slot(), editor, outcome }
282	}
283
284	/// Cells before the editor text: the two-cell prompt plus a gap —
285	/// identical on every tier.
286	const fn input_offset() -> u16 {
287		3
288	}
289
290	/// The `╰─` composer prompt composed from the tier's round border.
291	fn input_prompt(charset: Charset) -> Str {
292		let (_, _, bl, _, horizontal, _) = charset.border(Border::Round);
293		fmts!("{bl}{horizontal}")
294	}
295
296	const fn input_width(width: u16) -> u16 {
297		width.saturating_sub(Self::input_offset()).saturating_sub(1)
298	}
299
300	fn paint_picker(pc: &mut PaintCtx<'_>, rect: Rect, y: u16, editor: &Editor) {
301		let Some(picker) = editor.picker() else {
302			return;
303		};
304		let (start, suggestions) = picker.visible_suggestions();
305		let overflow = picker.len() > suggestions.len();
306		let row_right = rect
307			.x
308			.saturating_add(rect.width.saturating_sub(u16::from(overflow)));
309		let primary_width = suggestions
310			.iter()
311			.filter_map(|suggestion| match suggestion.display() {
312				SuggestionDisplay::Text(name) => Some(visible_width(name).saturating_add(2)),
313				SuggestionDisplay::Emoji { .. } => None,
314			})
315			.max()
316			.unwrap_or(12)
317			.clamp(12, 32);
318
319		for (offset, suggestion) in suggestions.iter().enumerate() {
320			let Ok(offset) = u16::try_from(offset) else {
321				break;
322			};
323			let row = y.saturating_add(offset);
324			if row >= pc.clip {
325				break;
326			}
327			let selected = start + usize::from(offset) == picker.selected();
328			let label = if selected { ink(GREEN) } else { ink(TEXT) };
329			let description = if selected { ink(GREEN) } else { ink(MUTED) };
330			pc.frame.put(
331				rect.x,
332				row,
333				if selected {
334					pc.ctx.charset.cursor()
335				} else {
336					"  "
337				},
338				label,
339			);
340			match suggestion.display() {
341				SuggestionDisplay::Text(name) => {
342					draw_line(
343						pc.frame,
344						rect.x.saturating_add(2),
345						row,
346						row_right.saturating_sub(rect.x.saturating_add(2)),
347						&[Span::new(name, label)],
348					);
349					if let Some(text) = suggestion.description()
350						&& rect.width > 40
351					{
352						let description_x = rect
353							.x
354							.saturating_add(2)
355							.saturating_add(primary_width)
356							.min(row_right);
357						draw_line(
358							pc.frame,
359							description_x,
360							row,
361							row_right.saturating_sub(description_x),
362							&[Span::new(text, description)],
363						);
364					}
365				},
366				SuggestionDisplay::Emoji { emoji, shortcode } => {
367					let mut column = pc.frame.put(rect.x.saturating_add(2), row, emoji, label);
368					column = pc.frame.put(column, row, "  ", label);
369					if shortcode.starts_with(':') {
370						pc.frame.put(column, row, shortcode, label);
371					} else {
372						column = pc.frame.put(column, row, ":", label);
373						column = pc.frame.put(column, row, shortcode, label);
374						pc.frame.put(column, row, ":", label);
375					}
376				},
377			}
378		}
379
380		if overflow && !suggestions.is_empty() {
381			let (track, thumb_glyph) = pc.ctx.charset.scrollbar();
382			let track_x = rect.x.saturating_add(rect.width.saturating_sub(1));
383			for offset in 0..suggestions.len() {
384				let Ok(offset) = u16::try_from(offset) else {
385					break;
386				};
387				pc.frame
388					.put(track_x, y.saturating_add(offset), track, ink(FAINT));
389			}
390			let thumb = picker
391				.selected()
392				.saturating_mul(suggestions.len().saturating_sub(1))
393				/ picker.len().saturating_sub(1);
394			pc.frame.put(
395				track_x,
396				y.saturating_add(u16::try_from(thumb).unwrap_or(u16::MAX)),
397				thumb_glyph,
398				ink(GREEN),
399			);
400		}
401	}
402}
403
404impl Component for DemoInput {
405	fn props(&self) -> &Props {
406		&self.props
407	}
408
409	fn props_mut(&mut self) -> &mut Props {
410		&mut self.props
411	}
412
413	fn slot(&self) -> Slot {
414		self.slot
415	}
416
417	fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
418		(6, 40)
419	}
420
421	fn height(&mut self, _ctx: &UiContext, width: u16) -> u16 {
422		let editor = self.editor.borrow();
423		editor
424			.input_height_for(Self::input_width(width))
425			.saturating_add(editor.picker_height())
426	}
427
428	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
429		pc.hits
430			.push(Hit { rect, slot: self.slot, tag: HitTag::Press });
431		let editor = self.editor.borrow();
432		let input_x = rect.x.saturating_add(Self::input_offset());
433		let input_width = Self::input_width(rect.width);
434		let input_height = editor.input_height_for(input_width);
435		let theme = Theme::default();
436		let mut in_comment = false;
437		for (offset, row) in editor.view(input_width).iter().enumerate() {
438			let row_y = rect
439				.y
440				.saturating_add(u16::try_from(offset).unwrap_or(u16::MAX));
441			if row_y >= pc.clip {
442				break;
443			}
444			if offset == 0 {
445				pc.frame
446					.put(rect.x, row_y, &Self::input_prompt(pc.ctx.charset), ink(FAINT));
447			}
448			let mut spans: SmallVec<Span<'_>, 16> = SmallVec::new();
449			if editor.options().xml {
450				let (runs, next) = highlight_xml(row.text, &theme, in_comment);
451				in_comment = next;
452				push_row_spans(&editor, row.text, &runs, &mut spans);
453			} else {
454				push_row_spans(&editor, row.text, &[], &mut spans);
455			}
456			draw_line(pc.frame, input_x, row_y, input_width, &spans);
457			if let Some(cursor_column) = row.cursor_column {
458				if cursor_column >= visible_width(row.text)
459					&& let Some(hint) = editor.inline_hint()
460				{
461					let hint_x = input_x.saturating_add(cursor_column).saturating_add(1);
462					let width = input_width.saturating_sub(cursor_column.saturating_add(1));
463					draw_line(pc.frame, hint_x, row_y, width, &[Span::new(
464						hint.as_str(),
465						ink(MUTED).dim(),
466					)]);
467				}
468				pc.frame.set_cursor(
469					input_x
470						.saturating_add(cursor_column)
471						.min(rect.x.saturating_add(rect.width.saturating_sub(2))),
472					row_y,
473				);
474			}
475		}
476		Self::paint_picker(pc, rect, rect.y.saturating_add(input_height), &editor);
477	}
478
479	fn focusable(&self) -> bool {
480		true
481	}
482
483	fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
484		let outcome = self.editor.borrow_mut().handle(key);
485		*self.outcome.borrow_mut() = Some(outcome);
486		// The editor owns every key while focused. In particular, an ignored
487		// picker key must not escape into `Ui`'s focus-ring navigation; the
488		// demo applies its quit policy from the recorded `EditOutcome`.
489		Flow::Consumed
490	}
491
492	fn mouse(
493		&mut self,
494		_ec: &mut EventCtx<'_>,
495		_tag: HitTag,
496		at: (u16, u16),
497		rect: Rect,
498		mouse: Mouse,
499	) -> Flow {
500		let width = Self::input_width(rect.width);
501		match mouse {
502			Mouse::Click => {
503				self.editor.borrow_mut().set_cursor_visual_row(
504					usize::from(at.1.saturating_sub(rect.y)),
505					at.0
506						.saturating_sub(rect.x.saturating_add(Self::input_offset())),
507					width,
508				);
509				Flow::Consumed
510			},
511			Mouse::WheelUp | Mouse::WheelDown => {
512				let delta = if mouse == Mouse::WheelUp { -1 } else { 1 };
513				if self
514					.editor
515					.borrow()
516					.scroll_rows(delta, width, usize::from(rect.height))
517				{
518					Flow::Consumed
519				} else {
520					Flow::Skip
521				}
522			},
523			_ => Flow::Skip,
524		}
525	}
526
527	fn paste(&mut self, _ec: &mut EventCtx<'_>, text: &str) -> Flow {
528		if matches!(self.editor.borrow_mut().insert_text(text), EditOutcome::Changed) {
529			Flow::Consumed
530		} else {
531			Flow::Skip
532		}
533	}
534}
535/// Whether the demo is working, and how the status bar's brand segment
536/// blends between its two states.
537struct WorkState {
538	working: bool,
539	/// When the current mode began; the working timer counts from here.
540	since:   Duration,
541	/// Brand foreground: [`GREEN`] while working, [`MUTED`] at rest.
542	fade:    Tween<Color>,
543}
544
545/// Powerline status split into a left brand group — spinner and session
546/// timer while working, the omp brand at rest, the foreground tweening
547/// between the two so neither swap ever snaps — and a right-docked
548/// session group (branch, context, cost). Panes too narrow for both
549/// groups fall back to one left-anchored band that sheds from the tail.
550struct DemoStatus {
551	props:   Props,
552	slot:    Slot,
553	work:    Rc<RefCell<WorkState>>,
554	model:   Rc<RefCell<Str>>,
555	charset: Charset,
556	right:   Status,
557}
558
559impl DemoStatus {
560	fn new(work: Rc<RefCell<WorkState>>, model: Rc<RefCell<Str>>, charset: Charset) -> Self {
561		let mut props = Props::new();
562		props.set(Prop::Id, STATUS_ID);
563		let right = Self::right_group(charset);
564		Self { props, slot: next_slot(), work, model, charset, right }
565	}
566
567	/// One styled band-group shell on the shared dark backdrop.
568	fn group() -> Status {
569		Status::new()
570			.with(Prop::Bg, Color::Rgb(18, 18, 18))
571			.with(Prop::Fg, TEXT)
572	}
573
574	/// The brand segment at `now`: spinner plus session timer while
575	/// working, the omp badge at rest, foreground riding the work fade.
576	fn brand_segment(&self, now: Duration) -> Segment {
577		let work = self.work.borrow();
578		let brand = if work.working {
579			fmts!(
580				"{} {}",
581				self.charset.spinner().at(now),
582				elapsed_label(now.saturating_sub(work.since))
583			)
584		} else {
585			fmts!("{} omp", self.charset.icon(Icon::Omp))
586		};
587		Segment::new()
588			.label(brand)
589			.with(Prop::Fg, work.fade.sample(now))
590	}
591
592	fn model_segment(&self) -> Segment {
593		Segment::new()
594			.label(fmts!("{} {}", self.charset.icon(Icon::Model), self.model.borrow()))
595			.with(Prop::Fg, GREEN)
596	}
597
598	fn git_segment(charset: Charset) -> Segment {
599		Segment::new()
600			.label(fmts!("{} main *5 +9", charset.icon(Icon::Branch)))
601			.with(Prop::Fg, CYAN)
602	}
603
604	fn context_segment(charset: Charset) -> Segment {
605		Segment::new()
606			.label(fmts!("{} 39.1%/1M", charset.icon(Icon::Context)))
607			.with(Prop::Fg, GOLD)
608	}
609
610	fn cost_segment() -> Segment {
611		Segment::new()
612			.label("$60.07 (sub) + $8.65 (adv)")
613			.with(Prop::Fg, PURPLE)
614	}
615
616	/// The left band group: brand and model.
617	fn left_group(&self, now: Duration) -> Status {
618		Self::group()
619			.segment(self.brand_segment(now))
620			.segment(self.model_segment())
621	}
622
623	/// The right band group: branch, context, and cost.
624	fn right_group(charset: Charset) -> Status {
625		Self::group()
626			.with_str(Prop::Align, "right")
627			.segment(Self::git_segment(charset))
628			.segment(Self::context_segment(charset))
629			.segment(Self::cost_segment())
630	}
631
632	/// Every segment in one band, for panes too narrow to split.
633	fn combined(&self, now: Duration) -> Status {
634		Self::group()
635			.segment(self.brand_segment(now))
636			.segment(self.model_segment())
637			.segment(Self::git_segment(self.charset))
638			.segment(Self::context_segment(self.charset))
639			.segment(Self::cost_segment())
640	}
641}
642
643impl Component for DemoStatus {
644	fn props(&self) -> &Props {
645		&self.props
646	}
647
648	fn props_mut(&mut self) -> &mut Props {
649		&mut self.props
650	}
651
652	fn slot(&self) -> Slot {
653		self.slot
654	}
655
656	fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
657		self.combined(Duration::ZERO).measure(ctx)
658	}
659
660	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
661		1
662	}
663
664	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
665		let mut left = self.left_group(pc.now);
666		let (_, left_width) = left.measure(pc.ctx);
667		let (_, right_width) = self.right.measure(pc.ctx);
668		if left_width.saturating_add(2).saturating_add(right_width) <= rect.width {
669			left.paint(pc, Rect::new(rect.x, rect.y, left_width, 1));
670			let dock = rect
671				.x
672				.saturating_add(rect.width)
673				.saturating_sub(right_width);
674			self
675				.right
676				.paint(pc, Rect::new(dock, rect.y, right_width, 1));
677		} else {
678			let mut combined = self.combined(pc.now);
679			combined.paint(pc, rect);
680		}
681		let work = self.work.borrow();
682		let fade_frame = work
683			.fade
684			.settles_at()
685			.min(pc.now.saturating_add(FADE_FRAME));
686		let deadline = match (work.working, work.fade.is_settled(pc.now)) {
687			(true, true) => Some(pc.ctx.charset.spinner().next_change(pc.now)),
688			(true, false) => Some(pc.ctx.charset.spinner().next_change(pc.now).min(fade_frame)),
689			(false, false) => Some(fade_frame),
690			(false, true) => None,
691		};
692		if let Some(at) = deadline {
693			pc.wake(self.slot, at);
694		}
695	}
696
697	fn paints_background(&self) -> bool {
698		false
699	}
700}
701
702/// One retained chat document update and its exact repainted row ranges.
703pub struct RenderedFrame<'a> {
704	pub(crate) frame:       &'a Frame,
705	pub(crate) stable_rows: u16,
706	pub(crate) damage:      SmallVec<(u16, u16), 4>,
707}
708
709/// Produces the animated transcript, work indicator, editor, and status
710/// line demo.
711pub struct Demo {
712	started_at:         Instant,
713	/// Detected presentation context shared by every retained subtree.
714	ctx:                UiContext,
715	/// Cancel hint resolved once through the context's charset.
716	cancel_hint:        &'static str,
717	editor_ui:          Ui,
718	editor:             Rc<RefCell<Editor>>,
719	edit_outcome:       Rc<RefCell<Option<EditOutcome>>>,
720	work:               Rc<RefCell<WorkState>>,
721	last_working:       bool,
722	model:              Rc<RefCell<Str>>,
723	/// Images staged on the composer, previewed above the status line.
724	attachments:        Attachments,
725	/// Append-only transcript log, replayed in full on geometry rebuilds.
726	transcript:         Vec<Entry>,
727	/// Entries already painted into the retained frame.
728	drawn_entries:      usize,
729	/// Rows covered by the drawn entries; doubles as `stable_rows`.
730	transcript_rows:    u16,
731	appended_messages:  usize,
732	emitted_shards:     u16,
733	last_viewport:      Size,
734	height_floor:       u16,
735	frame:              Frame,
736	/// Geometry of the retained live panel chrome.
737	live_panel:         Option<Rect>,
738	/// Reusable text and placement state for the animated shard rows.
739	live_rows:          [LiveRowCache; LIVE_SHARD_ROWS as usize],
740	/// One build buffer rotated through the row caches without reallocating.
741	live_label_scratch: StrMut,
742	/// Columns reserved at the right edge for a composited rail; the
743	/// editor and title dock against the remaining visible width.
744	right_inset:        u16,
745	/// The composer submitted `/switch`; the host opens the model picker.
746	switch_requested:   bool,
747}
748
749impl Demo {
750	/// Starts the demo's animation clock, presenting through the host's
751	/// detected context.
752	pub fn new(ctx: &UiContext) -> Self {
753		let editor = Rc::new(RefCell::new({
754			let mut editor = Editor::new(EditorOptions::default());
755			editor.set_completion(Box::new(SlashCommands::new(demo_commands())));
756			editor
757		}));
758		let edit_outcome = Rc::new(RefCell::new(None));
759		let work = Rc::new(RefCell::new(WorkState {
760			working: true,
761			since:   Duration::ZERO,
762			fade:    Tween::settled(GREEN),
763		}));
764		let model = Rc::new(RefCell::new(Str::new_static("Fable 5++")));
765		let pane = EditorPane::new()
766			.input(DemoInput::new(Rc::clone(&editor), Rc::clone(&edit_outcome)))
767			.status(DemoStatus::new(Rc::clone(&work), Rc::clone(&model), ctx.charset));
768		let attachments = pane.attachments();
769		let editor_ui = Ui::from_root(pane, 0, ctx.clone());
770		Self {
771			started_at: Instant::now(),
772			ctx: ctx.clone(),
773			cancel_hint: ctx.charset.icon(Icon::Cancellable),
774			editor_ui,
775			editor,
776			edit_outcome,
777			work,
778			last_working: true,
779			model,
780			attachments,
781			transcript: vec![Entry::Command],
782			drawn_entries: 0,
783			transcript_rows: 0,
784			appended_messages: 0,
785			emitted_shards: 0,
786			last_viewport: Size::new(0, 0),
787			height_floor: 0,
788			frame: Frame::new(Size::new(0, 0)),
789			live_panel: None,
790			live_rows: std::array::from_fn(|_| LiveRowCache::new()),
791			live_label_scratch: StrMut::with_capacity(40),
792			right_inset: 0,
793			switch_requested: false,
794		}
795	}
796
797	/// Routes a key through the editor and reports whether the demo should
798	/// exit. Quit policy lives here, not in the editor: once the editor
799	/// reports a key unused, `esc` first cancels running work and only quits
800	/// at rest; `ctrl-c` always quits.
801	pub fn handle_key(&mut self, key: Key) -> bool {
802		*self.edit_outcome.borrow_mut() = None;
803		let _ = self.editor_ui.handle_key(key);
804		let outcome = self
805			.edit_outcome
806			.borrow_mut()
807			.take()
808			.unwrap_or(EditOutcome::Ignored);
809		match outcome {
810			EditOutcome::Submitted(text) => {
811				let trimmed = text.trim();
812				if trimmed == "/switch" {
813					self.switch_requested = true;
814					return false;
815				}
816				if let Some(path) = trimmed
817					.strip_prefix("/attach")
818					.filter(|rest| rest.is_empty() || rest.starts_with(' '))
819				{
820					let path = path.trim().to_string();
821					if !path.is_empty() {
822						self.attach_image(&path);
823					}
824					return false;
825				}
826				let _ = self.attachments.take();
827				self.refresh_composer();
828				self
829					.transcript
830					.push(Entry::Submitted(Box::new(Submission::new(
831						text,
832						Self::message_width(self.last_viewport.width),
833						&self.ctx,
834					))));
835				self.set_working(true, self.started_at.elapsed());
836				false
837			},
838			EditOutcome::Changed => {
839				self.reconcile_attachments();
840				false
841			},
842			EditOutcome::Ignored => {
843				if key == Key::Ctrl('c') {
844					return true;
845				}
846				if key != Key::Esc {
847					return false;
848				}
849				if self.work.borrow().working {
850					self.set_working(false, self.started_at.elapsed());
851					return false;
852				}
853				true
854			},
855		}
856	}
857
858	/// Consumes a pending `/switch` request submitted through the composer.
859	pub fn take_switch_request(&mut self) -> bool {
860		std::mem::take(&mut self.switch_requested)
861	}
862
863	/// Routes a document-space mouse report into the editor UI.
864	pub fn handle_mouse(&mut self, report: &MouseReport) {
865		let editor_height = self.editor_ui.height();
866		let editor_y = self.frame.size().height.saturating_sub(editor_height);
867		let editor_bottom = editor_y.saturating_add(editor_height);
868		if report.row < editor_y || report.row >= editor_bottom {
869			return;
870		}
871		let _ = self
872			.editor_ui
873			.handle_mouse(report.col, report.row - editor_y, report.kind);
874	}
875
876	/// Switches the work state and retargets the brand fade. The status bar
877	/// repaints immediately and the fade departs from whatever color is on
878	/// screen, so rapid cancel/resume never snaps.
879	fn set_working(&mut self, working: bool, now: Duration) {
880		{
881			let mut work = self.work.borrow_mut();
882			if work.working == working {
883				return;
884			}
885			work.working = working;
886			work.since = now;
887			let target = if working { GREEN } else { MUTED };
888			work
889				.fade
890				.retarget(now, target, BRAND_FADE, Easing::EaseInOut);
891		}
892		self.editor_ui.invalidate(STATUS_ID);
893	}
894
895	/// Reflects a session model switch in the status bar's model segment.
896	pub fn set_model(&mut self, name: &str) {
897		*self.model.borrow_mut() = Str::from(name);
898		self.editor_ui.invalidate(STATUS_ID);
899	}
900
901	/// Routes sanitized bracketed paste text through the editor. Dropped
902	/// paths to existing image files (quoted, escaped, `file://`, or
903	/// multi-file) and any large paste collapse into composer attachment
904	/// chips instead of raw text.
905	pub fn handle_paste(&mut self, text: &str) {
906		let paths = omp_tui::paste::dropped_paths(text);
907		if !paths.is_empty()
908			&& paths.iter().all(|path| {
909				omp_tui::paste::is_image_path(path) && std::path::Path::new(path.as_str()).is_file()
910			}) {
911			for path in &paths {
912				self.attach_image(path);
913			}
914			return;
915		}
916		if text.lines().count() > 10 || text.len() > 1000 {
917			self.attach_paste(text);
918			return;
919		}
920		let _ = self.editor_ui.handle_paste(text);
921	}
922
923	/// Routes Ctrl+Shift+V clipboard text into the composer verbatim: no
924	/// attachment staging, no large-paste collapse — the text stays inline
925	/// and editable.
926	pub fn handle_paste_raw(&mut self, text: &str) {
927		let _ = self.editor_ui.handle_paste_raw(text);
928	}
929
930	/// Stages `path` on the composer and mentions it in the prompt as an
931	/// atomic `<icon> #N` chip expanding to `<ref image=N/>` on submit.
932	fn attach_image(&mut self, path: &str) {
933		let attachment = self.attachments.push_image(path);
934		let payload = format!("<ref image={}/>", attachment.marker);
935		self.insert_chip(&attachment, &payload);
936	}
937
938	/// Collapses a large paste into a staged attachment card and an atomic
939	/// composer chip expanding back to the pasted text on submit.
940	fn attach_paste(&mut self, text: &str) {
941		let attachment = self.attachments.push_text(text);
942		self.insert_chip(&attachment, text);
943	}
944
945	/// Inserts one attachment chip as an atomic editor reference.
946	fn insert_chip(&mut self, attachment: &Attachment, payload: &str) {
947		let chip = chip_label(attachment, self.ctx.charset);
948		{
949			let mut editor = self.editor.borrow_mut();
950			let _ = editor.insert_reference(&chip, payload);
951			let _ = editor.insert_text(" ");
952		}
953		self.refresh_composer();
954	}
955
956	/// Hides staged attachments whose chip the user deleted from the
957	/// composer (and re-shows them after an undo). Presence is derived
958	/// from the buffer's atomic ranges, never from text matching.
959	fn reconcile_attachments(&mut self) {
960		let charset = self.ctx.charset;
961		let changed = {
962			let editor = self.editor.borrow();
963			let text = editor.text();
964			let ranges = editor.atom_ranges();
965			self.attachments.set_visible(|attachment| {
966				let chip = chip_label(attachment, charset);
967				ranges
968					.iter()
969					.any(|&(start, end)| text.get(start..end) == Some(chip.as_str()))
970			})
971		};
972		if changed {
973			self.refresh_composer();
974		}
975	}
976
977	/// Relayouts the composer after out-of-band state changed its height.
978	fn refresh_composer(&mut self) {
979		let width = self.editor_ui.frame().size().width;
980		if width > 0 {
981			self.editor_ui.resize(width);
982		}
983	}
984
985	/// Reserves `cols` at the right edge for a composited rail, so the
986	/// composer's right-docked chrome stays visible beside it. The next
987	/// render relayouts the editor at the narrowed width.
988	pub const fn set_right_inset(&mut self, cols: u16) {
989		self.right_inset = cols;
990	}
991
992	/// The width the composer may actually occupy at `viewport`.
993	fn composer_width(&self, viewport: Size) -> u16 {
994		viewport.width.saturating_sub(self.right_inset).max(1)
995	}
996
997	/// Updates the retained logical document and reports its repainted rows.
998	pub fn render(&mut self, viewport: Size) -> RenderedFrame<'_> {
999		self.render_at(viewport, self.started_at.elapsed())
1000	}
1001
1002	fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003		if viewport.width == 0 || viewport.height == 0 {
1004			self.last_viewport = viewport;
1005			self.height_floor = 0;
1006			self.drawn_entries = 0;
1007			self.transcript_rows = 0;
1008			self.live_panel = None;
1009			self.frame = Frame::new(viewport);
1010			return RenderedFrame {
1011				frame:       &self.frame,
1012				stable_rows: 0,
1013				damage:      SmallVec::new(),
1014			};
1015		}
1016		let composer_width = self.composer_width(viewport);
1017		if self.editor_ui.frame().size().width != composer_width {
1018			self.editor_ui.resize(composer_width);
1019		}
1020		// Fires due animation wakes (the status bar's spinner and brand
1021		// fade) so the blit below picks up fresh retained pixels.
1022		self.editor_ui.tick(elapsed);
1023		let editor_changed = self.editor_ui.take_frame_damage();
1024
1025		// A viewport change starts a fresh renderer session: replay the
1026		// whole transcript log at the new width. Between rebuilds the log
1027		// is append-only and every drawn row is final, so selections over
1028		// transcript text stay anchored to it in every terminal.
1029		let rebuild = self.last_viewport != viewport;
1030		if rebuild {
1031			self.last_viewport = viewport;
1032			self.height_floor = 0;
1033			self.drawn_entries = 0;
1034			self.transcript_rows = 0;
1035			let message_width = Self::message_width(viewport.width);
1036			for entry in &mut self.transcript {
1037				if let Entry::Submitted(submission) = entry {
1038					submission.resize(message_width, &self.ctx);
1039				}
1040			}
1041		}
1042		while self.appended_messages < Self::visible_messages(elapsed) {
1043			self.transcript.push(Entry::Message(self.appended_messages));
1044			self.appended_messages += 1;
1045		}
1046		while self.emitted_shards < Self::finished_shards(elapsed) {
1047			self.emitted_shards += 1;
1048			self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049		}
1050
1051		let mut new_rows = 0_u16;
1052		for entry in &self.transcript[self.drawn_entries..] {
1053			new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054		}
1055		let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056		let editor_height = self.editor_ui.height();
1057		// Native scrollback is append-only, so the logical document may
1058		// never shrink while the seam is live: band rows that close again
1059		// (extra input lines) become blank padding that heals as the
1060		// transcript grows.
1061		let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062		self.height_floor = self.height_floor.max(natural_height);
1063		let document_height = self.height_floor.max(viewport.height);
1064		let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065		let margin = u16::from(viewport.width >= 50);
1066		let content_width = viewport.width.saturating_sub(margin * 2);
1067		let editor_y = document_height.saturating_sub(editor_height);
1068		let title_y = editor_y.saturating_sub(1);
1069		let working_y = title_y.saturating_sub(1);
1070		let panel_height = LIVE_SHARD_ROWS + 2;
1071		let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072		let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073		let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074		if rebuild {
1075			self.frame = Frame::new(Size::new(viewport.width, document_height));
1076		} else {
1077			self.frame.resize_height(document_height, base_style());
1078		}
1079		if repaint_suffix {
1080			self.frame.fill(
1081				Rect::new(
1082					0,
1083					transcript_damage_start,
1084					viewport.width,
1085					document_height.saturating_sub(transcript_damage_start),
1086				),
1087				base_style(),
1088			);
1089		}
1090
1091		// Paint the new transcript entries; rows above `transcript_rows`
1092		// are final and never repainted.
1093		let mut y = self.transcript_rows;
1094		for index in self.drawn_entries..self.transcript.len() {
1095			let used = self.draw_entry_at(index, y, viewport.width);
1096			y = y.saturating_add(used);
1097		}
1098		self.drawn_entries = self.transcript.len();
1099		self.transcript_rows = y;
1100
1101		// The live band repaints in place at the bottom of the document.
1102		let animation_frame = Self::animation_frame(elapsed);
1103		let panel_changed = draw_live_panel(
1104			&mut self.frame,
1105			&mut self.live_rows,
1106			&mut self.live_label_scratch,
1107			panel,
1108			repaint_suffix,
1109			self.emitted_shards,
1110			animation_frame,
1111			self.ctx.charset,
1112		);
1113		let working = self.work.borrow().working;
1114		let working_changed = self.last_working != working;
1115		if !repaint_suffix && self.last_working && !working {
1116			self
1117				.frame
1118				.fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119		}
1120		if working {
1121			Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122		}
1123		Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124		if repaint_suffix || editor_changed {
1125			self
1126				.frame
1127				.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128		}
1129		let mut damage = SmallVec::new();
1130		if repaint_suffix {
1131			damage.push((transcript_damage_start, document_height));
1132		} else {
1133			if panel_changed {
1134				damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135			}
1136			if working || working_changed {
1137				damage.push((working_y, working_y.saturating_add(1)));
1138			}
1139			if editor_changed {
1140				damage.push((editor_y, document_height));
1141			}
1142		}
1143		self.last_working = working;
1144		self.live_panel = Some(panel);
1145
1146		RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147	}
1148
1149	fn generation(elapsed: Duration) -> u64 {
1150		u64::try_from(elapsed.as_millis() / EMIT_INTERVAL.as_millis()).unwrap_or(u64::MAX)
1151	}
1152
1153	fn animation_frame(elapsed: Duration) -> u64 {
1154		u64::try_from(elapsed.as_millis() / 80).unwrap_or(u64::MAX)
1155	}
1156
1157	fn visible_messages(elapsed: Duration) -> usize {
1158		let interval = MESSAGE_INTERVAL.as_millis();
1159		usize::try_from(elapsed.as_millis() / interval + 1)
1160			.unwrap_or(usize::MAX)
1161			.min(4)
1162	}
1163
1164	/// Shards whose permanent result line has been appended by `elapsed`:
1165	/// two per emit tick, capped well inside `u16` document heights.
1166	fn finished_shards(elapsed: Duration) -> u16 {
1167		u16::try_from(Self::generation(elapsed).saturating_mul(2).min(60_000))
1168			.expect("finished shard count is clamped")
1169	}
1170
1171	/// Rows the bottom live band occupies: the shard panel, a blank
1172	/// separator, the activity row, the title air row, and the editor
1173	/// block.
1174	const fn band_height(editor_height: u16) -> u16 {
1175		LIVE_SHARD_ROWS + 2 + 3 + editor_height
1176	}
1177
1178	/// Rows `entry` will occupy at `width`, including its trailing blank.
1179	fn entry_height(entry: &Entry, width: u16, ctx: &UiContext) -> u16 {
1180		match entry {
1181			Entry::Command => 5,
1182			Entry::Message(message) => {
1183				let mut scratch = Frame::new(Size::new(width, 48));
1184				Self::draw_message(&mut scratch, 0, *message, width, ctx.charset)
1185			},
1186			Entry::ShardDone(_) => 1,
1187			Entry::Submitted(submission) => submission.height().saturating_add(1),
1188		}
1189	}
1190
1191	const fn message_width(width: u16) -> u16 {
1192		let narrowed = width.saturating_sub(3);
1193		if narrowed == 0 { 1 } else { narrowed }
1194	}
1195
1196	/// Paints one transcript entry at `y` and returns the rows it used.
1197	fn draw_entry_at(&mut self, index: usize, y: u16, width: u16) -> u16 {
1198		Self::draw_entry(&mut self.frame, &self.transcript[index], y, width, &self.ctx)
1199	}
1200
1201	/// Paints `entry` into any frame at `y` and returns the rows it used,
1202	/// including the trailing blank.
1203	fn draw_entry(frame: &mut Frame, entry: &Entry, y: u16, width: u16, ctx: &UiContext) -> u16 {
1204		let margin = u16::from(width >= 50);
1205		let content_width = width.saturating_sub(margin * 2);
1206		match entry {
1207			Entry::Command => {
1208				draw_command_box(frame, Rect::new(margin, y, content_width, 4), ctx.charset);
1209				5
1210			},
1211			Entry::Message(message) => Self::draw_message(frame, y, *message, width, ctx.charset),
1212			Entry::ShardDone(shard) => {
1213				Self::draw_shard_done(frame, y, *shard, width, ctx.charset);
1214				1
1215			},
1216			Entry::Submitted(submission) => {
1217				draw_submission(frame, y, submission, ctx.charset);
1218				submission.height().saturating_add(1)
1219			},
1220		}
1221	}
1222
1223	/// Composes exactly one viewport of throwaway resize-drag content at the
1224	/// new geometry: the live band anchors to the bottom, then transcript
1225	/// entries are walked backward and rewrapped at `viewport.width` until
1226	/// the screen is full — O(viewport) work per drag frame, with the
1227	/// topmost entry sliced when it only partially fits. Retained transcript
1228	/// state is untouched, so the settle rebuild replays full history
1229	/// exactly once.
1230	pub fn render_resize_preview(&mut self, viewport: Size) -> Frame {
1231		let elapsed = self.started_at.elapsed();
1232		let mut frame = Frame::new(viewport);
1233		if viewport.width == 0 || viewport.height == 0 {
1234			return frame;
1235		}
1236		frame.fill(Rect::new(0, 0, viewport.width, viewport.height), base_style());
1237		let composer_width = self.composer_width(viewport);
1238		if self.editor_ui.frame().size().width != composer_width {
1239			self.editor_ui.resize(composer_width);
1240		}
1241		self.editor_ui.tick(elapsed);
1242
1243		// The live band, laid out exactly like the retained document's.
1244		let margin = u16::from(viewport.width >= 50);
1245		let content_width = viewport.width.saturating_sub(margin * 2);
1246		let editor_height = self.editor_ui.height();
1247		let editor_y = viewport.height.saturating_sub(editor_height);
1248		let title_y = editor_y.saturating_sub(1);
1249		let working_y = title_y.saturating_sub(1);
1250		let panel_height = LIVE_SHARD_ROWS + 2;
1251		let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1252		draw_live_panel(
1253			&mut frame,
1254			&mut self.live_rows,
1255			&mut self.live_label_scratch,
1256			Rect::new(margin, panel_y, content_width, panel_height),
1257			true,
1258			self.emitted_shards,
1259			Self::animation_frame(elapsed),
1260			self.ctx.charset,
1261		);
1262		if self.work.borrow().working {
1263			Self::draw_working(&mut frame, working_y, elapsed, self.cancel_hint);
1264		}
1265		Self::draw_session_title(&mut frame, title_y, self.right_inset);
1266		frame.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1267
1268		// Transcript tail, bottom-up above the band.
1269		let mut remaining = panel_y;
1270		for entry in self.transcript.iter().rev() {
1271			if remaining == 0 {
1272				break;
1273			}
1274			let height = Self::entry_height(entry, viewport.width, &self.ctx);
1275			if height == 0 {
1276				continue;
1277			}
1278			if height <= remaining {
1279				remaining -= height;
1280				Self::draw_entry(&mut frame, entry, remaining, viewport.width, &self.ctx);
1281			} else {
1282				// Slice the bottom rows of the partially visible entry.
1283				let mut scratch = Frame::new(Size::new(viewport.width, height));
1284				scratch.fill(Rect::new(0, 0, viewport.width, height), base_style());
1285				Self::draw_entry(&mut scratch, entry, 0, viewport.width, &self.ctx);
1286				frame.blit(&scratch, height - remaining, remaining, 0, 0);
1287				remaining = 0;
1288			}
1289		}
1290		frame
1291	}
1292
1293	/// Paints the n-th scripted message and returns rows used including
1294	/// the trailing blank. Measurement draws into a scratch frame.
1295	fn draw_message(frame: &mut Frame, y: u16, message: usize, width: u16, charset: Charset) -> u16 {
1296		let margin = u16::from(width >= 50);
1297		let content_width = width.saturating_sub(margin * 2);
1298		if message == 2 {
1299			draw_edit_box(frame, Rect::new(margin, y, content_width, EDIT_BOX_HEIGHT), charset);
1300			return EDIT_BOX_HEIGHT + 1;
1301		}
1302		let bottom = frame.size().height;
1303		let spans = Self::message_spans(message);
1304		// Prose flows edge-to-edge grapheme-exact — no side pads — so every
1305		// wrapped row re-joins byte-for-byte in native selection.
1306		let used = draw_flowed(frame, Rect::new(0, y, width, bottom.saturating_sub(y)), &spans);
1307		used.saturating_add(1)
1308	}
1309
1310	fn message_spans(message: usize) -> SmallVec<Span<'static>, 3> {
1311		let mut spans = SmallVec::new();
1312		match message {
1313			0 => {
1314				spans.push(Span::new("Transcript rows are ", prose_style()));
1315				spans.push(Span::new("append-only", code_style()));
1316				spans.push(Span::new(
1317					": every line is painted once, becomes stable, and rides into native scrollback \
1318					 with any selection anchored to it.",
1319					prose_style(),
1320				));
1321			},
1322			1 => {
1323				spans.push(Span::new(
1324					"Only the bottom band repaints in place — the live shard panel, the activity \
1325					 shimmer, and the composer. Rows above it are never rewritten.",
1326					prose_style(),
1327				));
1328			},
1329			_ => {
1330				spans.push(Span::new(
1331					"On terminals that move margin-scrolled rows into scrollback, commits scroll only \
1332					 the stable transcript through a ",
1333					prose_style(),
1334				));
1335				spans.push(Span::new("DECSTBM top region", code_style()));
1336				spans.push(Span::new(", so the live band never shifts on screen.", prose_style()));
1337			},
1338		}
1339		spans
1340	}
1341
1342	/// Appends a finished shard's permanent one-line result.
1343	fn draw_shard_done(frame: &mut Frame, y: u16, shard: u16, width: u16, charset: Charset) {
1344		let margin = u16::from(width >= 50);
1345		let prefix = fmts!(" {} shard {shard:03} passed", charset.check());
1346		let detail = fmts!("  workspace-{shard:03}.test.ts  [100%]");
1347		draw_line(frame, margin + 1, y, width.saturating_sub(margin * 2).saturating_sub(2), &[
1348			Span::new(prefix.as_str(), ink(GREEN)),
1349			Span::new(detail.as_str(), ink(MUTED)),
1350		]);
1351	}
1352
1353	/// Shimmering activity line above the editor. The spinner and timer
1354	/// live in the status bar's brand segment; this row only narrates.
1355	fn draw_working(frame: &mut Frame, y: u16, elapsed: Duration, hint: &str) {
1356		if y >= frame.size().height || frame.size().width < 4 {
1357			return;
1358		}
1359		let start = u16::from(frame.size().width >= 50);
1360		let mut column = start;
1361		let length = xutf::graphemes_str(WORKING_MESSAGE)
1362			.count()
1363			.saturating_add(xutf::graphemes_str(hint).count())
1364			.saturating_add(1);
1365		let length = u16::try_from(length).unwrap_or(u16::MAX);
1366		let shimmer = Shimmer::new(elapsed, SHIMMER_PERIOD, length);
1367		let right = frame.size().width.saturating_sub(1);
1368		draw_shimmer(frame, &mut column, start, y, right, hint, shimmer, ink(CYAN));
1369		draw_shimmer(frame, &mut column, start, y, right, " ", shimmer, ink(GREEN));
1370		draw_shimmer(frame, &mut column, start, y, right, WORKING_MESSAGE, shimmer, ink(GREEN));
1371	}
1372
1373	/// The session title resting right-aligned in the air row between
1374	/// the working narration and the status band — against the visible
1375	/// right bound, inside any rail reservation — so the gap reads as
1376	/// session identity instead of dead space.
1377	fn draw_session_title(frame: &mut Frame, y: u16, right_inset: u16) {
1378		let width = frame.size().width.saturating_sub(right_inset);
1379		let title_width = visible_width(SESSION_TITLE);
1380		if y >= frame.size().height || width < title_width.saturating_add(2) {
1381			return;
1382		}
1383		let x = width.saturating_sub(title_width.saturating_add(1));
1384		draw_line(frame, x, y, title_width, &[Span::new(SESSION_TITLE, ink(FAINT).italic())]);
1385	}
1386}
1387
1388/// The closed four-row command box that opens the transcript.
1389fn draw_command_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1390	draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1391	if rect.width < 4 || rect.height < 4 {
1392		return;
1393	}
1394
1395	let content_x = rect.x + 2;
1396	let content_width = rect.width.saturating_sub(4);
1397	let header = [
1398		Span::new(" PARALLEL TEST RUN ", panel_ink(GREEN).bold()),
1399		Span::new("results append below · live rows in the bottom panel", panel_ink(MUTED)),
1400	];
1401	draw_line(frame, content_x, rect.y + 1, content_width, &header);
1402	let command = [
1403		Span::new("$ ", panel_ink(MUTED)),
1404		Span::new("bun test --parallel=8", panel_ink(CYAN)),
1405		Span::new(" --timeout=30000 --all-workspaces", panel_ink(TEXT)),
1406	];
1407	draw_line(frame, content_x, rect.y + 2, content_width, &command);
1408}
1409
1410/// The live band's shard panel: twelve mutable rows that repaint in place
1411/// every frame and never enter native scrollback.
1412fn draw_live_panel(
1413	frame: &mut Frame,
1414	rows: &mut [LiveRowCache; LIVE_SHARD_ROWS as usize],
1415	label_scratch: &mut StrMut,
1416	rect: Rect,
1417	repaint_chrome: bool,
1418	emitted_shards: u16,
1419	animation_frame: u64,
1420	charset: Charset,
1421) -> bool {
1422	let mut changed = repaint_chrome;
1423	if repaint_chrome {
1424		draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1425	}
1426	if rect.width < 4 || rect.height < 3 {
1427		return changed;
1428	}
1429
1430	if repaint_chrome {
1431		let title = [
1432			Span::new(" LIVE SHARDS ", panel_ink(GREEN).bold()),
1433			Span::new("mutable rows repaint in place ", panel_ink(MUTED)),
1434		];
1435		draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1436	}
1437	let content_x = rect.x + 2;
1438	let content_width = rect.width.saturating_sub(4);
1439	for row in 0..rect.height.saturating_sub(2) {
1440		let shard = emitted_shards.saturating_add(row).saturating_add(1);
1441		let phase = (u64::from(row) + animation_frame) % 11;
1442		let (prefix_phase, symbol, state, state_style, progress) = match phase {
1443			0 => (
1444				0,
1445				"⠼",
1446				"running",
1447				panel_ink(GREEN).bold(),
1448				(u64::from(row) * 17 + animation_frame * 7) % 100,
1449			),
1450			1..=7 => {
1451				(1, "·", "working", panel_ink(MUTED), (u64::from(row) * 17 + animation_frame * 7) % 100)
1452			},
1453			_ => (2, "·", "queued ", panel_ink(FAINT), 0),
1454		};
1455		let row_y = rect.y + 1 + row;
1456		let right = content_x
1457			.saturating_add(content_width)
1458			.min(frame.size().width);
1459		let cache = &mut rows[usize::from(row)];
1460		let prefix_changed = repaint_chrome
1461			|| !cache.prefix_valid
1462			|| cache.prefix_shard != shard
1463			|| cache.prefix_phase != prefix_phase;
1464		changed |= prefix_changed;
1465		let label_x = if prefix_changed {
1466			let prefix = fmts!(" {symbol} shard {shard:03} ");
1467			let prefix_width = prefix.len().saturating_sub(symbol.len()).saturating_add(1);
1468			let next_x = content_x
1469				.saturating_add(u16::try_from(prefix_width).unwrap_or(u16::MAX))
1470				.saturating_add(u16::try_from(state.len()).unwrap_or(u16::MAX))
1471				.saturating_add(2)
1472				.min(right);
1473			if !repaint_chrome && cache.label_x != next_x {
1474				clear_cached_label(frame, cache, row_y, right);
1475			}
1476			let next_x = draw_line(frame, content_x, row_y, content_width, &[
1477				Span::new(prefix.as_str(), state_style),
1478				Span::new(state, state_style),
1479				Span::new("  ", panel_ink(FAINT)),
1480			]);
1481			cache.prefix_shard = shard;
1482			cache.prefix_phase = prefix_phase;
1483			cache.prefix_valid = true;
1484			next_x
1485		} else {
1486			cache.label_x
1487		};
1488		let moved = cache.label_x != label_x;
1489		let label_changed = repaint_chrome
1490			|| moved
1491			|| !cache.label_valid
1492			|| cache.label_shard != shard
1493			|| cache.label_progress != progress;
1494		changed |= label_changed;
1495		if label_changed {
1496			label_scratch.truncate(0);
1497			write!(label_scratch, "workspace-{shard:03}.test.ts  [{progress:>3}%]")
1498				.expect("shard label formatting is infallible");
1499			let resized = cache.label.len() != label_scratch.len();
1500			if !repaint_chrome && resized && !moved {
1501				clear_cached_label(frame, cache, row_y, right);
1502			}
1503			let width = right.saturating_sub(label_x);
1504			if repaint_chrome || moved || resized {
1505				frame.put_clipped(label_x, row_y, width, label_scratch.as_str(), panel_ink(MUTED));
1506			} else {
1507				draw_ascii_changes(
1508					frame,
1509					label_x,
1510					row_y,
1511					width,
1512					cache.label.as_str(),
1513					label_scratch.as_str(),
1514					panel_ink(MUTED),
1515				);
1516			}
1517			std::mem::swap(&mut cache.label, label_scratch);
1518			cache.label_shard = shard;
1519			cache.label_progress = progress;
1520			cache.label_valid = true;
1521		}
1522		cache.label_x = label_x;
1523	}
1524	changed
1525}
1526
1527fn clear_cached_label(frame: &mut Frame, cache: &LiveRowCache, y: u16, right: u16) {
1528	if cache.label.is_empty() {
1529		return;
1530	}
1531	let width = u16::try_from(cache.label.len())
1532		.unwrap_or(u16::MAX)
1533		.min(right.saturating_sub(cache.label_x));
1534	frame.fill(Rect::new(cache.label_x, y, width, 1), panel_style());
1535}
1536
1537/// Repaints only changed byte runs within an equal-length ASCII label.
1538fn draw_ascii_changes(
1539	frame: &mut Frame,
1540	x: u16,
1541	y: u16,
1542	width: u16,
1543	previous: &str,
1544	next: &str,
1545	style: Style,
1546) {
1547	if width == 0 || previous == next {
1548		return;
1549	}
1550	if previous.len() != next.len() || !previous.is_ascii() || !next.is_ascii() {
1551		frame.put_clipped(x, y, width, next, style);
1552		return;
1553	}
1554	let previous = previous.as_bytes();
1555	let next_bytes = next.as_bytes();
1556	let limit = previous.len().min(usize::from(width));
1557	let mut index = 0;
1558	while index < limit {
1559		while index < limit && previous[index] == next_bytes[index] {
1560			index += 1;
1561		}
1562		let start = index;
1563		while index < limit && previous[index] != next_bytes[index] {
1564			index += 1;
1565		}
1566		if start < index {
1567			let offset = u16::try_from(start).unwrap_or(u16::MAX);
1568			frame.put_clipped(
1569				x.saturating_add(offset),
1570				y,
1571				u16::try_from(index - start).unwrap_or(u16::MAX),
1572				&next[start..index],
1573				style,
1574			);
1575		}
1576	}
1577}
1578
1579fn draw_edit_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1580	draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1581	if rect.width < 8 || rect.height < EDIT_BOX_HEIGHT {
1582		return;
1583	}
1584
1585	let title = [
1586		Span::new(" Live ", panel_ink(GREEN).bold()),
1587		Span::new("band · selection semantics ", panel_ink(CYAN)),
1588	];
1589	draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1590	draw_line(frame, rect.x + 2, rect.y + 1, rect.width.saturating_sub(4), &[
1591		Span::new(charset.check(), panel_ink(GREEN).bold()),
1592		Span::new(" ", panel_ink(GREEN)),
1593		Span::new("Transcript selections ride with the text", panel_ink(TEXT)),
1594	]);
1595	draw_line(frame, rect.x + 2, rect.y + 2, rect.width.saturating_sub(4), &[Span::new(
1596		"  margin commits pin the band on kitty-class terminals",
1597		panel_ink(MUTED),
1598	)]);
1599}
1600
1601/// Paints a submitted message: the prompt gutter, then the rendered
1602/// Markdown document blitted beside it (or the raw lines when the text
1603/// isn't renderable as Markdown).
1604fn draw_submission(frame: &mut Frame, y: u16, submission: &Submission, charset: Charset) {
1605	if frame.size().width < 4 {
1606		return;
1607	}
1608	let Some(view) = &submission.view else {
1609		for (offset, line) in submission.text.split('\n').enumerate() {
1610			let Ok(offset) = u16::try_from(offset) else {
1611				break;
1612			};
1613			let row = y.saturating_add(offset);
1614			if row >= frame.size().height {
1615				break;
1616			}
1617			let prompt = if offset == 0 { charset.cursor() } else { "  " };
1618			let text_x = frame.put(1, row, prompt, ink(GREEN).bold());
1619			let width = frame
1620				.size()
1621				.width
1622				.saturating_sub(1)
1623				.saturating_sub(text_x.saturating_sub(1));
1624			draw_submission_text(frame, text_x, row, width, line, charset);
1625		}
1626		return;
1627	};
1628	frame.put(1, y, charset.cursor(), ink(GREEN).bold());
1629	frame.blit(view.frame(), 0, view.height(), 3, y);
1630}
1631fn explicit_line_count(text: &str) -> u16 {
1632	u16::try_from(
1633		text
1634			.bytes()
1635			.filter(|byte| *byte == b'\n')
1636			.count()
1637			.saturating_add(1),
1638	)
1639	.unwrap_or(u16::MAX)
1640}
1641
1642/// Paints a rounded panel box through the tier's border glyphs.
1643fn draw_box(frame: &mut Frame, rect: Rect, border: Style, fill: Style, charset: Charset) {
1644	if rect.width == 0 || rect.height == 0 {
1645		return;
1646	}
1647	let (tl, tr, _, _, horizontal, vertical) = charset.border(Border::Round);
1648	frame.fill(rect, fill);
1649	let mut glyph = [0_u8; 4];
1650	if rect.width == 1 {
1651		frame.put(rect.x, rect.y, vertical.encode_utf8(&mut glyph), border);
1652		return;
1653	}
1654
1655	let right = rect.x + rect.width - 1;
1656	let bottom = rect.y + rect.height - 1;
1657	frame.put(rect.x, rect.y, tl.encode_utf8(&mut glyph), border);
1658	frame.put(right, rect.y, tr.encode_utf8(&mut glyph), border);
1659	for x in rect.x + 1..right {
1660		frame.put(x, rect.y, horizontal.encode_utf8(&mut glyph), border);
1661	}
1662
1663	if rect.height > 1 {
1664		draw_box_bottom(frame, rect, border, charset);
1665	}
1666	for row in rect.y + 1..bottom {
1667		frame.put(rect.x, row, vertical.encode_utf8(&mut glyph), border);
1668		frame.put(right, row, vertical.encode_utf8(&mut glyph), border);
1669	}
1670}
1671
1672fn draw_box_bottom(frame: &mut Frame, rect: Rect, border: Style, charset: Charset) {
1673	if rect.width < 2 || rect.height < 2 {
1674		return;
1675	}
1676	let (_, _, bl, br, horizontal, _) = charset.border(Border::Round);
1677	let mut glyph = [0_u8; 4];
1678	let right = rect.x + rect.width - 1;
1679	let bottom = rect.y + rect.height - 1;
1680	frame.put(rect.x, bottom, bl.encode_utf8(&mut glyph), border);
1681	frame.put(right, bottom, br.encode_utf8(&mut glyph), border);
1682	for x in rect.x + 1..right {
1683		frame.put(x, bottom, horizontal.encode_utf8(&mut glyph), border);
1684	}
1685}
1686
1687fn draw_line(frame: &mut Frame, x: u16, y: u16, width: u16, spans: &[Span<'_>]) -> u16 {
1688	let right = x.saturating_add(width).min(frame.size().width);
1689	let mut column = x;
1690	for span in spans {
1691		column = frame.put_clipped(column, y, right.saturating_sub(column), span.text, span.style);
1692		if column >= right {
1693			break;
1694		}
1695	}
1696	column
1697}
1698
1699/// Flows `spans` grapheme-exact across the rect like a bare terminal,
1700/// preserving all whitespace and flagging each exactly-filled row boundary
1701/// soft so native selection copies the paragraph as one unbroken line.
1702/// Returns the rows used.
1703fn draw_flowed(frame: &mut Frame, rect: Rect, spans: &[Span<'_>]) -> u16 {
1704	if rect.width == 0 || rect.height == 0 {
1705		return 0;
1706	}
1707	let full_row = rect.x == 0 && rect.width == frame.size().width;
1708	let mut row = 0_u16;
1709	let mut column = 0_u16;
1710	let mut drew_anything = false;
1711
1712	for span in spans {
1713		for grapheme in xutf::graphemes_str(span.text) {
1714			let grapheme_width = visible_width(grapheme);
1715			if grapheme_width == 0 || grapheme_width > rect.width {
1716				continue;
1717			}
1718			if column.saturating_add(grapheme_width) > rect.width {
1719				// Only an exactly-filled row is byte-joinable by autowrap.
1720				if full_row && column == rect.width {
1721					frame.set_soft_wrap(rect.y.saturating_add(row));
1722				}
1723				row += 1;
1724				column = 0;
1725			}
1726			if row >= rect.height {
1727				return rect.height;
1728			}
1729			frame.put(rect.x + column, rect.y + row, grapheme, span.style);
1730			column += grapheme_width;
1731			drew_anything = true;
1732		}
1733	}
1734
1735	if drew_anything { row + 1 } else { 0 }
1736}
1737
1738fn visible_width(text: &str) -> u16 {
1739	u16::try_from(xutf::width_str(text)).unwrap_or(u16::MAX)
1740}
1741
1742/// Finds the first `<ref image=N/>` tag in submitted text: its byte range
1743/// plus the marker number `N`.
1744fn next_ref_tag(text: &str) -> Option<(usize, usize, usize)> {
1745	const HEAD: &str = "<ref image=";
1746	let mut from = 0;
1747	while let Some(at) = text[from..].find(HEAD) {
1748		let start = from + at;
1749		let body = &text[start + HEAD.len()..];
1750		let digits = body.bytes().take_while(u8::is_ascii_digit).count();
1751		if digits > 0 && body[digits..].starts_with("/>") {
1752			let marker = body[..digits].parse().unwrap_or(usize::MAX);
1753			return Some((start, start + HEAD.len() + digits + 2, marker));
1754		}
1755		from = start + HEAD.len();
1756	}
1757	None
1758}
1759
1760/// Splits one composer input row into base-styled text and attachment
1761/// chips, chip styling winning over any overlapping XML run.
1762///
1763/// Chips are located through the buffer's atomic ranges — like the XML
1764/// pass, styling happens at paint time, and typed lookalike text is never
1765/// recolored.
1766fn push_row_spans<'a>(
1767	editor: &Editor,
1768	row: &'a str,
1769	runs: &[SyntaxRun],
1770	spans: &mut SmallVec<Span<'a>, 16>,
1771) {
1772	let text = editor.text();
1773	let buffer_start = text.as_ptr() as usize;
1774	let row_start = (row.as_ptr() as usize).saturating_sub(buffer_start);
1775	let row_end = row_start + row.len();
1776	// Chip segments clipped to this row; style derives from the FULL atom
1777	// text, so a chip wrapped across rows keeps its color on every row.
1778	let mut chips: SmallVec<(usize, usize, Style), 4> = SmallVec::new();
1779	for (start, end) in editor.atom_ranges() {
1780		let from = start.max(row_start);
1781		let to = end.min(row_end);
1782		if from < to {
1783			chips.push((from - row_start, to - row_start, chip_style(&text[start..end])));
1784		}
1785	}
1786	// The style and extent of the base segment covering `at`: its XML run,
1787	// or plain text up to the next run.
1788	let base = |at: usize| {
1789		runs
1790			.iter()
1791			.find(|run| run.start <= at && at < run.end)
1792			.map_or_else(
1793				|| {
1794					let next = runs
1795						.iter()
1796						.map(|run| run.start)
1797						.filter(|start| *start > at)
1798						.min()
1799						.unwrap_or(row.len());
1800					(next, base_style())
1801				},
1802				|run| (run.end, run.style),
1803			)
1804	};
1805	fn emit<'a>(
1806		row: &'a str,
1807		base: &impl Fn(usize) -> (usize, Style),
1808		from: usize,
1809		to: usize,
1810		spans: &mut SmallVec<Span<'a>, 16>,
1811	) {
1812		let mut at = from;
1813		while at < to {
1814			let (run_end, style) = base(at);
1815			let end = run_end.min(to);
1816			spans.push(Span::new(&row[at..end], style));
1817			at = end;
1818		}
1819	}
1820	let mut at = 0;
1821	for (start, end, style) in chips {
1822		emit(row, &base, at, start, spans);
1823		spans.push(Span::new(&row[start..end], style));
1824		at = end;
1825	}
1826	emit(row, &base, at, row.len(), spans);
1827}
1828
1829/// Style for one atomic chip: a trailing `#N` selects the marker's
1830/// identity color; other atoms stay plain.
1831fn chip_style(chip: &str) -> Style {
1832	let Some(hash) = chip.rfind('#') else {
1833		return base_style();
1834	};
1835	let digits = &chip[hash + 1..];
1836	if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
1837		return base_style();
1838	}
1839	match digits.parse::<usize>() {
1840		Ok(marker) if marker > 0 => ink(attachment_color(marker)).bold(),
1841		_ => base_style(),
1842	}
1843}
1844
1845/// Paints one transcript line, rendering each `<ref image=N/>` tag as a
1846/// compact `<icon> #N` pill filled with the attachment's identity color.
1847fn draw_submission_text(
1848	frame: &mut Frame,
1849	x: u16,
1850	y: u16,
1851	width: u16,
1852	line: &str,
1853	charset: Charset,
1854) {
1855	let icon = charset.icon(Icon::Image);
1856	let mut chips: SmallVec<(usize, usize, String, usize), 4> = SmallVec::new();
1857	let mut base = 0;
1858	while let Some((start, end, marker)) = next_ref_tag(&line[base..]) {
1859		chips.push((base + start, base + end, format!("{icon} #{marker}"), marker));
1860		base += end;
1861	}
1862	let mut spans: SmallVec<Span<'_>, 8> = SmallVec::new();
1863	let mut at = 0;
1864	for (start, end, label, marker) in &chips {
1865		if *start > at {
1866			spans.push(Span::new(&line[at..*start], ink(TEXT)));
1867		}
1868		spans.push(Span::new(label, Style::new().fg(PANEL).bg(attachment_color(*marker)).bold()));
1869		at = *end;
1870	}
1871	if at < line.len() {
1872		spans.push(Span::new(&line[at..], ink(TEXT)));
1873	}
1874	draw_line(frame, x, y, width, &spans);
1875}
1876
1877/// Chrome outside a panel is transparent: no `bg`, so the terminal's own
1878/// background (and any image or blur behind it) shows through. Only the
1879/// panel boxes below opt into a fill.
1880const fn base_style() -> Style {
1881	Style::new().fg(TEXT)
1882}
1883
1884const fn panel_style() -> Style {
1885	Style::new().fg(TEXT).bg(PANEL)
1886}
1887
1888const fn ink(color: Color) -> Style {
1889	Style::new().fg(color)
1890}
1891
1892const fn panel_ink(color: Color) -> Style {
1893	Style::new().fg(color).bg(PANEL)
1894}
1895
1896const fn prose_style() -> Style {
1897	Style::new().fg(MUTED).italic()
1898}
1899
1900const fn code_style() -> Style {
1901	Style::new().fg(GREEN)
1902}
1903
1904#[cfg(test)]
1905mod tests {
1906	use std::{hint::black_box, str, time::Instant};
1907
1908	use omp_tui::{
1909		Key, Mods, Mouse, MouseButton, MouseReport, Renderer,
1910		test_support::{TerminalModel, frame_row_text},
1911	};
1912
1913	use super::{
1914		BRAND_FADE, CANCEL_HINT, Charset, Demo, DemoInput, Duration, FAINT, GREEN, INPUT_PROMPT,
1915		MUTED, RenderedFrame, Shimmer, Size, UiContext, WORKING_MESSAGE, elapsed_label, ink,
1916	};
1917
1918	/// The nerd-tier context every rendering assertion in this module
1919	/// expects (glyph fixtures are authored for it).
1920	fn test_ctx() -> UiContext {
1921		UiContext { charset: Charset::NerdFont, ..UiContext::default() }
1922	}
1923	fn present<W: std::io::Write>(
1924		renderer: &mut Renderer<W>,
1925		rendered: RenderedFrame<'_>,
1926		viewport: Size,
1927	) -> std::io::Result<omp_tui::PaintStats> {
1928		renderer.present_damaged(
1929			rendered.frame,
1930			rendered.damage.as_slice(),
1931			viewport.height,
1932			rendered.stable_rows,
1933		)
1934	}
1935
1936	/// Renders one step of an interactive session and asserts the emitted
1937	/// ANSI leaves the terminal exactly matching the frame's live window.
1938	fn replay_step(
1939		demo: &mut Demo,
1940		renderer: &mut Renderer<Vec<u8>>,
1941		terminal: &mut TerminalModel,
1942		viewport: Size,
1943		elapsed_ms: u64,
1944		key: Option<char>,
1945	) {
1946		if let Some(character) = key {
1947			demo.handle_key(Key::Char(character));
1948		}
1949		let rendered = demo.render_at(viewport, Duration::from_millis(elapsed_ms));
1950		let height = rendered.frame.size().height;
1951		let rows: Vec<String> = (0..height)
1952			.map(|row| frame_row_text(rendered.frame, row))
1953			.collect();
1954		present(renderer, rendered, viewport).expect("replay paint succeeds");
1955		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("ANSI is UTF-8");
1956		terminal.apply(&output);
1957		let window_top = renderer
1958			.committed_rows()
1959			.max(height.saturating_sub(viewport.height));
1960		let expected: Vec<String> = (0..viewport.height)
1961			.map(|row| rows[usize::from(window_top.saturating_add(row))].clone())
1962			.collect();
1963		let actual = terminal.visible_rows();
1964		for (row, (have, want)) in actual.iter().zip(&expected).enumerate() {
1965			assert_eq!(
1966				have, want,
1967				"terminal row {row} diverged from the frame at {elapsed_ms}ms after key {key:?}"
1968			);
1969		}
1970	}
1971
1972	#[test]
1973	fn interactive_picker_session_replays_cell_for_cell() {
1974		let viewport = Size::new(157, 46);
1975		let mut demo = Demo::new(&test_ctx());
1976		let mut renderer = Renderer::new(Vec::new());
1977		let mut terminal = TerminalModel::new(157, 46);
1978
1979		let script: &[(u64, Option<char>)] = &[
1980			(0, None),
1981			(700, None),
1982			(2_600, Some(':')),
1983			(2_650, Some('e')),
1984			(3_500, None),
1985			(4_000, Some('m')),
1986			(4_400, Some('q')),
1987			(4_700, None),
1988			(5_400, None),
1989			(6_100, None),
1990			(6_800, None),
1991			(7_500, None),
1992			(8_200, None),
1993		];
1994		for &(elapsed_ms, key) in script {
1995			replay_step(&mut demo, &mut renderer, &mut terminal, viewport, elapsed_ms, key);
1996		}
1997	}
1998
1999	/// The same class of interactive session committed through DECSTBM
2000	/// margins: pinned band rows and native history must replay
2001	/// cell-for-cell on the margin-scrollback terminal model.
2002	#[test]
2003	fn interactive_session_replays_cell_for_cell_with_margin_commits() {
2004		let viewport = Size::new(157, 46);
2005		let mut demo = Demo::new(&test_ctx());
2006		let mut renderer = Renderer::new(Vec::new());
2007		renderer.set_margin_scrollback(true);
2008		let mut terminal = TerminalModel::new(157, 46);
2009
2010		let script: &[(u64, Option<char>)] = &[
2011			(0, None),
2012			(700, None),
2013			(2_600, Some(':')),
2014			(2_650, Some('e')),
2015			(3_500, None),
2016			(4_400, Some('q')),
2017			(5_400, None),
2018			(6_100, None),
2019			(6_800, None),
2020			(7_500, None),
2021			(8_200, None),
2022		];
2023		for &(elapsed_ms, key) in script {
2024			replay_step(&mut demo, &mut renderer, &mut terminal, viewport, elapsed_ms, key);
2025		}
2026	}
2027	fn has_csi_command(output: &str, command: u8) -> bool {
2028		let bytes = output.as_bytes();
2029		let mut index = 0;
2030		while index + 1 < bytes.len() {
2031			if bytes[index] != 0x1b || bytes[index + 1] != b'[' {
2032				index += 1;
2033				continue;
2034			}
2035			index += 2;
2036			while let Some(byte) = bytes.get(index) {
2037				if (0x40..=0x7e).contains(byte) {
2038					if *byte == command {
2039						return true;
2040					}
2041					break;
2042				}
2043				index += 1;
2044			}
2045			index += 1;
2046		}
2047		false
2048	}
2049
2050	#[test]
2051	fn classic_shimmer_moves_a_bright_crest_across_the_message() {
2052		let low = ink(FAINT);
2053		let high = ink(GREEN).bold();
2054		let period = Duration::from_secs(1);
2055		// One second sweeps the 50-cell padded track, so 200ms puts the
2056		// crest ten cells in — exactly over the first glyph.
2057		let before_band = Shimmer::new(Duration::ZERO, period, 30);
2058		let at_crest = Shimmer::new(Duration::from_millis(200), period, 30);
2059
2060		assert_eq!(before_band.pick(0, low, ink(MUTED), high), low);
2061		assert_eq!(at_crest.pick(0, low, ink(MUTED), high), high);
2062	}
2063
2064	#[test]
2065	fn elapsed_label_stays_compact_across_units() {
2066		assert_eq!(elapsed_label(Duration::from_secs(20)), "20s");
2067		assert_eq!(elapsed_label(Duration::from_secs(60)), "1m");
2068		assert_eq!(elapsed_label(Duration::from_mins(15)), "15m");
2069		assert_eq!(elapsed_label(Duration::from_hours(1)), "1h");
2070		assert_eq!(elapsed_label(Duration::from_hours(10)), "10h");
2071		assert_eq!(elapsed_label(Duration::from_hours(100)), "99h");
2072	}
2073
2074	#[test]
2075	fn status_brand_swaps_spinner_for_omp_across_work_states() {
2076		let viewport = Size::new(120, 32);
2077		let mut demo = Demo::new(&test_ctx());
2078		let rows_at = |demo: &mut Demo, elapsed| {
2079			let rendered = demo.render_at(viewport, elapsed);
2080			(0..rendered.frame.size().height)
2081				.map(|row| frame_row_text(rendered.frame, row))
2082				.collect::<Vec<_>>()
2083		};
2084		fn status_of(rows: &[String]) -> &str {
2085			rows
2086				.iter()
2087				.find(|row| row.contains("Fable 5++"))
2088				.expect("status row must be present")
2089		}
2090
2091		let rows = rows_at(&mut demo, Duration::ZERO);
2092		assert!(status_of(&rows).starts_with("\u{e0b6} ⠋ 0s"), "{}", status_of(&rows));
2093		let activity = rows
2094			.iter()
2095			.find(|row| row.contains(WORKING_MESSAGE.trim()))
2096			.expect("activity row narrates while working");
2097		assert!(
2098			activity.trim_start().starts_with(CANCEL_HINT),
2099			"the cancel hint leads the activity row"
2100		);
2101
2102		let rows = rows_at(&mut demo, Duration::from_millis(80));
2103		assert!(status_of(&rows).starts_with("\u{e0b6} ⠙ 0s"), "the ticked spinner advances");
2104		let rows = rows_at(&mut demo, Duration::from_secs(65));
2105		assert!(status_of(&rows).contains(" 1m"), "the session timer stays compact");
2106
2107		demo.set_working(false, Duration::from_secs(66));
2108		let rows = rows_at(&mut demo, Duration::from_secs(67));
2109		assert!(status_of(&rows).starts_with("\u{e0b6} 󰵗 omp"), "{}", status_of(&rows));
2110		assert!(
2111			rows.iter().all(|row| !row.contains(WORKING_MESSAGE.trim())),
2112			"the activity row clears at rest"
2113		);
2114
2115		demo.set_working(true, Duration::from_secs(70));
2116		let rows = rows_at(&mut demo, Duration::from_secs(75));
2117		assert!(status_of(&rows).contains(" 5s"), "resuming restarts the session timer");
2118	}
2119
2120	#[test]
2121	fn esc_rests_work_then_quits_and_submit_resumes() {
2122		let mut demo = Demo::new(&test_ctx());
2123		let esc = Key::Esc;
2124
2125		assert!(!demo.handle_key(esc), "the first esc only cancels the running work");
2126		assert!(!demo.work.borrow().working);
2127
2128		for character in "go".chars() {
2129			demo.handle_key(Key::Char(character));
2130		}
2131		assert!(!demo.handle_key(Key::Enter));
2132		assert!(demo.work.borrow().working, "submitting a message resumes work");
2133
2134		assert!(!demo.handle_key(esc), "esc cancels the resumed work");
2135		assert!(demo.handle_key(esc), "esc at rest quits");
2136
2137		let mut fresh = Demo::new(&test_ctx());
2138		assert!(fresh.handle_key(Key::Ctrl('c')), "ctrl-c quits even while working");
2139	}
2140
2141	fn mouse_report(kind: Mouse, col: u16, row: u16, button: MouseButton) -> MouseReport {
2142		MouseReport { kind, col, row, button, mods: Mods::default(), pressed: true }
2143	}
2144
2145	#[test]
2146	fn editor_click_translates_document_row_and_moves_the_caret() {
2147		let viewport = Size::new(80, 24);
2148		let mut demo = Demo::new(&test_ctx());
2149		for character in "abcdef".chars() {
2150			demo.handle_key(Key::Char(character));
2151		}
2152		let document_height = demo.render_at(viewport, Duration::ZERO).frame.size().height;
2153		let editor_y = document_height.saturating_sub(demo.editor_ui.height());
2154
2155		demo.handle_mouse(&mouse_report(
2156			Mouse::Click,
2157			DemoInput::input_offset() + 2,
2158			editor_y + 1,
2159			MouseButton::Left,
2160		));
2161
2162		let editor = demo.editor.borrow();
2163		assert_eq!(editor.text(), "abcdef");
2164		assert_eq!(editor.view(DemoInput::input_width(viewport.width))[0].cursor_column, Some(2));
2165	}
2166
2167	#[test]
2168	fn click_above_editor_block_is_ignored() {
2169		let viewport = Size::new(80, 24);
2170		let mut demo = Demo::new(&test_ctx());
2171		for character in "abcdef".chars() {
2172			demo.handle_key(Key::Char(character));
2173		}
2174		let document_height = demo.render_at(viewport, Duration::ZERO).frame.size().height;
2175		let editor_y = document_height.saturating_sub(demo.editor_ui.height());
2176		let before = demo
2177			.editor
2178			.borrow()
2179			.view(DemoInput::input_width(viewport.width))[0]
2180			.cursor_column;
2181
2182		demo.handle_mouse(&mouse_report(
2183			Mouse::Click,
2184			DemoInput::input_offset(),
2185			editor_y.saturating_sub(1),
2186			MouseButton::Left,
2187		));
2188
2189		assert_eq!(
2190			demo
2191				.editor
2192				.borrow()
2193				.view(DemoInput::input_width(viewport.width))[0]
2194				.cursor_column,
2195			before
2196		);
2197	}
2198
2199	#[test]
2200	fn wheel_over_editor_preserves_submitted_transcript() {
2201		let viewport = Size::new(80, 24);
2202		let mut demo = Demo::new(&test_ctx());
2203		for character in "keep this".chars() {
2204			demo.handle_key(Key::Char(character));
2205		}
2206		demo.handle_key(Key::Enter);
2207		let submitted_row = |demo: &mut Demo| {
2208			let rendered = demo.render_at(viewport, Duration::ZERO);
2209			(0..rendered.frame.size().height)
2210				.find(|&row| frame_row_text(rendered.frame, row).contains("keep this"))
2211		};
2212		let before = submitted_row(&mut demo).expect("submission appended to the transcript");
2213		let document_height = demo.frame.size().height;
2214		let editor_y = document_height.saturating_sub(demo.editor_ui.height());
2215
2216		demo.handle_mouse(&mouse_report(
2217			Mouse::WheelDown,
2218			DemoInput::input_offset(),
2219			editor_y + 1,
2220			MouseButton::WheelDown,
2221		));
2222
2223		assert_eq!(
2224			submitted_row(&mut demo),
2225			Some(before),
2226			"the submitted transcript row must survive wheel input over the editor"
2227		);
2228	}
2229
2230	#[test]
2231	fn brand_fade_is_continuous_across_rapid_cancel_and_resume() {
2232		let mut demo = Demo::new(&test_ctx());
2233		let cancel_at = Duration::from_secs(1);
2234		demo.set_working(false, cancel_at);
2235		let midway = cancel_at + BRAND_FADE / 2;
2236		let color = demo.work.borrow().fade.sample(midway);
2237		assert!(color != GREEN && color != MUTED, "midway the brand sits between its endpoints");
2238
2239		demo.set_working(true, midway);
2240		assert_eq!(
2241			demo.work.borrow().fade.sample(midway),
2242			color,
2243			"resuming mid-fade departs from the color already on screen"
2244		);
2245	}
2246
2247	#[test]
2248	fn growing_tick_commits_new_rows_without_repainting_transcript() {
2249		let viewport = Size::new(80, 24);
2250		let mut demo = Demo::new(&test_ctx());
2251		let mut renderer = Renderer::new(Vec::new());
2252		let rendered = demo.render_at(viewport, Duration::from_millis(1_500));
2253		let initial_height = rendered.frame.size().height;
2254		present(&mut renderer, rendered, viewport).expect("warm demo paint succeeds");
2255		renderer.writer_mut().clear();
2256
2257		let rendered = demo.render_at(viewport, Duration::from_millis(2_100));
2258		assert_eq!(
2259			rendered.frame.size().height,
2260			initial_height.saturating_add(2),
2261			"one emit tick appends two shard result rows"
2262		);
2263		let stats = present(&mut renderer, rendered, viewport).expect("growth tick paints");
2264		let output = String::from_utf8(renderer.into_inner()).expect("renderer output is UTF-8");
2265
2266		assert_eq!(stats.committed_rows, 2, "appended rows commit into native scrollback");
2267		assert_eq!(output.matches("\r\n").count(), 2);
2268		assert!(!has_csi_command(&output, b'H'));
2269		assert!(
2270			!output.contains("append-only") && !output.contains("PARALLEL TEST RUN"),
2271			"stable transcript rows must never be re-emitted"
2272		);
2273	}
2274
2275	#[test]
2276	fn active_picker_stays_open_while_the_transcript_commits() {
2277		let viewport = Size::new(120, 32);
2278		let mut demo = Demo::new(&test_ctx());
2279		for character in ":joy".chars() {
2280			assert!(!demo.handle_key(Key::Char(character)));
2281		}
2282
2283		let mut renderer = Renderer::new(Vec::new());
2284		let rendered = demo.render_at(viewport, Duration::from_millis(100));
2285		present(&mut renderer, rendered, viewport).expect("initial picker paint succeeds");
2286		renderer.writer_mut().clear();
2287
2288		let rendered = demo.render_at(viewport, Duration::from_millis(800));
2289		let stats =
2290			present(&mut renderer, rendered, viewport).expect("growing picker frame succeeds");
2291		let output = String::from_utf8(renderer.into_inner()).expect("renderer output is UTF-8");
2292
2293		assert_eq!(stats.committed_rows, 3, "the transcript keeps committing under the picker");
2294		assert!(!has_csi_command(&output, b'H'));
2295		assert_eq!(output.matches("\r\n").count(), 3);
2296	}
2297
2298	#[test]
2299	fn closing_the_picker_never_shrinks_the_document_mid_stream() {
2300		// A large viewport keeps `committed == window_top`, so every tick
2301		// commits rows into native scrollback. Transient picker rows must not
2302		// strand that ratchet when they close (issue: bottom UI crept down as
2303		// the transcript regrew through the leftover blank strip).
2304		let viewport = Size::new(157, 46);
2305		let mut demo = Demo::new(&test_ctx());
2306		let mut renderer = Renderer::new(Vec::new());
2307
2308		let rendered = demo.render_at(viewport, Duration::ZERO);
2309		present(&mut renderer, rendered, viewport).expect("initial paint succeeds");
2310
2311		for character in ":e".chars() {
2312			demo.handle_key(Key::Char(character));
2313		}
2314		let rendered = demo.render_at(viewport, Duration::from_millis(100));
2315		let open_height = rendered.frame.size().height;
2316		present(&mut renderer, rendered, viewport).expect("open picker paints");
2317
2318		demo.handle_key(Key::Esc);
2319		let rendered = demo.render_at(viewport, Duration::from_millis(200));
2320		assert_eq!(
2321			rendered.frame.size().height,
2322			open_height,
2323			"closing the picker must not shrink the frame"
2324		);
2325		present(&mut renderer, rendered, viewport)
2326			.expect("closed picker paints without violating committed history");
2327
2328		for elapsed_ms in [700, 1_400, 2_100, 2_800] {
2329			let rendered = demo.render_at(viewport, Duration::from_millis(elapsed_ms));
2330			assert!(rendered.frame.size().height >= open_height);
2331			present(&mut renderer, rendered, viewport)
2332				.expect("streaming after picker close stays monotonic");
2333		}
2334	}
2335
2336	#[test]
2337	fn editor_chrome_contains_the_rounded_status_and_soft_prompt_without_a_border() {
2338		let viewport = Size::new(120, 32);
2339		let mut demo = Demo::new(&test_ctx());
2340		// Rest the brand so the chrome shows the omp badge, not the spinner.
2341		demo.set_working(false, Duration::ZERO);
2342		let rendered = demo.render_at(viewport, Duration::ZERO);
2343		let status_y = (0..rendered.frame.size().height)
2344			.find(|&row| frame_row_text(rendered.frame, row).contains("󰵗 omp"))
2345			.expect("status row must be present");
2346		let status_row = frame_row_text(rendered.frame, status_y);
2347		let input_row = frame_row_text(rendered.frame, status_y.saturating_add(1));
2348		for row in status_y..rendered.frame.size().height {
2349			let text = frame_row_text(rendered.frame, row);
2350			let text = text.strip_prefix(INPUT_PROMPT).unwrap_or(&text);
2351			assert!(
2352				!text
2353					.chars()
2354					.any(|glyph| matches!(glyph, '╭' | '╮' | '╰' | '╯' | '│' | '─')),
2355				"unexpected editor border on row {row}: {text}",
2356			);
2357		}
2358		assert_eq!(input_row, INPUT_PROMPT);
2359		let mut renderer = Renderer::new(Vec::new());
2360		present(&mut renderer, rendered, viewport).expect("editor frame paints");
2361		let output = String::from_utf8(renderer.into_inner()).expect("renderer output is UTF-8");
2362
2363		let model_segment = format!("{} Fable 5++", Charset::NerdFont.icon(super::Icon::Model));
2364		for segment in
2365			["󰵗 omp", model_segment.as_str(), " main *5 +9", " 39.1%/1M", "$60.07 (sub) + $8.65 (adv)"]
2366		{
2367			assert!(output.contains(segment), "missing status segment: {segment}");
2368		}
2369		assert!(status_row.starts_with("\u{e0b6} 󰵗 omp"));
2370		assert!(
2371			status_row.contains('\u{e0b2}'),
2372			"the right group opens with a mirrored cap: {status_row}"
2373		);
2374		assert!(
2375			status_row.ends_with("(adv)"),
2376			"the right group ends flat against the margin: {status_row}"
2377		);
2378		assert!(!status_row.contains('─'), "status row must not retain the editor border");
2379		assert!(
2380			output.contains("\r\x1b[3C\x1b[?25h"),
2381			"focused editor caret must sit beyond the continuation prompt"
2382		);
2383		assert!(output.contains("48;2;18;18;18"), "status group must paint its dark background band");
2384	}
2385
2386	#[test]
2387	fn right_docked_chrome_reserves_the_rail_inset() {
2388		let viewport = Size::new(140, 40);
2389		let mut demo = Demo::new(&test_ctx());
2390		demo.set_right_inset(30);
2391		let rendered = demo.render_at(viewport, Duration::ZERO);
2392		let rows: Vec<String> = (0..rendered.frame.size().height)
2393			.map(|row| frame_row_text(rendered.frame, row))
2394			.collect();
2395		let visible = viewport.width - 30;
2396		let band = rows
2397			.iter()
2398			.find(|row| row.contains("(adv)"))
2399			.expect("split band row");
2400		let title = rows
2401			.iter()
2402			.find(|row| row.contains(super::SESSION_TITLE))
2403			.expect("session title row");
2404		for (label, row) in [("band", band), ("title", title)] {
2405			assert!(
2406				super::visible_width(row.trim_end()) <= visible,
2407				"{label} must dock inside the rail reservation: {row}"
2408			);
2409		}
2410		assert!(
2411			super::visible_width(band.trim_end()) > visible.saturating_sub(2),
2412			"the band still docks flush against the visible bound: {band}"
2413		);
2414	}
2415
2416	#[test]
2417	fn shifted_text_and_picker_keys_stay_owned_by_the_editor() {
2418		let viewport = Size::new(120, 32);
2419		let mut demo = Demo::new(&test_ctx());
2420		for character in "Hello World!".chars() {
2421			assert!(!demo.handle_key(Key::Char(character)));
2422		}
2423		let mut renderer = Renderer::new(Vec::new());
2424		let rendered = demo.render_at(viewport, Duration::ZERO);
2425		present(&mut renderer, rendered, viewport).expect("shifted text paints");
2426		let output =
2427			str::from_utf8(renderer.writer_mut().as_slice()).expect("renderer output is UTF-8");
2428		assert!(output.contains("Hello World!"));
2429		assert!(!demo.handle_key(Key::Enter));
2430
2431		assert!(!demo.handle_key(Key::Char('/')));
2432		assert!(!demo.handle_key(Key::Char('s')));
2433		assert!(!demo.handle_key(Key::Char('e')));
2434		assert!(!demo.handle_key(Key::BackTab));
2435		renderer.writer_mut().clear();
2436		let rendered = demo.render_at(viewport, Duration::ZERO);
2437		present(&mut renderer, rendered, viewport).expect("picker paints after back-tab");
2438		let picker_output =
2439			str::from_utf8(renderer.writer_mut().as_slice()).expect("renderer output is UTF-8");
2440		assert!(picker_output.contains("security"), "picker remains open after back-tab");
2441
2442		assert!(!demo.handle_key(Key::Tab));
2443		renderer.writer_mut().clear();
2444		let rendered = demo.render_at(viewport, Duration::ZERO);
2445		let accepted_in_frame = (0..rendered.frame.size().height)
2446			.any(|row| frame_row_text(rendered.frame, row).contains("/security"));
2447		present(&mut renderer, rendered, viewport).expect("accepted completion paints");
2448		assert!(accepted_in_frame, "tab accepts the selected slash command");
2449
2450		assert!(!demo.handle_key(Key::Esc));
2451		assert!(!demo.handle_key(Key::Esc), "the demo-level esc cancels the running work first");
2452		assert!(demo.handle_key(Key::Esc));
2453	}
2454
2455	#[test]
2456	fn multiline_input_grows_inside_the_editor_and_keeps_the_caret_visible() {
2457		let viewport = Size::new(120, 32);
2458		let mut demo = Demo::new(&test_ctx());
2459		demo.set_working(false, Duration::ZERO);
2460		let (initial_height, initial_status_y) = {
2461			let rendered = demo.render_at(viewport, Duration::ZERO);
2462			let height = rendered.frame.size().height;
2463			let status_y = (0..height)
2464				.find(|&row| frame_row_text(rendered.frame, row).contains("󰵗 omp"))
2465				.expect("initial editor status row");
2466			(height, status_y)
2467		};
2468		for text in ["first", "second", "third"] {
2469			for character in text.chars() {
2470				demo.handle_key(Key::Char(character));
2471			}
2472			if text != "third" {
2473				demo.handle_key(Key::ShiftEnter);
2474			}
2475		}
2476
2477		let mut renderer = Renderer::new(Vec::new());
2478		let rendered = demo.render_at(viewport, Duration::ZERO);
2479		let height = rendered.frame.size().height;
2480		let rows: Vec<String> = (0..height)
2481			.map(|row| frame_row_text(rendered.frame, row))
2482			.collect();
2483		let status_y = rows
2484			.iter()
2485			.position(|row| row.contains("󰵗 omp"))
2486			.and_then(|row| u16::try_from(row).ok())
2487			.expect("grown editor status row");
2488		let input_rows: Vec<u16> = ["first", "second", "third"]
2489			.into_iter()
2490			.map(|text| {
2491				rows
2492					.iter()
2493					.position(|row| row.contains(text))
2494					.and_then(|row| u16::try_from(row).ok())
2495					.unwrap_or_else(|| panic!("missing input row {text:?}"))
2496			})
2497			.collect();
2498
2499		assert_eq!(height, initial_height, "editor growth is absorbed by the blank band padding");
2500		assert_eq!(
2501			status_y,
2502			initial_status_y.saturating_sub(2),
2503			"the editor chrome rises as it grows"
2504		);
2505		assert_eq!(
2506			input_rows,
2507			[status_y + 1, status_y + 2, status_y + 3],
2508			"input lines must occupy distinct rows below the status chrome",
2509		);
2510		assert_eq!(input_rows[2].saturating_add(1), height, "third line stays inside the document");
2511		assert!(
2512			["first", "second", "third"]
2513				.into_iter()
2514				.all(|text| !rows[usize::from(status_y)].contains(text)),
2515			"input must not overwrite the status chrome",
2516		);
2517		assert!(rows[usize::from(input_rows[0])].starts_with("╰─ first"));
2518		assert!(rows[usize::from(input_rows[1])].starts_with("   second"));
2519		assert!(rows[usize::from(input_rows[2])].starts_with("   third"));
2520
2521		present(&mut renderer, rendered, viewport).expect("multiline editor paints");
2522		let editor_output =
2523			str::from_utf8(renderer.writer_mut().as_slice()).expect("renderer output is UTF-8");
2524		assert!(
2525			editor_output.contains("\r\x1b[8C\x1b[?25h"),
2526			"presented caret must account for the prompt before `third`",
2527		);
2528		renderer.writer_mut().clear();
2529
2530		assert!(!demo.handle_key(Key::Enter));
2531		let rendered = demo.render_at(viewport, Duration::ZERO);
2532		present(&mut renderer, rendered, viewport)
2533			.expect("multiline submission preserves the immutable seam");
2534		let submission_output =
2535			String::from_utf8(renderer.into_inner()).expect("renderer output is UTF-8");
2536		for text in ["first", "second", "third"] {
2537			assert!(submission_output.contains(text), "submission lost {text:?}");
2538		}
2539	}
2540
2541	#[test]
2542	fn transcript_header_is_emitted_once_across_commits() {
2543		let viewport = Size::new(80, 24);
2544		let mut renderer = Renderer::new(Vec::new());
2545		let mut demo = Demo::new(&test_ctx());
2546
2547		for elapsed_ms in [0, 80, 699, 700, 780, 1_400, 2_100] {
2548			let rendered = demo.render_at(viewport, Duration::from_millis(elapsed_ms));
2549			present(&mut renderer, rendered, viewport)
2550				.expect("demo frame satisfies the immutable seam contract");
2551		}
2552
2553		let output = String::from_utf8(renderer.into_inner()).expect("renderer output is UTF-8");
2554		assert_eq!(output.matches("PARALLEL TEST RUN").count(), 1);
2555		assert!(!output.contains("\x1b[3J"));
2556	}
2557	#[test]
2558	fn ten_minute_session_repaints_only_the_live_suffix() {
2559		let viewport = Size::new(120, 32);
2560		let mut demo = Demo::new(&test_ctx());
2561		let warm = demo.render_at(viewport, Duration::from_millis(599_900));
2562		let previous_stable_rows = warm.stable_rows;
2563
2564		let rendered = demo.render_at(viewport, Duration::from_mins(10));
2565
2566		assert_eq!(rendered.damage.first().map(|range| range.0), Some(previous_stable_rows));
2567		let damaged_rows: u16 = rendered
2568			.damage
2569			.iter()
2570			.map(|&(start, end)| end.saturating_sub(start))
2571			.sum();
2572		assert!(
2573			damaged_rows <= viewport.height + 8,
2574			"damaged rows must stay bounded to the live suffix: {:?}",
2575			rendered.damage
2576		);
2577	}
2578
2579	/// Steady rendering and presentation must stay independent of immutable
2580	/// transcript size. Run with `cargo test -p omp-tui --release --example
2581	/// chat -- --ignored perf --nocapture`.
2582	#[test]
2583	#[ignore = "release-mode perf smoke, run explicitly"]
2584	fn perf_render_cost_does_not_grow_with_history() {
2585		let viewport = Size::new(120, 32);
2586		let frame_cost = |elapsed| {
2587			let mut demo = Demo::new(&test_ctx());
2588			let mut renderer = Renderer::new(std::io::sink());
2589			let rendered = demo.render_at(viewport, elapsed);
2590			present(&mut renderer, rendered, viewport).expect("warm-up presentation succeeds");
2591			const FRAMES: u32 = 100;
2592			let started_at = Instant::now();
2593			for frame in 0..FRAMES {
2594				let rendered =
2595					demo.render_at(viewport, elapsed + Duration::from_millis(u64::from(frame)));
2596				black_box(
2597					present(&mut renderer, rendered, viewport).expect("steady presentation succeeds"),
2598				);
2599			}
2600			started_at.elapsed() / FRAMES
2601		};
2602
2603		let short = frame_cost(Duration::from_secs(30));
2604		let long = frame_cost(Duration::from_mins(10));
2605		println!("steady frame: {short:?} at 30s vs {long:?} at 10m");
2606		assert!(
2607			long.as_nanos() < short.as_nanos() * 3,
2608			"frame cost still scales with immutable history: {short:?} -> {long:?}"
2609		);
2610	}
2611
2612	fn rows_of(demo: &mut Demo, viewport: Size) -> Vec<String> {
2613		let rendered = demo.render_at(viewport, Duration::ZERO);
2614		(0..rendered.frame.size().height)
2615			.map(|row| frame_row_text(rendered.frame, row))
2616			.collect()
2617	}
2618
2619	#[test]
2620	fn attach_command_stages_a_framed_preview_and_a_colored_chip() {
2621		let dir = std::env::temp_dir().join(format!("omp-chat-attach-cmd-{}", std::process::id()));
2622		std::fs::create_dir_all(&dir).unwrap();
2623		let path = dir.join("shot.png");
2624		let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
2625		png.extend(528_u32.to_be_bytes());
2626		png.extend(200_u32.to_be_bytes());
2627		std::fs::write(&path, png).unwrap();
2628
2629		let viewport = Size::new(120, 40);
2630		let mut demo = Demo::new(&test_ctx());
2631		for character in format!("/attach {}", path.display()).chars() {
2632			assert!(!demo.handle_key(Key::Char(character)));
2633		}
2634		assert!(!demo.handle_key(Key::Enter));
2635		let rows = rows_of(&mut demo, viewport);
2636		let caption_row = rows
2637			.iter()
2638			.position(|row| row.contains("#1"))
2639			.expect("preview frame caption above the composer");
2640		assert!(
2641			rows.iter().any(|row| row.contains("528x200")),
2642			"the frame's bottom edge captions the probed resolution"
2643		);
2644		let status_row = rows
2645			.iter()
2646			.position(|row| row.contains("Fable 5++"))
2647			.expect("status row");
2648		assert!(caption_row < status_row, "the preview band sits above the status line");
2649		assert_eq!(
2650			rows.iter().filter(|row| row.contains("#1")).count(),
2651			2,
2652			"the chip is mentioned in the prompt as well as the frame caption"
2653		);
2654
2655		// The chip paints in the attachment's identity color.
2656		let mut renderer = Renderer::new(Vec::new());
2657		let rendered = demo.render_at(viewport, Duration::ZERO);
2658		present(&mut renderer, rendered, viewport).expect("chip paints");
2659		let output =
2660			str::from_utf8(renderer.writer_mut().as_slice()).expect("renderer output is UTF-8");
2661		assert!(output.contains("38;2;255;179;102"), "composer chip and frame use identity color #1");
2662
2663		for character in "ship it".chars() {
2664			demo.handle_key(Key::Char(character));
2665		}
2666		assert!(!demo.handle_key(Key::Enter));
2667		let rows = rows_of(&mut demo, viewport);
2668		assert!(
2669			rows.iter().any(|row| row.contains("#1 ship it")),
2670			"the transcript renders the ref tag as a compact chip pill"
2671		);
2672		assert!(
2673			!rows.iter().any(|row| row.contains("<ref image=1/>")),
2674			"the raw ref tag never renders"
2675		);
2676		assert_eq!(
2677			rows.iter().filter(|row| row.contains("#1")).count(),
2678			1,
2679			"the preview band collapsed after submit"
2680		);
2681		std::fs::remove_dir_all(&dir).ok();
2682	}
2683
2684	#[test]
2685	fn deleting_a_chip_hides_its_card_and_undo_restores_it() {
2686		let dir = std::env::temp_dir().join(format!("omp-chat-attach-del-{}", std::process::id()));
2687		std::fs::create_dir_all(&dir).unwrap();
2688		let path = dir.join("gone.png");
2689		std::fs::write(&path, b"\x89PNG\r\n\x1a\n").unwrap();
2690
2691		let viewport = Size::new(120, 40);
2692		let mut demo = Demo::new(&test_ctx());
2693		demo.handle_paste(path.to_str().expect("temp path is UTF-8"));
2694		assert!(
2695			rows_of(&mut demo, viewport)
2696				.iter()
2697				.any(|row| row.contains("#1"))
2698		);
2699
2700		// Backspace over the trailing space, then the chip: one unit.
2701		demo.handle_key(Key::Backspace);
2702		demo.handle_key(Key::Backspace);
2703		let rows = rows_of(&mut demo, viewport);
2704		assert!(
2705			!rows.iter().any(|row| row.contains("#1")),
2706			"deleting the chip removes the card from the band"
2707		);
2708
2709		// Undo brings the chip and its card back.
2710		demo.handle_key(Key::Ctrl('_'));
2711		assert!(
2712			rows_of(&mut demo, viewport)
2713				.iter()
2714				.any(|row| row.contains("#1"))
2715		);
2716
2717		// Deleted again and submitted: the image must not reach the chat.
2718		demo.handle_key(Key::Ctrl('_'));
2719		demo.handle_key(Key::Backspace);
2720		demo.handle_key(Key::Backspace);
2721		for character in "done".chars() {
2722			demo.handle_key(Key::Char(character));
2723		}
2724		demo.handle_key(Key::Enter);
2725		let rows = rows_of(&mut demo, viewport);
2726		assert!(rows.iter().any(|row| row.contains("done")));
2727		assert!(
2728			!rows
2729				.iter()
2730				.any(|row| row.contains("#1") || row.contains("<ref image=1/>")),
2731			"a deleted attachment never reaches the transcript"
2732		);
2733		std::fs::remove_dir_all(&dir).ok();
2734	}
2735
2736	#[test]
2737	fn large_pastes_collapse_into_text_cards_and_expand_on_submit() {
2738		let viewport = Size::new(120, 44);
2739		let mut demo = Demo::new(&test_ctx());
2740		let paste = (0..12)
2741			.map(|n| format!("line{n}"))
2742			.collect::<Vec<_>>()
2743			.join("\n");
2744		demo.handle_paste(&paste);
2745		let rows = rows_of(&mut demo, viewport);
2746		assert!(rows.iter().any(|row| row.contains("#1")), "paste stages a numbered card");
2747		assert!(rows.iter().any(|row| row.contains("+12 lines")), "the card captions the paste size");
2748		assert!(
2749			rows
2750				.iter()
2751				.any(|row| row.contains("line0") && !row.contains("line10")),
2752			"the card previews the leading paste text"
2753		);
2754
2755		demo.handle_key(Key::Enter);
2756		let rows = rows_of(&mut demo, viewport);
2757		assert!(
2758			rows.iter().any(|row| row.contains("line11")),
2759			"the submitted transcript expands the full paste"
2760		);
2761		assert!(!rows.iter().any(|row| row.contains("+12 lines")), "the card collapsed");
2762	}
2763
2764	#[test]
2765	fn pasting_an_image_path_stages_an_attachment_instead_of_inserting() {
2766		let dir = std::env::temp_dir().join(format!("omp-chat attach-{}", std::process::id()));
2767		std::fs::create_dir_all(&dir).unwrap();
2768		let path = dir.join("paste drop.png");
2769		let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
2770		png.extend([0; 16]);
2771		std::fs::write(&path, png).unwrap();
2772
2773		let viewport = Size::new(120, 40);
2774		let mut demo = Demo::new(&test_ctx());
2775		// Finder-style drop: quoted because the directory and file name
2776		// both contain spaces.
2777		demo.handle_paste(&format!("'{}'", path.display()));
2778		let rows = rows_of(&mut demo, viewport);
2779		assert!(
2780			rows.iter().any(|row| row.contains("#1")),
2781			"pasted image is staged as a framed preview"
2782		);
2783		let input_row = rows
2784			.iter()
2785			.rev()
2786			.find(|row| row.contains(INPUT_PROMPT))
2787			.expect("composer prompt row");
2788		assert!(input_row.contains("#1"), "the attachment is mentioned in the prompt: {input_row}");
2789		assert!(
2790			!input_row.contains("drop.png"),
2791			"the path must not be inserted into the input: {input_row}"
2792		);
2793		std::fs::remove_dir_all(&dir).ok();
2794	}
2795}