Skip to main content

vtcode_tui/core_tui/runner/
mod.rs

1use 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, SetTitle},
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
17use super::{
18    session::{Session, config::AppearanceConfig},
19    types::{
20        FocusChangeCallback, InlineCommand, InlineEvent, InlineEventCallback, InlineTheme,
21        SlashCommandItem,
22    },
23};
24
25mod drive;
26mod events;
27mod signal;
28mod surface;
29mod terminal_io;
30mod terminal_modes;
31
32use drive::{DriveRuntimeOptions, drive_terminal};
33use events::{EventListener, spawn_event_loop};
34use signal::SignalCleanupGuard;
35use surface::TerminalSurface;
36use terminal_io::{drain_terminal_events, finalize_terminal, prepare_terminal};
37use terminal_modes::{enable_terminal_modes, restore_terminal_modes};
38
39const ALTERNATE_SCREEN_ERROR: &str = "failed to enter alternate inline screen";
40
41pub struct TuiOptions {
42    pub theme: InlineTheme,
43    pub placeholder: Option<String>,
44    pub surface_preference: UiSurfacePreference,
45    pub inline_rows: u16,
46    pub show_logs: bool,
47    pub log_theme: Option<String>,
48    pub event_callback: Option<InlineEventCallback>,
49    pub focus_callback: Option<FocusChangeCallback>,
50    pub active_pty_sessions: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
51    pub input_activity_counter: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
52    pub keyboard_protocol: crate::config::KeyboardProtocolConfig,
53    pub workspace_root: Option<std::path::PathBuf>,
54    pub slash_commands: Vec<SlashCommandItem>,
55    pub appearance: Option<AppearanceConfig>,
56    pub app_name: String,
57}
58
59pub async fn run_tui(
60    mut commands: UnboundedReceiver<InlineCommand>,
61    events: UnboundedSender<InlineEvent>,
62    options: TuiOptions,
63) -> Result<()> {
64    // Create a guard to mark TUI as initialized during the session
65    // This ensures the panic hook knows to restore terminal state
66    let _panic_guard = crate::ui::tui::panic_hook::TuiPanicGuard::new();
67
68    let _signal_guard = SignalCleanupGuard::new()?;
69
70    let surface = TerminalSurface::detect(options.surface_preference, options.inline_rows)?;
71    set_log_theme_name(options.log_theme.clone());
72    let mut session = Session::new_with_logs(
73        options.theme,
74        options.placeholder,
75        surface.rows(),
76        options.show_logs,
77        options.appearance.clone(),
78        options.slash_commands,
79        options.app_name.clone(),
80    );
81    session.show_logs = options.show_logs;
82    session.active_pty_sessions = options.active_pty_sessions;
83    session.set_workspace_root(options.workspace_root.clone());
84    if options.show_logs {
85        let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel();
86        session.set_log_receiver(log_rx);
87        register_tui_log_sender(log_tx);
88    } else {
89        clear_tui_log_sender();
90    }
91
92    let keyboard_flags = crate::config::keyboard_protocol_to_flags(&options.keyboard_protocol);
93    let mut stderr = io::stderr();
94    let cursor_position_saved = match execute!(stderr, SavePosition) {
95        Ok(_) => true,
96        Err(error) => {
97            tracing::debug!(%error, "failed to save cursor position for inline session");
98            false
99        }
100    };
101    let mode_state = enable_terminal_modes(&mut stderr, keyboard_flags)?;
102    if surface.use_alternate() {
103        execute!(stderr, EnterAlternateScreen).context(ALTERNATE_SCREEN_ERROR)?;
104    }
105
106    // Set initial terminal title with project name.
107    let initial_title = options
108        .workspace_root
109        .as_ref()
110        .and_then(|path| {
111            path.file_name()
112                .or_else(|| path.parent()?.file_name())
113                .map(|name| format!("> {} ({})", options.app_name, name.to_string_lossy()))
114        })
115        .unwrap_or_else(|| format!("> {}", options.app_name));
116
117    if let Err(error) = execute!(stderr, SetTitle(&initial_title)) {
118        tracing::debug!(%error, "failed to set initial terminal title");
119    }
120
121    let backend = CrosstermBackend::new(stderr);
122    let mut terminal = Terminal::new(backend).context("failed to initialize inline terminal")?;
123    prepare_terminal(&mut terminal)?;
124
125    // Create event listener and channels using the new async pattern
126    let (mut input_listener, event_channels) = EventListener::new();
127    let cancellation_token = CancellationToken::new();
128    let event_loop_token = cancellation_token.clone();
129    let event_channels_for_loop = event_channels.clone();
130    let rx_paused = event_channels.rx_paused.clone();
131    let last_input_elapsed_ms = event_channels.last_input_elapsed_ms.clone();
132    let session_start = event_channels.session_start;
133
134    // Ensure any capability or resize responses emitted during terminal setup are not treated as
135    // the user's first keystrokes.
136    drain_terminal_events();
137
138    // Spawn the async event loop after the terminal is fully configured so the first keypress is
139    // captured immediately (avoids cooked-mode buffering before raw mode is enabled).
140    let event_loop_handle = tokio::spawn(async move {
141        spawn_event_loop(
142            event_channels_for_loop.tx.clone(),
143            event_loop_token,
144            rx_paused,
145            last_input_elapsed_ms,
146            session_start,
147        )
148        .await;
149    });
150
151    let drive_result = drive_terminal(
152        &mut terminal,
153        &mut session,
154        &mut commands,
155        &events,
156        &mut input_listener,
157        event_channels,
158        DriveRuntimeOptions {
159            event_callback: options.event_callback,
160            focus_callback: options.focus_callback,
161            use_alternate_screen: surface.use_alternate(),
162            input_activity_counter: options.input_activity_counter,
163            keyboard_flags,
164        },
165    )
166    .await;
167
168    // Gracefully shutdown the event loop
169    cancellation_token.cancel();
170    let _ = tokio::time::timeout(Duration::from_millis(100), event_loop_handle).await;
171
172    // Drain any pending events before finalizing terminal and disabling modes
173    drain_terminal_events();
174
175    // Clear current line to remove any echoed characters (like ^C)
176    let _ = execute!(io::stderr(), MoveToColumn(0), Clear(ClearType::CurrentLine));
177
178    let finalize_result = finalize_terminal(&mut terminal);
179    let leave_alternate_result = if surface.use_alternate() {
180        Some(execute!(terminal.backend_mut(), LeaveAlternateScreen))
181    } else {
182        None
183    };
184
185    if let Some(result) = leave_alternate_result
186        && let Err(error) = result
187    {
188        tracing::warn!(%error, "failed to leave alternate screen");
189    }
190
191    // Restore terminal modes (handles all modes including raw mode)
192    let restore_modes_result = restore_terminal_modes(&mode_state);
193    if let Err(error) = restore_modes_result {
194        tracing::warn!(%error, "failed to restore terminal modes");
195    }
196
197    // Clear terminal title on exit.
198    session.clear_terminal_title();
199
200    if cursor_position_saved && let Err(error) = execute!(io::stderr(), RestorePosition) {
201        tracing::debug!(%error, "failed to restore cursor position for inline session");
202    }
203
204    drive_result?;
205    finalize_result?;
206
207    clear_tui_log_sender();
208
209    Ok(())
210}