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