mod connect;
mod dialog;
mod events;
mod focus;
pub(crate) mod input;
mod input_submit;
mod keys;
pub(crate) mod mention;
pub(crate) mod paste_burst;
mod permissions;
mod selection;
pub(crate) mod slash;
mod state;
mod terminal;
mod todos;
mod update_check;
pub use connect::{create_app, start_connection};
pub use events::{handle_acp_event, handle_terminal_event};
pub use focus::{FocusManager, FocusOwner, FocusTarget};
pub use input::InputState;
pub(crate) use selection::normalize_selection;
pub use state::{
App, AppStatus, BlockCache, ChatMessage, ChatViewport, HelpView, IncrementalMarkdown,
InlinePermission, LoginHint, MessageBlock, MessageRole, ModeInfo, ModeState, SelectionKind,
SelectionPoint, SelectionState, TodoItem, TodoStatus, ToolCallInfo, WelcomeBlock,
};
pub use update_check::start_update_check;
use agent_client_protocol::{self as acp, Agent as _};
use crossterm::event::{
EventStream, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use futures::{FutureExt as _, StreamExt};
use std::time::{Duration, Instant};
#[allow(clippy::too_many_lines, clippy::cast_precision_loss)]
pub async fn run_tui(app: &mut App) -> anyhow::Result<()> {
let mut terminal = ratatui::init();
let mut os_shutdown = Box::pin(wait_for_shutdown_signal());
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::event::EnableBracketedPaste,
crossterm::event::EnableMouseCapture,
crossterm::event::EnableFocusChange,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
);
let mut events = EventStream::new();
let tick_duration = Duration::from_millis(16);
let mut last_render = Instant::now();
loop {
let time_to_next = tick_duration.saturating_sub(last_render.elapsed());
tokio::select! {
Some(Ok(event)) = events.next() => {
events::handle_terminal_event(app, event);
}
Some(event) = app.event_rx.recv() => {
events::handle_acp_event(app, event);
}
shutdown = &mut os_shutdown => {
if let Err(err) = shutdown {
tracing::warn!(%err, "OS shutdown signal listener failed");
}
app.should_quit = true;
}
() = tokio::time::sleep(time_to_next) => {}
}
loop {
if let Some(Some(Ok(event))) = events.next().now_or_never() {
events::handle_terminal_event(app, event);
continue;
}
match app.event_rx.try_recv() {
Ok(event) => {
events::handle_acp_event(app, event);
}
Err(_) => break,
}
}
if !app.pending_paste_text.is_empty() {
finalize_pending_paste_event(app);
}
let suppress_render_for_active_paste =
app.paste_burst.is_paste() && app.paste_burst.is_active();
if app.paste_burst.is_paste() {
app.pending_submit = false;
if app.paste_burst.is_settled() {
finalize_paste_burst(app);
app.paste_burst.reset();
}
}
if app.pending_submit {
app.pending_submit = false;
finalize_deferred_submit(app);
}
app.drain_key_count = 0;
if app.should_quit {
break;
}
if suppress_render_for_active_paste {
continue;
}
let is_animating =
matches!(app.status, AppStatus::Connecting | AppStatus::Thinking | AppStatus::Running);
if is_animating {
app.spinner_frame = app.spinner_frame.wrapping_add(1);
app.needs_redraw = true;
}
let scroll_delta = (app.viewport.scroll_target as f32 - app.viewport.scroll_pos).abs();
if scroll_delta >= 0.01 {
app.needs_redraw = true;
}
if terminal::update_terminal_outputs(app) {
app.needs_redraw = true;
}
if app.force_redraw {
terminal.clear()?;
app.force_redraw = false;
app.needs_redraw = true;
}
if app.needs_redraw {
if let Some(ref mut perf) = app.perf {
perf.next_frame();
}
if app.perf.is_some() {
app.mark_frame_presented(Instant::now());
}
#[allow(clippy::drop_non_drop)]
{
let timer = app.perf.as_ref().map(|p| p.start("frame_total"));
let draw_timer = app.perf.as_ref().map(|p| p.start("frame::terminal_draw"));
terminal.draw(|f| crate::ui::render(f, app))?;
drop(draw_timer);
drop(timer);
}
app.needs_redraw = false;
last_render = Instant::now();
}
}
for tool_id in std::mem::take(&mut app.pending_permission_ids) {
if let Some((mi, bi)) = app.tool_call_index.get(&tool_id).copied()
&& let Some(MessageBlock::ToolCall(tc)) =
app.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi))
{
let tc = tc.as_mut();
if let Some(pending) = tc.pending_permission.take()
&& let Some(last_opt) = pending.options.last()
{
let _ = pending.response_tx.send(acp::RequestPermissionResponse::new(
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
last_opt.option_id.clone(),
)),
));
}
}
}
if matches!(app.status, AppStatus::Thinking | AppStatus::Running)
&& let Some(ref conn) = app.conn
&& let Some(sid) = app.session_id.clone()
{
let _ = conn.cancel(acp::CancelNotification::new(sid)).await;
}
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::event::DisableBracketedPaste,
crossterm::event::DisableMouseCapture,
crossterm::event::DisableFocusChange,
PopKeyboardEnhancementFlags
);
ratatui::restore();
Ok(())
}
async fn wait_for_shutdown_signal() -> std::io::Result<()> {
#[cfg(unix)]
{
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
tokio::select! {
sigint = tokio::signal::ctrl_c() => {
sigint?;
}
_ = sigterm.recv() => {}
}
Ok(())
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c().await
}
}
fn finalize_pending_paste_event(app: &mut App) {
let pasted = std::mem::take(&mut app.pending_paste_text);
if pasted.is_empty() {
return;
}
if app.input.append_to_active_paste_block(&pasted) {
return;
}
let line_count = input::count_text_lines(&pasted);
if line_count > input::PASTE_PLACEHOLDER_LINE_THRESHOLD {
app.input.insert_paste_block(&pasted);
} else {
app.input.insert_str(&pasted);
}
}
fn finalize_paste_burst(app: &mut App) {
let full_text = app.input.text();
let full_text = input::trim_trailing_line_breaks(&full_text);
if full_text.is_empty() {
app.input.clear();
return;
}
let line_count = input::count_text_lines(full_text);
if line_count > input::PASTE_PLACEHOLDER_LINE_THRESHOLD {
app.input.clear();
app.input.insert_paste_block(full_text);
} else {
app.input.set_text(full_text);
}
}
fn finalize_deferred_submit(app: &mut App) {
while app.input.lines.len() > 1 && app.input.lines.last().is_some_and(String::is_empty) {
app.input.lines.pop();
}
app.input.cursor_row = app.input.lines.len().saturating_sub(1);
app.input.cursor_col = app.input.lines.last().map_or(0, |l| l.chars().count());
app.input.version += 1;
app.input.sync_textarea_engine();
input_submit::submit_input(app);
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::Event;
#[test]
fn pending_paste_chunks_are_merged_before_threshold_check() {
let mut app = App::test_default();
events::handle_terminal_event(&mut app, Event::Paste("a\nb\nc\nd\ne\nf".to_owned()));
events::handle_terminal_event(&mut app, Event::Paste("\ng\nh\ni\nj\nk".to_owned()));
assert!(app.input.is_empty());
assert!(!app.pending_paste_text.is_empty());
finalize_pending_paste_event(&mut app);
assert_eq!(app.input.lines, vec!["[Pasted Text 1 - 11 lines]"]);
assert_eq!(app.input.text(), "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk");
}
#[test]
fn pending_paste_chunk_appends_to_existing_placeholder() {
let mut app = App::test_default();
app.input.insert_paste_block("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk");
app.pending_paste_text = "\nl\nm".to_owned();
finalize_pending_paste_event(&mut app);
assert_eq!(app.input.lines, vec!["[Pasted Text 1 - 13 lines]"]);
assert_eq!(app.input.text(), "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm");
}
}