Skip to main content

mixtape_cli/repl/
mod.rs

1//! Interactive REPL for mixtape agents
2
3mod approval;
4mod commands;
5mod core;
6mod formatter;
7mod input;
8mod presentation;
9mod spinner;
10mod status;
11
12use crate::error::CliError;
13use commands::{handle_special_command, SpecialCommandResult};
14use core::{input_prompt, print_input_padding, print_welcome, reset_input_style};
15use input::InputStyleHelper;
16use rustyline::config::Config;
17use rustyline::error::ReadlineError;
18use rustyline::{Cmd, Editor, KeyEvent};
19use spinner::Spinner;
20use status::{clear_status_line, update_status_line};
21
22use mixtape_core::{Agent, AgentError, AgentEvent, AgentResponse, AuthorizationResponse};
23use serde_json::Value;
24use std::sync::{Arc, Mutex};
25use tokio::sync::mpsc;
26
27/// Permission request data: (proposal_id, tool_name, params_hash, params)
28type PermissionData = (String, String, String, Value);
29
30pub use approval::{
31    print_confirmation, prompt_for_approval, read_input, ApprovalPrompter, DefaultPrompter,
32    PermissionRequest, SimplePrompter,
33};
34pub use commands::Verbosity;
35pub use presentation::{
36    indent_lines, new_event_queue, print_result_separator, print_tool_footer, print_tool_header,
37    EventPresenter, PresentationHook,
38};
39
40/// Run an interactive REPL for the agent
41///
42/// This provides a command-line interface with:
43/// - Up/down arrow history
44/// - Ctrl+R reverse search
45/// - Multi-line input support
46/// - Special commands (!shell, /help, etc)
47/// - Automatic session management
48/// - Rich tool presentation with CLIPresenter formatting
49/// - Tool approval prompts (when using Registry approval mode)
50///
51/// # Errors
52///
53/// Returns `CliError` which can be:
54/// - `Agent` - Agent execution errors
55/// - `Session` - Session storage errors
56/// - `Readline` - Input/readline errors
57/// - `Io` - Filesystem errors (history loading/saving)
58///
59/// # Example
60/// ```ignore
61/// use mixtape_core::{Agent, ClaudeSonnet4_5};
62/// use mixtape_cli::run_cli;
63///
64/// let agent = Agent::builder()
65///     .bedrock(ClaudeSonnet4_5)
66///     .build()
67///     .await?;
68///
69/// run_cli(agent).await?;
70/// ```
71pub async fn run_cli(agent: Agent) -> Result<(), CliError> {
72    let agent = Arc::new(agent);
73
74    // Event queue for tool presentation (allows controlled output timing)
75    let event_queue = new_event_queue();
76
77    // Add presentation hook that queues events
78    agent.add_hook(PresentationHook::new(Arc::clone(&event_queue)));
79
80    // Presenter for formatting and printing queued events
81    let verbosity = Arc::new(Mutex::new(Verbosity::Normal));
82    let presenter = EventPresenter::new(
83        Arc::clone(&agent),
84        Arc::clone(&verbosity),
85        Arc::clone(&event_queue),
86    );
87
88    // Set up permission handling channel (once, for entire session)
89    let (perm_tx, perm_rx) = mpsc::unbounded_channel::<PermissionData>();
90    let perm_rx = Arc::new(tokio::sync::Mutex::new(perm_rx));
91    agent.add_hook(move |event: &AgentEvent| {
92        if let AgentEvent::PermissionRequired {
93            proposal_id,
94            tool_name,
95            params_hash,
96            params,
97            ..
98        } = event
99        {
100            let _ = perm_tx.send((
101                proposal_id.clone(),
102                tool_name.clone(),
103                params_hash.clone(),
104                params.clone(),
105            ));
106        }
107    });
108
109    print_welcome(&agent).await?;
110
111    let config = Config::default();
112    let mut rl: Editor<InputStyleHelper, rustyline::history::DefaultHistory> =
113        Editor::with_config(config)?;
114    rl.set_helper(Some(InputStyleHelper));
115
116    // Bind Ctrl-J to insert newline instead of submitting
117    rl.bind_sequence(KeyEvent::ctrl('J'), Cmd::Newline);
118
119    let history_path = dirs::cache_dir()
120        .map(|p| p.join("mixtape/history.txt"))
121        .unwrap_or_else(|| ".mixtape/history.txt".into());
122
123    // Load history
124    if history_path.exists() {
125        rl.load_history(&history_path).ok();
126    }
127
128    loop {
129        // Update persistent status line at bottom of terminal
130        update_status_line(&agent);
131
132        print_input_padding();
133        let readline = rl.readline(input_prompt());
134        reset_input_style();
135
136        match readline {
137            Ok(line) => {
138                let line = line.trim();
139
140                if line.is_empty() {
141                    continue;
142                }
143
144                rl.add_history_entry(line)?;
145
146                // Handle special commands
147                if let Some(result) = handle_special_command(line, &agent, &verbosity).await? {
148                    match result {
149                        SpecialCommandResult::Exit => break,
150                        SpecialCommandResult::Continue => continue,
151                    }
152                }
153
154                // Show animated thinking indicator
155                println!(); // Move to new line, clearing input background
156                let spinner = Spinner::new("thinking");
157
158                // Run agent with permission handling
159                let result = run_with_permissions(
160                    Arc::clone(&agent),
161                    line.to_string(),
162                    spinner,
163                    Arc::clone(&perm_rx),
164                    &presenter,
165                )
166                .await;
167
168                match result {
169                    Ok(response) => {
170                        println!("\n{}\n", response);
171                        update_status_line(&agent);
172                    }
173                    Err(e) => {
174                        eprintln!("āŒ Error: {}\n", e);
175                        update_status_line(&agent);
176                    }
177                }
178            }
179            Err(ReadlineError::Interrupted) => {
180                // Ctrl+C - just continue
181                println!("^C");
182                continue;
183            }
184            Err(ReadlineError::Eof) => {
185                // Ctrl+D - exit
186                break;
187            }
188            Err(err) => {
189                eprintln!("Error: {:?}", err);
190                break;
191            }
192        }
193    }
194
195    // Clear persistent status line on exit
196    clear_status_line();
197
198    // Gracefully shutdown agent (disconnects MCP servers)
199    agent.shutdown().await;
200
201    // Save history
202    if let Some(parent) = history_path.parent() {
203        std::fs::create_dir_all(parent).ok();
204    }
205    rl.save_history(&history_path)?;
206
207    println!("\nšŸ‘‹ Goodbye!\n");
208    Ok(())
209}
210
211/// Run agent with interactive permission handling
212async fn run_with_permissions<F: formatter::ToolFormatter>(
213    agent: Arc<Agent>,
214    input: String,
215    spinner: Spinner,
216    perm_rx: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<PermissionData>>>,
217    presenter: &EventPresenter<F>,
218) -> Result<AgentResponse, AgentError> {
219    // Spawn agent run in background
220    let agent_clone = Arc::clone(&agent);
221    let mut handle = tokio::spawn(async move { agent_clone.run(&input).await });
222
223    // Lock the receiver for this run
224    let mut rx = perm_rx.lock().await;
225
226    // Track if spinner is still active
227    let mut spinner = Some(spinner);
228
229    // Wait for permission requests or agent completion
230    loop {
231        tokio::select! {
232            biased;  // Always check permission requests first
233
234            // Check for permission requests
235            Some((proposal_id, tool_name, params_hash, params)) = rx.recv() => {
236                // Stop spinner before prompting for input
237                if let Some(s) = spinner.take() {
238                    s.stop().await;
239                }
240
241                // Print any queued output before showing the prompt
242                presenter.flush();
243
244                // Format tool input for display in approval prompt
245                let formatted_display =
246                    agent.format_tool_input(&tool_name, &params, mixtape_core::Display::Cli);
247
248                let request = PermissionRequest {
249                    tool_name: tool_name.clone(),
250                    tool_use_id: proposal_id.clone(),
251                    params_hash: params_hash.clone(),
252                    formatted_display,
253                };
254
255                let response = approval::prompt_for_approval(&request);
256
257                match response {
258                    AuthorizationResponse::Once => {
259                        agent.authorize_once(&proposal_id).await.ok();
260                    }
261                    AuthorizationResponse::Trust { grant } => {
262                        agent
263                            .respond_to_authorization(
264                                &proposal_id,
265                                AuthorizationResponse::Trust { grant },
266                            )
267                            .await
268                            .ok();
269                    }
270                    AuthorizationResponse::Deny { reason } => {
271                        agent.deny_authorization(&proposal_id, reason).await.ok();
272                    }
273                }
274
275                // Restart spinner after handling permission
276                spinner = Some(Spinner::new("thinking"));
277            }
278
279            // Agent finished
280            result = &mut handle => {
281                // Stop spinner if still running
282                if let Some(s) = spinner.take() {
283                    s.stop().await;
284                }
285                // Print any remaining queued output
286                presenter.flush();
287                return result.unwrap_or_else(|e| Err(AgentError::Tool(e.to_string().into())));
288            }
289        }
290    }
291}