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#[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 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 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
83pub fn spawn_session_with_options(theme: InlineTheme, options: SessionOptions) -> anyhow::Result<InlineSession> {
85 use crossterm::tty::IsTty;
86
87 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
158pub 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 #[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}