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    set_log_theme_name(options.log_theme.clone());
67    let mut session = Session::new_with_logs(
68        options.theme,
69        options.placeholder,
70        surface.rows(),
71        options.show_logs,
72        options.appearance.clone(),
73        options.slash_commands,
74        options.app_name.clone(),
75    );
76    session.show_logs = options.show_logs;
77    session.active_pty_sessions = options.active_pty_sessions;
78    session.set_workspace_root(options.workspace_root.clone());
79    if options.show_logs {
80        let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel();
81        session.set_log_receiver(log_rx);
82        register_tui_log_sender(log_tx);
83    } else {
84        clear_tui_log_sender();
85    }
86
87    let keyboard_flags = crate::config::keyboard_protocol_to_flags(&options.keyboard_protocol);
88    let mut stderr = io::stderr();
89    let cursor_position_saved = match execute!(stderr, SavePosition) {
90        Ok(_) => true,
91        Err(error) => {
92            tracing::debug!(%error, "failed to save cursor position for inline session");
93            false
94        }
95    };
96    let mode_state = enable_terminal_modes(&mut stderr, keyboard_flags)?;
97    if surface.use_alternate() {
98        execute!(stderr, EnterAlternateScreen).context(ALTERNATE_SCREEN_ERROR)?;
99    }
100
101    // Set initial terminal title with project name.
102    let initial_title = options
103        .workspace_root
104        .as_ref()
105        .and_then(|path| {
106            path.file_name()
107                .or_else(|| path.parent()?.file_name())
108                .map(|name| format!("> {} ({})", options.app_name, name.to_string_lossy()))
109        })
110        .unwrap_or_else(|| format!("> {}", options.app_name));
111
112    if let Err(error) = execute!(stderr, SetTitle(&initial_title)) {
113        tracing::debug!(%error, "failed to set initial terminal title");
114    }
115
116    let backend = CrosstermBackend::new(stderr);
117    let mut terminal = Terminal::new(backend).context("failed to initialize inline terminal")?;
118    prepare_terminal(&mut terminal)?;
119
120    // Create event listener and channels using the new async pattern
121    let (mut input_listener, event_channels) = EventListener::new();
122    let cancellation_token = CancellationToken::new();
123    let event_loop_token = cancellation_token.clone();
124    let event_channels_for_loop = event_channels.clone();
125    let rx_paused = event_channels.rx_paused.clone();
126    let last_input_elapsed_ms = event_channels.last_input_elapsed_ms.clone();
127    let session_start = event_channels.session_start;
128
129    // Ensure any capability or resize responses emitted during terminal setup are not treated as
130    // the user's first keystrokes.
131    drain_terminal_events();
132
133    // Spawn the async event loop after the terminal is fully configured so the first keypress is
134    // captured immediately (avoids cooked-mode buffering before raw mode is enabled).
135    let event_loop_handle = tokio::spawn(async move {
136        spawn_event_loop(
137            event_channels_for_loop.tx.clone(),
138            event_loop_token,
139            rx_paused,
140            last_input_elapsed_ms,
141            session_start,
142        )
143        .await;
144    });
145
146    let drive_result = drive_terminal(
147        &mut terminal,
148        &mut session,
149        &mut commands,
150        &events,
151        &mut input_listener,
152        event_channels,
153        options.event_callback,
154        surface.use_alternate(),
155        keyboard_flags,
156    )
157    .await;
158
159    // Gracefully shutdown the event loop
160    cancellation_token.cancel();
161    let _ = tokio::time::timeout(Duration::from_millis(100), event_loop_handle).await;
162
163    // Drain any pending events before finalizing terminal and disabling modes
164    drain_terminal_events();
165
166    // Clear current line to remove any echoed characters (like ^C)
167    let _ = execute!(io::stderr(), MoveToColumn(0), Clear(ClearType::CurrentLine));
168
169    let finalize_result = finalize_terminal(&mut terminal);
170    let leave_alternate_result = if surface.use_alternate() {
171        Some(execute!(terminal.backend_mut(), LeaveAlternateScreen))
172    } else {
173        None
174    };
175
176    if let Some(result) = leave_alternate_result
177        && let Err(error) = result
178    {
179        tracing::warn!(%error, "failed to leave alternate screen");
180    }
181
182    // Restore terminal modes (handles all modes including raw mode)
183    let restore_modes_result = restore_terminal_modes(&mode_state);
184    if let Err(error) = restore_modes_result {
185        tracing::warn!(%error, "failed to restore terminal modes");
186    }
187
188    // Clear terminal title on exit.
189    session.clear_terminal_title();
190
191    if cursor_position_saved && let Err(error) = execute!(io::stderr(), RestorePosition) {
192        tracing::debug!(%error, "failed to restore cursor position for inline session");
193    }
194
195    drive_result?;
196    finalize_result?;
197
198    clear_tui_log_sender();
199
200    Ok(())
201}