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