Skip to main content

agent_commander/
tui.rs

1//! Interactive terminal launch and capture for supported agent clients.
2
3use 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/// Options for launching and driving an agent's real interactive terminal UI.
17#[derive(Debug, Clone)]
18pub struct AgentTuiOptions {
19    /// Agent CLI name.
20    pub tool: String,
21    /// Directory in which the agent runs.
22    pub working_directory: PathBuf,
23    /// Override for the agent executable.
24    pub executable: Option<String>,
25    /// Arguments inserted before the generated agent arguments.
26    pub prefix_args: Vec<String>,
27    /// Additional agent arguments.
28    pub extra_args: Vec<String>,
29    /// Additional environment variables.
30    pub extra_env: HashMap<String, String>,
31    /// Optional model name or alias.
32    pub model: Option<String>,
33    /// Initial user prompt.
34    pub prompt: Option<String>,
35    /// Optional system prompt.
36    pub system_prompt: Option<String>,
37    /// Marker that must be observed before sending the initial prompt.
38    pub prompt_after: Option<String>,
39    /// Control key sent after the initial prompt.
40    pub prompt_key: TerminalKey,
41    /// Session identifier to resume.
42    pub resume: Option<String>,
43    /// Request the client's native read-only mode.
44    pub read_only: bool,
45    /// Request the client's native plan-only mode where distinct.
46    pub plan_only: bool,
47    /// Request approval for individual operations.
48    pub approve_each: bool,
49    /// Suppress the default autonomous safety-bypass flags.
50    pub skip_default_safety_flags: bool,
51    /// Initial terminal width.
52    pub cols: u16,
53    /// Initial terminal height.
54    pub rows: u16,
55    /// Time without output before a frame is considered settled.
56    pub settle_duration: Duration,
57    /// Input, control-key, and resize interactions.
58    pub interactions: Vec<TerminalInteraction>,
59    /// Marker after which the capture process can be stopped.
60    pub stop_marker: Option<String>,
61    /// Grace period after the stop marker.
62    pub stop_marker_grace: Duration,
63    /// Overall capture timeout.
64    pub timeout: Duration,
65    /// Directory for transcript, frame, cast, and animation artifacts.
66    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/// Executable, argv, cwd, and environment for an interactive agent launch.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct AgentTuiLaunch {
103    /// Executable to launch.
104    pub file: String,
105    /// Shell-free argument vector.
106    pub args: Vec<String>,
107    /// Working directory.
108    pub cwd: Option<PathBuf>,
109    /// Environment overrides.
110    pub env: HashMap<String, String>,
111}
112
113/// Stable semantic event extracted from an agent TUI transcript.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum AgentTuiEvent {
116    /// User or assistant message.
117    Message {
118        /// Normalized lowercase role.
119        role: String,
120        /// Visible message content.
121        content: String,
122    },
123    /// Tool invocation shown by the client.
124    ToolCall {
125        /// Tool name.
126        name: String,
127        /// Visible tool input.
128        input: String,
129    },
130}
131
132/// Terminal capture and normalized events for one agent.
133#[derive(Debug, Clone)]
134pub struct AgentTuiCapture {
135    /// Agent client name.
136    pub tool: String,
137    /// Lossless terminal capture and replay data.
138    pub terminal: TerminalCapture,
139    /// Stable semantic events extracted from the unrolled transcript.
140    pub events: Vec<AgentTuiEvent>,
141}
142
143/// Error while preparing or capturing an interactive agent terminal.
144#[derive(Debug, Error)]
145pub enum AgentTuiError {
146    /// Launch options are unsupported or incomplete.
147    #[error("{0}")]
148    Launch(String),
149    /// PTY capture failed.
150    #[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
276/// Build a shell-free interactive launch for a supported agent client.
277pub 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
327/// Extract stable message and tool-call events without removing repeated states.
328pub 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
355/// Capture and drive an agent's real interactive terminal interface.
356pub 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}