vtcode_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::config::types::UiSurfacePreference;
15use crate::options::FullscreenInteractionSettings;
16use crate::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(&mut self, receiver: UnboundedReceiver<crate::core_tui::log::LogEntry>);
64 fn set_fullscreen_active(&mut self, active: bool);
65 fn set_fullscreen_interaction(&mut self, config: FullscreenInteractionSettings);
66}
67
68impl TuiCommand for crate::core_tui::types::InlineCommand {
69 fn is_suspend_event_loop(&self) -> bool {
70 matches!(
71 self,
72 crate::core_tui::types::InlineCommand::SuspendEventLoop
73 )
74 }
75
76 fn is_resume_event_loop(&self) -> bool {
77 matches!(self, crate::core_tui::types::InlineCommand::ResumeEventLoop)
78 }
79
80 fn is_clear_input_queue(&self) -> bool {
81 matches!(self, crate::core_tui::types::InlineCommand::ClearInputQueue)
82 }
83
84 fn is_force_redraw(&self) -> bool {
85 matches!(self, crate::core_tui::types::InlineCommand::ForceRedraw)
86 }
87
88 fn is_stop_event_stream(&self) -> bool {
89 matches!(self, crate::core_tui::types::InlineCommand::StopEventStream)
90 }
91
92 fn is_start_event_stream(&self) -> bool {
93 matches!(
94 self,
95 crate::core_tui::types::InlineCommand::StartEventStream
96 )
97 }
98}
99
100use super::types::FocusChangeCallback;
101
102mod drive;
103mod events;
104mod signal;
105mod surface;
106pub(crate) mod terminal_io;
107mod terminal_modes;
108
109use drive::{DriveRuntimeOptions, drive_terminal};
110use events::{EventListener, TerminalEvent, spawn_event_loop};
111use signal::SignalCleanupGuard;
112use surface::TerminalSurface;
113use terminal_io::{drain_terminal_events, finalize_terminal, prepare_terminal};
114use terminal_modes::{TerminalModeState, enable_terminal_modes, restore_terminal_modes};
115
116pub(super) struct EventStreamController {
122 cancellation_token: CancellationToken,
123 join_handle: Option<tokio::task::JoinHandle<()>>,
124 event_tx: UnboundedSender<TerminalEvent>,
125 rx_paused: std::sync::Arc<std::sync::atomic::AtomicBool>,
126 last_input_elapsed_ms: std::sync::Arc<std::sync::atomic::AtomicU64>,
127 session_start: std::time::Instant,
128}
129
130impl EventStreamController {
131 pub(super) fn new(
132 cancellation_token: CancellationToken,
133 join_handle: tokio::task::JoinHandle<()>,
134 event_tx: UnboundedSender<TerminalEvent>,
135 rx_paused: std::sync::Arc<std::sync::atomic::AtomicBool>,
136 last_input_elapsed_ms: std::sync::Arc<std::sync::atomic::AtomicU64>,
137 session_start: std::time::Instant,
138 ) -> Self {
139 Self {
140 cancellation_token,
141 join_handle: Some(join_handle),
142 event_tx,
143 rx_paused,
144 last_input_elapsed_ms,
145 session_start,
146 }
147 }
148
149 pub(super) async fn stop(&mut self) {
152 self.cancellation_token.cancel();
153 if let Some(handle) = self.join_handle.take() {
154 let _ = tokio::time::timeout(Duration::from_millis(100), handle).await;
155 }
156 self.cancellation_token = CancellationToken::new();
157 }
158
159 pub(super) fn start(&mut self) {
162 let token = self.cancellation_token.clone();
163 let event_tx = self.event_tx.clone();
164 let rx_paused = self.rx_paused.clone();
165 let last_input = self.last_input_elapsed_ms.clone();
166 let session_start = self.session_start;
167 self.join_handle = Some(tokio::spawn(async move {
168 spawn_event_loop(event_tx, token, rx_paused, last_input, session_start).await;
169 }));
170 }
171
172 pub(super) async fn shutdown(&mut self) {
174 if let Some(handle) = self.join_handle.take() {
175 self.cancellation_token.cancel();
176 let _ = tokio::time::timeout(Duration::from_millis(100), handle).await;
177 }
178 }
179}
180
181struct TerminalModeRestoreGuard {
182 state: Option<TerminalModeState>,
183}
184
185impl TerminalModeRestoreGuard {
186 fn new(state: TerminalModeState) -> Self {
187 Self { state: Some(state) }
188 }
189
190 fn state_mut(&mut self) -> &mut TerminalModeState {
191 self.state
192 .as_mut()
193 .expect("terminal mode restore guard must stay armed until shutdown")
194 }
195
196 fn restore(&mut self) -> Result<()> {
197 if let Some(state) = self.state.take() {
198 restore_terminal_modes(&state)?;
199 }
200 Ok(())
201 }
202
203 fn restore_silently(&mut self) {
204 if self.state.is_some() {
205 let _ = execute!(io::stderr(), MoveToColumn(0), Clear(ClearType::CurrentLine));
206 if let Err(error) = self.restore() {
207 tracing::warn!(%error, "failed to restore terminal modes");
208 }
209 }
210 }
211}
212
213impl Drop for TerminalModeRestoreGuard {
214 fn drop(&mut self) {
215 self.restore_silently();
216 }
217}
218
219pub struct TuiOptions<E> {
220 pub surface_preference: UiSurfacePreference,
221 pub inline_rows: u16,
222 pub show_logs: bool,
223 pub log_theme: Option<String>,
224 pub event_callback: Option<EventCallback<E>>,
225 pub focus_callback: Option<FocusChangeCallback>,
226 pub active_pty_sessions: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
227 pub input_activity_counter: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
228 pub keyboard_protocol: crate::config::KeyboardProtocolConfig,
229 pub fullscreen: FullscreenInteractionSettings,
230 pub workspace_root: Option<std::path::PathBuf>,
231}
232
233pub async fn run_tui<S, F>(
234 mut commands: UnboundedReceiver<S::Command>,
235 events: UnboundedSender<S::Event>,
236 options: TuiOptions<S::Event>,
237 make_session: F,
238) -> Result<()>
239where
240 S: TuiSessionDriver,
241 F: FnOnce(u16) -> S,
242{
243 let _panic_guard = crate::ui::tui::panic_hook::TuiPanicGuard::new();
246
247 let _signal_guard = SignalCleanupGuard::new()?;
248
249 let surface = TerminalSurface::detect(options.surface_preference, options.inline_rows)?;
250 set_log_theme_name(options.log_theme.clone());
251 let mut session = make_session(surface.rows());
252 session.set_show_logs(options.show_logs);
253 session.set_active_pty_sessions(options.active_pty_sessions);
254 session.set_workspace_root(options.workspace_root.clone());
255 session.set_fullscreen_active(surface.use_alternate());
256 session.set_fullscreen_interaction(options.fullscreen);
257 if options.show_logs {
258 let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel();
259 session.set_log_receiver(log_rx);
260 register_tui_log_sender(log_tx);
261 } else {
262 clear_tui_log_sender();
263 }
264
265 let keyboard_flags = crate::config::keyboard_protocol_to_flags(&options.keyboard_protocol);
266 let mut stderr = io::stderr();
267 let mut mode_restore_guard = TerminalModeRestoreGuard::new(enable_terminal_modes(
268 &mut stderr,
269 keyboard_flags,
270 &options.fullscreen,
271 )?);
272 mode_restore_guard
273 .state_mut()
274 .save_cursor_position(&mut stderr);
275 if surface.use_alternate() {
276 mode_restore_guard
277 .state_mut()
278 .enter_alternate_screen(&mut stderr)?;
279 }
280
281 session.update_terminal_title();
282
283 let backend = CrosstermBackend::new(stderr);
284 let mut terminal = Terminal::new(backend).context("failed to initialize inline terminal")?;
285 prepare_terminal(&mut terminal)?;
286
287 let (mut input_listener, event_channels) = EventListener::new();
289 let cancellation_token = CancellationToken::new();
290 let event_loop_token = cancellation_token.clone();
291 let event_channels_for_loop = event_channels.clone();
292 let rx_paused = event_channels.rx_paused.clone();
293 let last_input_elapsed_ms = event_channels.last_input_elapsed_ms.clone();
294 let session_start = event_channels.session_start;
295
296 drain_terminal_events();
299
300 let event_tx_for_controller = event_channels_for_loop.tx.clone();
302
303 let event_loop_handle = tokio::spawn(async move {
306 spawn_event_loop(
307 event_channels_for_loop.tx.clone(),
308 event_loop_token,
309 rx_paused,
310 last_input_elapsed_ms,
311 session_start,
312 )
313 .await;
314 });
315
316 let mut event_stream = EventStreamController::new(
317 cancellation_token,
318 event_loop_handle,
319 event_tx_for_controller,
320 event_channels.rx_paused.clone(),
321 event_channels.last_input_elapsed_ms.clone(),
322 event_channels.session_start,
323 );
324
325 let drive_result = drive_terminal(
326 &mut terminal,
327 &mut session,
328 &mut commands,
329 &events,
330 &mut input_listener,
331 event_channels,
332 DriveRuntimeOptions {
333 event_callback: options.event_callback,
334 focus_callback: options.focus_callback,
335 use_alternate_screen: surface.use_alternate(),
336 input_activity_counter: options.input_activity_counter,
337 keyboard_flags,
338 fullscreen: options.fullscreen,
339 },
340 &mut event_stream,
341 )
342 .await;
343
344 event_stream.shutdown().await;
346
347 drain_terminal_events();
349
350 let _ = execute!(io::stderr(), MoveToColumn(0), Clear(ClearType::CurrentLine));
352
353 let finalize_result = finalize_terminal(&mut terminal);
354
355 if let Err(error) = mode_restore_guard.restore() {
357 tracing::warn!(%error, "failed to restore terminal modes");
358 }
359
360 session.clear_terminal_title();
362
363 drive_result?;
364 finalize_result?;
365
366 clear_tui_log_sender();
367 vtcode_commons::trace_flush::flush_trace_log();
368
369 Ok(())
370}