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