1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
//! TUI Runner
//!
//! Main event loop and terminal setup for the TUI.
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};
/// Force-restore terminal state. Safe to call from signal handlers and panic hooks.
fn force_restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
LeaveAlternateScreen,
DisableBracketedPaste,
DisableFocusChange,
DisableMouseCapture
);
let _ = execute!(io::stdout(), crossterm::cursor::Show);
}
/// Run the TUI application
pub async fn run(mut app: App) -> Result<()> {
// Install panic hook that restores terminal before printing the panic.
// Without this, a panic leaves the terminal in raw mode with no cursor.
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
force_restore_terminal();
default_hook(info);
}));
// SIGINT (Ctrl+C) handler — forces terminal restoration and exits.
// In raw mode, Ctrl+C is just byte 0x03 and crossterm may eat it if
// stuck mid-escape-sequence. This handler bypasses the event loop.
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();
// Give a moment for terminal to restore, then hard-exit
std::process::exit(130); // 128 + SIGINT(2)
}
});
// Setup terminal
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)?;
// Signal that the TUI owns stdout — suppress_stdio() will skip fd 1
// redirection to avoid racing with ratatui's escape-sequence writes.
crate::utils::fd_suppress::set_tui_active(true);
// Force a full clear so stale content from a previous exec() restart
// is wiped.
terminal.clear()?;
// Drain any stale terminal events (e.g. mouse events queued after a crash
// where DisableMouseCapture never ran). Without this, queued escape
// sequences leak into the input buffer as raw characters.
while crossterm::event::poll(std::time::Duration::from_millis(10))? {
let _ = crossterm::event::read();
}
// Fast sync init: decide mode and arm the header card. If we're going
// to Chat, draw a first frame immediately so the user sees the header
// card + empty chat + input *before* the blocking session load runs.
// Onboarding skips the first frame so the wizard renders on first paint.
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))?;
}
// Now do the slow async init: load last session, sessions list, pane
// preload, update check, DB integrity warnings. The header card stays
// visible during this and vanishes on the 500ms timer from its arming
// point inside `initialize_sync`.
app.initialize().await?;
// Start terminal event listener
let event_sender = app.event_sender();
EventHandler::start_terminal_listener(event_sender);
// Run main loop
let result = run_loop(&mut terminal, &mut app, &sigint_flag).await;
// Restore terminal
crate::utils::fd_suppress::set_tui_active(false);
force_restore_terminal();
result
}
/// Main event loop
async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
sigint_flag: &AtomicBool,
) -> Result<()> {
use super::events::TuiEvent;
// Tracks the last applied mouse-capture state so we only call
// Enable/DisableMouseCapture when the app's desired state changes.
let mut mouse_capture_applied = true;
loop {
// Check SIGINT flag (redundant safety — the handler already exits,
// but this catches the race where the flag is set before exit)
if sigint_flag.load(Ordering::SeqCst) {
break;
}
// Sync mouse-capture state if the user toggled it via F12.
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;
}
// Flush debounced session refresh from remote channels.
// Only fires after 500ms of quiet to avoid blocking the loop with
// rapid DB queries during multi-tool Telegram runs.
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);
}
}
// Consume pending resize (just clears the flag; ratatui's
// autoresize inside draw() handles the actual buffer resize).
app.pending_resize.take();
// Wrap every frame in synchronized output (DEC private mode
// 2026) so the terminal buffers all escape sequences and
// displays the complete frame atomically. This eliminates
// flicker on resize because the clear + full redraw appear as
// a single update to the terminal.
let _ = execute!(
terminal.backend_mut(),
crossterm::terminal::BeginSynchronizedUpdate
);
// Render — wrap in catch_unwind so a render-time panic (e.g. a
// ratatui buffer OOB from some edge-case layout) is caught, logged,
// and the loop continues instead of crashing the whole TUI.
// Reborrow terminal/app as fresh mutable references for this
// iteration so moving them into the catch_unwind closure doesn't
// consume the outer references permanently.
let term_ref: &mut Terminal<CrosstermBackend<io::Stdout>> = &mut *terminal;
let app_ref: &mut App = &mut *app;
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()
};
tracing::error!("[TUI] render panic caught: {}", msg);
app.error_message = Some(format!("render panic: {}", msg));
// Try to recover the terminal state for the next frame.
let _ = terminal.clear();
}
}
let _ = execute!(
terminal.backend_mut(),
crossterm::terminal::EndSynchronizedUpdate
);
// Check for quit
if app.should_quit {
break;
}
// Wait for at least one event (with timeout for animation refresh)
let event =
tokio::time::timeout(tokio::time::Duration::from_millis(100), app.next_event()).await;
if let Ok(Some(event)) = event {
// Disable mouse capture when losing focus so the terminal stops
// queuing SGR mouse sequences that pile up while unfocused.
// Re-enable on focus regain and clear any garbage from input.
match &event {
TuiEvent::FocusLost => {
execute!(terminal.backend_mut(), DisableMouseCapture)?;
mouse_capture_applied = false;
}
TuiEvent::FocusGained => {
// Only re-enable mouse capture if the user hasn't
// explicitly turned it off via F12 (selection mode).
if app.mouse_capture_enabled {
execute!(terminal.backend_mut(), EnableMouseCapture)?;
mouse_capture_applied = true;
}
// Clear any garbage that leaked into the input buffer
// while mouse capture was active in another tmux pane.
app.clear_escape_garbage();
}
_ => {}
}
if let Err(e) = app.handle_event(event).await {
app.error_message = Some(e.to_string());
}
// Drain all remaining queued events before re-rendering.
// Coalesce Ticks, Scrolls, and streaming chunks to avoid redundant
// re-renders. Streaming chunks are batched with a time budget so the
// TUI stays responsive to keyboard/mouse input during long streams.
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());
}
// Batch streaming chunks for up to 30ms before yielding
// to render. This lets multiple chunks coalesce into one
// re-render instead of O(n) renders per second.
if is_chunk && drain_start.elapsed() >= std::time::Duration::from_millis(30)
{
break;
}
}
None => break,
}
}
// Apply coalesced scroll as a single operation
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(())
}