1use crate::tools::{agent, claude, codex, gemini, opencode, qwen};
4use command_stream::terminal::{
5 capture_terminal, TerminalCapture, TerminalCaptureError, TerminalCaptureOptions,
6 TerminalInteraction, TerminalKey,
7};
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::time::Duration;
11use thiserror::Error;
12
13const READ_ONLY_OPENCODE_PERMISSION: &str = r#"{"edit":"deny","bash":"deny","task":"deny"}"#;
14const SUPPORTED_TOOLS: [&str; 6] = ["claude", "codex", "opencode", "agent", "gemini", "qwen"];
15
16#[derive(Debug, Clone)]
18pub struct AgentTuiOptions {
19 pub tool: String,
21 pub working_directory: PathBuf,
23 pub executable: Option<String>,
25 pub prefix_args: Vec<String>,
27 pub extra_args: Vec<String>,
29 pub extra_env: HashMap<String, String>,
31 pub model: Option<String>,
33 pub prompt: Option<String>,
35 pub system_prompt: Option<String>,
37 pub prompt_after: Option<String>,
39 pub prompt_key: TerminalKey,
41 pub resume: Option<String>,
43 pub read_only: bool,
45 pub plan_only: bool,
47 pub approve_each: bool,
49 pub skip_default_safety_flags: bool,
51 pub cols: u16,
53 pub rows: u16,
55 pub settle_duration: Duration,
57 pub interactions: Vec<TerminalInteraction>,
59 pub stop_marker: Option<String>,
61 pub stop_marker_grace: Duration,
63 pub timeout: Duration,
65 pub artifact_directory: Option<PathBuf>,
67}
68
69impl Default for AgentTuiOptions {
70 fn default() -> Self {
71 Self {
72 tool: String::new(),
73 working_directory: PathBuf::new(),
74 executable: None,
75 prefix_args: Vec::new(),
76 extra_args: Vec::new(),
77 extra_env: HashMap::new(),
78 model: None,
79 prompt: None,
80 system_prompt: None,
81 prompt_after: None,
82 prompt_key: TerminalKey::Enter,
83 resume: None,
84 read_only: false,
85 plan_only: false,
86 approve_each: false,
87 skip_default_safety_flags: false,
88 cols: 80,
89 rows: 24,
90 settle_duration: Duration::from_millis(35),
91 interactions: Vec::new(),
92 stop_marker: None,
93 stop_marker_grace: Duration::from_millis(250),
94 timeout: Duration::from_secs(30),
95 artifact_directory: None,
96 }
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct AgentTuiLaunch {
103 pub file: String,
105 pub args: Vec<String>,
107 pub cwd: Option<PathBuf>,
109 pub env: HashMap<String, String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum AgentTuiEvent {
116 Message {
118 role: String,
120 content: String,
122 },
123 ToolCall {
125 name: String,
127 input: String,
129 },
130}
131
132#[derive(Debug, Clone)]
134pub struct AgentTuiCapture {
135 pub tool: String,
137 pub terminal: TerminalCapture,
139 pub events: Vec<AgentTuiEvent>,
141}
142
143#[derive(Debug, Error)]
145pub enum AgentTuiError {
146 #[error("{0}")]
148 Launch(String),
149 #[error(transparent)]
151 Capture(#[from] TerminalCaptureError),
152}
153
154fn mapped_model(tool: &str, model: &str) -> String {
155 match tool {
156 "claude" => claude::map_model_to_id(model),
157 "codex" => codex::map_model_to_id(model),
158 "opencode" => opencode::map_model_to_id(model),
159 "agent" => agent::map_model_to_id(model),
160 "gemini" => gemini::map_model_to_id(model),
161 "qwen" => qwen::map_model_to_id(model),
162 _ => model.to_string(),
163 }
164}
165
166fn push_model(args: &mut Vec<String>, options: &AgentTuiOptions) {
167 if let Some(model) = &options.model {
168 args.push("--model".into());
169 args.push(mapped_model(&options.tool, model));
170 }
171}
172
173fn claude_args(options: &AgentTuiOptions) -> Vec<String> {
174 let mut args = Vec::new();
175 if options.read_only {
176 args.extend(["--permission-mode".into(), "plan".into()]);
177 } else if options.approve_each {
178 args.extend(["--permission-mode".into(), "default".into()]);
179 } else if !options.skip_default_safety_flags {
180 args.push("--dangerously-skip-permissions".into());
181 }
182 push_model(&mut args, options);
183 if let Some(system_prompt) = &options.system_prompt {
184 args.extend(["--append-system-prompt".into(), system_prompt.clone()]);
185 }
186 if let Some(resume) = &options.resume {
187 args.extend(["--resume".into(), resume.clone()]);
188 }
189 args.push("--ax-screen-reader".into());
190 args
191}
192
193fn codex_args(options: &AgentTuiOptions) -> Vec<String> {
194 let mut args = Vec::new();
195 push_model(&mut args, options);
196 if options.read_only {
197 args.extend([
198 "--sandbox".into(),
199 "read-only".into(),
200 "--ask-for-approval".into(),
201 "never".into(),
202 ]);
203 } else if options.approve_each {
204 args.extend(["--ask-for-approval".into(), "on-request".into()]);
205 } else if !options.skip_default_safety_flags {
206 args.push("--dangerously-bypass-approvals-and-sandbox".into());
207 }
208 args.push("--no-alt-screen".into());
209 if let Some(resume) = &options.resume {
210 args.extend(["resume".into(), resume.clone()]);
211 }
212 args
213}
214
215fn opencode_args(options: &AgentTuiOptions) -> Vec<String> {
216 let mut args = vec!["--mini".into(), "--no-replay".into()];
217 push_model(&mut args, options);
218 if let Some(resume) = &options.resume {
219 args.extend(["--session".into(), resume.clone()]);
220 }
221 if !options.read_only && !options.approve_each && !options.skip_default_safety_flags {
222 args.push("--auto".into());
223 }
224 args
225}
226
227fn agent_args(options: &AgentTuiOptions) -> Vec<String> {
228 let mut args = Vec::new();
229 push_model(&mut args, options);
230 let permission = if options.read_only {
231 Some("readonly")
232 } else if options.plan_only {
233 Some("plan")
234 } else if options.approve_each {
235 Some("ask")
236 } else {
237 None
238 };
239 if let Some(permission) = permission {
240 args.extend(["--permission-mode".into(), permission.into()]);
241 }
242 if let Some(resume) = &options.resume {
243 args.extend(["--resume".into(), resume.clone()]);
244 }
245 args
246}
247
248fn gemini_args(options: &AgentTuiOptions) -> Vec<String> {
249 let mut args = Vec::new();
250 push_model(&mut args, options);
251 if options.read_only {
252 args.extend(["--approval-mode".into(), "plan".into()]);
253 } else if options.approve_each {
254 args.extend(["--approval-mode".into(), "default".into()]);
255 } else if !options.skip_default_safety_flags {
256 args.push("--yolo".into());
257 }
258 if let Some(resume) = &options.resume {
259 args.extend(["--resume".into(), resume.clone()]);
260 }
261 args
262}
263
264fn qwen_args(options: &AgentTuiOptions) -> Vec<String> {
265 let mut args = Vec::new();
266 push_model(&mut args, options);
267 if options.read_only {
268 args.push("--read-only".into());
269 }
270 if let Some(resume) = &options.resume {
271 args.extend(["--resume".into(), resume.clone()]);
272 }
273 args
274}
275
276pub fn build_agent_tui_launch(options: &AgentTuiOptions) -> Result<AgentTuiLaunch, AgentTuiError> {
278 if !SUPPORTED_TOOLS.contains(&options.tool.as_str()) {
279 return Err(AgentTuiError::Launch(format!(
280 "unsupported TUI tool: {}",
281 options.tool
282 )));
283 }
284 if options.working_directory.as_os_str().is_empty() {
285 return Err(AgentTuiError::Launch(
286 "working_directory is required".into(),
287 ));
288 }
289 let generated = match options.tool.as_str() {
290 "claude" => claude_args(options),
291 "codex" => codex_args(options),
292 "opencode" => opencode_args(options),
293 "agent" => agent_args(options),
294 "gemini" => gemini_args(options),
295 "qwen" => qwen_args(options),
296 _ => unreachable!("supported tools were validated"),
297 };
298 let mut args =
299 Vec::with_capacity(options.prefix_args.len() + generated.len() + options.extra_args.len());
300 args.extend(options.prefix_args.clone());
301 args.extend(generated);
302 args.extend(options.extra_args.clone());
303 let mut env = options.extra_env.clone();
304 if options.tool == "opencode" && options.read_only {
305 env.insert(
306 "OPENCODE_PERMISSION".into(),
307 READ_ONLY_OPENCODE_PERMISSION.into(),
308 );
309 }
310 Ok(AgentTuiLaunch {
311 file: options
312 .executable
313 .clone()
314 .unwrap_or_else(|| options.tool.clone()),
315 args,
316 cwd: Some(options.working_directory.clone()),
317 env,
318 })
319}
320
321fn strip_marker(line: &str) -> &str {
322 line.trim_start_matches(|character: char| {
323 matches!(character, '│' | '┃' | '>' | '›' | '❯' | '•' | '*') || character.is_whitespace()
324 })
325}
326
327pub fn normalize_tui_transcript(transcript: &str) -> Vec<AgentTuiEvent> {
329 transcript
330 .lines()
331 .filter_map(|raw_line| {
332 let line = strip_marker(raw_line.trim());
333 let (kind, content) = line.split_once(':')?;
334 if kind.eq_ignore_ascii_case("user") || kind.eq_ignore_ascii_case("assistant") {
335 return Some(AgentTuiEvent::Message {
336 role: kind.to_ascii_lowercase(),
337 content: content.trim().into(),
338 });
339 }
340 if kind.eq_ignore_ascii_case("tool_call") || kind.eq_ignore_ascii_case("tool call") {
341 let (name, input) = content
342 .trim()
343 .split_once(char::is_whitespace)
344 .unwrap_or_else(|| (content.trim(), ""));
345 return Some(AgentTuiEvent::ToolCall {
346 name: name.into(),
347 input: input.trim().into(),
348 });
349 }
350 None
351 })
352 .collect()
353}
354
355pub fn capture_agent_tui(options: AgentTuiOptions) -> Result<AgentTuiCapture, AgentTuiError> {
357 let launch = build_agent_tui_launch(&options)?;
358 let combined_prompt = match (&options.system_prompt, &options.prompt) {
359 (Some(system), Some(prompt)) if options.tool != "claude" => {
360 Some(format!("{system}\n\n{prompt}"))
361 }
362 (Some(system), None) if options.tool != "claude" => Some(system.clone()),
363 (_, prompt) => prompt.clone(),
364 };
365 let mut interactions =
366 Vec::with_capacity(options.interactions.len() + usize::from(combined_prompt.is_some()));
367 if let Some(prompt) = combined_prompt {
368 interactions.push(TerminalInteraction {
369 after: options.prompt_after.clone(),
370 text: Some(prompt),
371 key: Some(options.prompt_key.clone()),
372 resize: None,
373 });
374 }
375 interactions.extend(options.interactions.clone());
376 let terminal = capture_terminal(TerminalCaptureOptions {
377 file: launch.file,
378 args: launch.args,
379 cwd: launch.cwd,
380 env: launch.env,
381 cols: options.cols,
382 rows: options.rows,
383 settle_duration: options.settle_duration,
384 interactions,
385 stop_marker: options.stop_marker,
386 stop_marker_grace: options.stop_marker_grace,
387 timeout: options.timeout,
388 artifact_directory: options.artifact_directory,
389 })?;
390 Ok(AgentTuiCapture {
391 tool: options.tool,
392 events: normalize_tui_transcript(&terminal.transcript),
393 terminal,
394 })
395}