vtcode_tui/core_tui/runner/
mod.rs1use std::io;
2use std::time::Duration;
3
4use anyhow::{Context, Result};
5use ratatui::crossterm::{
6 cursor::{MoveToColumn, RestorePosition, SavePosition},
7 execute,
8 terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
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::ui::tui::log::{clear_tui_log_sender, register_tui_log_sender, set_log_theme_name};
16
17type EventCallback<E> = std::sync::Arc<dyn Fn(&E) + Send + Sync + 'static>;
18
19pub trait TuiCommand {
20 fn is_suspend_event_loop(&self) -> bool;
21 fn is_resume_event_loop(&self) -> bool;
22 fn is_clear_input_queue(&self) -> bool;
23 fn is_force_redraw(&self) -> bool;
24}
25
26pub trait TuiSessionDriver {
27 type Command: TuiCommand;
28 type Event;
29
30 fn handle_command(&mut self, command: Self::Command);
31 #[allow(clippy::type_complexity)]
32 fn handle_event(
33 &mut self,
34 event: crossterm::event::Event,
35 events: &UnboundedSender<Self::Event>,
36 callback: Option<&(dyn Fn(&Self::Event) + Send + Sync + 'static)>,
37 );
38 fn handle_tick(&mut self);
39 fn render(&mut self, frame: &mut ratatui::Frame<'_>);
40 fn take_redraw(&mut self) -> bool;
41 fn use_steady_cursor(&self) -> bool;
42 fn should_exit(&self) -> bool;
43 fn request_exit(&mut self);
44 fn mark_dirty(&mut self);
45 fn update_terminal_title(&mut self);
46 fn clear_terminal_title(&mut self);
47 fn is_running_activity(&self) -> bool;
48 fn has_status_spinner(&self) -> bool;
49 fn thinking_spinner_active(&self) -> bool;
50 fn has_active_navigation_ui(&self) -> bool;
51 fn apply_coalesced_scroll(&mut self, line_delta: i32, page_delta: i32);
52 fn set_show_logs(&mut self, show: bool);
53 fn set_active_pty_sessions(
54 &mut self,
55 sessions: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
56 );
57 fn set_workspace_root(&mut self, root: Option<std::path::PathBuf>);
58 fn set_log_receiver(&mut self, receiver: UnboundedReceiver<crate::core_tui::log::LogEntry>);
59}
60
61impl TuiCommand for crate::core_tui::types::InlineCommand {
62 fn is_suspend_event_loop(&self) -> bool {
63 matches!(
64 self,
65 crate::core_tui::types::InlineCommand::SuspendEventLoop
66 )
67 }
68
69 fn is_resume_event_loop(&self) -> bool {
70 matches!(self, crate::core_tui::types::InlineCommand::ResumeEventLoop)
71 }
72
73 fn is_clear_input_queue(&self) -> bool {
74 matches!(self, crate::core_tui::types::InlineCommand::ClearInputQueue)
75 }
76
77 fn is_force_redraw(&self) -> bool {
78 matches!(self, crate::core_tui::types::InlineCommand::ForceRedraw)
79 }
80}
81
82use super::types::FocusChangeCallback;
83
84mod drive;
85mod events;
86mod signal;
87mod surface;
88mod terminal_io;
89mod terminal_modes;
90
91use drive::{DriveRuntimeOptions, drive_terminal};
92use events::{EventListener, spawn_event_loop};
93use signal::SignalCleanupGuard;
94use surface::TerminalSurface;
95use terminal_io::{drain_terminal_events, finalize_terminal, prepare_terminal};
96use terminal_modes::{enable_terminal_modes, restore_terminal_modes};
97
98const ALTERNATE_SCREEN_ERROR: &str = "failed to enter alternate inline screen";
99
100pub struct TuiOptions<E> {
101 pub surface_preference: UiSurfacePreference,
102 pub inline_rows: u16,
103 pub show_logs: bool,
104 pub log_theme: Option<String>,
105 pub event_callback: Option<EventCallback<E>>,
106 pub focus_callback: Option<FocusChangeCallback>,
107 pub active_pty_sessions: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
108 pub input_activity_counter: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
109 pub keyboard_protocol: crate::config::KeyboardProtocolConfig,
110 pub workspace_root: Option<std::path::PathBuf>,
111}
112
113pub async fn run_tui<S, F>(
114 mut commands: UnboundedReceiver<S::Command>,
115 events: UnboundedSender<S::Event>,
116 options: TuiOptions<S::Event>,
117 make_session: F,
118) -> Result<()>
119where
120 S: TuiSessionDriver,
121 F: FnOnce(u16) -> S,
122{
123 let _panic_guard = crate::ui::tui::panic_hook::TuiPanicGuard::new();
126
127 let _signal_guard = SignalCleanupGuard::new()?;
128
129 let surface = TerminalSurface::detect(options.surface_preference, options.inline_rows)?;
130 set_log_theme_name(options.log_theme.clone());
131 let mut session = make_session(surface.rows());
132 session.set_show_logs(options.show_logs);
133 session.set_active_pty_sessions(options.active_pty_sessions);
134 session.set_workspace_root(options.workspace_root.clone());
135 if options.show_logs {
136 let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel();
137 session.set_log_receiver(log_rx);
138 register_tui_log_sender(log_tx);
139 } else {
140 clear_tui_log_sender();
141 }
142
143 let keyboard_flags = crate::config::keyboard_protocol_to_flags(&options.keyboard_protocol);
144 let mut stderr = io::stderr();
145 let cursor_position_saved = match execute!(stderr, SavePosition) {
146 Ok(_) => true,
147 Err(error) => {
148 tracing::debug!(%error, "failed to save cursor position for inline session");
149 false
150 }
151 };
152 let mode_state = enable_terminal_modes(&mut stderr, keyboard_flags)?;
153 if surface.use_alternate() {
154 execute!(stderr, EnterAlternateScreen).context(ALTERNATE_SCREEN_ERROR)?;
155 }
156
157 session.update_terminal_title();
158
159 let backend = CrosstermBackend::new(stderr);
160 let mut terminal = Terminal::new(backend).context("failed to initialize inline terminal")?;
161 prepare_terminal(&mut terminal)?;
162
163 let (mut input_listener, event_channels) = EventListener::new();
165 let cancellation_token = CancellationToken::new();
166 let event_loop_token = cancellation_token.clone();
167 let event_channels_for_loop = event_channels.clone();
168 let rx_paused = event_channels.rx_paused.clone();
169 let last_input_elapsed_ms = event_channels.last_input_elapsed_ms.clone();
170 let session_start = event_channels.session_start;
171
172 drain_terminal_events();
175
176 let event_loop_handle = tokio::spawn(async move {
179 spawn_event_loop(
180 event_channels_for_loop.tx.clone(),
181 event_loop_token,
182 rx_paused,
183 last_input_elapsed_ms,
184 session_start,
185 )
186 .await;
187 });
188
189 let drive_result = drive_terminal(
190 &mut terminal,
191 &mut session,
192 &mut commands,
193 &events,
194 &mut input_listener,
195 event_channels,
196 DriveRuntimeOptions {
197 event_callback: options.event_callback,
198 focus_callback: options.focus_callback,
199 use_alternate_screen: surface.use_alternate(),
200 input_activity_counter: options.input_activity_counter,
201 keyboard_flags,
202 },
203 )
204 .await;
205
206 cancellation_token.cancel();
208 let _ = tokio::time::timeout(Duration::from_millis(100), event_loop_handle).await;
209
210 drain_terminal_events();
212
213 let _ = execute!(io::stderr(), MoveToColumn(0), Clear(ClearType::CurrentLine));
215
216 let finalize_result = finalize_terminal(&mut terminal);
217 let leave_alternate_result = if surface.use_alternate() {
218 Some(execute!(terminal.backend_mut(), LeaveAlternateScreen))
219 } else {
220 None
221 };
222
223 if let Some(result) = leave_alternate_result
224 && let Err(error) = result
225 {
226 tracing::warn!(%error, "failed to leave alternate screen");
227 }
228
229 let restore_modes_result = restore_terminal_modes(&mode_state);
231 if let Err(error) = restore_modes_result {
232 tracing::warn!(%error, "failed to restore terminal modes");
233 }
234
235 session.clear_terminal_title();
237
238 if cursor_position_saved && let Err(error) = execute!(io::stderr(), RestorePosition) {
239 tracing::debug!(%error, "failed to restore cursor position for inline session");
240 }
241
242 drive_result?;
243 finalize_result?;
244
245 clear_tui_log_sender();
246 vtcode_commons::trace_flush::flush_trace_log();
247
248 Ok(())
249}