mod completer; mod highlighter; mod prompt; mod replay; use self::completer::ReplCompleter; use self::highlighter::ReplHighlighter; use self::prompt::ReplPrompt; use crate::client::{ Message, MessageRole, call_chat_completions, call_chat_completions_streaming, init_client, oauth, }; use crate::config::{ AgentVariables, AppConfig, AssertState, Input, LastMessage, MacroState, RequestContext, StateFlags, flatten_prompt_messages, macro_execute, resolve_prompt_args, sanitize_display_text, }; use crate::config::{AssetCategory, paths}; use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail}; use crate::hooks::{self, HookEvent}; use crate::render::render_error; use crate::utils::{ AbortSignal, SHELL, abortable_run_with_spinner, create_abort_signal, dimmed_text, drain_stale_tty_input, run_command, set_text, temp_file, }; use crate::sandbox::SANDBOX_ENV_FLAG; use crate::{config, graph, mcp, resolve_oauth_client}; use anyhow::{Context, Result, bail}; use crossterm::cursor::SetCursorStyle; use fancy_regex::Regex; use indoc::indoc; use inquire::Text; use log::warn; use parking_lot::RwLock; use reedline::CursorConfig; use reedline::{ ColumnarMenu, EditCommand, EditMode, Emacs, FileBackedHistory, KeyCode, KeyModifiers, Keybindings, Reedline, ReedlineEvent, ReedlineMenu, ValidationResult, Validator, Vi, default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings, }; use reedline::{MenuBuilder, Signal}; use std::collections::HashMap; use std::sync::LazyLock; use std::time::Duration; use std::{env, process, sync::Arc}; use tokio::task; const MENU_NAME: &str = "completion_menu"; /// Bounded wait applied at process-exit seams (REPL teardown, headless and /// shell-execute exits) so already-fired exit hooks — `session.ended`, the /// final `turn.*` / `agent.*` events — finish spawning before the process /// tears down. Never used mid-session. pub(crate) const EXIT_HOOK_DRAIN_TIMEOUT: Duration = Duration::from_secs(2); /// Brackets one user-visible turn at an outer boundary (`ask`, /// `start_directive`, `shell_execute`): `enter` fires `turn.started`, and /// `finish` fires exactly one of `turn.completed` / `turn.interrupted` / /// `turn.failed`. The interrupted-vs-failed discrimination lives here and /// nowhere else: a ctrl-c abort wins over the result, then an error means /// failed (carrying `COYOTE_ERROR`), otherwise completed. Tool rounds, /// auto-continue, and compression retries run inside one bracket and never /// re-fire turn events. pub(crate) struct TurnBracket(()); impl TurnBracket { #[must_use] pub(crate) fn enter(ctx: &RequestContext) -> Self { hooks::fire(HookEvent::TurnStarted, ctx, &[], None); Self(()) } pub(crate) fn finish( self, ctx: &RequestContext, abort_signal: &AbortSignal, result: Result, ) -> Result { if abort_signal.aborted_ctrlc() { hooks::fire(HookEvent::TurnInterrupted, ctx, &[], None); } else { match &result { Ok(_) => hooks::fire(HookEvent::TurnCompleted, ctx, &[], None), Err(err) => hooks::fire( HookEvent::TurnFailed, ctx, &[("COYOTE_ERROR", format!("{err:#}"))], None, ), } } result } } pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {" [SYSTEM REMINDER - TODO CONTINUATION] You have incomplete tasks. Rules: 1. BEFORE marking a todo done: verify the work compiles/works. No premature completion. 2. If a todo is broad (e.g. \"implement X and implement Y\"): break it into specific subtasks FIRST using todo__add, then work on those.\n\ 3. Each todo should be atomic and be \"single responsibility\" - completable in one focused action. 4. If you are blocked on user input or a user-side action: call todo__pause with the reason (if available); otherwise ask the user directly in your reply and end your turn. NEVER mark unfinished todos done just to stop this reminder. 5. Otherwise, continue with the next pending item now. Call tools immediately." }; static REPL_COMMANDS: LazyLock<[ReplCommand; 63]> = LazyLock::new(|| { [ ReplCommand::new(".help", "Show this help guide", AssertState::pass()), ReplCommand::new(".info", "Show system info", AssertState::pass()), ReplCommand::new( ".info tools", "Show the list of enabled tools to be passed to the LLM", AssertState::True(StateFlags::FUNCTION_CALLING), ), ReplCommand::new( ".info mcp-server", "Show an MCP server's tool filters and effective catalog", AssertState::pass(), ), ReplCommand::new( ".authenticate", "Authenticate the current model client via OAuth (if configured)", AssertState::pass(), ), ReplCommand::new( ".mcp auth", "Authenticate with an MCP server via OAuth", AssertState::pass(), ), ReplCommand::new( ".mcp enable", "Enable a single MCP server in the current context", AssertState::pass(), ), ReplCommand::new( ".mcp disable", "Disable a single MCP server in the current context", AssertState::pass(), ), ReplCommand::new( ".tool enable", "Enable a single tool in the current context", AssertState::True(StateFlags::FUNCTION_CALLING), ), ReplCommand::new( ".tool disable", "Disable a single tool in the current context", AssertState::True(StateFlags::FUNCTION_CALLING), ), ReplCommand::new( ".edit config", "Modify configuration file", AssertState::False(StateFlags::AGENT), ), ReplCommand::new( ".edit mcp-config", "Modify the MCP servers configuration file", AssertState::False(StateFlags::AGENT), ), ReplCommand::new(".model", "Switch LLM model", AssertState::pass()), ReplCommand::new( ".prompt", "Invoke an MCP prompt and submit the result as chat input", AssertState::pass(), ), ReplCommand::new( ".temp-role", "Set a temporary role using a prompt", AssertState::False(StateFlags::SESSION | StateFlags::AGENT), ), ReplCommand::new( ".role", "Create or switch to a role", AssertState::False(StateFlags::SESSION | StateFlags::AGENT), ), ReplCommand::new( ".info role", "Show role info", AssertState::True(StateFlags::ROLE), ), ReplCommand::new( ".edit role", "Modify current role", AssertState::TrueFalse(StateFlags::ROLE, StateFlags::SESSION), ), ReplCommand::new( ".save role", "Save current role to file", AssertState::TrueFalse( StateFlags::ROLE, StateFlags::SESSION_EMPTY | StateFlags::SESSION, ), ), ReplCommand::new( ".exit role", "Exit active role", AssertState::TrueFalse(StateFlags::ROLE, StateFlags::SESSION), ), ReplCommand::new( ".session", "Start or switch to a session", AssertState::False(StateFlags::SESSION_EMPTY | StateFlags::SESSION), ), ReplCommand::new( ".empty session", "Clear session messages", AssertState::True(StateFlags::SESSION), ), ReplCommand::new( ".undo", "Undo the last exchange and restore the prompt", AssertState::True(StateFlags::SESSION), ), ReplCommand::new( ".compress session", "Compress session messages", AssertState::True(StateFlags::SESSION), ), ReplCommand::new( ".info session", "Show session info", AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION), ), ReplCommand::new( ".edit session", "Modify current session", AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION), ), ReplCommand::new( ".save session", "Save current session to file", AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION), ), ReplCommand::new( ".exit session", "Exit active session", AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION), ), ReplCommand::new( ".fork", "Fork the active session into a new named copy", AssertState::True(StateFlags::SESSION), ), ReplCommand::new(".agent", "Use an agent", AssertState::bare()), ReplCommand::new( ".starter", "Use a conversation starter", AssertState::True(StateFlags::AGENT), ), ReplCommand::new( ".edit agent-config", "Modify agent configuration file", AssertState::True(StateFlags::AGENT), ), ReplCommand::new( ".info agent", "Show agent info", AssertState::True(StateFlags::AGENT), ), ReplCommand::new( ".exit agent", "Leave agent", AssertState::True(StateFlags::AGENT), ), ReplCommand::new( ".clear todo", "Clear the todo list and stop auto-continuation", AssertState::pass(), ), ReplCommand::new( ".info todo", "Show the current todo list driving auto-continuation", AssertState::True(StateFlags::AUTO_CONTINUE), ), ReplCommand::new( ".rag", "Initialize or access RAG", AssertState::False(StateFlags::AGENT), ), ReplCommand::new( ".rag attach", "Attach to a pre-existing external RAG", AssertState::False(StateFlags::AGENT), ), ReplCommand::new( ".edit rag-docs", "Add or remove documents from an existing RAG", AssertState::TrueFalse(StateFlags::RAG, StateFlags::AGENT), ), ReplCommand::new( ".rebuild rag", "Rebuild RAG for document changes", AssertState::True(StateFlags::RAG), ), ReplCommand::new( ".sources rag", "Show citation sources used in last query", AssertState::True(StateFlags::RAG), ), ReplCommand::new( ".info rag", "Show RAG info", AssertState::True(StateFlags::RAG), ), ReplCommand::new( ".exit rag", "Leave RAG", AssertState::TrueFalse(StateFlags::RAG, StateFlags::AGENT), ), ReplCommand::new(".macro", "Execute a macro", AssertState::pass()), ReplCommand::new( ".skill", "Create a new skill", AssertState::True(StateFlags::SKILLS_ENABLED), ), ReplCommand::new( ".skill load", "Load a skill into the current context", AssertState::True(StateFlags::SKILLS_ENABLED), ), ReplCommand::new( ".skill loaded", "List currently-loaded skills", AssertState::True(StateFlags::SKILLS_ENABLED), ), ReplCommand::new( ".skill unload", "Unload a skill from the current context", AssertState::True(StateFlags::SKILLS_ENABLED), ), ReplCommand::new( ".edit skill", "Modify an existing skill by name", AssertState::True(StateFlags::SKILLS_ENABLED), ), ReplCommand::new( ".file", "Include files, directories, URLs or commands", AssertState::pass(), ), ReplCommand::new( ".continue", "Continue previous response", AssertState::pass(), ), ReplCommand::new( ".recover", "Recover interrupted session after API error or Ctrl+C", AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION), ), ReplCommand::new( ".regenerate", "Regenerate last response", AssertState::pass(), ), ReplCommand::new(".copy", "Copy last response", AssertState::pass()), ReplCommand::new(".set", "Modify runtime settings", AssertState::pass()), ReplCommand::new( ".reasoning", "Set the reasoning effort level for the current model", AssertState::pass(), ), ReplCommand::new( ".delete", "Delete roles, sessions, RAGs, or agents", AssertState::pass(), ), ReplCommand::new( ".list", "List roles, sessions, agents, RAGs, macros, skills, prompts, tools, MCP servers, or bundles", AssertState::pass(), ), ReplCommand::new( ".vault", "View or modify the Coyote vault", AssertState::pass(), ), ReplCommand::new( ".install", "Reinstall bundled assets, install a bundle from a git repo, or update an installed bundle", AssertState::pass(), ), ReplCommand::new( ".uninstall", "Uninstall an installed bundle (delete its owned files and MCP entries)", AssertState::pass(), ), ReplCommand::new( ".update", "Update Coyote to the latest release (or a specified version)", AssertState::pass(), ), ReplCommand::new(".exit", "Exit REPL", AssertState::pass()), ] }); static COMMAND_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*(\.\S*)\s*").unwrap()); static MULTILINE_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?s)^\s*:::\s*(.*)\s*:::\s*$").unwrap()); pub struct Repl { ctx: Arc>, editor: Reedline, prompt: ReplPrompt, abort_signal: AbortSignal, } impl Repl { pub fn init(ctx: RequestContext) -> Result { let app = Arc::clone(&ctx.app.config); let ctx = Arc::new(RwLock::new(ctx)); let editor = Self::create_editor(Arc::clone(&ctx), app.as_ref())?; let prompt = ReplPrompt::new(Arc::clone(&ctx)); let abort_signal = create_abort_signal(); Ok(Self { ctx, editor, prompt, abort_signal, }) } #[allow(clippy::await_holding_lock)] pub async fn run(&mut self) -> Result<()> { if AssertState::False(StateFlags::AGENT | StateFlags::RAG).assert(self.ctx.read().state()) { print!( r#"Welcome to {} {} Type ".help" for additional help. "#, env!("CARGO_CRATE_NAME"), env!("CARGO_PKG_VERSION"), ); if env::var_os(SANDBOX_ENV_FLAG).is_some() { eprintln!( "Sandbox mode is enabled. All changes made to the Coyote config will not persist to the host machine." ); } } { let (compressed, active) = { let ctx = self.ctx.read(); match &ctx.session { Some(session) => replay::snapshot(session), None => (Vec::new(), Vec::new()), } }; if !compressed.is_empty() || !active.is_empty() { let app = Arc::clone(&self.ctx.read().app.config); replay::render(app.as_ref(), &compressed, &active)?; let last_msgs: &[Message] = if !active.is_empty() { &active } else { &compressed }; let assistant_text = last_msgs .iter() .rev() .find(|m| m.role == MessageRole::Assistant) .and_then(|m| m.content.as_text()) .map(str::to_string); if let Some(output) = assistant_text { let user_text = last_msgs .iter() .rev() .find(|m| m.role == MessageRole::User) .and_then(|m| m.content.as_text()) .unwrap_or("") .to_string(); let ctx = self.ctx.read(); if let Ok(input) = Input::from_str(&ctx, &user_text, None) { drop(ctx); self.ctx.write().last_message = Some(LastMessage::new(input, output)); } } } } // Discard any stray terminal-query reply bytes (e.g. late colorsaurus // OSC 11 / DA1 responses) so they don't get injected into the prompt. drain_stale_tty_input(); print_pause_banner(&self.ctx.read()); loop { if self.abort_signal.aborted_ctrld() { break; } let sig = self.editor.read_line(&self.prompt); match sig { Ok(Signal::Success(line)) => { self.abort_signal.reset(); let result = { let mut ctx = self.ctx.write(); run_repl_command(&mut ctx, self.abort_signal.clone(), &line).await }; match result { Ok(exit) => { if exit { break; } if let Some(text) = self.ctx.write().pending_prefill.take() { self.editor .run_edit_commands(&[EditCommand::InsertString(text)]); } } Err(err) => { render_error(err); println!() } } } Ok(Signal::CtrlC) => { self.abort_signal.set_ctrlc(); if let Some(supervisor) = self.ctx.read().supervisor.clone() { supervisor.read().cancel_recursive(); } println!("(To exit, press Ctrl+D or enter \".exit\")\n"); } Ok(Signal::CtrlD) => { self.abort_signal.set_ctrld(); break; } _ => {} } } if let Some(supervisor) = self.ctx.read().supervisor.clone() { supervisor.read().cancel_recursive(); } let exit_result = self.ctx.write().exit_session(); self.ctx .write() .top_level_agent_finished(exit_result.as_ref().err()); hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await; exit_result } fn create_editor(ctx: Arc>, app: &AppConfig) -> Result { let completer = ReplCompleter::new(Arc::clone(&ctx)); let highlighter = ReplHighlighter::new(); let menu = Self::create_menu(); let edit_mode = Self::create_edit_mode(app); let cursor_config = CursorConfig { vi_insert: Some(SetCursorStyle::BlinkingBar), vi_normal: Some(SetCursorStyle::SteadyBlock), emacs: None, }; let mut editor = Reedline::create() .with_completer(Box::new(completer)) .with_highlighter(Box::new(highlighter)) .with_menu(menu) .with_edit_mode(edit_mode) .with_cursor_config(cursor_config) .with_quick_completions(true) .with_partial_completions(true) .use_bracketed_paste(true) .with_validator(Box::new(ReplValidator)) .with_ansi_colors(true); if let Ok(cmd) = app.editor() { let temp_file = temp_file("-repl-", ".md"); let command = process::Command::new(cmd); editor = editor.with_buffer_editor(command, temp_file); } if app.save_shell_history { let ctx = ctx.read(); let history_path = paths::repl_history_file(&ctx.session); if let Ok(history) = FileBackedHistory::with_file(1000, history_path) { editor = editor.with_history(Box::new(history)); } } Ok(editor) } fn extra_keybindings(keybindings: &mut Keybindings) { keybindings.add_binding( KeyModifiers::NONE, KeyCode::Tab, ReedlineEvent::UntilFound(vec![ ReedlineEvent::Menu(MENU_NAME.to_string()), ReedlineEvent::MenuNext, ]), ); keybindings.add_binding( KeyModifiers::SHIFT, KeyCode::BackTab, ReedlineEvent::MenuPrevious, ); keybindings.add_binding( KeyModifiers::CONTROL, KeyCode::Enter, ReedlineEvent::Edit(vec![EditCommand::InsertNewline]), ); keybindings.add_binding( KeyModifiers::CONTROL, KeyCode::Char('j'), ReedlineEvent::Edit(vec![EditCommand::InsertNewline]), ); } fn create_edit_mode(app: &AppConfig) -> Box { let edit_mode: Box = if app.keybindings == "vi" { let mut insert_keybindings = default_vi_insert_keybindings(); Self::extra_keybindings(&mut insert_keybindings); Box::new(Vi::new(insert_keybindings, default_vi_normal_keybindings())) } else { let mut keybindings = default_emacs_keybindings(); Self::extra_keybindings(&mut keybindings); Box::new(Emacs::new(keybindings)) }; edit_mode } fn create_menu() -> ReedlineMenu { let completion_menu = ColumnarMenu::default().with_name(MENU_NAME); ReedlineMenu::EngineCompleter(Box::new(completion_menu)) } } #[derive(Debug, Clone)] pub struct ReplCommand { name: &'static str, description: &'static str, state: AssertState, } impl ReplCommand { fn new(name: &'static str, desc: &'static str, state: AssertState) -> Self { Self { name, description: desc, state, } } fn is_valid(&self, flags: StateFlags) -> bool { self.state.assert(flags) } } /// A default validator which checks for mismatched quotes and brackets struct ReplValidator; impl Validator for ReplValidator { fn validate(&self, line: &str) -> ValidationResult { let line = line.trim(); if line.starts_with(r#":::"#) && !line[3..].ends_with(r#":::"#) { ValidationResult::Incomplete } else { ValidationResult::Complete } } } pub async fn run_repl_command( ctx: &mut RequestContext, abort_signal: AbortSignal, mut line: &str, ) -> Result { ctx.pending_tasks_guardrail_count = 0; if let Ok(Some(captures)) = MULTILINE_RE.captures(line) && let Some(text_match) = captures.get(1) { line = text_match.as_str(); } match parse_command(line) { Some((cmd, args)) => match cmd { ".help" => { dump_repl_help(); } ".info" => match args { Some("role") => { let info = ctx.role_info()?; print!("{info}"); } Some("session") => { let app = Arc::clone(&ctx.app.config); let info = ctx.session_info(app.as_ref())?; print!("{info}"); } Some("rag") => { let info = ctx.rag_info()?; print!("{info}"); } Some("agent") => { let info = ctx.agent_info()?; print!("{info}"); } Some("tools") => { let info = ctx.tools_info()?; print!("{info}"); } Some("todo") => { let info = ctx.todo_info()?; print!("{info}"); } Some(arg) if arg.starts_with("mcp-server") => { let mut parts = arg.splitn(2, char::is_whitespace); parts.next(); let name = parts.next().map(str::trim).unwrap_or(""); if name.is_empty() { bail!("Usage: .info mcp-server "); } let info = ctx.mcp_server_info(name).await?; print!("{info}"); } Some(_) => unknown_command()?, None => { let app = Arc::clone(&ctx.app.config); let output = ctx.sysinfo(app.as_ref())?; print!("{output}"); } }, ".model" => match args { Some(name) => { let app = Arc::clone(&ctx.app.config); ctx.set_model_on_role_like(app.as_ref(), name)?; } None => println!("Usage: .model "), }, ".authenticate" => { let current_model = ctx.current_model().clone(); let app = Arc::clone(&ctx.app.config); let client = init_client(&app, current_model)?; if !client.supports_oauth() { bail!( "Client '{}' doesn't either support OAuth or isn't configured to use it (i.e. uses an API key instead)", client.name() ); } let clients = ctx.app.config.clients.clone(); let (client_name, provider) = resolve_oauth_client(Some(client.name()), &clients)?; oauth::run_oauth_flow(&*provider, &client_name).await?; } ".mcp" => match args { Some(args) => { let mut parts = args.splitn(2, char::is_whitespace); let sub = parts.next().unwrap_or("").trim(); let rest = parts.next().map(str::trim).unwrap_or(""); match sub { "auth" => { if rest.is_empty() { println!("Usage: .mcp auth "); } else { let server_name = rest; let server_spec = ctx .app .mcp_config .as_ref() .and_then(|c| c.mcp_servers.get(server_name)) .cloned(); match server_spec { None => { bail!("MCP server '{}' not found in mcp.json.", server_name) } Some(spec) if !spec.is_remote() => bail!( "MCP server '{}' uses stdio transport; \ OAuth is only supported for http/sse servers.", server_name ), Some(spec) => { let url = spec .url .as_deref() .expect("validated: remote spec has url"); let client_id = spec .oauth .as_ref() .and_then(|o| o.client_id.as_deref()); let callback_port = spec.oauth.as_ref().and_then(|o| o.callback_port); let redirect_host = spec .oauth .as_ref() .and_then(|o| o.redirect_host.as_deref()); mcp::oauth::run_mcp_oauth_flow( server_name, url, client_id, callback_port, redirect_host, ) .await?; println!("Authentication saved."); if ctx.app.config.mcp_server_support { let app = Arc::clone(&ctx.app.config); ctx.bootstrap_tools( app.as_ref(), true, abort_signal.clone(), ) .await?; if ctx.tool_scope.mcp_runtime.get(server_name).is_some() { println!( "āœ“ MCP server '{server_name}' started and attached to the current context." ); } else { println!( "MCP server '{server_name}' is not enabled in the current context. \ Run `.mcp enable {server_name}` to attach it." ); } } } } } } "enable" | "disable" => { if rest.is_empty() { println!("Usage: .mcp {sub} "); } else { ctx.toggle_mcp_server(sub, rest, abort_signal.clone()) .await?; } } _ => unknown_command()?, } } None => println!( r#"Usage: .mcp auth # Authenticate with an MCP server via OAuth .mcp enable # Enable a single MCP server in the current context .mcp disable # Disable a single MCP server in the current context"# ), }, ".tool" => match args { Some(args) => { let mut parts = args.splitn(2, char::is_whitespace); let sub = parts.next().unwrap_or("").trim(); let rest = parts.next().map(str::trim).unwrap_or(""); match sub { "enable" | "disable" => { if rest.is_empty() { println!("Usage: .tool {sub} "); } else { ctx.toggle_tool(sub, rest)?; } } _ => unknown_command()?, } } None => println!( r#"Usage: .tool enable # Enable a single tool in the current context .tool disable # Disable a single tool in the current context"# ), }, ".prompt" => { let (words, _) = split_args_text(args.unwrap_or_default(), cfg!(windows)); match words.as_slice() { [server, name, rest @ ..] => { let provided = parse_prompt_call_args(rest)?; let prompts = ctx.tool_scope.mcp_runtime.list_prompts(server).await?; let declared = prompts .into_iter() .find(|prompt| prompt.name == *name) .with_context(|| { format!("Prompt '{name}' not found on MCP server '{server}'") })? .arguments .unwrap_or_default(); let (mut arguments, missing) = resolve_prompt_args(&declared, provided); for key in missing { let value = Text::new(&prompt_arg_inquire_label(server, name, &key)) .prompt() .with_context(|| { format!("Failed to read prompt argument '{key}'") })?; arguments.insert(key, value); } let result = ctx .tool_scope .mcp_runtime .prompt(server, name, arguments) .await?; let flattened = flatten_prompt_messages(&result.messages); let input = Input::from_str(ctx, &flattened, None)?; ask(ctx, abort_signal.clone(), input, true).await?; } _ => println!("Usage: .prompt [key=value ...]"), } } ".temp-role" => match args { Some(text) => { let app = Arc::clone(&ctx.app.config); ctx.use_temp_role(app.as_ref(), text)?; } None => println!("Usage: .temp-role ..."), }, ".role" => match args { Some(args) => match args.split_once(['\n', ' ']) { Some((name, text)) => { let app = Arc::clone(&ctx.app.config); let role = ctx.retrieve_role(app.as_ref(), name.trim())?; let input = Input::from_str(ctx, text, Some(role))?; ask(ctx, abort_signal.clone(), input, false).await?; } None => { let name = args; let app = Arc::clone(&ctx.app.config); if !paths::has_role(name) { ctx.new_role(app.as_ref(), name)?; } ctx.use_role(app.as_ref(), name, abort_signal.clone()) .await?; } }, None => println!( r#"Usage: .role # If the role exists, switch to it; otherwise, create a new role .role [text]... # Temporarily switch to the role, send the text, and switch back"# ), }, ".skill" => { let trimmed = args.map(str::trim).unwrap_or(""); let mut parts = trimmed.splitn(2, char::is_whitespace); let first = parts.next().unwrap_or(""); let rest = parts.next().map(str::trim).unwrap_or(""); match first { "" => println!( r#"Usage: .skill loaded # List currently-loaded skills .skill load # Load a skill into the current context .skill unload # Unload a loaded skill .skill # Open the skill in $EDITOR; create with a scaffold if missing # (Use `.edit skill ` to edit an existing skill without the create-if-missing behavior.)"# ), "loaded" => ctx.list_loaded_skills(), "load" => { if rest.is_empty() { println!("Usage: .skill load "); } else { ctx.load_skill_repl(rest, abort_signal.clone()).await?; } } "unload" => { if rest.is_empty() { println!("Usage: .skill unload "); } else { ctx.unload_skill_repl(rest, abort_signal.clone()).await?; } } name => { let app = Arc::clone(&ctx.app.config); ctx.upsert_skill(app.as_ref(), name)?; } } } ".session" => { if let Some(name) = graph::active_agent_graph_name(ctx) { bail!( "Graph-based agent '{name}' does not support sessions. \ The graph manages its own state." ); } let app = Arc::clone(&ctx.app.config); ctx.use_session(app.as_ref(), args, abort_signal.clone()) .await?; if ctx.maybe_autoname_session() { let color = if app.light_theme() { nu_ansi_term::Color::LightGray } else { nu_ansi_term::Color::DarkGray }; eprintln!("\nšŸ“¢ {}", color.italic().paint("Autonaming the session."),); if let Err(err) = ctx.autoname_session(app.as_ref()).await { warn!("Failed to autonaming the session: {err}"); } if let Some(session) = ctx.session.as_mut() { session.set_autonaming(false); } } if let Some(session) = &ctx.session { let (compressed, active) = replay::snapshot(session); replay::render(app.as_ref(), &compressed, &active)?; } print_pause_banner(ctx); } ".install" => match parse_repl_install(args) { ReplInstallDispatch::Builtins(category) => config::install_assets(category)?, ReplInstallDispatch::Unified(value) => { config::install_or_update_from_repl_args(value)?; } ReplInstallDispatch::Help => println!("{}", repl_install_help()), ReplInstallDispatch::Usage => println!( "Usage: .install <{}> | .install \ [--git-host ] [--filter ] [--force] (see `.install --help`)", AssetCategory::NAMES.join("|") ), }, ".update" => { if ctx.macro_flag { bail!("Cannot perform this operation because you are in a macro") } let version = args.map(|s| s.trim().to_string()); task::spawn_blocking(move || config::run_self_update(version, false)).await??; } ".rag" => match split_first_arg(args) { Some(("attach", rest)) => match rest { Some(name) if !name.trim().is_empty() => { ctx.attach_rag(name.trim(), abort_signal.clone()).await?; } _ => println!("Usage: .rag attach "), }, _ => { ctx.use_rag(args, abort_signal.clone()).await?; } }, ".agent" => match split_first_arg(args) { Some((agent_name, args)) => { let (new_args, _) = split_args_text(args.unwrap_or_default(), cfg!(windows)); let (session_name, variable_pairs) = match new_args.first() { Some(name) if name.contains('=') => (None, new_args.as_slice()), Some(name) => (Some(name.as_str()), &new_args[1..]), None => (None, &[] as &[String]), }; let variables: AgentVariables = variable_pairs .iter() .filter_map(|v| v.split_once('=')) .map(|(key, value)| (key.to_string(), value.to_string())) .collect(); if variables.len() != variable_pairs.len() { bail!("Some variable values are not key=value pairs"); } if !variables.is_empty() { ctx.agent_variables = Some(variables.clone()); } let app = Arc::clone(&ctx.app.config); ctx.use_agent(app.as_ref(), agent_name, session_name, abort_signal.clone()) .await?; ctx.top_level_agent_started(); if let Some(session) = &ctx.session { let (compressed, active) = replay::snapshot(session); replay::render(app.as_ref(), &compressed, &active)?; print_pause_banner(ctx); } } None => { println!(r#"Usage: .agent [session-name] [key=value]..."#) } }, ".starter" => match args { Some(id) => { let mut text = None; if let Some(agent) = ctx.agent.as_ref() { for (i, value) in agent.conversation_starters().iter().enumerate() { if (i + 1).to_string() == id { text = Some(value.clone()); } } } match text { Some(text) => { println!("{}", dimmed_text(&format!(">> {text}"))); let input = Input::from_str(ctx, &text, None)?; ask(ctx, abort_signal.clone(), input, true).await?; } None => { bail!("Invalid starter value"); } } } None => { let banner = ctx.agent_banner()?; ctx.app.config.print_markdown(&banner)?; } }, ".fork" => { ctx.fork_session(args)?; } ".save" => match split_first_arg(args) { Some(("role", name)) => { ctx.save_role(name)?; } Some(("session", name)) => { ctx.save_session(name)?; } _ => { println!(r#"Usage: .save [name]"#) } }, ".edit" => { if ctx.macro_flag { bail!("Cannot perform this operation because you are in a macro") } match args { Some("config") => { ctx.edit_config()?; } Some("role") => { let app = Arc::clone(&ctx.app.config); ctx.edit_role(app.as_ref(), abort_signal.clone()).await?; } Some("session") => { let app = Arc::clone(&ctx.app.config); ctx.edit_session(app.as_ref())?; } Some("rag-docs") => { ctx.edit_rag_docs(abort_signal.clone()).await?; } Some("agent-config") => { let app = Arc::clone(&ctx.app.config); ctx.edit_agent_config(app.as_ref())?; } Some("mcp-config") => { ctx.edit_mcp_config()?; } Some(s) if s == "skill" || s.starts_with("skill ") => { let name = s.strip_prefix("skill").unwrap_or("").trim(); if name.is_empty() { println!("Usage: .edit skill "); } else if let Err(e) = paths::validate_skill_name(name) { bail!(e); } else if !paths::has_skill(name) { bail!( "Skill '{name}' is not installed (expected at {})", paths::skill_file(name).display() ); } else { let app = Arc::clone(&ctx.app.config); ctx.upsert_skill(app.as_ref(), name)?; } } _ => { println!( r#"Usage: .edit >"# ) } } } ".compress" => match args { Some("session") => { abortable_run_with_spinner( ctx.compress_session(), "Compressing", abort_signal.clone(), ) .await?; hooks::fire(HookEvent::SessionCompressed, ctx, &[], None); println!("āœ“ Successfully compressed the session."); } _ => { println!(r#"Usage: .compress session"#) } }, ".empty" => match args { Some("session") => { ctx.empty_session()?; } _ => { println!(r#"Usage: .empty session"#) } }, ".undo" => { if let Some(name) = graph::active_agent_graph_name(ctx) { bail!( "Graph-based agent '{name}' does not support .undo. \ The graph manages its own state." ); } ctx.undo_last_exchange()?; } ".rebuild" => match args { Some("rag") => { ctx.rebuild_rag(abort_signal.clone()).await?; } _ => { println!(r#"Usage: .rebuild rag"#) } }, ".sources" => match args { Some("rag") => { let output = ctx.rag_sources()?; println!("{output}"); } _ => { println!(r#"Usage: .sources rag"#) } }, ".macro" => match split_first_arg(args) { Some((sub @ ("enable" | "disable"), rest)) => { match rest.and_then(|v| v.split_whitespace().next()) { Some(name) => ctx.macro_toggle(name, sub == "enable")?, None => println!("Usage: .macro {sub} "), } } Some((name, extra)) => { let policy = ctx.macro_policy(); match policy.find(name).map(|row| &row.state) { Some(state) if state.is_invocable() => { macro_execute(ctx, name, extra, abort_signal.clone()).await?; } Some(MacroState::DisabledRuntime) => bail!( r#"Macro '{name}' is disabled. Re-enable it with ".macro enable {name}""# ), Some(MacroState::Locked { level }) => bail!( "Macro '{name}' is restricted by {} enabled_macros", ctx.macro_lock_owner(*level) ), Some(MacroState::Invalid { reason }) => { bail!("Macro '{name}' is invalid: {reason}") } Some(_) | None => { if extra.is_none() { let app = Arc::clone(&ctx.app.config); ctx.new_macro(app.as_ref(), name)?; } else { macro_execute(ctx, name, extra, abort_signal.clone()).await?; } } } } None => println!( r#"Usage: .macro [text]... # Execute a macro .macro enable # Re-enable a runtime-disabled macro .macro disable # Disable a macro for the rest of this process"# ), }, ".file" => match args { Some(args) => { let (files, text) = split_args_text(args, cfg!(windows)); let input = Input::from_files_with_spinner( ctx, text, files, None, abort_signal.clone(), ) .await?; ask(ctx, abort_signal.clone(), input, true).await?; } None => println!( r#"Usage: .file ... [-- ...] .file /tmp/file.txt .file src/ Cargo.toml -- analyze .file https://example.com/file.txt -- summarize .file https://example.com/image.png -- recognize text .file `git diff` -- Generate git commit message .file jina:https://example.com .file %% -- translate last reply to english"# ), }, ".continue" => { let LastMessage { mut input, output, .. } = match ctx .last_message .as_ref() .filter(|v| v.continuous && !v.output.is_empty()) .cloned() { Some(v) => v, None => bail!("Unable to continue the response"), }; input.set_continue_output(&output); ask(ctx, abort_signal.clone(), input, true).await?; } ".recover" => { if !ctx.has_recoverable_interruption() { bail!("Unable to recover: no interrupted session response to recover from"); } let recovery_text = args .unwrap_or("Please continue from where you left off.") .to_string(); let recovery_input = Input::from_str(ctx, &recovery_text, None)?; ask(ctx, abort_signal.clone(), recovery_input, false).await?; } ".regenerate" => { let LastMessage { mut input, .. } = match ctx.last_message.as_ref().filter(|v| v.continuous).cloned() { Some(v) => v, None => bail!("Unable to regenerate the response"), }; let app = Arc::clone(&ctx.app.config); input.set_regenerate(ctx.extract_role(&app)?); ask(ctx, abort_signal.clone(), input, true).await?; } ".set" => match args { Some(args) => { ctx.update(args, abort_signal).await?; } _ => { println!("Usage: .set ...") } }, ".reasoning" => match args { Some(level) => { let set_args = format!("reasoning_effort {level}"); ctx.update(&set_args, abort_signal).await?; } None => { println!("Usage: .reasoning ") } }, ".delete" => match args { Some(args) => { ctx.delete(args)?; } _ => { println!("Usage: .delete ") } }, ".uninstall" => match parse_repl_uninstall(args) { ReplUninstallDispatch::Run(name, assume_yes) => { config::uninstall_bundle(&name, assume_yes)? } ReplUninstallDispatch::Help => println!("{}", repl_uninstall_help()), ReplUninstallDispatch::Usage => { println!("Usage: .uninstall [--yes] (see `.uninstall --help`)") } }, ".list" => match args.map(str::trim) { Some("prompts") => { ctx.list_mcp_prompts().await?; } Some(args) => { ctx.list_assets(args)?; } _ => { println!( "Usage: .list " ) } }, ".copy" => { let output = ctx .last_message .as_ref() .filter(|v| !v.output.is_empty()) .map(|v| v.output.clone()) .or_else(|| { ctx.session.as_ref().and_then(|s| { s.messages() .iter() .rev() .chain(s.compressed_messages().iter().rev()) .find(|m| m.role == MessageRole::Assistant) .and_then(|m| m.content.as_text()) .map(str::to_string) }) }); match output { Some(v) if !v.is_empty() => { set_text(&v).context("Failed to copy the last chat response")?; } _ => bail!("No chat response to copy"), } } ".exit" => match args { Some("role") => { ctx.exit_role()?; let app = Arc::clone(&ctx.app.config); ctx.bootstrap_tools(app.as_ref(), true, abort_signal.clone()) .await?; } Some("session") => { if ctx.agent.is_some() { ctx.exit_agent_session()?; } else { ctx.exit_session()?; } let app = Arc::clone(&ctx.app.config); ctx.bootstrap_tools(app.as_ref(), true, abort_signal.clone()) .await?; } Some("rag") => { ctx.exit_rag()?; } Some("agent") => { let app = Arc::clone(&ctx.app.config); ctx.exit_agent(app.as_ref())?; ctx.bootstrap_tools(app.as_ref(), true, abort_signal.clone()) .await?; } Some(_) => unknown_command()?, None => { return Ok(true); } }, ".clear" => match args { Some("messages") => { bail!("Use '.empty session' instead"); } Some("todo") => { let config = ctx.auto_continue_config(); if !config.enabled { bail!( "Auto-continue is not enabled. Set 'auto_continue: true' in your config to enable it." ); } if ctx.todo_list.is_empty() { println!("Todo list is already empty."); } else { ctx.clear_todo_list(); println!("Todo list cleared."); } } _ => unknown_command()?, }, ".vault" => match split_first_arg(args) { Some(("add", name)) => { if let Some(name) = name { ctx.app.vault.add_secret(name)?; } else { println!("Usage: .vault add "); } } Some(("get", name)) => { if let Some(name) = name { ctx.app.vault.get_secret(name, true)?; } else { println!("Usage: .vault get "); } } Some(("update", name)) => { if let Some(name) = name { ctx.app.vault.update_secret(name)?; } else { println!("Usage: .vault update "); } } Some(("delete", name)) => { if let Some(name) = name { ctx.app.vault.delete_secret(name)?; } else { println!("Usage: .vault delete "); } } Some(("list", _)) => { ctx.app.vault.list_secrets(true)?; } None | Some(_) => { println!("Usage: .vault [name]") } }, _ => { let name = cmd.strip_prefix('.').unwrap_or(cmd); let policy = ctx.macro_policy(); match policy.find(name).map(|row| &row.state) { Some(MacroState::Enabled) => { macro_execute(ctx, name, args, abort_signal.clone()).await?; } Some(MacroState::DisabledRuntime) => bail!( r#"Macro '{name}' is disabled. Re-enable it with ".macro enable {name}""# ), Some(MacroState::Locked { level }) => bail!( "Macro '{name}' is restricted by {} enabled_macros", ctx.macro_lock_owner(*level) ), Some(MacroState::Invalid { reason }) => { bail!("Macro '{name}' is invalid: {reason}") } _ => unknown_command()?, } } }, None => { if let Some(cmd) = try_extract_shell_command(line) { handle_shell_passthrough(cmd)?; } else { reset_continuation(ctx); ctx.resume_auto_continue(); let input = Input::from_str(ctx, line, None)?; ask(ctx, abort_signal.clone(), input, true).await?; } } } if !ctx.macro_flag { println!(); } Ok(false) } async fn ask( ctx: &mut RequestContext, abort_signal: AbortSignal, input: Input, with_embeddings: bool, ) -> Result<()> { let bracket = TurnBracket::enter(ctx); let result = ask_inner(ctx, abort_signal.clone(), input, with_embeddings).await; bracket.finish(ctx, &abort_signal, result) } async fn ask_inner( ctx: &mut RequestContext, abort_signal: AbortSignal, input: Input, with_embeddings: bool, ) -> Result<()> { let mut input = input; let mut with_embeddings = with_embeddings; loop { if input.is_empty() { return Ok(()); } ctx.session_abort = Some(abort_signal.clone()); if with_embeddings { input.use_embeddings(abort_signal.clone()).await?; with_embeddings = false; } while ctx.is_compressing_session() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; } let app = Arc::clone(&ctx.app.config); if graph::active_agent_graph_name(ctx).is_some() { ctx.before_chat_completion(&input)?; let output = graph::run_active_agent_graph(ctx, &input.text(), abort_signal.clone()).await?; app.print_markdown(&output)?; ctx.after_chat_completion(app.as_ref(), &input, &output, &[])?; print_turn_divider(); return Ok(()); } let client = input.create_client()?; ctx.before_chat_completion(&input)?; let (output, tool_results) = { let result = if input.stream() { call_chat_completions_streaming(&input, client.as_ref(), ctx, abort_signal.clone()) .await } else { call_chat_completions( &input, true, false, client.as_ref(), ctx, abort_signal.clone(), ) .await }; match result { Ok(v) => v, Err(err) => { ctx.on_chat_completion_error(app.as_ref(), &input); return Err(err); } } }; ctx.after_chat_completion(app.as_ref(), &input, &output, &tool_results)?; if !tool_results.is_empty() { input = input.merge_tool_results(output, tool_results); continue; } match check_pending_tasks_guardrail(ctx) { GuardrailAction::Inject(prompt) => { input = Input::from_str(ctx, &prompt, None)?; continue; } GuardrailAction::ForceTerminate(ids) => { warn!( "Pending-agent guardrail force-cancelled {} agent(s) after max reminders: {:?}", ids.len(), ids ); } GuardrailAction::NoAction => {} } let do_continue = should_continue(ctx) && !abort_signal.aborted_ctrlc(); if do_continue { let full_prompt = { let config = ctx.auto_continue_config(); let todo_state = ctx.todo_list.render_for_model(); let remaining = ctx.todo_list.incomplete_count(); ctx.set_last_continuation_response(output.clone()); ctx.increment_auto_continue_count(); let count = ctx.auto_continue_count; let max = config.max_continues; let prompt = config .continuation_prompt .as_deref() .unwrap_or(DEFAULT_CONTINUATION_PROMPT); let color = if app.light_theme() { nu_ansi_term::Color::LightGray } else { nu_ansi_term::Color::DarkGray }; eprintln!( "\nšŸ“‹ {}", color.italic().paint(format!( "Auto-continuing ({count}/{max}): {remaining} incomplete todo(s) remain" )) ); format!("{prompt}\n\n{todo_state}") }; input = Input::from_str(ctx, &full_prompt, None)?; continue; } reset_continuation(ctx); print_pause_banner(ctx); if ctx.maybe_autoname_session() { let color = if app.light_theme() { nu_ansi_term::Color::LightGray } else { nu_ansi_term::Color::DarkGray }; eprintln!("\nšŸ“¢ {}", color.italic().paint("Autonaming the session."),); if let Err(err) = ctx.autoname_session(app.as_ref()).await { warn!("Failed to autonaming the session: {err}"); } if let Some(session) = ctx.session.as_mut() { session.set_autonaming(false); } } let keep_last = ctx.compression_keep_last(); let needs_compression = ctx .session .as_ref() .is_some_and(|s| s.needs_compression(app.compression_threshold, keep_last)); if needs_compression { let agent_can_continue_after_compress = should_continue(ctx) && !abort_signal.aborted_ctrlc(); if let Some(session) = ctx.session.as_mut() { session.set_compressing(true); } let color = if app.light_theme() { nu_ansi_term::Color::LightGray } else { nu_ansi_term::Color::DarkGray }; eprintln!("\nšŸ“¢ {}", color.italic().paint("Compressing the session."),); auto_compress_session(ctx).await; if let Some(session) = ctx.session.as_mut() { session.set_compressing(false); } if agent_can_continue_after_compress { let full_prompt = { let config = ctx.auto_continue_config(); let todo_state = ctx.todo_list.render_for_model(); let remaining = ctx.todo_list.incomplete_count(); ctx.increment_auto_continue_count(); let count = ctx.auto_continue_count; let max = config.max_continues; let prompt = config .continuation_prompt .as_deref() .unwrap_or(DEFAULT_CONTINUATION_PROMPT); let color = if app.light_theme() { nu_ansi_term::Color::LightGray } else { nu_ansi_term::Color::DarkGray }; eprintln!( "\nšŸ“‹ {}", color.italic().paint(format!( "Auto-continuing after compression ({count}/{max}): {remaining} incomplete todo(s) remain" )) ); format!("{prompt}\n\n{todo_state}") }; input = Input::from_str(ctx, &full_prompt, None)?; continue; } } print_turn_divider(); return Ok(()); } } fn print_turn_divider() { let width = crossterm::terminal::size() .map(|(columns, _)| columns as usize) .unwrap_or(33); println!("{}", dimmed_text(&"─".repeat(width))); println!(); } async fn auto_compress_session(ctx: &mut RequestContext) { match ctx.compress_session().await { Ok(()) => hooks::fire(HookEvent::SessionCompressed, ctx, &[], None), Err(err) => warn!("Failed to compress the session: {err}"), } } fn should_continue(ctx: &RequestContext) -> bool { let config = ctx.auto_continue_config(); ctx.app.config.function_calling_support && config.enabled && ctx.auto_continue_count < config.max_continues && ctx.todo_list.has_incomplete() && ctx.auto_continue_paused.is_none() } fn reset_continuation(ctx: &mut RequestContext) { ctx.reset_continuation_count(); } fn pause_banner_text(ctx: &RequestContext) -> Option { let reason = ctx.auto_continue_paused.as_deref()?; if !ctx.todo_list.has_incomplete() { return None; } let remaining = ctx.todo_list.incomplete_count(); Some(format!( "Auto-continue paused, awaiting user ({remaining} incomplete todo(s) preserved): {reason}" )) } fn print_pause_banner(ctx: &RequestContext) { if let Some(text) = pause_banner_text(ctx) { let color = if ctx.app.config.light_theme() { nu_ansi_term::Color::LightGray } else { nu_ansi_term::Color::DarkGray }; eprintln!("\nāø {}", color.italic().paint(text)); } } fn unknown_command() -> Result<()> { bail!(r#"Unknown command. Type ".help" for additional help."#); } #[derive(Debug, PartialEq)] enum ReplInstallDispatch<'a> { Builtins(AssetCategory), Unified(&'a str), Help, Usage, } fn parse_repl_install(args: Option<&str>) -> ReplInstallDispatch<'_> { let trimmed = args.map(str::trim).unwrap_or(""); if trimmed .split_whitespace() .any(|token| token == "--help" || token == "-h") { return ReplInstallDispatch::Help; } let mut parts = trimmed.splitn(2, char::is_whitespace); match parts.next() { Some(name) if !name.is_empty() => { let rest = parts.next().map(str::trim).unwrap_or(""); match AssetCategory::parse(name) { Some(category) if rest.is_empty() => ReplInstallDispatch::Builtins(category), Some(_) => ReplInstallDispatch::Usage, None => ReplInstallDispatch::Unified(trimmed), } } _ => ReplInstallDispatch::Usage, } } fn repl_install_help() -> String { format!( r#"Install built-in assets, install a bundle from a Git source, or update an installed bundle. Usage: .install Reinstall built-in assets ({categories}) .install [#ref] Install a bundle from {default_host} (change the host with --git-host) .install [#ref] Install a bundle from any Git URL, scp-style path, or local path .install [#ref] Update an installed bundle from its recorded source Flags: --git-host Host the shorthand expands against (default {default_host}) --filter Restrict a remote install to one category ({filters}) --force Overwrite all conflicts without prompting (remote installs only) Suffix # to pin a branch, tag, or commit. List installed bundles with `.list bundles`; remove one with `.uninstall `."#, categories = AssetCategory::NAMES.join("|"), default_host = config::DEFAULT_GIT_HOST, filters = config::InstallFilter::NAMES.join("|"), ) } fn repl_uninstall_help() -> String { r#"Remove an installed bundle: delete the files it owns and the mcp.json entries it added. Usage: .uninstall [--yes] Flags: --yes, -y Skip the confirmation prompt Files you modified after install are prompted for individually and kept by default; --yes never deletes modified files. List installed bundles with `.list bundles`."# .to_string() } #[derive(Debug, PartialEq)] enum ReplUninstallDispatch { Run(String, bool), Help, Usage, } fn parse_repl_uninstall(args: Option<&str>) -> ReplUninstallDispatch { let mut assume_yes = false; let mut names = Vec::new(); for token in args.unwrap_or("").split_whitespace() { match token { "--help" | "-h" => return ReplUninstallDispatch::Help, "--yes" | "-y" => assume_yes = true, other if other.starts_with('-') => return ReplUninstallDispatch::Usage, other => names.push(other), } } match names.as_slice() { [name] => ReplUninstallDispatch::Run(name.to_string(), assume_yes), _ => ReplUninstallDispatch::Usage, } } pub fn builtin_command_names() -> Vec<&'static str> { let mut names: Vec<&'static str> = REPL_COMMANDS .iter() .filter_map(|cmd| cmd.name.split_whitespace().next()) .filter_map(|name| name.strip_prefix('.')) .collect(); names.sort_unstable(); names.dedup(); names } fn dump_repl_help() { let head = REPL_COMMANDS .iter() .map(|cmd| format!("{:<24} {}", cmd.name, cmd.description)) .collect::>() .join("\n"); println!( r###"{head} {:<24} Run an arbitrary shell command (stdout/stderr stream to your terminal; Ctrl+C interrupts) Custom commands (macros): macros are coyote's custom commands. An enabled macro runs top-level as . [args...], equivalent to ".macro ". List them with ".list macros"; toggle them with ".macro enable|disable ". Type ::: to start multi-line editing, type ::: to finish it. Press Ctrl+O to open an editor for editing the input buffer. Press Ctrl+C to cancel the response, Ctrl+D to exit the REPL."###, "!", ); } fn parse_command(line: &str) -> Option<(&str, Option<&str>)> { match COMMAND_RE.captures(line) { Ok(Some(captures)) => { let cmd = captures.get(1)?.as_str(); let args = line[captures[0].len()..].trim(); let args = if args.is_empty() { None } else { Some(args) }; Some((cmd, args)) } _ => None, } } fn try_extract_shell_command(line: &str) -> Option<&str> { let rest = line.strip_prefix('!')?; Some(rest.trim_start()) } fn handle_shell_passthrough(cmd: &str) -> Result<()> { if cmd.is_empty() { eprintln!("Usage: !"); return Ok(()); } let status = run_command(&SHELL.cmd, &[&SHELL.arg, cmd], None)?; if status != 0 { eprintln!("[exit {status}]"); } Ok(()) } fn split_first_arg(args: Option<&str>) -> Option<(&str, Option<&str>)> { args.map(|v| match v.split_once(' ') { Some((subcmd, args)) => (subcmd, Some(args.trim())), None => (v, None), }) } fn parse_prompt_call_args(words: &[String]) -> Result> { let mut args = HashMap::new(); for word in words { let Some((key, value)) = word.split_once('=') else { bail!("Invalid prompt argument '{word}': arguments must be key=value pairs"); }; args.insert(key.to_string(), unquote_prompt_value(value).to_string()); } Ok(args) } fn unquote_prompt_value(value: &str) -> &str { let quoted = value.len() >= 2 && ((value.starts_with('"') && value.ends_with('"')) || (value.starts_with('\'') && value.ends_with('\''))); if quoted { &value[1..value.len() - 1] } else { value } } fn prompt_arg_inquire_label(server: &str, prompt: &str, arg: &str) -> String { format!( "Prompt '{}' on '{}' requires '{}':", sanitize_display_text(prompt), sanitize_display_text(server), sanitize_display_text(arg) ) } pub fn split_args_text(line: &str, is_win: bool) -> (Vec, &str) { let mut words = Vec::new(); let mut word = String::new(); let mut unbalance: Option = None; let mut prev_char: Option = None; let mut text_starts_at = None; let unquote_word = |word: &str| { if ((word.starts_with('"') && word.ends_with('"')) || (word.starts_with('\'') && word.ends_with('\''))) && word.len() >= 2 { word[1..word.len() - 1].to_string() } else { word.to_string() } }; let chars: Vec = line.chars().collect(); for (i, char) in chars.iter().cloned().enumerate() { match unbalance { Some(ub_char) if ub_char == char => { word.push(char); unbalance = None; } Some(_) => { word.push(char); } None => match char { ' ' | '\t' | '\r' | '\n' => { if char == '\r' && chars.get(i + 1) == Some(&'\n') { continue; } if let Some('\\') = prev_char.filter(|_| !is_win) { word.push(char); } else if !word.is_empty() { if word == "--" { word.clear(); text_starts_at = Some(i + 1); break; } words.push(unquote_word(&word)); word.clear(); } } '\'' | '"' | '`' => { word.push(char); unbalance = Some(char); } '\\' => { if is_win || prev_char.map(|c| c == '\\').unwrap_or_default() { word.push(char); } } _ => { word.push(char); } }, } prev_char = Some(char); } if !word.is_empty() && word != "--" { words.push(unquote_word(&word)); } let text = match text_starts_at { Some(start) => &line[start..], None => "", }; (words, text) } #[cfg(test)] mod tests { use super::*; use crate::client::{ClientConfig, Model}; use crate::config::{AppState, RoleLike, Session, WorkingMode}; use crate::hooks::{HookDef, HooksMap, test_sink}; use anyhow::anyhow; use serial_test::serial; use std::future::Future; const TURN_EVENTS: [&str; 4] = [ "turn.started", "turn.completed", "turn.interrupted", "turn.failed", ]; fn ctx_with_hooks(events: &[&str], marker: &str) -> RequestContext { let mut hooks_map = HooksMap::default(); for event in events { hooks_map.insert( event.to_string(), vec![HookDef { name: format!("{marker}-{event}"), command: "true".to_string(), }], ); } let mut app = AppState::test_default(); app.config = Arc::new(AppConfig { hooks: hooks_map, ..Default::default() }); RequestContext::new(Arc::new(app), WorkingMode::Repl) } /// Sink captures for `marker`, ordered (started, completed, interrupted, failed). fn turn_counts(marker: &str) -> (usize, usize, usize, usize) { let captures = test_sink::snapshot(); let count = |event: &str| { captures .iter() .filter(|capture| capture.hook_name == format!("{marker}-{event}")) .count() }; ( count("turn.started"), count("turn.completed"), count("turn.interrupted"), count("turn.failed"), ) } #[tokio::test] #[serial] async fn turn_bracket_ok_final_fires_started_and_completed() { let _sink = test_sink::install(); let marker = "tb-ok-x7q"; let ctx = ctx_with_hooks(&TURN_EVENTS, marker); let abort_signal = create_abort_signal(); let bracket = TurnBracket::enter(&ctx); let result: Result<()> = async { Ok(()) }.await; bracket.finish(&ctx, &abort_signal, result).unwrap(); assert_eq!(turn_counts(marker), (1, 1, 0, 0)); } #[tokio::test] #[serial] async fn turn_bracket_tool_rounds_then_final_fire_one_turn() { let _sink = test_sink::install(); let marker = "tb-rounds-p2m"; let ctx = ctx_with_hooks(&TURN_EVENTS, marker); let abort_signal = create_abort_signal(); let bracket = TurnBracket::enter(&ctx); let result: Result<()> = async { // Simulates a turn that loops through tool rounds before the // final round; rounds happen inside the bracket and never re-fire. let mut rounds = 0; loop { rounds += 1; if rounds == 3 { break Ok(()); } } } .await; bracket.finish(&ctx, &abort_signal, result).unwrap(); assert_eq!(turn_counts(marker), (1, 1, 0, 0)); } #[tokio::test] #[serial] async fn turn_bracket_error_fires_failed_with_error_env() { let _sink = test_sink::install(); let marker = "tb-err-k4d"; let ctx = ctx_with_hooks(&TURN_EVENTS, marker); let abort_signal = create_abort_signal(); let bracket = TurnBracket::enter(&ctx); let result: Result<()> = async { Err(anyhow!("turn exploded")) }.await; let err = bracket.finish(&ctx, &abort_signal, result).unwrap_err(); assert_eq!(err.to_string(), "turn exploded"); assert_eq!(turn_counts(marker), (1, 0, 0, 1)); let captures = test_sink::snapshot(); let failed = captures .iter() .find(|capture| capture.hook_name == format!("{marker}-turn.failed")) .expect("turn.failed capture"); assert_eq!( failed.envs.get("COYOTE_ERROR").map(String::as_str), Some("turn exploded") ); } #[tokio::test] #[serial] async fn turn_bracket_ctrlc_error_fires_interrupted_not_failed() { let _sink = test_sink::install(); let marker = "tb-ctrlc-err-j6w"; let ctx = ctx_with_hooks(&TURN_EVENTS, marker); let abort_signal = create_abort_signal(); abort_signal.set_ctrlc(); let bracket = TurnBracket::enter(&ctx); let result: Result<()> = async { Err(anyhow!("Aborted!")) }.await; assert!(bracket.finish(&ctx, &abort_signal, result).is_err()); assert_eq!(turn_counts(marker), (1, 0, 1, 0)); } #[tokio::test] #[serial] async fn turn_bracket_ctrlc_with_ok_exit_fires_interrupted() { let _sink = test_sink::install(); let marker = "tb-ctrlc-ok-r3v"; let ctx = ctx_with_hooks(&TURN_EVENTS, marker); let abort_signal = create_abort_signal(); abort_signal.set_ctrlc(); let bracket = TurnBracket::enter(&ctx); let result: Result<()> = async { Ok(()) }.await; bracket.finish(&ctx, &abort_signal, result).unwrap(); assert_eq!(turn_counts(marker), (1, 0, 1, 0)); } /// Drives a deep REPL future to completion on a thread with extra stack /// headroom: nested `run_repl_command`/`compress_session` poll frames /// are deep in debug builds and overflow the default test-thread stack /// (Windows exhausts it first). fn run_async(f: F) -> F::Output where F: Future + Send, F::Output: Send, { std::thread::scope(|scope| { std::thread::Builder::new() .stack_size(8 * 1024 * 1024) .spawn_scoped(scope, || { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(f) }) .unwrap() .join() .unwrap() }) } #[test] #[serial] fn compress_command_failure_fires_no_session_compressed() { let _sink = test_sink::install(); let marker = "compress-fail-z9t"; run_async(async { let mut ctx = ctx_with_hooks(&["session.compressed"], marker); ctx.session = Some(Session::default()); let abort_signal = create_abort_signal(); // Boxed: `run_repl_command`'s state machine is far larger than a // test thread's stack. let result = Box::pin(run_repl_command( &mut ctx, abort_signal, ".compress session", )) .await; assert!(result.is_err(), "compressing an empty session must fail"); }); assert_eq!( test_sink::snapshot() .iter() .filter(|capture| capture.hook_name.starts_with(marker)) .count(), 0, "a failed compression must fire no session.compressed hook" ); } /// A ctx whose compression can actually SUCCEED offline: `dry_run` /// makes the summarization call echo instead of hitting the network, /// the default openai client config makes the session model /// resolvable, and the session already holds a user exchange so /// `compress_session` has something to summarize. fn compressible_ctx(marker: &str) -> RequestContext { let mut hooks_map = HooksMap::default(); hooks_map.insert( "session.compressed".to_string(), vec![HookDef { name: format!("{marker}-session.compressed"), command: "true".to_string(), }], ); let mut app = AppState::test_default(); app.config = Arc::new(AppConfig { hooks: hooks_map, dry_run: true, clients: vec![ClientConfig::default()], ..Default::default() }); let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Repl); let mut session = Session::default(); session.set_model(Model::new("openai", "test-compress-model")); ctx.session = Some(session); let input = Input::from_str(&ctx, "hello there", None).unwrap(); ctx.session .as_mut() .unwrap() .add_message(&input, "hi") .unwrap(); ctx } fn session_compressed_count(marker: &str) -> usize { test_sink::snapshot() .iter() .filter(|capture| capture.hook_name == format!("{marker}-session.compressed")) .count() } #[test] #[serial] fn compress_command_success_fires_session_compressed_once() { let _sink = test_sink::install(); let marker = "compress-ok-w2p"; run_async(async { let mut ctx = compressible_ctx(marker); let abort_signal = create_abort_signal(); // Boxed: `run_repl_command`'s state machine is far larger than a // test thread's stack. let result = Box::pin(run_repl_command( &mut ctx, abort_signal, ".compress session", )) .await; assert!( result.is_ok(), "dry-run compression must succeed: {result:?}" ); }); assert_eq!( session_compressed_count(marker), 1, "a successful `.compress session` must fire session.compressed exactly once" ); } #[test] #[serial] fn auto_compress_success_fires_session_compressed_once() { // Covers the ask-loop call site through its extracted seam; // driving `needs_compression` itself requires a full LLM turn and // is exercised end-to-end by integration coverage, not here. let _sink = test_sink::install(); let marker = "auto-compress-ok-f8r"; run_async(async { let mut ctx = compressible_ctx(marker); auto_compress_session(&mut ctx).await; }); assert_eq!( session_compressed_count(marker), 1, "a successful auto-compression must fire session.compressed exactly once" ); } #[test] #[serial] fn auto_compress_failure_fires_no_session_compressed_and_swallows_error() { let _sink = test_sink::install(); let marker = "auto-compress-fail-d3k"; run_async(async { let mut ctx = compressible_ctx(marker); // An empty session makes compress_session fail; the seam must // swallow the error (no panic, no propagation) and fire nothing. ctx.session = Some(Session::default()); auto_compress_session(&mut ctx).await; }); assert_eq!( session_compressed_count(marker), 0, "a failed auto-compression must fire no session.compressed hook" ); } #[test] fn test_process_command_line() { assert_eq!(parse_command(" ."), Some((".", None))); assert_eq!(parse_command(" .role"), Some((".role", None))); assert_eq!(parse_command(" .role "), Some((".role", None))); assert_eq!( parse_command(" .set dry_run true"), Some((".set", Some("dry_run true"))) ); assert_eq!( parse_command(" .set dry_run true "), Some((".set", Some("dry_run true"))) ); assert_eq!( parse_command(".prompt \nabc\n"), Some((".prompt", Some("abc"))) ); } #[test] fn test_split_args_text() { assert_eq!(split_args_text("", false), (vec![], "")); assert_eq!( split_args_text("file.txt", false), (vec!["file.txt".into()], "") ); assert_eq!( split_args_text("file.txt --", false), (vec!["file.txt".into()], "") ); assert_eq!( split_args_text("file.txt -- hello", false), (vec!["file.txt".into()], "hello") ); assert_eq!( split_args_text("file.txt -- \thello", false), (vec!["file.txt".into()], "\thello") ); assert_eq!( split_args_text("file.txt --\nhello", false), (vec!["file.txt".into()], "hello") ); assert_eq!( split_args_text("file.txt --\r\nhello", false), (vec!["file.txt".into()], "hello") ); assert_eq!( split_args_text("file.txt --\rhello", false), (vec!["file.txt".into()], "hello") ); assert_eq!( split_args_text(r#"file1.txt 'file2.txt' "file3.txt""#, false), ( vec!["file1.txt".into(), "file2.txt".into(), "file3.txt".into()], "" ) ); assert_eq!( split_args_text(r#"./file1.txt 'file1 - Copy.txt' file\ 2.txt"#, false), ( vec![ "./file1.txt".into(), "file1 - Copy.txt".into(), "file 2.txt".into() ], "" ) ); assert_eq!( split_args_text(r#".\file.txt C:\dir\file.txt"#, true), (vec![".\\file.txt".into(), "C:\\dir\\file.txt".into()], "") ); } #[test] fn repl_commands_has_63_entries() { assert_eq!(REPL_COMMANDS.len(), 63); } #[test] fn parse_prompt_call_args_splits_on_first_equals_and_unquotes() { let words = vec![ "path=notes.txt".to_string(), r#"style="a b""#.to_string(), "expr=a=b".to_string(), ]; let args = parse_prompt_call_args(&words).unwrap(); assert_eq!(args["path"], "notes.txt"); assert_eq!(args["style"], "a b"); assert_eq!(args["expr"], "a=b"); } #[test] fn parse_prompt_call_args_rejects_words_without_equals() { let err = parse_prompt_call_args(&["positional".to_string()]) .unwrap_err() .to_string(); assert_eq!( err, "Invalid prompt argument 'positional': arguments must be key=value pairs" ); } #[test] fn prompt_arg_inquire_label_sanitizes_all_components() { assert_eq!( prompt_arg_inquire_label("srv", "summarize", "path"), "Prompt 'summarize' on 'srv' requires 'path':" ); assert_eq!( prompt_arg_inquire_label("s\u{1b}[31mrv", "sum\u{1b}]0;x\u{7}marize", "pa\u{7}th"), "Prompt 'summarize' on 'srv' requires 'pa th':" ); } #[test] fn parse_repl_install_routes_categories_to_builtins() { assert_eq!( parse_repl_install(Some("agents")), ReplInstallDispatch::Builtins(AssetCategory::Agents) ); } #[test] fn parse_repl_install_routes_other_values_to_unified_dispatch() { assert_eq!( parse_repl_install(Some("https://github.com/x/y")), ReplInstallDispatch::Unified("https://github.com/x/y") ); assert_eq!( parse_repl_install(Some("my-bundle")), ReplInstallDispatch::Unified("my-bundle") ); } #[test] fn parse_repl_install_empty_args_ask_for_usage() { assert_eq!(parse_repl_install(None), ReplInstallDispatch::Usage); assert_eq!(parse_repl_install(Some(" ")), ReplInstallDispatch::Usage); } #[test] fn parse_repl_install_rejects_extra_tokens_after_a_category() { assert_eq!( parse_repl_install(Some("agents --force")), ReplInstallDispatch::Usage ); assert_eq!( parse_repl_install(Some("agents extra")), ReplInstallDispatch::Usage ); } #[test] fn parse_repl_install_routes_help_from_any_position() { assert_eq!( parse_repl_install(Some("--help")), ReplInstallDispatch::Help ); assert_eq!(parse_repl_install(Some("-h")), ReplInstallDispatch::Help); assert_eq!( parse_repl_install(Some("agents --help")), ReplInstallDispatch::Help ); assert_eq!( parse_repl_install(Some("owner/repo --help")), ReplInstallDispatch::Help ); } #[test] fn parse_repl_uninstall_routes_run_help_and_usage() { assert_eq!( parse_repl_uninstall(Some("my-bundle --yes")), ReplUninstallDispatch::Run("my-bundle".to_string(), true) ); assert_eq!( parse_repl_uninstall(Some("my-bundle")), ReplUninstallDispatch::Run("my-bundle".to_string(), false) ); assert_eq!( parse_repl_uninstall(Some("--help")), ReplUninstallDispatch::Help ); assert_eq!( parse_repl_uninstall(Some("my-bundle -h")), ReplUninstallDispatch::Help ); assert_eq!( parse_repl_uninstall(Some("--force my-bundle")), ReplUninstallDispatch::Usage ); assert_eq!(parse_repl_uninstall(None), ReplUninstallDispatch::Usage); } #[test] fn repl_install_and_uninstall_help_text_cover_the_full_surface() { let install = repl_install_help(); for needle in [ "--git-host", "--filter", "--force", "#ref", "owner/repo", ".list bundles", ] { assert!(install.contains(needle), "install help missing {needle}"); } let uninstall = repl_uninstall_help(); for needle in ["--yes", ".list bundles", ""] { assert!( uninstall.contains(needle), "uninstall help missing {needle}" ); } } #[test] fn builtin_command_names_are_sorted_deduped_first_words_without_dots() { let names = builtin_command_names(); assert!(!names.is_empty()); for name in &names { assert!(!name.starts_with('.'), "'{name}' should not keep the dot"); assert!(!name.contains(' '), "'{name}' should be a single word"); } assert!( names.windows(2).all(|w| w[0] < w[1]), "names should be sorted and deduplicated: {names:?}" ); assert!(names.contains(&"help")); assert!(names.contains(&"macro")); } #[test] fn repl_commands_all_start_with_dot() { for cmd in REPL_COMMANDS.iter() { assert!( cmd.name.starts_with('.'), "Command '{}' should start with '.'", cmd.name ); } } #[test] fn repl_commands_no_empty_descriptions() { for cmd in REPL_COMMANDS.iter() { assert!( !cmd.description.is_empty(), "Command '{}' has empty description", cmd.name ); } } #[test] fn repl_commands_help_is_always_available() { let help = REPL_COMMANDS.iter().find(|c| c.name == ".help").unwrap(); assert!(help.is_valid(StateFlags::empty())); assert!(help.is_valid(StateFlags::ROLE)); assert!(help.is_valid(StateFlags::AGENT)); } #[test] fn repl_commands_exit_is_always_available() { let exit = REPL_COMMANDS.iter().find(|c| c.name == ".exit").unwrap(); assert!(exit.is_valid(StateFlags::empty())); assert!(exit.is_valid(StateFlags::all())); } #[test] fn repl_commands_info_role_requires_role() { let cmd = REPL_COMMANDS .iter() .find(|c| c.name == ".info role") .unwrap(); assert!(cmd.is_valid(StateFlags::ROLE)); assert!(!cmd.is_valid(StateFlags::empty())); assert!(!cmd.is_valid(StateFlags::SESSION_EMPTY)); } #[test] fn repl_commands_session_blocked_when_already_in_session() { let cmd = REPL_COMMANDS.iter().find(|c| c.name == ".session").unwrap(); assert!(cmd.is_valid(StateFlags::empty())); assert!(!cmd.is_valid(StateFlags::SESSION)); assert!(!cmd.is_valid(StateFlags::SESSION_EMPTY)); } #[test] fn repl_commands_exit_session_requires_session() { let cmd = REPL_COMMANDS .iter() .find(|c| c.name == ".exit session") .unwrap(); assert!(cmd.is_valid(StateFlags::SESSION)); assert!(cmd.is_valid(StateFlags::SESSION_EMPTY)); assert!(!cmd.is_valid(StateFlags::empty())); } #[test] fn repl_commands_exit_agent_requires_agent() { let cmd = REPL_COMMANDS .iter() .find(|c| c.name == ".exit agent") .unwrap(); assert!(cmd.is_valid(StateFlags::AGENT)); assert!(!cmd.is_valid(StateFlags::empty())); } #[test] fn repl_commands_agent_only_when_bare() { let cmd = REPL_COMMANDS.iter().find(|c| c.name == ".agent").unwrap(); assert!(cmd.is_valid(StateFlags::empty())); assert!(!cmd.is_valid(StateFlags::ROLE)); assert!(!cmd.is_valid(StateFlags::SESSION)); assert!(!cmd.is_valid(StateFlags::AGENT)); } #[test] fn repl_commands_role_blocked_in_session_or_agent() { let cmd = REPL_COMMANDS.iter().find(|c| c.name == ".role").unwrap(); assert!(cmd.is_valid(StateFlags::empty())); assert!(!cmd.is_valid(StateFlags::SESSION)); assert!(!cmd.is_valid(StateFlags::AGENT)); } #[test] fn repl_commands_prompt_always_available() { let cmd = REPL_COMMANDS.iter().find(|c| c.name == ".prompt").unwrap(); assert!(cmd.is_valid(StateFlags::empty())); assert!(cmd.is_valid(StateFlags::ROLE)); assert!(cmd.is_valid(StateFlags::SESSION)); assert!(cmd.is_valid(StateFlags::AGENT)); } #[test] fn repl_commands_temp_role_blocked_in_session_or_agent() { let cmd = REPL_COMMANDS .iter() .find(|c| c.name == ".temp-role") .unwrap(); assert!(cmd.is_valid(StateFlags::empty())); assert!(cmd.is_valid(StateFlags::ROLE)); assert!(!cmd.is_valid(StateFlags::SESSION)); assert!(!cmd.is_valid(StateFlags::AGENT)); } #[test] fn repl_commands_rag_blocked_in_agent() { let cmd = REPL_COMMANDS.iter().find(|c| c.name == ".rag").unwrap(); assert!(cmd.is_valid(StateFlags::empty())); assert!(cmd.is_valid(StateFlags::ROLE)); assert!(!cmd.is_valid(StateFlags::AGENT)); } #[test] fn repl_commands_starter_requires_agent() { let cmd = REPL_COMMANDS.iter().find(|c| c.name == ".starter").unwrap(); assert!(cmd.is_valid(StateFlags::AGENT)); assert!(!cmd.is_valid(StateFlags::empty())); } #[test] fn repl_commands_clear_todo_always_available() { let cmd = REPL_COMMANDS .iter() .find(|c| c.name == ".clear todo") .unwrap(); assert!(cmd.is_valid(StateFlags::AGENT)); assert!(cmd.is_valid(StateFlags::empty())); assert!(cmd.is_valid(StateFlags::SESSION)); assert!(cmd.is_valid(StateFlags::ROLE)); } #[test] fn repl_commands_edit_role_requires_role_not_session() { let cmd = REPL_COMMANDS .iter() .find(|c| c.name == ".edit role") .unwrap(); assert!(cmd.is_valid(StateFlags::ROLE)); assert!(!cmd.is_valid(StateFlags::empty())); assert!(!cmd.is_valid(StateFlags::ROLE | StateFlags::SESSION)); } #[test] fn repl_commands_exit_rag_requires_rag_not_agent() { let cmd = REPL_COMMANDS .iter() .find(|c| c.name == ".exit rag") .unwrap(); assert!(cmd.is_valid(StateFlags::RAG)); assert!(!cmd.is_valid(StateFlags::empty())); assert!(!cmd.is_valid(StateFlags::RAG | StateFlags::AGENT)); } #[test] fn parse_command_plain_text_returns_none() { assert!(parse_command("hello world").is_none()); } #[test] fn parse_command_empty_returns_none() { assert!(parse_command("").is_none()); } #[test] fn parse_command_whitespace_only_returns_none() { assert!(parse_command(" ").is_none()); } #[test] fn parse_command_dot_only() { assert_eq!(parse_command("."), Some((".", None))); } #[test] fn try_extract_shell_command_strips_bang() { assert_eq!(try_extract_shell_command("!ls"), Some("ls")); assert_eq!(try_extract_shell_command("!ls -la"), Some("ls -la")); } #[test] fn try_extract_shell_command_trims_inner_whitespace() { assert_eq!(try_extract_shell_command("! echo hi"), Some("echo hi")); assert_eq!(try_extract_shell_command("! ls"), Some("ls")); } #[test] fn try_extract_shell_command_only_bang_yields_empty() { assert_eq!(try_extract_shell_command("!"), Some("")); assert_eq!(try_extract_shell_command("! "), Some("")); } #[test] fn try_extract_shell_command_rejects_leading_whitespace() { assert!(try_extract_shell_command(" !ls").is_none()); assert!(try_extract_shell_command("\t!ls").is_none()); } #[test] fn try_extract_shell_command_rejects_inline_bang() { assert!(try_extract_shell_command("echo !foo").is_none()); assert!(try_extract_shell_command("hello world").is_none()); } #[test] fn try_extract_shell_command_strips_one_leading_bang() { assert_eq!(try_extract_shell_command("!!ls"), Some("!ls")); } #[test] fn try_extract_shell_command_preserves_pipes_and_redirects() { assert_eq!( try_extract_shell_command("!ls -la | grep yaml"), Some("ls -la | grep yaml") ); assert_eq!( try_extract_shell_command("!cat foo.txt > /tmp/out"), Some("cat foo.txt > /tmp/out") ); assert_eq!( try_extract_shell_command(r#"!echo "$HOME""#), Some(r#"echo "$HOME""#) ); } #[test] fn split_first_arg_none_input() { assert!(split_first_arg(None).is_none()); } #[test] fn split_first_arg_single_word() { assert_eq!(split_first_arg(Some("role")), Some(("role", None))); } #[test] fn split_first_arg_two_words() { assert_eq!( split_first_arg(Some("role test-role")), Some(("role", Some("test-role"))) ); } #[test] fn split_first_arg_with_extra_spaces() { assert_eq!( split_first_arg(Some("session my-session")), Some(("session", Some("my-session"))) ); } #[test] fn repl_command_is_valid_pass_always_true() { let cmd = ReplCommand::new(".test", "desc", AssertState::pass()); assert!(cmd.is_valid(StateFlags::empty())); assert!(cmd.is_valid(StateFlags::all())); } #[test] fn repl_command_is_valid_respects_true() { let cmd = ReplCommand::new(".test", "desc", AssertState::True(StateFlags::ROLE)); assert!(cmd.is_valid(StateFlags::ROLE)); assert!(!cmd.is_valid(StateFlags::empty())); } #[test] fn repl_command_is_valid_respects_false() { let cmd = ReplCommand::new(".test", "desc", AssertState::False(StateFlags::AGENT)); assert!(cmd.is_valid(StateFlags::empty())); assert!(!cmd.is_valid(StateFlags::AGENT)); } #[test] fn multiline_regex_captures_content_between_markers() { let input = ":::\nhello world\n:::"; let captures = MULTILINE_RE.captures(input).unwrap().unwrap(); let content = captures.get(1).unwrap().as_str(); assert_eq!(content.trim(), "hello world"); } #[test] fn multiline_regex_does_not_match_single_marker() { let input = ":::\nhello world"; let result = MULTILINE_RE.captures(input).unwrap(); assert!(result.is_none()); } #[test] fn multiline_regex_does_not_match_plain_text() { let input = "hello world"; let result = MULTILINE_RE.captures(input).unwrap(); assert!(result.is_none()); } fn banner_ctx() -> RequestContext { use crate::config::{AppState, WorkingMode}; RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd) } #[test] fn pause_banner_text_paused_with_incomplete_todos() { let mut ctx = banner_ctx(); ctx.add_todo("write code"); ctx.add_todo("run tests"); ctx.pause_auto_continue("Which database?"); let text = pause_banner_text(&ctx).unwrap(); assert!(text.contains("Which database?"), "{text}"); assert!(text.contains("2 incomplete todo(s) preserved"), "{text}"); } #[test] fn pause_banner_text_none_when_all_todos_complete() { let mut ctx = banner_ctx(); let id = ctx.add_todo("write code"); ctx.mark_todo_done(id); ctx.pause_auto_continue("Which database?"); assert_eq!(pause_banner_text(&ctx), None); } #[test] fn pause_banner_text_none_when_unpaused() { let mut ctx = banner_ctx(); ctx.add_todo("write code"); assert_eq!(pause_banner_text(&ctx), None); } }