use super::app::App;
use super::events::EventHandler;
use super::render;
use anyhow::Result;
use crossterm::{
event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::io;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, Mutex};
static LAST_PANIC_LOCATION: LazyLock<Mutex<Option<PanicInfo>>> = LazyLock::new(|| Mutex::new(None));
#[derive(Clone)]
struct PanicInfo {
file: String,
line: u32,
column: u32,
opencrabs_frame: Option<String>,
}
fn first_opencrabs_frame(bt: &std::backtrace::Backtrace) -> Option<String> {
let text = format!("{}", bt);
let mut lines = text.lines().peekable();
while let Some(line) = lines.next() {
let trimmed = line.trim();
if trimmed.contains("opencrabs::")
&& !trimmed.contains("first_opencrabs_frame")
&& !trimmed.contains("runner::")
{
let symbol = trimmed
.trim_start_matches(|c: char| c.is_ascii_digit() || c == ':' || c == ' ')
.to_string();
let at = lines
.peek()
.filter(|l| l.trim().starts_with("at "))
.map(|l| l.trim().to_string())
.unwrap_or_default();
return Some(if at.is_empty() {
symbol
} else {
format!("{} {}", symbol, at)
});
}
}
None
}
fn force_restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
LeaveAlternateScreen,
DisableBracketedPaste,
DisableFocusChange,
DisableMouseCapture
);
let _ = execute!(io::stdout(), crossterm::cursor::Show);
}
pub async fn run(mut app: App) -> Result<()> {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Some(loc) = info.location() {
let bt = std::backtrace::Backtrace::force_capture();
let opencrabs_frame = first_opencrabs_frame(&bt);
if let Ok(mut slot) = LAST_PANIC_LOCATION.lock() {
*slot = Some(PanicInfo {
file: loc.file().to_string(),
line: loc.line(),
column: loc.column(),
opencrabs_frame,
});
}
}
force_restore_terminal();
default_hook(info);
}));
let sigint_flag = Arc::new(AtomicBool::new(false));
let sigint_clone = sigint_flag.clone();
tokio::spawn(async move {
if let Ok(()) = tokio::signal::ctrl_c().await {
sigint_clone.store(true, Ordering::SeqCst);
force_restore_terminal();
std::process::exit(130); }
});
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(
stdout,
EnterAlternateScreen,
EnableBracketedPaste,
EnableFocusChange,
EnableMouseCapture
)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
crate::utils::fd_suppress::set_tui_active(true);
terminal.clear()?;
while crossterm::event::poll(std::time::Duration::from_millis(10))? {
let _ = crossterm::event::read();
}
let draw_first_frame = app.initialize_sync();
if draw_first_frame {
let app_ref: &mut App = &mut app;
terminal.draw(move |f| render::render(f, app_ref))?;
}
app.initialize().await?;
let event_sender = app.event_sender();
EventHandler::start_terminal_listener(event_sender);
let result = run_loop(&mut terminal, &mut app, &sigint_flag).await;
crate::utils::fd_suppress::set_tui_active(false);
force_restore_terminal();
result
}
async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
sigint_flag: &AtomicBool,
) -> Result<()> {
use super::events::TuiEvent;
let mut mouse_capture_applied = true;
loop {
if sigint_flag.load(Ordering::SeqCst) {
break;
}
if app.mouse_capture_enabled != mouse_capture_applied {
if app.mouse_capture_enabled {
execute!(terminal.backend_mut(), EnableMouseCapture)?;
} else {
execute!(terminal.backend_mut(), DisableMouseCapture)?;
}
mouse_capture_applied = app.mouse_capture_enabled;
}
if let Some((session_id, queued_at)) = app.pending_session_refresh
&& queued_at.elapsed() >= std::time::Duration::from_millis(500)
{
app.pending_session_refresh = None;
if app.is_current_session(session_id)
&& !app.processing_sessions.contains(&session_id)
&& let Err(e) = app.load_session(session_id).await
{
tracing::warn!("Debounced session refresh failed: {}", e);
}
}
app.pending_resize.take();
let _ = execute!(
terminal.backend_mut(),
crossterm::terminal::BeginSynchronizedUpdate
);
let term_ref: &mut Terminal<CrosstermBackend<io::Stdout>> = &mut *terminal;
let app_ref: &mut App = &mut *app;
if let Ok(mut slot) = LAST_PANIC_LOCATION.lock() {
*slot = None;
}
let draw_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
term_ref.draw(move |f| render::render(f, app_ref))
}));
match draw_result {
Ok(Ok(_)) => {}
Ok(Err(e)) => return Err(e.into()),
Err(panic_payload) => {
let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = panic_payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown render panic".to_string()
};
let (loc, caller) = LAST_PANIC_LOCATION
.lock()
.ok()
.and_then(|slot| slot.clone())
.map(|info| {
let loc = format!(" at {}:{}:{}", info.file, info.line, info.column);
let caller = info
.opencrabs_frame
.map(|f| format!(" — caller: {}", f))
.unwrap_or_default();
(loc, caller)
})
.unwrap_or_default();
tracing::error!("[TUI] render panic caught{}{}: {}", loc, caller, msg);
app.error_message = Some(format!("render panic{}{}: {}", loc, caller, msg));
let _ = terminal.clear();
}
}
let _ = execute!(
terminal.backend_mut(),
crossterm::terminal::EndSynchronizedUpdate
);
if app.should_quit {
break;
}
let event =
tokio::time::timeout(tokio::time::Duration::from_millis(100), app.next_event()).await;
if let Ok(Some(event)) = event {
match &event {
TuiEvent::FocusLost => {
execute!(terminal.backend_mut(), DisableMouseCapture)?;
mouse_capture_applied = false;
}
TuiEvent::FocusGained => {
if app.mouse_capture_enabled {
execute!(terminal.backend_mut(), EnableMouseCapture)?;
mouse_capture_applied = true;
}
app.clear_escape_garbage();
}
_ => {}
}
if let Err(e) = app.handle_event(event).await {
app.error_message = Some(e.to_string());
}
let mut pending_scroll: i32 = 0;
let drain_start = std::time::Instant::now();
loop {
match app.try_next_event() {
Some(TuiEvent::Tick) => continue,
Some(TuiEvent::MouseScroll(dir)) => {
pending_scroll += dir as i32;
}
Some(event) => {
let is_chunk = matches!(
event,
TuiEvent::ResponseChunk { .. } | TuiEvent::ReasoningChunk { .. }
);
if let Err(e) = app.handle_event(event).await {
app.error_message = Some(e.to_string());
}
if is_chunk && drain_start.elapsed() >= std::time::Duration::from_millis(30)
{
break;
}
}
None => break,
}
}
if pending_scroll > 0 {
app.scroll_offset = app.scroll_offset.saturating_add(pending_scroll as usize);
} else if pending_scroll < 0 {
app.scroll_offset = app
.scroll_offset
.saturating_sub(pending_scroll.unsigned_abs() as usize);
}
}
}
Ok(())
}