Skip to main content

vtcode_ui/tui/
session_options.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use crate::tui::config::KeyboardProtocolConfig;
5use crate::tui::config::UiSurfacePreference;
6use hashbrown::HashMap;
7
8use crate::tui::core_tui::app::session::AppSession;
9use crate::tui::core_tui::app::types::{
10    FocusChangeCallback, InlineEventCallback, InlineHandle, InlineSession, InlineTheme, PreviewCallback,
11    SlashCommandItem, TransientActivitySignal,
12};
13use crate::tui::core_tui::log;
14use crate::tui::core_tui::runner::{TuiOptions, run_tui};
15use crate::tui::core_tui::session::action::BindingStore;
16use crate::tui::core_tui::session::config::AppearanceConfig;
17use crate::tui::options::{FullscreenInteractionSettings, KeyboardProtocolSettings, SessionSurface};
18
19/// Standalone session launch options for reusable integrations.
20#[derive(Clone)]
21pub struct SessionOptions {
22    pub placeholder: Option<String>,
23    pub surface_preference: SessionSurface,
24    pub inline_rows: u16,
25    pub event_callback: Option<InlineEventCallback>,
26    pub focus_callback: Option<FocusChangeCallback>,
27    pub active_pty_sessions: Option<Arc<std::sync::atomic::AtomicUsize>>,
28    pub input_activity_counter: Option<Arc<std::sync::atomic::AtomicU64>>,
29    pub keyboard_protocol: KeyboardProtocolSettings,
30    pub fullscreen: FullscreenInteractionSettings,
31    pub workspace_root: Option<PathBuf>,
32    pub slash_commands: Vec<SlashCommandItem>,
33    pub appearance: Option<AppearanceConfig>,
34    pub app_name: String,
35    pub non_interactive_hint: Option<String>,
36    /// User-customizable keybindings (action_name → key spec list).
37    /// Merged on top of built-in defaults.
38    pub key_bindings: HashMap<String, Vec<String>>,
39    pub preview_callback: Option<PreviewCallback>,
40}
41
42impl Default for SessionOptions {
43    fn default() -> Self {
44        Self {
45            placeholder: None,
46            surface_preference: SessionSurface::default(),
47            inline_rows: crate::tui::config::constants::ui::DEFAULT_INLINE_VIEWPORT_ROWS,
48            event_callback: None,
49            focus_callback: None,
50            active_pty_sessions: None,
51            input_activity_counter: None,
52            keyboard_protocol: KeyboardProtocolSettings::default(),
53            fullscreen: FullscreenInteractionSettings::default(),
54            workspace_root: None,
55            slash_commands: Vec::new(),
56            appearance: None,
57            app_name: "Agent TUI".to_string(),
58            non_interactive_hint: None,
59            key_bindings: HashMap::new(),
60            preview_callback: None,
61        }
62    }
63}
64
65impl SessionOptions {
66    /// Build options from a host adapter's defaults.
67    fn from_host(host: &impl crate::tui::host::HostAdapter) -> Self {
68        let defaults = host.session_defaults();
69        Self {
70            surface_preference: defaults.surface_preference,
71            inline_rows: defaults.inline_rows,
72            keyboard_protocol: defaults.keyboard_protocol,
73            fullscreen: defaults.fullscreen,
74            workspace_root: host.workspace_root(),
75            slash_commands: host.slash_commands(),
76            app_name: host.app_name(),
77            non_interactive_hint: host.non_interactive_hint(),
78            ..Self::default()
79        }
80    }
81}
82
83/// Spawn a session using standalone options and local config types.
84pub fn spawn_session_with_options(theme: InlineTheme, options: SessionOptions) -> anyhow::Result<InlineSession> {
85    use crossterm::tty::IsTty;
86
87    // Check stdin is a terminal BEFORE spawning the task
88    if !std::io::stdin().is_tty() {
89        return Err(anyhow::anyhow!(
90            "cannot run interactive TUI: stdin is not a terminal (must be run in an interactive terminal)"
91        ));
92    }
93
94    let (command_tx, command_rx) = tokio::sync::mpsc::unbounded_channel();
95    let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel();
96    let show_logs = log::is_tui_log_capture_enabled();
97    let transient_active = Arc::new(TransientActivitySignal::default());
98    let transient_active_for_session = transient_active.clone();
99
100    let worker = tokio::spawn(async move {
101        if let Err(error) = run_tui(
102            command_rx,
103            event_tx,
104            TuiOptions {
105                surface_preference: UiSurfacePreference::from(options.surface_preference),
106                inline_rows: options.inline_rows,
107                show_logs,
108                log_theme: None,
109                event_callback: options.event_callback,
110                focus_callback: options.focus_callback,
111                active_pty_sessions: options.active_pty_sessions,
112                input_activity_counter: options.input_activity_counter,
113                keyboard_protocol: KeyboardProtocolConfig::from(options.keyboard_protocol),
114                fullscreen: options.fullscreen,
115                workspace_root: options.workspace_root,
116                preview_callback: options.preview_callback.clone(),
117            },
118            move |rows| {
119                let bindings = BindingStore::new(options.key_bindings.clone());
120                let mut session = AppSession::new_with_logs_and_bindings(
121                    theme,
122                    options.placeholder,
123                    rows,
124                    show_logs,
125                    options.appearance,
126                    options.slash_commands,
127                    options.app_name,
128                    bindings,
129                );
130                session.set_transient_activity_signal(transient_active_for_session.clone());
131                session
132            },
133        )
134        .await
135        {
136            let error_msg = error.to_string();
137            if error_msg.contains("stdin is not a terminal") {
138                eprintln!("Error: Interactive TUI requires a proper terminal.");
139                if let Some(hint) = options.non_interactive_hint.as_deref() {
140                    eprintln!("{hint}");
141                } else {
142                    eprintln!("Use a non-interactive mode in your host app for piped input.");
143                }
144            } else {
145                eprintln!("Error: TUI startup failed: {error:#}");
146            }
147            tracing::error!(%error, "inline session terminated unexpectedly");
148        }
149    });
150
151    Ok(InlineSession {
152        handle: InlineHandle::new_with_transient_signal(command_tx, transient_active),
153        events: event_rx,
154        worker: Some(worker),
155    })
156}
157
158/// Spawn a session using defaults from a host adapter.
159pub fn spawn_session_with_host(
160    theme: InlineTheme,
161    host: &impl crate::tui::host::HostAdapter,
162) -> anyhow::Result<InlineSession> {
163    spawn_session_with_options(theme, SessionOptions::from_host(host))
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    struct DemoHost;
171
172    impl crate::tui::host::WorkspaceInfoProvider for DemoHost {
173        fn workspace_name(&self) -> String {
174            "demo".to_string()
175        }
176
177        fn workspace_root(&self) -> Option<PathBuf> {
178            Some(PathBuf::from("/workspace/demo"))
179        }
180    }
181
182    impl crate::tui::host::NotificationProvider for DemoHost {
183        fn set_terminal_focused(&self, _focused: bool) {}
184    }
185
186    impl crate::tui::host::ThemeProvider for DemoHost {
187        fn available_themes(&self) -> Vec<String> {
188            vec!["default".to_string()]
189        }
190
191        fn active_theme_name(&self) -> Option<String> {
192            Some("default".to_string())
193        }
194    }
195
196    impl crate::tui::host::HostAdapter for DemoHost {
197        fn session_defaults(&self) -> crate::tui::host::HostSessionDefaults {
198            crate::tui::host::HostSessionDefaults {
199                surface_preference: SessionSurface::Inline,
200                inline_rows: 24,
201                keyboard_protocol: KeyboardProtocolSettings::default(),
202                fullscreen: FullscreenInteractionSettings::default(),
203            }
204        }
205    }
206
207    // SessionOptions behavior tests.
208
209    #[test]
210    fn session_options_from_host_uses_defaults() {
211        let options = SessionOptions::from_host(&DemoHost);
212
213        assert_eq!(options.surface_preference, SessionSurface::Inline);
214        assert_eq!(options.inline_rows, 24);
215        assert_eq!(options.workspace_root, Some(PathBuf::from("/workspace/demo")));
216    }
217
218    #[test]
219    fn session_options_default_uses_inline_surface() {
220        assert_eq!(SessionOptions::default().surface_preference, SessionSurface::Inline);
221    }
222}