Skip to main content

vtcode_ui/tui/core_tui/runner/
mod.rs

1use 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
131/// Controls the lifecycle of the async crossterm event stream.
132///
133/// The event loop must be fully stopped before launching an external editor
134/// that needs stdin (e.g., nvim), otherwise the background EventStream task
135/// competes with the editor for terminal input, causing freezes.
136pub(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    /// Cancel the current event loop task and await its termination.
165    /// Creates a fresh CancellationToken for the next `start()` call.
166    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    /// Spawn a new event loop task with a fresh EventStream.
175    /// Safe to call multiple times after `stop()`.
176    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    /// Ensure the event loop is stopped for final cleanup on TUI exit.
188    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    // Create a guard to mark TUI as initialized during the session
259    // This ensures the panic hook knows to restore terminal state
260    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    // Create event listener and channels using the new async pattern
303    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    // Ensure any capability or resize responses emitted during terminal setup are not treated as
312    // the user's first keystrokes.
313    drain_terminal_events();
314
315    // Clone the sender before moving event_channels_for_loop into tokio::spawn.
316    let event_tx_for_controller = event_channels_for_loop.tx.clone();
317
318    // Spawn the async event loop after the terminal is fully configured so the first keypress is
319    // captured immediately (avoids cooked-mode buffering before raw mode is enabled).
320    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    // Gracefully shutdown the event loop (may already be stopped by StopEventStream)
360    event_stream.shutdown().await;
361
362    // Drain any pending events before finalizing terminal and disabling modes
363    drain_terminal_events();
364
365    // Clear current line to remove any echoed characters (like ^C)
366    let _ = execute!(io::stderr(), MoveToColumn(0), Clear(ClearType::CurrentLine));
367
368    let finalize_result = finalize_terminal(&mut terminal);
369
370    // Restore terminal modes (handles all modes including raw mode)
371    if let Err(error) = mode_restore_guard.restore() {
372        tracing::warn!(%error, "failed to restore terminal modes");
373    }
374
375    // Clear terminal title on exit.
376    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}