vtcode_core/ui/
tui.rs

1use anyhow::{Context, Result};
2use crossterm::event::{Event as CrosstermEvent, EventStream};
3use futures::StreamExt;
4use ratatui::{Terminal, TerminalOptions, Viewport, backend::CrosstermBackend};
5use std::io;
6use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
7
8mod events;
9mod render;
10mod state;
11mod ui;
12mod utils;
13
14pub use state::{
15    RatatuiCommand, RatatuiEvent, RatatuiHandle, RatatuiMessageKind, RatatuiSegment,
16    RatatuiSession, RatatuiTextStyle, RatatuiTheme,
17};
18pub use utils::{convert_style, parse_tui_color, theme_from_styles};
19
20use state::{RatatuiLoop, TerminalGuard, TerminalSurface};
21use utils::create_ticker;
22
23pub fn spawn_session(theme: RatatuiTheme, placeholder: Option<String>) -> Result<RatatuiSession> {
24    let (command_tx, command_rx) = mpsc::unbounded_channel();
25    let (event_tx, event_rx) = mpsc::unbounded_channel();
26
27    tokio::spawn(async move {
28        if let Err(err) = run_ratatui(command_rx, event_tx, theme, placeholder).await {
29            tracing::error!(error = ?err, "ratatui session terminated unexpectedly");
30        }
31    });
32
33    Ok(RatatuiSession {
34        handle: RatatuiHandle { sender: command_tx },
35        events: event_rx,
36    })
37}
38
39async fn run_ratatui(
40    commands: UnboundedReceiver<RatatuiCommand>,
41    events: UnboundedSender<RatatuiEvent>,
42    theme: RatatuiTheme,
43    placeholder: Option<String>,
44) -> Result<()> {
45    let surface = TerminalSurface::detect().context("failed to resolve terminal surface")?;
46    let mut stdout = io::stdout();
47    let backend = CrosstermBackend::new(&mut stdout);
48    let mut terminal = match surface {
49        TerminalSurface::Alternate => {
50            Terminal::new(backend).context("failed to initialize ratatui terminal")?
51        }
52        TerminalSurface::Inline { rows } => Terminal::with_options(
53            backend,
54            TerminalOptions {
55                viewport: Viewport::Inline(rows),
56            },
57        )
58        .context("failed to initialize ratatui terminal")?,
59    };
60    let _guard =
61        TerminalGuard::activate(surface).context("failed to configure terminal for ratatui")?;
62    terminal
63        .clear()
64        .context("failed to clear terminal for ratatui")?;
65
66    let mut app = RatatuiLoop::new(theme, placeholder);
67    let mut command_rx = commands;
68    let mut event_stream = EventStream::new();
69    let mut redraw = true;
70    let mut ticker = create_ticker();
71
72    loop {
73        if app.drain_command_queue(&mut command_rx) {
74            redraw = true;
75        }
76
77        if redraw {
78            terminal
79                .draw(|frame| app.draw(frame))
80                .context("failed to draw ratatui frame")?;
81            redraw = false;
82        }
83
84        if app.should_exit() {
85            break;
86        }
87
88        tokio::select! {
89            biased;
90            cmd = command_rx.recv() => {
91                if let Some(command) = cmd {
92                    if app.handle_command(command) {
93                        redraw = true;
94                    }
95                } else {
96                    app.set_should_exit();
97                }
98            }
99            event = event_stream.next() => {
100                match event {
101                    Some(Ok(evt)) => {
102                        if matches!(evt, CrosstermEvent::Resize(_, _)) {
103                            terminal
104                                .autoresize()
105                                .context("failed to autoresize terminal viewport")?;
106                        }
107                        if app.handle_event(evt, &events)? {
108                            redraw = true;
109                        }
110                    }
111                    Some(Err(_)) => {
112                        redraw = true;
113                    }
114                    None => {}
115                }
116            }
117            _ = ticker.tick() => {
118                if app.needs_tick() {
119                    redraw = true;
120                }
121            }
122        }
123
124        if app.should_exit() {
125            break;
126        }
127    }
128
129    terminal.show_cursor().ok();
130    terminal
131        .clear()
132        .context("failed to clear terminal after ratatui session")?;
133
134    Ok(())
135}