vtcode_ui/tui/core_tui/runner/
mod.rs1use std::io;
2use std::time::Duration;
3
4use anyhow::{Context, Result};
5use ratatui::crossterm::{
6 cursor::MoveToColumn,
7 execute,
8 terminal::{Clear, ClearType},
9};
10use ratatui::{Terminal, backend::CrosstermBackend};
11use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
12use tokio_util::sync::CancellationToken;
13
14use crate::tui::config::types::UiSurfacePreference;
15use crate::tui::options::FullscreenInteractionSettings;
16use crate::tui::ui::tui::log::{clear_tui_log_sender, register_tui_log_sender, set_log_theme_name};
17
18type EventCallback<E> = std::sync::Arc<dyn Fn(&E) + Send + Sync + 'static>;
19
20pub trait TuiCommand {
21 fn is_suspend_event_loop(&self) -> bool;
22 fn is_resume_event_loop(&self) -> bool;
23 fn is_clear_input_queue(&self) -> bool;
24 fn is_force_redraw(&self) -> bool;
25 fn is_stop_event_stream(&self) -> bool;
26 fn is_start_event_stream(&self) -> bool;
27}
28
29pub trait TuiSessionDriver {
30 type Command: TuiCommand;
31 type Event;
32
33 fn handle_command(&mut self, command: Self::Command);
34 #[expect(clippy::type_complexity)]
35 fn handle_event(
36 &mut self,
37 event: crossterm::event::Event,
38 events: &UnboundedSender<Self::Event>,
39 callback: Option<&(dyn Fn(&Self::Event) + Send + Sync + 'static)>,
40 );
41 fn handle_tick(&mut self);
42 fn render(&mut self, frame: &mut ratatui::Frame<'_>);
43 fn take_redraw(&mut self) -> bool;
44 fn use_steady_cursor(&self) -> bool;
45 fn is_hovering_link(&self) -> bool;
46 fn is_selecting_text(&self) -> bool;
47 fn should_exit(&self) -> bool;
48 fn request_exit(&mut self);
49 fn mark_dirty(&mut self);
50 fn update_terminal_title(&mut self);
51 fn clear_terminal_title(&mut self);
52 fn is_running_activity(&self) -> bool;
53 fn has_status_spinner(&self) -> bool;
54 fn thinking_spinner_active(&self) -> bool;
55 fn has_active_navigation_ui(&self) -> bool;
56 fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32);
57 fn set_show_logs(&mut self, show: bool);
58 fn set_active_pty_sessions(
59 &mut self,
60 sessions: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
61 );
62 fn set_workspace_root(&mut self, root: Option<std::path::PathBuf>);
63 fn set_log_receiver(
64 &mut self,
65 receiver: UnboundedReceiver<crate::tui::core_tui::log::LogEntry>,
66 );
67 fn set_fullscreen_active(&mut self, active: bool);
68 fn set_fullscreen_interaction(&mut self, config: FullscreenInteractionSettings);
69}
70
71impl TuiCommand for crate::tui::core_tui::types::InlineCommand {
72 fn is_suspend_event_loop(&self) -> bool {
73 matches!(
74 self,
75 crate::tui::core_tui::types::InlineCommand::SuspendEventLoop
76 )
77 }
78
79 fn is_resume_event_loop(&self) -> bool {
80 matches!(
81 self,
82 crate::tui::core_tui::types::InlineCommand::ResumeEventLoop
83 )
84 }
85
86 fn is_clear_input_queue(&self) -> bool {
87 matches!(
88 self,
89 crate::tui::core_tui::types::InlineCommand::ClearInputQueue
90 )
91 }
92
93 fn is_force_redraw(&self) -> bool {
94 matches!(
95 self,
96 crate::tui::core_tui::types::InlineCommand::ForceRedraw
97 )
98 }
99
100 fn is_stop_event_stream(&self) -> bool {
101 matches!(
102 self,
103 crate::tui::core_tui::types::InlineCommand::StopEventStream
104 )
105 }
106
107 fn is_start_event_stream(&self) -> bool {
108 matches!(
109 self,
110 crate::tui::core_tui::types::InlineCommand::StartEventStream
111 )
112 }
113}
114
115use super::types::FocusChangeCallback;
116
117mod drive;
118mod events;
119mod signal;
120mod surface;
121pub(crate) mod terminal_io;
122mod terminal_modes;
123
124use drive::{DriveRuntimeOptions, drive_terminal};
125use events::{EventListener, TerminalEvent, spawn_event_loop};
126use signal::SignalCleanupGuard;
127use surface::TerminalSurface;
128use terminal_io::{drain_terminal_events, finalize_terminal, prepare_terminal};
129use terminal_modes::{TerminalModeState, enable_terminal_modes, restore_terminal_modes};
130
131pub(super) struct EventStreamController {
137 cancellation_token: CancellationToken,
138 join_handle: Option<tokio::task::JoinHandle<()>>,
139 event_tx: UnboundedSender<TerminalEvent>,
140 rx_paused: std::sync::Arc<std::sync::atomic::AtomicBool>,
141 last_input_elapsed_ms: std::sync::Arc<std::sync::atomic::AtomicU64>,
142 session_start: std::time::Instant,
143}
144
145impl EventStreamController {
146 pub(super) fn new(
147 cancellation_token: CancellationToken,
148 join_handle: tokio::task::JoinHandle<()>,
149 event_tx: UnboundedSender<TerminalEvent>,
150 rx_paused: std::sync::Arc<std::sync::atomic::AtomicBool>,
151 last_input_elapsed_ms: std::sync::Arc<std::sync::atomic::AtomicU64>,
152 session_start: std::time::Instant,
153 ) -> Self {
154 Self {
155 cancellation_token,
156 join_handle: Some(join_handle),
157 event_tx,
158 rx_paused,
159 last_input_elapsed_ms,
160 session_start,
161 }
162 }
163
164 pub(super) async fn stop(&mut self) {
167 self.cancellation_token.cancel();
168 if let Some(handle) = self.join_handle.take() {
169 let _ = tokio::time::timeout(Duration::from_millis(100), handle).await;
170 }
171 self.cancellation_token = CancellationToken::new();
172 }
173
174 pub(super) fn start(&mut self) {
177 let token = self.cancellation_token.clone();
178 let event_tx = self.event_tx.clone();
179 let rx_paused = self.rx_paused.clone();
180 let last_input = self.last_input_elapsed_ms.clone();
181 let session_start = self.session_start;
182 self.join_handle = Some(tokio::spawn(async move {
183 spawn_event_loop(event_tx, token, rx_paused, last_input, session_start).await;
184 }));
185 }
186
187 pub(super) async fn shutdown(&mut self) {
189 if let Some(handle) = self.join_handle.take() {
190 self.cancellation_token.cancel();
191 let _ = tokio::time::timeout(Duration::from_millis(100), handle).await;
192 }
193 }
194}
195
196struct TerminalModeRestoreGuard {
197 state: Option<TerminalModeState>,
198}
199
200impl TerminalModeRestoreGuard {
201 fn new(state: TerminalModeState) -> Self {
202 Self { state: Some(state) }
203 }
204
205 fn state_mut(&mut self) -> &mut TerminalModeState {
206 self.state
207 .as_mut()
208 .expect("terminal mode restore guard must stay armed until shutdown")
209 }
210
211 fn restore(&mut self) -> Result<()> {
212 if let Some(state) = self.state.take() {
213 restore_terminal_modes(&state)?;
214 }
215 Ok(())
216 }
217
218 fn restore_silently(&mut self) {
219 if self.state.is_some() {
220 let _ = execute!(io::stderr(), MoveToColumn(0), Clear(ClearType::CurrentLine));
221 if let Err(error) = self.restore() {
222 tracing::warn!(%error, "failed to restore terminal modes");
223 }
224 }
225 }
226}
227
228impl Drop for TerminalModeRestoreGuard {
229 fn drop(&mut self) {
230 self.restore_silently();
231 }
232}
233
234pub struct TuiOptions<E> {
235 pub surface_preference: UiSurfacePreference,
236 pub inline_rows: u16,
237 pub show_logs: bool,
238 pub log_theme: Option<String>,
239 pub event_callback: Option<EventCallback<E>>,
240 pub focus_callback: Option<FocusChangeCallback>,
241 pub active_pty_sessions: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
242 pub input_activity_counter: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
243 pub keyboard_protocol: crate::tui::config::KeyboardProtocolConfig,
244 pub fullscreen: FullscreenInteractionSettings,
245 pub workspace_root: Option<std::path::PathBuf>,
246}
247
248pub async fn run_tui<S, F>(
249 mut commands: UnboundedReceiver<S::Command>,
250 events: UnboundedSender<S::Event>,
251 options: TuiOptions<S::Event>,
252 make_session: F,
253) -> Result<()>
254where
255 S: TuiSessionDriver,
256 F: FnOnce(u16) -> S,
257{
258 let _panic_guard = crate::tui::ui::tui::panic_hook::TuiPanicGuard::new();
261
262 let _signal_guard = SignalCleanupGuard::new()?;
263
264 let surface = TerminalSurface::detect(options.surface_preference, options.inline_rows)?;
265 set_log_theme_name(options.log_theme.clone());
266 let mut session = make_session(surface.rows());
267 session.set_show_logs(options.show_logs);
268 session.set_active_pty_sessions(options.active_pty_sessions);
269 session.set_workspace_root(options.workspace_root.clone());
270 session.set_fullscreen_active(surface.use_alternate());
271 session.set_fullscreen_interaction(options.fullscreen);
272 if options.show_logs {
273 let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel();
274 session.set_log_receiver(log_rx);
275 register_tui_log_sender(log_tx);
276 } else {
277 clear_tui_log_sender();
278 }
279
280 let keyboard_flags = crate::tui::config::keyboard_protocol_to_flags(&options.keyboard_protocol);
281 let mut stderr = io::stderr();
282 let mut mode_restore_guard = TerminalModeRestoreGuard::new(enable_terminal_modes(
283 &mut stderr,
284 keyboard_flags,
285 &options.fullscreen,
286 )?);
287 mode_restore_guard
288 .state_mut()
289 .save_cursor_position(&mut stderr);
290 if surface.use_alternate() {
291 mode_restore_guard
292 .state_mut()
293 .enter_alternate_screen(&mut stderr)?;
294 }
295
296 session.update_terminal_title();
297
298 let backend = CrosstermBackend::new(stderr);
299 let mut terminal = Terminal::new(backend).context("failed to initialize inline terminal")?;
300 prepare_terminal(&mut terminal)?;
301
302 let (mut input_listener, event_channels) = EventListener::new();
304 let cancellation_token = CancellationToken::new();
305 let event_loop_token = cancellation_token.clone();
306 let event_channels_for_loop = event_channels.clone();
307 let rx_paused = event_channels.rx_paused.clone();
308 let last_input_elapsed_ms = event_channels.last_input_elapsed_ms.clone();
309 let session_start = event_channels.session_start;
310
311 drain_terminal_events();
314
315 let event_tx_for_controller = event_channels_for_loop.tx.clone();
317
318 let event_loop_handle = tokio::spawn(async move {
321 spawn_event_loop(
322 event_channels_for_loop.tx.clone(),
323 event_loop_token,
324 rx_paused,
325 last_input_elapsed_ms,
326 session_start,
327 )
328 .await;
329 });
330
331 let mut event_stream = EventStreamController::new(
332 cancellation_token,
333 event_loop_handle,
334 event_tx_for_controller,
335 event_channels.rx_paused.clone(),
336 event_channels.last_input_elapsed_ms.clone(),
337 event_channels.session_start,
338 );
339
340 let drive_result = drive_terminal(
341 &mut terminal,
342 &mut session,
343 &mut commands,
344 &events,
345 &mut input_listener,
346 event_channels,
347 DriveRuntimeOptions {
348 event_callback: options.event_callback,
349 focus_callback: options.focus_callback,
350 use_alternate_screen: surface.use_alternate(),
351 input_activity_counter: options.input_activity_counter,
352 keyboard_flags,
353 fullscreen: options.fullscreen,
354 },
355 &mut event_stream,
356 )
357 .await;
358
359 event_stream.shutdown().await;
361
362 drain_terminal_events();
364
365 let _ = execute!(io::stderr(), MoveToColumn(0), Clear(ClearType::CurrentLine));
367
368 let finalize_result = finalize_terminal(&mut terminal);
369
370 if let Err(error) = mode_restore_guard.restore() {
372 tracing::warn!(%error, "failed to restore terminal modes");
373 }
374
375 session.clear_terminal_title();
377
378 drive_result?;
379 finalize_result?;
380
381 clear_tui_log_sender();
382 vtcode_commons::trace_flush::flush_trace_log();
383
384 Ok(())
385}