1mod 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);
28const PASTE_READ_TIMEOUT: Duration = Duration::from_secs(10);
31
32struct PasteRead {
36 clipboard: oneshot::Receiver<Option<Clipboard>>,
37 scope: ClipboardRead,
38 abandon_at: Instant,
39}
40
41impl PasteRead {
42 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 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#[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 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 let mut drag_alt = false;
118 let mut overlay_stale = false;
119 let mut resize = None;
120 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 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 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 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 if let Ok(Some(clipboard)) = clipboard
311 && let Some(text) = clipboard_paste_text(clipboard)
312 && overlay.is_none()
313 && !sidebar.focused()
314 {
315 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 () = 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 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 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 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
429async 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 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
519fn 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 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
564fn 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
603fn rail_layers(sidebar: &mut Sidebar, viewport: Size, elapsed: Duration) -> SmallVec<Layer<'_>, 2> {
607 sidebar.layer(viewport, elapsed).into_iter().collect()
608}
609
610enum Overlay {
614 Picker(ModelPicker),
615 Palette(CommandPalette),
616}
617
618enum OverlayEvent {
620 Consumed,
622 Close,
624 Pick(usize),
626 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#[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#[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 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#[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 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 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
832async 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}