Skip to main content

chat/
main.rs

1//! Interactive chat demo for the TUI component gallery.
2mod commands;
3mod demo;
4mod picker;
5mod sidebar;
6mod welcome;
7
8use std::{io, time::Duration};
9
10use omp_tui::{
11	AltScreenUse, Charset, CursorStyle, InputEvent, Key, Layer, Mouse, Pasted, Renderer, Size,
12	Terminal, TerminalEvent, TerminalOptions, TtyOut, UiContext, detect,
13	paste::{self, Clipboard, ClipboardRead},
14};
15use smallvec::SmallVec;
16use tokio::{sync::oneshot, time::Instant};
17
18use crate::{
19	commands::{CommandPalette, PaletteAction, PaletteEvent},
20	demo::{Demo, RenderedFrame},
21	picker::{MODELS, ModelPicker, PickerEvent},
22	sidebar::Sidebar,
23	welcome::Welcome,
24};
25
26const FRAME_INTERVAL: Duration = Duration::from_millis(33);
27const RESIZE_SETTLE: Duration = Duration::from_millis(120);
28/// Ceiling for one background clipboard read; backend subprocesses cap
29/// themselves at 5–8 s, so this only fires on a hung native handle.
30const PASTE_READ_TIMEOUT: Duration = Duration::from_secs(10);
31
32/// One in-flight background clipboard read (Ctrl+V / Ctrl+Shift+V): the
33/// pending one-shot receiver, the requested scope, and the absolute
34/// instant at which a hung reader is abandoned.
35struct PasteRead {
36	clipboard:  oneshot::Receiver<Option<Clipboard>>,
37	scope:      ClipboardRead,
38	abandon_at: Instant,
39}
40
41impl PasteRead {
42	/// Kicks the blocking clipboard read onto its background thread.
43	fn start(scope: ClipboardRead) -> Self {
44		Self {
45			clipboard: paste::spawn_clipboard_read(scope),
46			scope,
47			abandon_at: Instant::now() + PASTE_READ_TIMEOUT,
48		}
49	}
50}
51
52#[tokio::main]
53async fn main() -> io::Result<()> {
54	let caps = detect();
55	// One detected presentation context — charset, graphics, appearance —
56	// threads through every scene instead of per-module hardcodes.
57	let ctx = UiContext::default().with_terminal_caps(&caps);
58	let mut terminal =
59		Terminal::enter(TerminalOptions::new(caps).cursor_style(CursorStyle::BlinkingBar))?;
60	let mut renderer = Renderer::new(TtyOut::new()?);
61	renderer.apply_caps(&caps)?;
62	run(&mut terminal, &mut renderer, &ctx).await
63}
64
65/// Drives the chat scene and guarantees the teardown scrub: the final
66/// inline screen persists into native scrollback once the shell resumes,
67/// so the rail must not be left composited even when the loop fails.
68/// Any overlay hold is released and the rail bands repaint from the raw
69/// transcript on every exit — clean quit or error alike, a loop error
70/// outranking a scrub failure. Only a renderer already poisoned by a
71/// writer failure skips the repaint.
72#[expect(
73	clippy::future_not_send,
74	reason = "chat components are deliberately confined to their terminal event-loop thread"
75)]
76async fn run<'a>(
77	terminal: &'a mut Terminal,
78	renderer: &'a mut Renderer<TtyOut>,
79	ctx: &'a UiContext,
80) -> io::Result<()> {
81	let result = chat(terminal, renderer, ctx).await;
82	let scrub = terminal.leave_alt().and_then(|()| renderer.clear_layers());
83	result.and(scrub)
84}
85
86#[expect(
87	clippy::future_not_send,
88	reason = "chat components are deliberately confined to their terminal event-loop thread"
89)]
90async fn chat<'a>(
91	terminal: &'a mut Terminal,
92	renderer: &'a mut Renderer<TtyOut>,
93	ctx: &'a UiContext,
94) -> io::Result<()> {
95	let mut viewport = terminal.size()?;
96	if !run_welcome(terminal, renderer, ctx.charset, &mut viewport).await? {
97		return Ok(());
98	}
99	// The welcome scene held the alternate screen; releasing it restores the
100	// untouched shell and the chat pushes inline from a clean slate.
101	terminal.leave_alt()?;
102
103	let mut demo = Demo::new(ctx);
104	let mut overlay: Option<Overlay> = None;
105	let mut current_model = 0_usize;
106	let started = Instant::now();
107	let mut sidebar = Sidebar::new(MODELS[current_model].name, ctx);
108	demo.set_right_inset(sidebar.reserved(viewport));
109	{
110		let rendered = demo.render(viewport);
111		let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
112		present(renderer, rendered, viewport, &layers)?;
113	}
114
115	// Alternate-screen ownership for the chat scene: a resize gesture borrows
116	// it for throwaway drag frames, an open overlay holds it for its lifetime.
117	let mut drag_alt = false;
118	let mut overlay_stale = false;
119	let mut resize = None;
120	// At most one in-flight background clipboard read (Ctrl+V/Ctrl+Shift+V).
121	let mut paste_read: Option<PasteRead> = None;
122	let mut next_frame = Instant::now() + FRAME_INTERVAL;
123	loop {
124		let paste_deadline = paste_read.as_ref().map(|read| read.abandon_at);
125		tokio::select! {
126			// The terminal branch pauses while a clipboard read is in flight:
127			// the event mailbox buffers input in order, so an Enter typed
128			// right after Ctrl+V lands *after* the paste instead of
129			// submitting an empty prompt. The read below is bounded, so the
130			// pause is too; retained App hosts get the finer-grained
131			// per-event queue instead.
132			event = terminal.next(), if paste_read.is_none() => match event? {
133				TerminalEvent::Resize => {
134					let now = Instant::now();
135					let resized = observe_resize(terminal, &mut viewport, &mut resize, now)?;
136					demo.set_right_inset(sidebar.reserved(viewport));
137					if overlay.is_some() && resized {
138						overlay_stale = true;
139					}
140				},
141				TerminalEvent::Debug(_) => {},
142				TerminalEvent::Closed => return Ok(()),
143				TerminalEvent::Input(event) => {
144					let Some(event) = user_event(terminal, renderer, event)? else {
145						continue;
146					};
147					match event {
148					InputEvent::Key(key) => {
149						if overlay.is_some() {
150							if key == Key::Ctrl('c') {
151								break;
152							}
153							let event = overlay
154								.as_mut()
155								.expect("overlay checked above")
156								.handle_key(key);
157							if apply_overlay_event(
158								event,
159								&mut overlay,
160								&mut current_model,
161								terminal,
162								renderer,
163								&mut demo,
164								&mut sidebar,
165								viewport,
166								started.elapsed(),
167								&mut overlay_stale,
168								&mut resize,
169								ctx,
170							)? {
171								break;
172							}
173						} else if key == Key::Ctrl('b') {
174							sidebar.toggle();
175							demo.set_right_inset(sidebar.reserved(viewport));
176						} else if key == Key::Ctrl('k') {
177							overlay = Some(Overlay::Palette(CommandPalette::open(ctx)));
178							open_overlay(
179								terminal,
180								renderer,
181								&mut demo,
182								overlay.as_mut().expect("palette just opened"),
183								&mut sidebar,
184								viewport,
185								started.elapsed(),
186								&mut drag_alt,
187								&mut overlay_stale,
188								&mut resize,
189							)?;
190						} else if sidebar.focused() {
191							if key == Key::Ctrl('c') {
192								break;
193							}
194							sidebar.handle_key(key);
195						} else if key == Key::Ctrl('p') || key == Key::Alt('p') {
196							overlay = Some(Overlay::Picker(ModelPicker::open(current_model, ctx)));
197							open_overlay(
198								terminal,
199								renderer,
200								&mut demo,
201								overlay.as_mut().expect("picker just opened"),
202								&mut sidebar,
203								viewport,
204								started.elapsed(),
205								&mut drag_alt,
206								&mut overlay_stale,
207								&mut resize,
208							)?;
209						} else if let Some(scope) = ClipboardRead::for_key(key) {
210							// The terminal did not claim the chord; read the
211							// system clipboard off-thread, preferring images
212							// unless the raw spelling asked for text only. A
213							// failed spawn closes the channel, so the receive
214							// branch below recovers input immediately.
215							paste_read = Some(PasteRead::start(scope));
216						} else {
217							let quit = demo.handle_key(key);
218							if demo.take_switch_request() {
219								overlay = Some(Overlay::Picker(ModelPicker::open(current_model, ctx)));
220								open_overlay(
221									terminal,
222									renderer,
223									&mut demo,
224									overlay.as_mut().expect("picker just opened"),
225									&mut sidebar,
226									viewport,
227									started.elapsed(),
228									&mut drag_alt,
229									&mut overlay_stale,
230									&mut resize,
231								)?;
232							}
233							if quit {
234								break;
235							}
236						}
237						next_frame = Instant::now();
238					},
239					InputEvent::Paste(text) => {
240						let event = overlay.as_mut().map(|active| active.handle_paste(&text));
241						match event {
242							Some(event) => {
243								if apply_overlay_event(
244									event,
245									&mut overlay,
246									&mut current_model,
247									terminal,
248									renderer,
249									&mut demo,
250									&mut sidebar,
251									viewport,
252									started.elapsed(),
253									&mut overlay_stale,
254									&mut resize,
255									ctx,
256								)? {
257									break;
258								}
259							},
260							None if sidebar.focused() => {},
261							None => demo.handle_paste(&text),
262						}
263						next_frame = Instant::now();
264					},
265					InputEvent::Mouse(report) => {
266						// An open overlay owns pointer input — hover, wheel,
267						// and clicks route through the compositor's band,
268						// never the occluded editor beneath.
269						let event = overlay
270							.as_mut()
271							.map(|active| active.handle_mouse(report.col, report.row, report.kind, viewport));
272						match event {
273							Some(event) => {
274								if apply_overlay_event(
275									event,
276									&mut overlay,
277									&mut current_model,
278									terminal,
279									renderer,
280									&mut demo,
281									&mut sidebar,
282									viewport,
283									started.elapsed(),
284									&mut overlay_stale,
285									&mut resize,
286									ctx,
287								)? {
288									break;
289								}
290							},
291							None => {
292								if !sidebar.handle_mouse(report.col, report.row, report.kind, viewport)
293								{
294									demo.handle_mouse(&report);
295								}
296							},
297						}
298						next_frame = Instant::now();
299					},
300					InputEvent::Focus(_) | InputEvent::Response(_) => {},
301				}
302				},
303			},
304			clipboard = async { (&mut paste_read.as_mut().expect("branch gated on Some").clipboard).await },
305				if paste_read.is_some() =>
306			{
307				let read = paste_read.take().expect("branch gated on Some");
308				// A closed channel (the reader thread never spawned) reads
309				// as an empty clipboard.
310				if let Ok(Some(clipboard)) = clipboard
311					&& let Some(text) = clipboard_paste_text(clipboard)
312					&& overlay.is_none()
313					&& !sidebar.focused()
314				{
315					// Ctrl+Shift+V inserts verbatim: no attachment staging,
316					// no large-paste collapse.
317					match read.scope {
318						ClipboardRead::Text => demo.handle_paste_raw(&text),
319						ClipboardRead::Smart => demo.handle_paste(&text),
320					}
321					next_frame = Instant::now();
322				}
323			},
324			// The deadline is absolute, so the frame tick recreating this
325			// branch's future cannot reset it: a hung reader is abandoned and
326			// terminal input re-enables. Dropping the receiver makes the
327			// reader's eventual send fail; the detached thread dies with the
328			// process instead of stalling shutdown.
329			() = deadline(paste_deadline) => {
330				paste_read = None;
331			},
332			() = deadline(Some(next_frame)) => {
333				let now = Instant::now();
334				let resized = observe_resize(terminal, &mut viewport, &mut resize, now)?;
335				demo.set_right_inset(sidebar.reserved(viewport));
336				if overlay.is_some() && resized {
337					overlay_stale = true;
338				}
339				if resize.is_some() {
340					// Drag frames compose exactly one viewport tail at the
341					// new geometry — O(viewport) per frame; the O(history)
342					// transcript reflow waits for the settle rebuild (or the
343					// overlay close). Without an overlay, a width change
344					// borrows the alternate screen (the inline transcript
345					// rewraps underneath) while height-only churn repaints
346					// in place — alt toggling on a height echo can
347					// self-sustain.
348					let preview = demo.render_resize_preview(viewport);
349					if let Some(active) = overlay.as_mut() {
350								  let mut layers = rail_layers(&mut sidebar, viewport, started.elapsed());
351								  layers.push(active.layer(viewport));
352								  renderer.preview_overlaid(&preview, &layers, viewport.height, "")?;
353							  } else {
354								  let width_changed =
355									  resize.is_some_and(|state| state.width_changed);
356								  let alt_enter = if drag_alt || !width_changed {
357									  None
358								  } else {
359									  let staged = terminal.stage_alt_enter(AltScreenUse::Resize);
360									  drag_alt = staged.is_some();
361									  staged
362								  };
363								  let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
364								  renderer.preview_overlaid(
365									  &preview,
366									  &layers,
367									  viewport.height,
368									  alt_enter.as_deref().unwrap_or(""),
369								  )?;
370							  }
371				} else if let Some(active) = overlay.as_mut() {
372					let rendered = demo.render(viewport);
373					let mut layers = rail_layers(&mut sidebar, viewport, started.elapsed());
374					layers.push(active.layer(viewport));
375					renderer.preview_overlaid(rendered.frame, &layers, viewport.height, "")?;
376				} else {
377					let rendered = demo.render(viewport);
378					let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
379					present(renderer, rendered, viewport, &layers)?;
380				}
381				next_frame = now + FRAME_INTERVAL;
382			},
383			() = deadline(resize.map(ResizeState::deadline)) => {
384				let now = Instant::now();
385				if !resize.is_some_and(|state| state.settled(now)) {
386					continue;
387				}
388				if overlay.is_some() {
389					// The overlay keeps holding the alternate screen; the
390					// transcript reflows once at close.
391					overlay_stale = true;
392					resize = None;
393					continue;
394				}
395				demo.set_right_inset(sidebar.reserved(viewport));
396				let rendered = demo.render(viewport);
397				let alt_exit = if drag_alt {
398					drag_alt = false;
399					terminal.stage_alt_leave().unwrap_or("")
400				} else {
401					""
402				};
403				renderer.rebuild(
404					rendered.frame.clone(),
405					viewport.height,
406					rendered.stable_rows,
407					alt_exit,
408				)?;
409				// The rebuild repainted the raw document; recomposite the
410				// rail on top without touching the fresh history.
411				let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
412				if !layers.is_empty() {
413					renderer.present_overlaid(
414						rendered.frame,
415						&[],
416						viewport.height,
417						rendered.stable_rows,
418						&layers,
419					)?;
420				}
421				resize = None;
422				next_frame = now + FRAME_INTERVAL;
423			},
424		}
425	}
426	Ok(())
427}
428
429/// Animates the welcome card until the user resumes into the chat demo
430/// (`Ok(true)`) or quits (`Ok(false)`), keeping `viewport` current across
431/// resizes.
432///
433/// The scene owns the alternate screen for its whole lifetime: entry rides
434/// the first card paint, mouse tracking is active throughout, and every
435/// geometry change repaints in place immediately. The main screen stays
436/// untouched underneath — the caller releases the hold on scene exit.
437async fn run_welcome<'a>(
438	terminal: &'a mut Terminal,
439	renderer: &'a mut Renderer<TtyOut>,
440	charset: Charset,
441	viewport: &'a mut Size,
442) -> io::Result<bool> {
443	let mut alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
444	let mut welcome = Welcome::new(charset);
445	let started = Instant::now();
446	let mut next_frame = Instant::now();
447	loop {
448		tokio::select! {
449			event = terminal.next() => match event? {
450				TerminalEvent::Resize => {
451					if let Some(size) = terminal.take_resize()? {
452						*viewport = size;
453					}
454				},
455				TerminalEvent::Debug(_) => {},
456				TerminalEvent::Closed => return Ok(false),
457				TerminalEvent::Input(event) => {
458					let Some(event) = user_event(terminal, renderer, event)? else {
459						continue;
460					};
461					match event {
462					InputEvent::Key(Key::Enter) => return Ok(true),
463					InputEvent::Key(Key::Esc | Key::Ctrl('c')) => return Ok(false),
464					InputEvent::Mouse(report) if matches!(report.kind, Mouse::Move | Mouse::Drag) => {
465						welcome.point_at(report.col, report.row);
466					},
467					InputEvent::Key(_)
468					| InputEvent::Mouse(_)
469					| InputEvent::Paste(_)
470					| InputEvent::Focus(_)
471					| InputEvent::Response(_) => {},
472				}
473				},
474			},
475			() = deadline(Some(next_frame)) => {
476				let now = Instant::now();
477				if let Some(size) = terminal.take_resize()? {
478					*viewport = size;
479				}
480				let frame = welcome.render(*viewport, started.elapsed());
481				renderer.preview(
482					frame,
483					viewport.height,
484					alt_enter.take().as_deref().unwrap_or(""),
485				)?;
486				next_frame = now + FRAME_INTERVAL;
487			},
488		}
489	}
490}
491
492#[derive(Clone, Copy)]
493struct ResizeState {
494	last_event:    Instant,
495	/// Whether any report in this gesture changed the width; only then does
496	/// the drag borrow the alternate screen.
497	width_changed: bool,
498}
499
500impl ResizeState {
501	const fn new(last_event: Instant, width_changed: bool) -> Self {
502		Self { last_event, width_changed }
503	}
504
505	const fn observe(&mut self, observed_at: Instant, width_changed: bool) {
506		self.last_event = observed_at;
507		self.width_changed |= width_changed;
508	}
509
510	fn deadline(self) -> Instant {
511		self.last_event + RESIZE_SETTLE
512	}
513
514	fn settled(self, now: Instant) -> bool {
515		now >= self.deadline()
516	}
517}
518
519/// Consumes the latest resize — SIGWINCH or DEC 2048 in-band — and (re)arms
520/// the settle window. Same-size reports outside a gesture are echoes
521/// (terminals re-reporting geometry across alternate-screen toggles) and are
522/// swallowed without arming a rebuild.
523fn observe_resize(
524	terminal: &mut Terminal,
525	viewport: &mut Size,
526	resize: &mut Option<ResizeState>,
527	observed_at: Instant,
528) -> io::Result<bool> {
529	let Some(size) = terminal.take_resize()? else {
530		return Ok(false);
531	};
532	if size == *viewport && resize.is_none() {
533		return Ok(false);
534	}
535	let width_changed = size.width != viewport.width;
536	*viewport = size;
537	match resize {
538		Some(state) => state.observe(observed_at, width_changed),
539		None => *resize = Some(ResizeState::new(observed_at, width_changed)),
540	}
541	Ok(true)
542}
543
544fn user_event(
545	terminal: &mut Terminal,
546	renderer: &mut Renderer<TtyOut>,
547	event: InputEvent,
548) -> io::Result<Option<InputEvent>> {
549	if terminal.handle_input_event(&event, renderer)? {
550		// A consumed response may have completed an enhanced-paste (OSC
551		// 5522) conversation; re-inject its payload as ordinary paste input
552		// so the normal routing below stages images and text alike.
553		return Ok(terminal.take_paste().and_then(|pasted| {
554			let text = match pasted {
555				Pasted::Text(text) => text,
556				Pasted::Image(image) => image.persist().ok()?.display().to_string().into(),
557			};
558			Some(InputEvent::Paste(text))
559		}));
560	}
561	Ok(Some(event))
562}
563
564/// Flattens a background clipboard read into paste text: images persist to
565/// a temp file whose path routes like a file drop, and copied file paths
566/// are quoted so spaces survive drop classification.
567fn clipboard_paste_text(clipboard: Clipboard) -> Option<String> {
568	match clipboard {
569		Clipboard::Text(text) => Some(text),
570		Clipboard::Image(image) => Some(image.persist().ok()?.display().to_string()),
571		Clipboard::Paths(paths) => {
572			let mut joined = String::new();
573			for path in &paths {
574				if !joined.is_empty() {
575					joined.push(' ');
576				}
577				joined.push('"');
578				joined.push_str(path);
579				joined.push('"');
580			}
581			Some(joined)
582		},
583	}
584}
585
586fn present(
587	renderer: &mut Renderer<TtyOut>,
588	rendered: RenderedFrame<'_>,
589	viewport: Size,
590	layers: &[Layer<'_>],
591) -> io::Result<()> {
592	renderer
593		.present_overlaid(
594			rendered.frame,
595			rendered.damage.as_slice(),
596			viewport.height,
597			rendered.stable_rows,
598			layers,
599		)
600		.map(|_| ())
601}
602
603/// The session rail as a layer slice for this frame: empty when toggled
604/// off or gated out by a small viewport, so callers composite it
605/// unconditionally.
606fn rail_layers(sidebar: &mut Sidebar, viewport: Size, elapsed: Duration) -> SmallVec<Layer<'_>, 2> {
607	sidebar.layer(viewport, elapsed).into_iter().collect()
608}
609
610/// The modal scene overlay holding the alternate screen: at most one is
611/// open at a time, and a palette action can swap it for the picker in
612/// place — the hold transfers without leaving the alternate screen.
613enum Overlay {
614	Picker(ModelPicker),
615	Palette(CommandPalette),
616}
617
618/// One routed overlay outcome, unified across overlay kinds.
619enum OverlayEvent {
620	/// Input handled; the overlay stays open.
621	Consumed,
622	/// Dismissed without effect.
623	Close,
624	/// The picker chose a model.
625	Pick(usize),
626	/// The palette activated an entry.
627	Run(PaletteAction),
628}
629
630impl From<PickerEvent> for OverlayEvent {
631	fn from(event: PickerEvent) -> Self {
632		match event {
633			PickerEvent::Consumed => Self::Consumed,
634			PickerEvent::Close => Self::Close,
635			PickerEvent::Pick(index) => Self::Pick(index),
636		}
637	}
638}
639
640impl From<PaletteEvent> for OverlayEvent {
641	fn from(event: PaletteEvent) -> Self {
642		match event {
643			PaletteEvent::Consumed => Self::Consumed,
644			PaletteEvent::Close => Self::Close,
645			PaletteEvent::Run(action) => Self::Run(action),
646		}
647	}
648}
649
650impl Overlay {
651	fn handle_key(&mut self, key: Key) -> OverlayEvent {
652		match self {
653			Self::Picker(picker) => picker.handle_key(key).into(),
654			Self::Palette(palette) => palette.handle_key(key).into(),
655		}
656	}
657
658	fn handle_paste(&mut self, text: &str) -> OverlayEvent {
659		match self {
660			Self::Picker(picker) => picker.handle_paste(text).into(),
661			Self::Palette(palette) => palette.handle_paste(text).into(),
662		}
663	}
664
665	fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> OverlayEvent {
666		match self {
667			Self::Picker(picker) => picker.handle_mouse(col, row, kind, viewport).into(),
668			Self::Palette(palette) => palette.handle_mouse(col, row, kind, viewport).into(),
669		}
670	}
671
672	fn layer(&mut self, viewport: Size) -> Layer<'_> {
673		match self {
674			Self::Picker(picker) => picker.layer(viewport),
675			Self::Palette(palette) => palette.layer(viewport),
676		}
677	}
678}
679
680/// Takes the alternate screen for the overlay's lifetime: entry rides the
681/// first composited paint, and a drag borrow already in flight simply
682/// transfers ownership (its settled rebuild then waits for close).
683#[expect(clippy::too_many_arguments, reason = "immediate-mode example threads its scene state")]
684fn open_overlay(
685	terminal: &mut Terminal,
686	renderer: &mut Renderer<TtyOut>,
687	demo: &mut Demo,
688	overlay: &mut Overlay,
689	sidebar: &mut Sidebar,
690	viewport: Size,
691	elapsed: Duration,
692	drag_alt: &mut bool,
693	overlay_stale: &mut bool,
694	resize: &mut Option<ResizeState>,
695) -> io::Result<()> {
696	if *drag_alt || resize.take().is_some() {
697		*drag_alt = false;
698		*overlay_stale = true;
699	}
700	let alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
701	let rendered = demo.render(viewport);
702	let mut layers = rail_layers(sidebar, viewport, elapsed);
703	layers.push(overlay.layer(viewport));
704	renderer
705		.preview_overlaid(
706			rendered.frame,
707			&layers,
708			viewport.height,
709			alt_enter.as_deref().unwrap_or(""),
710		)
711		.map(|_| ())
712}
713
714/// Releases the overlay's alternate-screen hold. Geometry churn while held
715/// rebuilds native history inside the same synchronized update as the buffer
716/// switch; otherwise the untouched main screen restores byte-exactly and one
717/// full-viewport present revalidates changes that only ever painted the
718/// alternate screen (streamed demo rows, the picked model).
719#[expect(clippy::too_many_arguments, reason = "immediate-mode example threads its scene state")]
720fn close_overlay(
721	terminal: &mut Terminal,
722	renderer: &mut Renderer<TtyOut>,
723	demo: &mut Demo,
724	sidebar: &mut Sidebar,
725	viewport: Size,
726	elapsed: Duration,
727	overlay_stale: &mut bool,
728	resize: &mut Option<ResizeState>,
729) -> io::Result<()> {
730	*resize = None;
731	let rendered = demo.render(viewport);
732	let layers = rail_layers(sidebar, viewport, elapsed);
733	if *overlay_stale {
734		*overlay_stale = false;
735		let alt_exit = terminal.stage_alt_leave().unwrap_or("");
736		renderer.rebuild(rendered.frame.clone(), viewport.height, rendered.stable_rows, alt_exit)?;
737		if !layers.is_empty() {
738			// The rebuild repainted the raw document; recomposite the rail
739			// without touching the fresh history.
740			renderer.present_overlaid(
741				rendered.frame,
742				&[],
743				viewport.height,
744				rendered.stable_rows,
745				&layers,
746			)?;
747		}
748	} else {
749		terminal.leave_alt()?;
750		renderer.present_overlaid(
751			rendered.frame,
752			&[(0, rendered.frame.size().height)],
753			viewport.height,
754			rendered.stable_rows,
755			&layers,
756		)?;
757	}
758	Ok(())
759}
760
761/// Applies one routed [`OverlayEvent`]: a model pick updates the session
762/// model, a palette action executes (`Switch Model` swaps the palette for
763/// the picker in place, keeping the alternate-screen hold), and every
764/// terminal event releases the hold. Returns `true` when the host should
765/// quit.
766#[expect(clippy::too_many_arguments, reason = "immediate-mode example threads its scene state")]
767fn apply_overlay_event(
768	event: OverlayEvent,
769	overlay: &mut Option<Overlay>,
770	current_model: &mut usize,
771	terminal: &mut Terminal,
772	renderer: &mut Renderer<TtyOut>,
773	demo: &mut Demo,
774	sidebar: &mut Sidebar,
775	viewport: Size,
776	elapsed: Duration,
777	overlay_stale: &mut bool,
778	resize: &mut Option<ResizeState>,
779	ctx: &UiContext,
780) -> io::Result<bool> {
781	let close = match event {
782		OverlayEvent::Close => true,
783		OverlayEvent::Pick(index) => {
784			*current_model = index;
785			demo.set_model(MODELS[index].name);
786			sidebar.set_model(MODELS[index].name);
787			true
788		},
789		OverlayEvent::Run(action) => match action {
790			PaletteAction::SwitchModel => {
791				let picker = overlay.insert(Overlay::Picker(ModelPicker::open(*current_model, ctx)));
792				// The palette already holds an interactive alternate-screen
793				// grant, so no drag borrow can be in flight: the staged alt
794				// entry inside `open_overlay` is a no-op and the hold
795				// transfers to the picker.
796				let mut drag_alt = false;
797				open_overlay(
798					terminal,
799					renderer,
800					demo,
801					picker,
802					sidebar,
803					viewport,
804					elapsed,
805					&mut drag_alt,
806					overlay_stale,
807					resize,
808				)?;
809				return Ok(false);
810			},
811			PaletteAction::ToggleSidebar => {
812				sidebar.toggle();
813				true
814			},
815			PaletteAction::Quit => return Ok(true),
816			PaletteAction::Insert(text) => {
817				// Stage the slash command in the composer; the close
818				// repaint below surfaces it immediately.
819				demo.handle_paste(&text);
820				true
821			},
822		},
823		OverlayEvent::Consumed => false,
824	};
825	if close {
826		*overlay = None;
827		close_overlay(terminal, renderer, demo, sidebar, viewport, elapsed, overlay_stale, resize)?;
828	}
829	Ok(false)
830}
831
832/// Sleeps until `at`; `None` disables the select branch.
833async fn deadline(at: Option<Instant>) {
834	match at {
835		Some(at) => tokio::time::sleep_until(at).await,
836		None => std::future::pending().await,
837	}
838}
839
840#[cfg(test)]
841mod tests {
842	use super::{Duration, Instant, RESIZE_SETTLE, ResizeState};
843
844	#[test]
845	fn resize_settle_window_restarts_at_each_event() {
846		let started_at = Instant::now();
847		let mut state = ResizeState::new(started_at, false);
848		state.observe(started_at + Duration::from_millis(100), true);
849
850		assert!(!state.settled(started_at + Duration::from_millis(219)));
851		assert!(state.settled(started_at + Duration::from_millis(220)));
852		assert_eq!(state.deadline(), started_at + Duration::from_millis(100) + RESIZE_SETTLE);
853		assert!(state.width_changed, "a width report anywhere in the gesture latches the borrow");
854	}
855}