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