Skip to main content

vtcode_tui/
session_options.rs

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