pub mod app;
pub mod channel;
pub mod command;
pub mod error;
pub mod event;
pub mod file_picker;
pub mod highlight;
pub mod hyperlink;
pub mod layout;
pub mod metrics;
pub mod render_cache;
pub(crate) mod session;
#[cfg(test)]
pub mod test_utils;
pub mod theme;
pub mod types;
pub mod widgets;
use std::io;
pub use app::App;
pub use channel::TuiChannel;
pub use command::TuiCommand;
pub use error::TuiError;
pub use event::{AgentEvent, AppEvent, CrosstermEventSource, EventReader, EventSource};
pub use metrics::{MetricsCollector, MetricsSnapshot};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc;
pub use types::{ChatMessage, InputMode, MessageRole, PasteState};
pub async fn run_tui(mut app: App, mut event_rx: mpsc::Receiver<AppEvent>) -> Result<(), TuiError> {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(
io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableMouseCapture,
crossterm::event::DisableBracketedPaste,
);
original_hook(info);
}));
let mut terminal = init_terminal()?;
let result = tui_loop(&mut app, &mut event_rx, &mut terminal).await;
restore_terminal(&mut terminal)?;
let _ = std::panic::take_hook();
result
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DirtyState {
Clean,
AnimationOnly,
Full,
}
async fn tui_loop(
app: &mut App,
event_rx: &mut mpsc::Receiver<AppEvent>,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<(), TuiError> {
let mut tick = tokio::time::interval(std::time::Duration::from_millis(250));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut dirty = DirtyState::Clean;
loop {
tokio::select! {
biased;
Some(event) = event_rx.recv() => {
app.handle_event(event);
dirty = DirtyState::Full;
}
agent_poll = app.poll_agent_event() => {
if let Some(agent_event) = agent_poll {
app.handle_agent_event(agent_event);
while let Ok(ev) = app.try_recv_agent_event() {
app.handle_agent_event(ev);
}
} else {
app.should_quit = true;
}
dirty = DirtyState::Full;
}
_ = tick.tick() => {
if dirty == DirtyState::Clean {
dirty = DirtyState::AnimationOnly;
}
}
}
app.poll_metrics();
app.poll_pending_file_index();
app.poll_pending_transcript();
app.refresh_task_snapshots();
let should_draw = match dirty {
DirtyState::Clean => false,
DirtyState::AnimationOnly => app.is_agent_busy(),
DirtyState::Full => true,
};
if should_draw {
terminal.draw(|frame| app.draw(frame))?;
let links = app.take_hyperlinks();
if !links.is_empty() {
hyperlink::write_osc8(terminal.backend_mut(), &links)?;
}
dirty = DirtyState::Clean;
}
if app.should_quit {
break;
}
}
Ok(())
}
fn init_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>, TuiError> {
crossterm::terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
crossterm::execute!(
stdout,
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableMouseCapture,
crossterm::event::EnableBracketedPaste,
)?;
let backend = CrosstermBackend::new(stdout);
Ok(Terminal::new(backend)?)
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<(), TuiError> {
crossterm::terminal::disable_raw_mode()?;
crossterm::execute!(
terminal.backend_mut(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableMouseCapture,
crossterm::event::DisableBracketedPaste,
)?;
terminal.show_cursor()?;
Ok(())
}
#[cfg(test)]
mod tests {
use tokio::sync::mpsc;
use crate::app::App;
use crate::metrics::MetricsSnapshot;
fn make_app() -> App {
let (user_tx, _user_rx) = mpsc::channel(1);
let (_agent_tx, agent_rx) = mpsc::channel(1);
App::new(user_tx, agent_rx)
}
#[test]
fn tick_arm_sets_dirty() {
let mut app = make_app();
app.poll_metrics();
app.poll_metrics();
let _: &MetricsSnapshot = &app.metrics;
}
}