diff --git a/docs/media/tui-lock-v2/runtime/40x12/jobs.png b/docs/media/tui-lock-v2/runtime/40x12/jobs.png new file mode 100644 index 00000000..39f8c5db Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/jobs.png differ diff --git a/docs/reference/cli.commands.json b/docs/reference/cli.commands.json index 946bbd33..efa88ccc 100644 --- a/docs/reference/cli.commands.json +++ b/docs/reference/cli.commands.json @@ -92,6 +92,27 @@ "required": false, "short": null }, + { + "help": "Isolated git worktree for this session (`--worktree` or `--worktree DIR`)", + "id": "worktree", + "long": "worktree", + "required": false, + "short": null + }, + { + "help": "Extra plugin folder (folder-of-plugins or a single plugin). Repeatable", + "id": "plugin_dir", + "long": "plugin-dir", + "required": false, + "short": null + }, + { + "help": "Attach unified diffs of files Execute/Bash changed (also `CORTEX_BASH_EDIT_DIFF=1`)", + "id": "bash_edit_diff", + "long": "bash-edit-diff", + "required": false, + "short": null + }, { "help": "Image files to attach to the initial prompt", "id": "images", @@ -357,6 +378,27 @@ "required": false, "short": null }, + { + "help": "Isolated git worktree for this run (`--worktree` or `--worktree DIR`)", + "id": "worktree", + "long": "worktree", + "required": false, + "short": null + }, + { + "help": "Extra plugin folder (folder-of-plugins or a single plugin). Repeatable", + "id": "plugin_dir", + "long": "plugin-dir", + "required": false, + "short": null + }, + { + "help": "Attach unified diffs of files Execute/Bash changed", + "id": "bash_edit_diff", + "long": "bash-edit-diff", + "required": false, + "short": null + }, { "help": "Additional directories that should be writable. Can be specified multiple times to add multiple directories", "id": "add_dir", @@ -1131,6 +1173,303 @@ "name": "delete", "subcommands": [] }, + { + "about": "Attach this terminal to a live Code session (session keeps running on detach)", + "arguments": [ + { + "help": "Code session id (not a URL)", + "id": "session_id", + "long": null, + "required": true, + "short": null + }, + { + "help": "Print the transcript and pending prompts, then detach (session keeps running)", + "id": "print_only", + "long": "print-only", + "required": false, + "short": null + }, + { + "help": "Enable verbose output (same as --log-level debug)", + "id": "verbose", + "long": "verbose", + "required": false, + "short": "v" + }, + { + "help": "Enable trace-level logging for debugging", + "id": "trace", + "long": "trace", + "required": false, + "short": null + }, + { + "help": "Control color output: auto (default), always, or never", + "id": "color", + "long": "color", + "required": false, + "short": null + }, + { + "help": "Print help (see more with '--help')", + "id": "help", + "long": "help", + "required": false, + "short": "h" + } + ], + "name": "attach", + "subcommands": [] + }, + { + "about": "List, follow, or stop background Code agents", + "arguments": [ + { + "help": "Enable verbose output (same as --log-level debug)", + "id": "verbose", + "long": "verbose", + "required": false, + "short": "v" + }, + { + "help": "Enable trace-level logging for debugging", + "id": "trace", + "long": "trace", + "required": false, + "short": null + }, + { + "help": "Control color output: auto (default), always, or never", + "id": "color", + "long": "color", + "required": false, + "short": null + }, + { + "help": "Print help (see more with '--help')", + "id": "help", + "long": "help", + "required": false, + "short": "h" + } + ], + "name": "jobs", + "subcommands": [ + { + "about": "List live Code sessions (cloud + attached)", + "arguments": [ + { + "help": null, + "id": "json", + "long": "json", + "required": false, + "short": null + }, + { + "help": "Enable verbose output (same as --log-level debug)", + "id": "verbose", + "long": "verbose", + "required": false, + "short": "v" + }, + { + "help": "Enable trace-level logging for debugging", + "id": "trace", + "long": "trace", + "required": false, + "short": null + }, + { + "help": "Control color output: auto (default), always, or never", + "id": "color", + "long": "color", + "required": false, + "short": null + }, + { + "help": "Print help (see more with '--help')", + "id": "help", + "long": "help", + "required": false, + "short": "h" + } + ], + "name": "list", + "subcommands": [] + }, + { + "about": "Print the transcript for a session", + "arguments": [ + { + "help": null, + "id": "session_id", + "long": null, + "required": true, + "short": null + }, + { + "help": "Enable verbose output (same as --log-level debug)", + "id": "verbose", + "long": "verbose", + "required": false, + "short": "v" + }, + { + "help": "Enable trace-level logging for debugging", + "id": "trace", + "long": "trace", + "required": false, + "short": null + }, + { + "help": "Control color output: auto (default), always, or never", + "id": "color", + "long": "color", + "required": false, + "short": null + }, + { + "help": "Print help (see more with '--help')", + "id": "help", + "long": "help", + "required": false, + "short": "h" + } + ], + "name": "logs", + "subcommands": [] + }, + { + "about": "Request stop; the session is not deleted", + "arguments": [ + { + "help": null, + "id": "session_id", + "long": null, + "required": true, + "short": null + }, + { + "help": "Enable verbose output (same as --log-level debug)", + "id": "verbose", + "long": "verbose", + "required": false, + "short": "v" + }, + { + "help": "Enable trace-level logging for debugging", + "id": "trace", + "long": "trace", + "required": false, + "short": null + }, + { + "help": "Control color output: auto (default), always, or never", + "id": "color", + "long": "color", + "required": false, + "short": null + }, + { + "help": "Print help (see more with '--help')", + "id": "help", + "long": "help", + "required": false, + "short": "h" + } + ], + "name": "stop", + "subcommands": [] + }, + { + "about": "Attach this terminal (same as `cortex attach`)", + "arguments": [ + { + "help": "Code session id (not a URL)", + "id": "session_id", + "long": null, + "required": true, + "short": null + }, + { + "help": "Print the transcript and pending prompts, then detach (session keeps running)", + "id": "print_only", + "long": "print-only", + "required": false, + "short": null + }, + { + "help": "Enable verbose output (same as --log-level debug)", + "id": "verbose", + "long": "verbose", + "required": false, + "short": "v" + }, + { + "help": "Enable trace-level logging for debugging", + "id": "trace", + "long": "trace", + "required": false, + "short": null + }, + { + "help": "Control color output: auto (default), always, or never", + "id": "color", + "long": "color", + "required": false, + "short": null + }, + { + "help": "Print help (see more with '--help')", + "id": "help", + "long": "help", + "required": false, + "short": "h" + } + ], + "name": "attach", + "subcommands": [] + }, + { + "about": "Print this message or the help of the given subcommand(s)", + "arguments": [], + "name": "help", + "subcommands": [ + { + "about": "List live Code sessions (cloud + attached)", + "arguments": [], + "name": "list", + "subcommands": [] + }, + { + "about": "Print the transcript for a session", + "arguments": [], + "name": "logs", + "subcommands": [] + }, + { + "about": "Request stop; the session is not deleted", + "arguments": [], + "name": "stop", + "subcommands": [] + }, + { + "about": "Attach this terminal (same as `cortex attach`)", + "arguments": [], + "name": "attach", + "subcommands": [] + }, + { + "about": "Print this message or the help of the given subcommand(s)", + "arguments": [], + "name": "help", + "subcommands": [] + } + ] + } + ] + }, { "about": "Authenticate with Cortex API", "arguments": [ @@ -9215,6 +9554,43 @@ "name": "delete", "subcommands": [] }, + { + "about": "Attach this terminal to a live Code session (session keeps running on detach)", + "arguments": [], + "name": "attach", + "subcommands": [] + }, + { + "about": "List, follow, or stop background Code agents", + "arguments": [], + "name": "jobs", + "subcommands": [ + { + "about": "List live Code sessions (cloud + attached)", + "arguments": [], + "name": "list", + "subcommands": [] + }, + { + "about": "Print the transcript for a session", + "arguments": [], + "name": "logs", + "subcommands": [] + }, + { + "about": "Request stop; the session is not deleted", + "arguments": [], + "name": "stop", + "subcommands": [] + }, + { + "about": "Attach this terminal (same as `cortex attach`)", + "arguments": [], + "name": "attach", + "subcommands": [] + } + ] + }, { "about": "Authenticate with Cortex API", "arguments": [], diff --git a/src/cortex-cli/src/attach_cmd.rs b/src/cortex-cli/src/attach_cmd.rs new file mode 100644 index 00000000..022c3ef7 --- /dev/null +++ b/src/cortex-cli/src/attach_cmd.rs @@ -0,0 +1,341 @@ +//! Live attach to a remote Code session (follow, send, approve/deny). + +use anyhow::{Result, bail}; +use clap::Parser; +use std::io::{self, BufRead, IsTerminal, Write}; + +use cortex_engine::client::{AUTH_REQUIRED, CodeAgentClient, CodeTurnMode}; +use cortex_engine::session_attach::parse_attach_target; +use cortex_login::load_auth_with_fallback; + +/// Attach this terminal to an in-flight Code session. +#[derive(Debug, Parser)] +pub struct AttachCli { + /// Code session id (not a URL). + pub session_id: String, + + /// Print the transcript and pending prompts, then detach (session keeps running). + #[arg(long)] + pub print_only: bool, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum FollowupLine { + Detach, + Approve(String), + Deny(String), + Invalid(String), + Message(String), +} + +pub(crate) fn parse_followup_line(trimmed: &str) -> FollowupLine { + if trimmed.is_empty() { + return FollowupLine::Detach; + } + if trimmed.eq_ignore_ascii_case("approve") || trimmed.eq_ignore_ascii_case("deny") { + return FollowupLine::Invalid( + "`approve ` / `deny ` need an invocation id. No turn was sent.".into(), + ); + } + if let Some(rest) = trimmed.strip_prefix("approve ") { + let id = rest.trim(); + if id.is_empty() { + return FollowupLine::Invalid( + "`approve ` needs an invocation id. No turn was sent.".into(), + ); + } + return FollowupLine::Approve(id.to_string()); + } + if let Some(rest) = trimmed.strip_prefix("deny ") { + let id = rest.trim(); + if id.is_empty() { + return FollowupLine::Invalid( + "`deny ` needs an invocation id. No turn was sent.".into(), + ); + } + return FollowupLine::Deny(id.to_string()); + } + FollowupLine::Message(trimmed.to_string()) +} + +impl AttachCli { + pub async fn run(self) -> Result<()> { + let id = parse_attach_target(&self.session_id)?; + let token = load_attach_token()?; + let client = CodeAgentClient::new(None, Some(token)); + client.resume_session(&id).await?; + let session = client.get_session(&id).await?; + println!("{}", attached_banner(&session)); + + let mut seen = std::collections::HashSet::new(); + print_unseen_messages(client.list_messages(&id).await?, &mut seen); + + if self.print_only || !io::stdin().is_terminal() { + println!("Detached. Session {id} is still running."); + return Ok(()); + } + + println!( + "Send a follow-up and press Enter. `approve ` / `deny ` for paused tools. Empty line detaches. Remote output is followed while this terminal is attached." + ); + follow_attached_session(&client, &id, seen).await?; + println!("Detached. Session {id} is still running."); + Ok(()) + } +} + +fn print_unseen_messages( + messages: Vec, + seen: &mut std::collections::HashSet, +) { + for message in unseen_messages(messages, seen) { + if let Some(line) = format_message_line(&message.role, &message.text) { + println!("{line}"); + } + } +} + +pub(crate) fn unseen_messages( + messages: Vec, + seen: &mut std::collections::HashSet, +) -> Vec { + messages + .into_iter() + .filter(|message| { + let key = if message.id.is_empty() { + format!("{}:{}", message.created_at, message.text) + } else { + message.id.clone() + }; + seen.insert(key) + }) + .collect() +} + +async fn follow_attached_session( + client: &CodeAgentClient, + id: &str, + mut seen: std::collections::HashSet, +) -> Result<()> { + let (tx, mut rx) = tokio::sync::mpsc::channel::>(8); + std::thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + if tx.blocking_send(line).is_err() { + break; + } + } + }); + loop { + tokio::select! { + line = rx.recv() => { + let Some(line) = line else { break; }; + match parse_followup_line(line?.trim()) { + FollowupLine::Detach => break, + FollowupLine::Invalid(msg) => println!("{msg}"), + FollowupLine::Approve(invocation) => { + client.approve_invocation(id, &invocation, true).await?; + println!("Approved {invocation}"); + } + FollowupLine::Deny(invocation) => { + client.approve_invocation(id, &invocation, false).await?; + println!("Denied {invocation}"); + } + FollowupLine::Message(text) => { + stream_followup(client, &text).await?; + } + } + } + _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => { + match client.list_messages(id).await { + Ok(messages) => print_unseen_messages(messages, &mut seen), + Err(e) => eprintln!("{}", e.user_friendly_message()), + } + } + } + } + Ok(()) +} + +async fn stream_followup(client: &CodeAgentClient, text: &str) -> Result<()> { + let mut stream = client.stream_turn(text, CodeTurnMode::Code).await?; + use futures::StreamExt; + while let Some(event) = stream.next().await { + match event { + Ok(cortex_engine::client::ResponseEvent::Delta(text)) => { + print!("{text}"); + let _ = io::stdout().flush(); + } + Ok(cortex_engine::client::ResponseEvent::Error(e)) => { + bail!("{e}"); + } + Ok(_) => {} + Err(e) => bail!("{}", e.user_friendly_message()), + } + } + println!(); + Ok(()) +} + +pub(crate) fn attached_banner(session: &cortex_engine::client::CodeSession) -> String { + format!( + "Attached to {} · {} · {} — detach leaves the session running", + session.id, + if session.title.is_empty() { + "Code session" + } else { + session.title.as_str() + }, + if session.state.is_empty() { + "live" + } else { + session.state.as_str() + } + ) +} + +pub(crate) fn format_message_line(role: &str, text: &str) -> Option { + if text.is_empty() { + return None; + } + let role = if role.is_empty() { "message" } else { role }; + Some(format!("{role}: {text}")) +} + +pub(crate) fn load_attach_token() -> Result { + let cortex_home = crate::utils::paths::get_cortex_home(); + let token = std::env::var("CORTEX_AUTH_TOKEN") + .ok() + .filter(|t| !t.is_empty()) + .or_else(|| { + std::env::var("CORTEX_API_KEY") + .ok() + .filter(|t| !t.is_empty()) + }) + .or_else(|| { + load_auth_with_fallback(&cortex_home) + .ok() + .flatten() + .and_then(|auth| auth.get_token().map(str::to_string)) + }); + token.ok_or_else(|| anyhow::anyhow!("{AUTH_REQUIRED}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use serial_test::serial; + + #[test] + fn attach_cli_requires_session_id() { + assert!(AttachCli::try_parse_from(["attach"]).is_err()); + let parsed = AttachCli::try_parse_from(["attach", "sess-9"]).unwrap(); + assert_eq!(parsed.session_id, "sess-9"); + assert!(!parsed.print_only); + let printed = AttachCli::try_parse_from(["attach", "sess-9", "--print-only"]).unwrap(); + assert!(printed.print_only); + } + + #[test] + fn followup_line_parses_approve_deny_detach() { + assert_eq!(parse_followup_line(""), FollowupLine::Detach); + assert_eq!( + parse_followup_line("approve inv-1"), + FollowupLine::Approve("inv-1".into()) + ); + assert_eq!( + parse_followup_line("deny inv-2"), + FollowupLine::Deny("inv-2".into()) + ); + assert_eq!( + parse_followup_line("keep going"), + FollowupLine::Message("keep going".into()) + ); + assert!(matches!( + parse_followup_line("approve"), + FollowupLine::Invalid(_) + )); + assert!(matches!( + parse_followup_line("deny"), + FollowupLine::Invalid(_) + )); + assert!(matches!( + parse_followup_line("approve "), + FollowupLine::Invalid(_) + )); + let mut seen = std::collections::HashSet::new(); + let first = cortex_engine::client::CodeMessage { + id: "m1".into(), + role: "assistant".into(), + text: "hello".into(), + created_at: String::new(), + }; + let again = first.clone(); + let later = cortex_engine::client::CodeMessage { + id: "m2".into(), + role: "assistant".into(), + text: "later".into(), + created_at: String::new(), + }; + assert_eq!(unseen_messages(vec![first], &mut seen).len(), 1); + assert!(unseen_messages(vec![again], &mut seen).is_empty()); + assert_eq!(unseen_messages(vec![later], &mut seen).len(), 1); + let session = cortex_engine::client::CodeSession { + id: "sess-9".into(), + runtime: "cloud".into(), + kind: String::new(), + host_status: String::new(), + state: String::new(), + model_slug: String::new(), + model_ref: String::new(), + title: String::new(), + created_at: String::new(), + host_id: String::new(), + stream: None, + }; + assert!(attached_banner(&session).contains("Code session")); + assert!(attached_banner(&session).contains("live")); + assert_eq!( + format_message_line("", "hi").as_deref(), + Some("message: hi") + ); + assert!(format_message_line("user", "").is_none()); + } + + #[tokio::test] + async fn attach_run_rejects_url_without_starting_local() { + let err = AttachCli { + session_id: "https://example.com/s".into(), + print_only: true, + } + .run() + .await + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("No local session was started"), "{msg}"); + } + + #[tokio::test] + #[serial] + async fn attach_run_requires_auth() { + let home = tempfile::tempdir().unwrap(); + unsafe { + std::env::remove_var("CORTEX_AUTH_TOKEN"); + std::env::remove_var("CORTEX_API_KEY"); + std::env::set_var("CORTEX_HOME", home.path()); + } + let err = AttachCli { + session_id: "sess-ok".into(), + print_only: true, + } + .run() + .await + .unwrap_err(); + assert!(err.to_string().contains("Not signed in"), "{err}"); + assert!(load_attach_token().is_err()); + unsafe { + std::env::remove_var("CORTEX_HOME"); + } + } +} diff --git a/src/cortex-cli/src/cli/args.rs b/src/cortex-cli/src/cli/args.rs index 0c5c3efb..4f3cf179 100644 --- a/src/cortex-cli/src/cli/args.rs +++ b/src/cortex-cli/src/cli/args.rs @@ -200,6 +200,33 @@ pub struct InteractiveArgs { #[arg(long = "add-dir", value_name = "DIR", help_heading = "Workspace")] pub add_dir: Vec, + /// Isolated git worktree for this session (`--worktree` or `--worktree DIR`). + #[arg( + long = "worktree", + value_name = "DIR", + num_args = 0..=1, + default_missing_value = "auto", + help_heading = "Workspace" + )] + pub worktree: Option, + + /// Extra plugin folder (folder-of-plugins or a single plugin). Repeatable. + #[arg( + long = "plugin-dir", + value_name = "DIR", + action = clap::ArgAction::Append, + help_heading = "Features" + )] + pub plugin_dir: Vec, + + /// Attach unified diffs of files Execute/Bash changed (also `CORTEX_BASH_EDIT_DIFF=1`). + #[arg( + long = "bash-edit-diff", + default_value_t = false, + help_heading = "Features" + )] + pub bash_edit_diff: bool, + /// Image files to attach to the initial prompt #[arg(long = "image", short = 'i', value_delimiter = ',', num_args = 1.., help_heading = "Workspace")] pub images: Vec, @@ -332,6 +359,16 @@ pub enum Commands { #[command(next_help_heading = categories::SESSION)] Delete(DeleteCommand), + /// Attach this terminal to a live Code session (session keeps running on detach) + #[command(display_order = 15)] + #[command(next_help_heading = categories::SESSION)] + Attach(crate::attach_cmd::AttachCli), + + /// List, follow, or stop background Code agents + #[command(display_order = 16)] + #[command(next_help_heading = categories::SESSION)] + Jobs(crate::jobs_cmd::JobsCli), + // ======================================================================== // 🔐 Authentication (order 20-29) // ======================================================================== @@ -876,1227 +913,8 @@ pub struct HistoryClearArgs { } #[cfg(test)] -mod tests { - use super::*; - use clap::Parser; - - // ========================================================================== - // LogLevel tests - // ========================================================================== - - #[test] - fn test_log_level_default() { - let default = LogLevel::default(); - assert_eq!(default, LogLevel::Info); - } - - #[test] - fn test_log_level_as_filter_str() { - assert_eq!(LogLevel::Error.as_filter_str(), "error"); - assert_eq!(LogLevel::Warn.as_filter_str(), "warn"); - assert_eq!(LogLevel::Info.as_filter_str(), "info"); - assert_eq!(LogLevel::Debug.as_filter_str(), "debug"); - assert_eq!(LogLevel::Trace.as_filter_str(), "trace"); - } - - #[test] - fn test_log_level_from_str_loose_valid() { - assert_eq!(LogLevel::from_str_loose("error"), Some(LogLevel::Error)); - assert_eq!(LogLevel::from_str_loose("warn"), Some(LogLevel::Warn)); - assert_eq!(LogLevel::from_str_loose("warning"), Some(LogLevel::Warn)); - assert_eq!(LogLevel::from_str_loose("info"), Some(LogLevel::Info)); - assert_eq!(LogLevel::from_str_loose("debug"), Some(LogLevel::Debug)); - assert_eq!(LogLevel::from_str_loose("trace"), Some(LogLevel::Trace)); - } - - #[test] - fn test_log_level_from_str_loose_case_insensitive() { - assert_eq!(LogLevel::from_str_loose("ERROR"), Some(LogLevel::Error)); - assert_eq!(LogLevel::from_str_loose("WARN"), Some(LogLevel::Warn)); - assert_eq!(LogLevel::from_str_loose("WARNING"), Some(LogLevel::Warn)); - assert_eq!(LogLevel::from_str_loose("INFO"), Some(LogLevel::Info)); - assert_eq!(LogLevel::from_str_loose("Debug"), Some(LogLevel::Debug)); - assert_eq!(LogLevel::from_str_loose("TrAcE"), Some(LogLevel::Trace)); - } - - #[test] - fn test_log_level_from_str_loose_invalid() { - assert_eq!(LogLevel::from_str_loose("invalid"), None); - assert_eq!(LogLevel::from_str_loose(""), None); - assert_eq!(LogLevel::from_str_loose("err"), None); - assert_eq!(LogLevel::from_str_loose("verbose"), None); - } - - #[test] - fn test_log_level_equality() { - assert_eq!(LogLevel::Error, LogLevel::Error); - assert_ne!(LogLevel::Error, LogLevel::Warn); - assert_ne!(LogLevel::Info, LogLevel::Debug); - } - - #[test] - fn test_log_level_clone() { - let level = LogLevel::Debug; - let cloned = level; - assert_eq!(level, cloned); - } - - // ========================================================================== - // ColorMode tests - // ========================================================================== - - #[test] - fn test_color_mode_default() { - let default = ColorMode::default(); - assert_eq!(default, ColorMode::Auto); - } - - #[test] - fn test_color_mode_equality() { - assert_eq!(ColorMode::Auto, ColorMode::Auto); - assert_eq!(ColorMode::Always, ColorMode::Always); - assert_eq!(ColorMode::Never, ColorMode::Never); - assert_ne!(ColorMode::Auto, ColorMode::Always); - assert_ne!(ColorMode::Always, ColorMode::Never); - } - - #[test] - fn test_color_mode_clone() { - let mode = ColorMode::Always; - let cloned = mode; - assert_eq!(mode, cloned); - } - - // ========================================================================== - // InteractiveArgs tests - // ========================================================================== - - #[test] - fn test_interactive_args_default() { - let args = InteractiveArgs::default(); - assert!(args.model.is_none()); - assert!(!args.oss); - assert!(args.config_profile.is_none()); - assert!(args.sandbox_mode.is_none()); - assert!(args.approval_policy.is_none()); - assert!(!args.full_auto); - assert!(!args.dangerously_bypass_approvals_and_sandbox); - assert!(args.cwd.is_none()); - assert!(args.add_dir.is_empty()); - assert!(args.images.is_empty()); - assert!(!args.web_search); - assert!(!args.alternate_screen); - assert!(!args.no_alternate_screen); - assert_eq!(args.log_level, LogLevel::Info); - assert!(!args.debug); - assert!(args.prompt.is_empty()); - } - - // ========================================================================== - // Cli parsing tests - // ========================================================================== - - #[test] - fn test_cli_no_args() { - let cli = Cli::try_parse_from(["cortex"]).expect("should parse with no args"); - assert!(cli.command.is_none()); - assert!(!cli.verbose); - assert!(!cli.trace); - assert_eq!(cli.color, ColorMode::Auto); - } - - #[test] - fn test_cli_verbose_flag() { - let cli = Cli::try_parse_from(["cortex", "--verbose"]).expect("should parse --verbose"); - assert!(cli.verbose); - } - - #[test] - fn test_cli_verbose_short_flag() { - let cli = Cli::try_parse_from(["cortex", "-v"]).expect("should parse -v"); - assert!(cli.verbose); - } - - #[test] - fn test_cli_trace_flag() { - let cli = Cli::try_parse_from(["cortex", "--trace"]).expect("should parse --trace"); - assert!(cli.trace); - } - - #[test] - fn test_cli_color_always() { - let cli = Cli::try_parse_from(["cortex", "--color", "always"]) - .expect("should parse --color always"); - assert_eq!(cli.color, ColorMode::Always); - } - - #[test] - fn test_cli_color_never() { - let cli = Cli::try_parse_from(["cortex", "--color", "never"]) - .expect("should parse --color never"); - assert_eq!(cli.color, ColorMode::Never); - } - - #[test] - fn test_cli_color_auto() { - let cli = - Cli::try_parse_from(["cortex", "--color", "auto"]).expect("should parse --color auto"); - assert_eq!(cli.color, ColorMode::Auto); - } - - #[test] - fn test_cli_model_short() { - let cli = Cli::try_parse_from(["cortex", "-m", "gpt-4o"]).expect("should parse -m"); - assert_eq!(cli.interactive.model, Some("gpt-4o".to_string())); - } - - #[test] - fn test_cli_model_long() { - let cli = Cli::try_parse_from(["cortex", "--model", "claude-sonnet-4-20250514"]) - .expect("should parse --model"); - assert_eq!( - cli.interactive.model, - Some("claude-sonnet-4-20250514".to_string()) - ); - } - - #[test] - fn test_cli_oss_flag() { - let cli = Cli::try_parse_from(["cortex", "--oss"]).expect("should parse --oss"); - assert!(cli.interactive.oss); - } - - #[test] - fn test_cli_profile_short() { - let cli = Cli::try_parse_from(["cortex", "-p", "work"]).expect("should parse -p"); - assert_eq!(cli.interactive.config_profile, Some("work".to_string())); - } - - #[test] - fn test_cli_profile_long() { - let cli = Cli::try_parse_from(["cortex", "--profile", "production"]) - .expect("should parse --profile"); - assert_eq!( - cli.interactive.config_profile, - Some("production".to_string()) - ); - } - - #[test] - fn test_cli_sandbox_mode() { - let cli = - Cli::try_parse_from(["cortex", "--sandbox", "strict"]).expect("should parse --sandbox"); - assert_eq!(cli.interactive.sandbox_mode, Some("strict".to_string())); - } - - #[test] - fn test_cli_approval_policy() { - let cli = Cli::try_parse_from(["cortex", "--ask-for-approval", "always"]) - .expect("should parse --ask-for-approval"); - assert_eq!(cli.interactive.approval_policy, Some("always".to_string())); - } - - #[test] - fn test_cli_approval_policy_short() { - let cli = Cli::try_parse_from(["cortex", "-a", "never"]).expect("should parse -a"); - assert_eq!(cli.interactive.approval_policy, Some("never".to_string())); - } - - #[test] - fn test_cli_full_auto() { - let cli = Cli::try_parse_from(["cortex", "--full-auto"]).expect("should parse --full-auto"); - assert!(cli.interactive.full_auto); - } - - #[test] - fn test_cli_dangerously_bypass() { - let cli = Cli::try_parse_from(["cortex", "--dangerously-bypass-approvals-and-sandbox"]) - .expect("should parse dangerous flag"); - assert!(cli.interactive.dangerously_bypass_approvals_and_sandbox); - } - - #[test] - fn test_cli_dangerously_bypass_yolo_alias() { - let cli = Cli::try_parse_from(["cortex", "--yolo"]).expect("should parse --yolo alias"); - assert!(cli.interactive.dangerously_bypass_approvals_and_sandbox); - } - - #[test] - fn test_cli_cwd_short() { - let cli = Cli::try_parse_from(["cortex", "-C", "/workspace"]).expect("should parse -C"); - assert_eq!(cli.interactive.cwd, Some(PathBuf::from("/workspace"))); - } - - #[test] - fn test_cli_cwd_long() { - let cli = - Cli::try_parse_from(["cortex", "--cd", "/tmp/project"]).expect("should parse --cd"); - assert_eq!(cli.interactive.cwd, Some(PathBuf::from("/tmp/project"))); - } - - #[test] - fn test_cli_add_dir() { - let cli = Cli::try_parse_from(["cortex", "--add-dir", "/extra/dir"]) - .expect("should parse --add-dir"); - assert_eq!(cli.interactive.add_dir, vec![PathBuf::from("/extra/dir")]); - } - - #[test] - fn test_cli_add_dir_multiple() { - let cli = Cli::try_parse_from(["cortex", "--add-dir", "/dir1", "--add-dir", "/dir2"]) - .expect("should parse multiple --add-dir"); - assert_eq!( - cli.interactive.add_dir, - vec![PathBuf::from("/dir1"), PathBuf::from("/dir2")] - ); - } - - #[test] - fn test_cli_image() { - let cli = Cli::try_parse_from(["cortex", "--image", "screenshot.png"]) - .expect("should parse --image"); - assert_eq!( - cli.interactive.images, - vec![PathBuf::from("screenshot.png")] - ); - } - - #[test] - fn test_cli_image_short() { - let cli = Cli::try_parse_from(["cortex", "-i", "photo.jpg"]).expect("should parse -i"); - assert_eq!(cli.interactive.images, vec![PathBuf::from("photo.jpg")]); - } - - #[test] - fn test_cli_image_comma_separated() { - let cli = Cli::try_parse_from(["cortex", "--image", "a.png,b.jpg,c.gif"]) - .expect("should parse comma-separated images"); - assert_eq!( - cli.interactive.images, - vec![ - PathBuf::from("a.png"), - PathBuf::from("b.jpg"), - PathBuf::from("c.gif") - ] - ); - } - - #[test] - fn test_cli_web_search() { - let cli = Cli::try_parse_from(["cortex", "--search"]).expect("should parse --search"); - assert!(cli.interactive.web_search); - } - - #[test] - fn test_cli_log_level() { - let cli = Cli::try_parse_from(["cortex", "--log-level", "debug"]) - .expect("should parse --log-level"); - assert_eq!(cli.interactive.log_level, LogLevel::Debug); - } - - #[test] - fn test_cli_log_level_short() { - let cli = Cli::try_parse_from(["cortex", "-L", "trace"]).expect("should parse -L"); - assert_eq!(cli.interactive.log_level, LogLevel::Trace); - } - - #[test] - fn test_cli_debug_flag() { - let cli = Cli::try_parse_from(["cortex", "--debug"]).expect("should parse --debug"); - assert!(cli.interactive.debug); - } - - #[test] - fn test_cli_alternate_screen_flag_defaults_unset() { - let cli = Cli::try_parse_from(["cortex"]).expect("should parse"); - assert!( - !cli.interactive.alternate_screen, - "the flag is unset unless passed; config default is always" - ); - assert!( - !cli.interactive.no_alternate_screen, - "inline opt-out is unset unless --no-alternate-screen is passed" - ); - let cli = Cli::try_parse_from(["cortex", "--alternate-screen"]) - .expect("should parse --alternate-screen"); - assert!(cli.interactive.alternate_screen); - assert!(!cli.interactive.no_alternate_screen); - let cli = Cli::try_parse_from(["cortex", "--no-alternate-screen"]) - .expect("should parse --no-alternate-screen"); - assert!(cli.interactive.no_alternate_screen); - assert!(!cli.interactive.alternate_screen); - } - - #[test] - fn test_cli_alternate_screen_conflicts_with_no_alternate_screen() { - let result = Cli::try_parse_from(["cortex", "--alternate-screen", "--no-alternate-screen"]); - match result { - Err(err) => { - let rendered = err.to_string(); - assert!( - rendered.contains("cannot be used with") || rendered.contains("conflict"), - "unexpected clap error: {rendered}" - ); - } - Ok(_) => panic!("--alternate-screen and --no-alternate-screen must conflict"), - } - } - - #[test] - fn test_cli_prompt_trailing() { - let cli = Cli::try_parse_from(["cortex", "write", "a", "unit", "test"]) - .expect("should parse trailing prompt"); - assert_eq!( - cli.interactive.prompt, - vec!["write", "a", "unit", "test"] - .into_iter() - .map(String::from) - .collect::>() - ); - } - - #[test] - fn test_cli_prompt_with_hyphens() { - let cli = Cli::try_parse_from(["cortex", "create", "--", "test", "--with", "options"]) - .expect("should parse prompt with hyphens"); - assert!(!cli.interactive.prompt.is_empty()); - } - - // ========================================================================== - // Subcommand tests - // ========================================================================== - - #[test] - fn test_cli_run_subcommand() { - let cli = Cli::try_parse_from(["cortex", "run"]).expect("should parse run subcommand"); - assert!(matches!(cli.command, Some(Commands::Run(_)))); - } - - #[test] - fn test_cli_exec_subcommand() { - let cli = Cli::try_parse_from(["cortex", "exec"]).expect("should parse exec subcommand"); - assert!(matches!(cli.command, Some(Commands::Exec(_)))); - } - - #[test] - fn test_cli_login_subcommand() { - let cli = Cli::try_parse_from(["cortex", "login"]).expect("should parse login subcommand"); - assert!(matches!(cli.command, Some(Commands::Login(_)))); - } - - #[test] - fn test_cli_logout_subcommand() { - let cli = - Cli::try_parse_from(["cortex", "logout"]).expect("should parse logout subcommand"); - assert!(matches!(cli.command, Some(Commands::Logout(_)))); - } - - #[test] - fn test_cli_whoami_subcommand() { - let cli = - Cli::try_parse_from(["cortex", "whoami"]).expect("should parse whoami subcommand"); - assert!(matches!(cli.command, Some(Commands::Whoami))); - } - - #[test] - fn test_cli_config_subcommand() { - let cli = - Cli::try_parse_from(["cortex", "config"]).expect("should parse config subcommand"); - assert!(matches!(cli.command, Some(Commands::Config(_)))); - } - - #[test] - fn test_cli_models_subcommand() { - let cli = - Cli::try_parse_from(["cortex", "models"]).expect("should parse models subcommand"); - assert!(matches!(cli.command, Some(Commands::Models(_)))); - } - - #[test] - fn test_cli_sessions_subcommand() { - let cli = - Cli::try_parse_from(["cortex", "sessions"]).expect("should parse sessions subcommand"); - assert!(matches!(cli.command, Some(Commands::Sessions(_)))); - } - - #[test] - fn test_cli_resume_subcommand() { - let cli = - Cli::try_parse_from(["cortex", "resume"]).expect("should parse resume subcommand"); - assert!(matches!(cli.command, Some(Commands::Resume(_)))); - } - - #[test] - fn test_cli_resume_with_session_id() { - let cli = Cli::try_parse_from(["cortex", "resume", "abc123"]) - .expect("should parse resume with session id"); - if let Some(Commands::Resume(resume)) = cli.command { - assert_eq!(resume.session_id, Some("abc123".to_string())); - } else { - panic!("Expected Resume command"); - } - } - - #[test] - fn test_cli_resume_last_flag() { - let cli = Cli::try_parse_from(["cortex", "resume", "--last"]) - .expect("should parse resume --last"); - if let Some(Commands::Resume(resume)) = cli.command { - assert!(resume.last); - } else { - panic!("Expected Resume command"); - } - } - - #[test] - fn test_cli_upgrade_subcommand() { - let cli = - Cli::try_parse_from(["cortex", "upgrade"]).expect("should parse upgrade subcommand"); - assert!(matches!(cli.command, Some(Commands::Upgrade(_)))); - } - - #[test] - fn test_cli_agent_subcommand() { - // Agent command requires a subcommand, so test with "list" - let cli = Cli::try_parse_from(["cortex", "agent", "list"]) - .expect("should parse agent list subcommand"); - assert!(matches!(cli.command, Some(Commands::Agent(_)))); - } - - // NOTE: test_cli_mcp_subcommand is skipped due to a pre-existing bug in mcp_cmd - // where "ls" is defined as both a command name and an alias, causing clap to panic. - // This is tracked as a known issue in the mcp_cmd module (types.rs:27). - - #[test] - fn test_cli_acp_subcommand() { - let cli = Cli::try_parse_from(["cortex", "acp"]).expect("should parse acp subcommand"); - assert!(matches!(cli.command, Some(Commands::Acp(_)))); - } - - // ========================================================================== - // LoginCommand tests - // ========================================================================== - - #[test] - fn test_login_command_default() { - let cli = Cli::try_parse_from(["cortex", "login"]).expect("should parse login"); - if let Some(Commands::Login(login)) = cli.command { - assert!(!login.with_api_key); - assert!(login.token.is_none()); - assert!(!login.use_device_code); - assert!(!login.use_sso); - assert!(login.issuer_base_url.is_none()); - assert!(login.client_id.is_none()); - assert!(login.action.is_none()); - } else { - panic!("Expected Login command"); - } - } - - #[test] - fn test_login_command_with_api_key() { - let cli = Cli::try_parse_from(["cortex", "login", "--with-api-key"]) - .expect("should parse login --with-api-key"); - if let Some(Commands::Login(login)) = cli.command { - assert!(login.with_api_key); - } else { - panic!("Expected Login command"); - } - } - - #[test] - fn test_login_command_with_token() { - let cli = Cli::try_parse_from(["cortex", "login", "--token", "mytoken123"]) - .expect("should parse login --token"); - if let Some(Commands::Login(login)) = cli.command { - assert_eq!(login.token, Some("mytoken123".to_string())); - } else { - panic!("Expected Login command"); - } - } - - #[test] - fn test_login_command_device_auth() { - let cli = Cli::try_parse_from(["cortex", "login", "--device-auth"]) - .expect("should parse login --device-auth"); - if let Some(Commands::Login(login)) = cli.command { - assert!(login.use_device_code); - } else { - panic!("Expected Login command"); - } - } - - #[test] - fn test_login_command_sso() { - let cli = - Cli::try_parse_from(["cortex", "login", "--sso"]).expect("should parse login --sso"); - if let Some(Commands::Login(login)) = cli.command { - assert!(login.use_sso); - } else { - panic!("Expected Login command"); - } - } - - #[test] - fn test_login_status_subcommand() { - let cli = - Cli::try_parse_from(["cortex", "login", "status"]).expect("should parse login status"); - if let Some(Commands::Login(login)) = cli.command { - assert!(matches!(login.action, Some(LoginSubcommand::Status))); - } else { - panic!("Expected Login command"); - } - } - - // ========================================================================== - // LogoutCommand tests - // ========================================================================== - - #[test] - fn test_logout_command_default() { - let cli = Cli::try_parse_from(["cortex", "logout"]).expect("should parse logout"); - if let Some(Commands::Logout(logout)) = cli.command { - assert!(!logout.yes); - assert!(!logout.all); - } else { - panic!("Expected Logout command"); - } - } - - #[test] - fn test_logout_command_yes() { - let cli = Cli::try_parse_from(["cortex", "logout", "-y"]).expect("should parse logout -y"); - if let Some(Commands::Logout(logout)) = cli.command { - assert!(logout.yes); - } else { - panic!("Expected Logout command"); - } - } - - #[test] - fn test_logout_command_all() { - let cli = - Cli::try_parse_from(["cortex", "logout", "--all"]).expect("should parse logout --all"); - if let Some(Commands::Logout(logout)) = cli.command { - assert!(logout.all); - } else { - panic!("Expected Logout command"); - } - } - - // ========================================================================== - // SessionsCommand tests - // ========================================================================== - - #[test] - fn test_sessions_command_default() { - let cli = Cli::try_parse_from(["cortex", "sessions"]).expect("should parse sessions"); - if let Some(Commands::Sessions(sessions)) = cli.command { - assert!(!sessions.all); - assert!(sessions.days.is_none()); - assert!(sessions.since.is_none()); - assert!(sessions.until.is_none()); - assert!(!sessions.favorites); - assert!(sessions.search.is_none()); - assert!(sessions.limit.is_none()); - assert!(!sessions.json); - } else { - panic!("Expected Sessions command"); - } - } - - #[test] - fn test_sessions_command_with_flags() { - let cli = Cli::try_parse_from([ - "cortex", - "sessions", - "--all", - "--days", - "7", - "--favorites", - "--limit", - "10", - "--json", - ]) - .expect("should parse sessions with flags"); - if let Some(Commands::Sessions(sessions)) = cli.command { - assert!(sessions.all); - assert_eq!(sessions.days, Some(7)); - assert!(sessions.favorites); - assert_eq!(sessions.limit, Some(10)); - assert!(sessions.json); - } else { - panic!("Expected Sessions command"); - } - } - - #[test] - fn test_sessions_command_search() { - let cli = Cli::try_parse_from(["cortex", "sessions", "--search", "fix bug"]) - .expect("should parse sessions --search"); - if let Some(Commands::Sessions(sessions)) = cli.command { - assert_eq!(sessions.search, Some("fix bug".to_string())); - } else { - panic!("Expected Sessions command"); - } - } - - // ========================================================================== - // DeleteCommand tests - // ========================================================================== - - #[test] - fn test_delete_command() { - let cli = Cli::try_parse_from(["cortex", "delete", "abc12345"]) - .expect("should parse delete with session id"); - if let Some(Commands::Delete(delete)) = cli.command { - assert_eq!(delete.session_id, "abc12345"); - assert!(!delete.yes); - assert!(!delete.force); - } else { - panic!("Expected Delete command"); - } - } - - #[test] - fn test_delete_command_with_flags() { - let cli = Cli::try_parse_from(["cortex", "delete", "abc12345", "-y", "-f"]) - .expect("should parse delete with flags"); - if let Some(Commands::Delete(delete)) = cli.command { - assert_eq!(delete.session_id, "abc12345"); - assert!(delete.yes); - assert!(delete.force); - } else { - panic!("Expected Delete command"); - } - } - - // ========================================================================== - // ConfigCommand tests - // ========================================================================== - - #[test] - fn test_config_command_default() { - let cli = Cli::try_parse_from(["cortex", "config"]).expect("should parse config"); - if let Some(Commands::Config(config)) = cli.command { - assert!(!config.json); - assert!(!config.edit); - assert!(config.action.is_none()); - } else { - panic!("Expected Config command"); - } - } - - #[test] - fn test_config_command_json() { - let cli = Cli::try_parse_from(["cortex", "config", "--json"]) - .expect("should parse config --json"); - if let Some(Commands::Config(config)) = cli.command { - assert!(config.json); - } else { - panic!("Expected Config command"); - } - } - - #[test] - fn test_config_command_edit() { - let cli = Cli::try_parse_from(["cortex", "config", "--edit"]) - .expect("should parse config --edit"); - if let Some(Commands::Config(config)) = cli.command { - assert!(config.edit); - } else { - panic!("Expected Config command"); - } - } - - #[test] - fn test_config_get_subcommand() { - let cli = Cli::try_parse_from(["cortex", "config", "get", "model"]) - .expect("should parse config get"); - if let Some(Commands::Config(config)) = cli.command { - if let Some(ConfigSubcommand::Get(get)) = config.action { - assert_eq!(get.key, "model"); - } else { - panic!("Expected Get subcommand"); - } - } else { - panic!("Expected Config command"); - } - } - - #[test] - fn test_config_set_subcommand() { - let cli = Cli::try_parse_from(["cortex", "config", "set", "model", "gpt-4o"]) - .expect("should parse config set"); - if let Some(Commands::Config(config)) = cli.command { - if let Some(ConfigSubcommand::Set(set)) = config.action { - assert_eq!(set.key, "model"); - assert_eq!(set.value, "gpt-4o"); - } else { - panic!("Expected Set subcommand"); - } - } else { - panic!("Expected Config command"); - } - } - - #[test] - fn test_config_unset_subcommand() { - let cli = Cli::try_parse_from(["cortex", "config", "unset", "api_key"]) - .expect("should parse config unset"); - if let Some(Commands::Config(config)) = cli.command { - if let Some(ConfigSubcommand::Unset(unset)) = config.action { - assert_eq!(unset.key, "api_key"); - } else { - panic!("Expected Unset subcommand"); - } - } else { - panic!("Expected Config command"); - } - } - - // ========================================================================== - // ServeCommand tests - // ========================================================================== - - #[test] - fn test_serve_command_default() { - let cli = Cli::try_parse_from(["cortex", "serve"]).expect("should parse serve"); - if let Some(Commands::Serve(serve)) = cli.command { - assert_eq!(serve.port, 3000); - assert_eq!(serve.host, "127.0.0.1"); - assert!(serve.auth_token.is_none()); - assert!(!serve.cors); - assert!(serve.cors_origins.is_empty()); - assert!(!serve.mdns); - assert!(!serve.no_mdns); - assert!(serve.mdns_name.is_none()); - } else { - panic!("Expected Serve command"); - } - } - - #[test] - fn test_serve_command_with_port() { - let cli = Cli::try_parse_from(["cortex", "serve", "--port", "8080"]) - .expect("should parse serve --port"); - if let Some(Commands::Serve(serve)) = cli.command { - assert_eq!(serve.port, 8080); - } else { - panic!("Expected Serve command"); - } - } - - #[test] - fn test_serve_command_with_host() { - let cli = Cli::try_parse_from(["cortex", "serve", "--host", "0.0.0.0"]) - .expect("should parse serve --host"); - if let Some(Commands::Serve(serve)) = cli.command { - assert_eq!(serve.host, "0.0.0.0"); - } else { - panic!("Expected Serve command"); - } - } - - #[test] - fn test_serve_command_cors() { - let cli = - Cli::try_parse_from(["cortex", "serve", "--cors"]).expect("should parse serve --cors"); - if let Some(Commands::Serve(serve)) = cli.command { - assert!(serve.cors); - } else { - panic!("Expected Serve command"); - } - } - - #[test] - fn test_serve_command_cors_origins() { - let cli = Cli::try_parse_from([ - "cortex", - "serve", - "--cors-origin", - "http://localhost:3000", - "--cors-origin", - "https://example.com", - ]) - .expect("should parse serve with cors origins"); - if let Some(Commands::Serve(serve)) = cli.command { - assert_eq!( - serve.cors_origins, - vec!["http://localhost:3000", "https://example.com"] - ); - } else { - panic!("Expected Serve command"); - } - } - - #[test] - fn test_serve_command_mdns() { - let cli = - Cli::try_parse_from(["cortex", "serve", "--mdns"]).expect("should parse serve --mdns"); - if let Some(Commands::Serve(serve)) = cli.command { - assert!(serve.mdns); - } else { - panic!("Expected Serve command"); - } - } - - // ========================================================================== - // InitCommand tests - // ========================================================================== - - #[test] - fn test_init_command_default() { - let cli = Cli::try_parse_from(["cortex", "init"]).expect("should parse init"); - if let Some(Commands::Init(init)) = cli.command { - assert!(!init.force); - assert!(!init.yes); - } else { - panic!("Expected Init command"); - } - } - - #[test] - fn test_init_command_force() { - let cli = Cli::try_parse_from(["cortex", "init", "-f"]).expect("should parse init -f"); - if let Some(Commands::Init(init)) = cli.command { - assert!(init.force); - } else { - panic!("Expected Init command"); - } - } - - #[test] - fn test_init_command_yes() { - let cli = Cli::try_parse_from(["cortex", "init", "-y"]).expect("should parse init -y"); - if let Some(Commands::Init(init)) = cli.command { - assert!(init.yes); - } else { - panic!("Expected Init command"); - } - } - - // ========================================================================== - // CompletionCommand tests - // ========================================================================== - - #[test] - fn test_completion_command_default() { - let cli = Cli::try_parse_from(["cortex", "completion"]).expect("should parse completion"); - if let Some(Commands::Completion(completion)) = cli.command { - assert!(completion.shell.is_none()); - assert!(!completion.install); - } else { - panic!("Expected Completion command"); - } - } - - #[test] - fn test_completion_command_bash() { - let cli = Cli::try_parse_from(["cortex", "completion", "bash"]) - .expect("should parse completion bash"); - if let Some(Commands::Completion(completion)) = cli.command { - assert_eq!(completion.shell, Some(clap_complete::Shell::Bash)); - } else { - panic!("Expected Completion command"); - } - } - - #[test] - fn test_completion_command_zsh() { - let cli = Cli::try_parse_from(["cortex", "completion", "zsh"]) - .expect("should parse completion zsh"); - if let Some(Commands::Completion(completion)) = cli.command { - assert_eq!(completion.shell, Some(clap_complete::Shell::Zsh)); - } else { - panic!("Expected Completion command"); - } - } - - #[test] - fn test_completion_command_install() { - let cli = Cli::try_parse_from(["cortex", "completion", "--install"]) - .expect("should parse completion --install"); - if let Some(Commands::Completion(completion)) = cli.command { - assert!(completion.install); - } else { - panic!("Expected Completion command"); - } - } - - // ========================================================================== - // HistoryCommand tests - // ========================================================================== - - #[test] - fn test_history_command_default() { - let cli = Cli::try_parse_from(["cortex", "history"]).expect("should parse history"); - if let Some(Commands::History(history)) = cli.command { - assert!(history.action.is_none()); - assert_eq!(history.limit, 20); - assert!(!history.all); - assert!(!history.json); - } else { - panic!("Expected History command"); - } - } - - #[test] - fn test_history_command_with_limit() { - let cli = Cli::try_parse_from(["cortex", "history", "-n", "50"]) - .expect("should parse history -n"); - if let Some(Commands::History(history)) = cli.command { - assert_eq!(history.limit, 50); - } else { - panic!("Expected History command"); - } - } - - #[test] - fn test_history_search_subcommand() { - let cli = Cli::try_parse_from(["cortex", "history", "search", "fix"]) - .expect("should parse history search"); - if let Some(Commands::History(history)) = cli.command { - if let Some(HistorySubcommand::Search(search)) = history.action { - assert_eq!(search.pattern, "fix"); - assert_eq!(search.limit, 20); - assert!(!search.json); - } else { - panic!("Expected Search subcommand"); - } - } else { - panic!("Expected History command"); - } - } - - #[test] - fn test_history_clear_subcommand() { - let cli = Cli::try_parse_from(["cortex", "history", "clear"]) - .expect("should parse history clear"); - if let Some(Commands::History(history)) = cli.command { - if let Some(HistorySubcommand::Clear(clear)) = history.action { - assert!(!clear.yes); - } else { - panic!("Expected Clear subcommand"); - } - } else { - panic!("Expected History command"); - } - } - - #[test] - fn test_history_clear_yes() { - let cli = Cli::try_parse_from(["cortex", "history", "clear", "-y"]) - .expect("should parse history clear -y"); - if let Some(Commands::History(history)) = cli.command { - if let Some(HistorySubcommand::Clear(clear)) = history.action { - assert!(clear.yes); - } else { - panic!("Expected Clear subcommand"); - } - } else { - panic!("Expected History command"); - } - } - - // ========================================================================== - // ServersCommand tests - // ========================================================================== - - #[test] - fn test_servers_command_default() { - let cli = Cli::try_parse_from(["cortex", "servers"]).expect("should parse servers"); - if let Some(Commands::Servers(servers)) = cli.command { - assert!(servers.action.is_none()); - assert_eq!(servers.timeout, 3); - assert!(!servers.json); - } else { - panic!("Expected Servers command"); - } - } - - #[test] - fn test_servers_command_with_timeout() { - let cli = Cli::try_parse_from(["cortex", "servers", "--timeout", "10"]) - .expect("should parse servers --timeout"); - if let Some(Commands::Servers(servers)) = cli.command { - assert_eq!(servers.timeout, 10); - } else { - panic!("Expected Servers command"); - } - } - - #[test] - fn test_servers_refresh_subcommand() { - let cli = Cli::try_parse_from(["cortex", "servers", "refresh"]) - .expect("should parse servers refresh"); - if let Some(Commands::Servers(servers)) = cli.command { - if let Some(ServersSubcommand::Refresh(refresh)) = servers.action { - assert_eq!(refresh.timeout, 5); - assert!(!refresh.json); - } else { - panic!("Expected Refresh subcommand"); - } - } else { - panic!("Expected Servers command"); - } - } - - // ========================================================================== - // get_long_version tests - // ========================================================================== - - #[test] - fn test_get_long_version() { - let version = get_long_version(); - assert!(!version.is_empty()); - // Version should contain the package version - assert!( - version.contains(env!("CARGO_PKG_VERSION")), - "Version should contain CARGO_PKG_VERSION" - ); - } - - // ========================================================================== - // Conflict tests - // ========================================================================== - - #[test] - fn test_dangerously_bypass_conflicts_with_approval_policy() { - let result = Cli::try_parse_from([ - "cortex", - "--dangerously-bypass-approvals-and-sandbox", - "--ask-for-approval", - "always", - ]); - assert!( - result.is_err(), - "Should fail when dangerous flag conflicts with approval policy" - ); - } - - #[test] - fn test_dangerously_bypass_conflicts_with_full_auto() { - let result = Cli::try_parse_from([ - "cortex", - "--dangerously-bypass-approvals-and-sandbox", - "--full-auto", - ]); - assert!( - result.is_err(), - "Should fail when dangerous flag conflicts with full-auto" - ); - } - - #[test] - fn test_resume_session_id_conflicts_with_last() { - let result = Cli::try_parse_from(["cortex", "resume", "abc123", "--last"]); - assert!( - result.is_err(), - "Should fail when session_id conflicts with --last" - ); - } - - #[test] - fn test_resume_pick_conflicts_with_session_id() { - let result = Cli::try_parse_from(["cortex", "resume", "abc123", "--pick"]); - assert!( - result.is_err(), - "Should fail when --pick conflicts with session_id" - ); - } - - #[test] - fn test_resume_pick_conflicts_with_last() { - let result = Cli::try_parse_from(["cortex", "resume", "--pick", "--last"]); - assert!( - result.is_err(), - "Should fail when --pick conflicts with --last" - ); - } - - #[test] - fn test_login_token_conflicts_with_api_key() { - let result = Cli::try_parse_from(["cortex", "login", "--token", "xyz", "--with-api-key"]); - assert!( - result.is_err(), - "Should fail when --token conflicts with --with-api-key" - ); - } - - #[test] - fn test_serve_mdns_conflicts_with_no_mdns() { - let result = Cli::try_parse_from(["cortex", "serve", "--mdns", "--no-mdns"]); - assert!( - result.is_err(), - "Should fail when --mdns conflicts with --no-mdns" - ); - } - - // ========================================================================== - // Alias tests - // ========================================================================== - - #[test] - fn test_run_alias_r() { - let cli = Cli::try_parse_from(["cortex", "r"]).expect("should parse 'r' alias for run"); - assert!(matches!(cli.command, Some(Commands::Run(_)))); - } - - #[test] - fn test_exec_alias_e() { - let cli = Cli::try_parse_from(["cortex", "e"]).expect("should parse 'e' alias for exec"); - assert!(matches!(cli.command, Some(Commands::Exec(_)))); - } - - #[test] - fn test_github_alias_gh() { - // GitHub command requires a subcommand, so test with "status" - let cli = Cli::try_parse_from(["cortex", "gh", "status"]) - .expect("should parse 'gh status' alias for github"); - assert!(matches!(cli.command, Some(Commands::Github(_)))); - } - - #[test] - fn test_compact_alias_gc() { - let cli = - Cli::try_parse_from(["cortex", "gc"]).expect("should parse 'gc' alias for compact"); - assert!(matches!(cli.command, Some(Commands::Compact(_)))); - } - - #[test] - fn test_compact_alias_cleanup() { - let cli = Cli::try_parse_from(["cortex", "cleanup"]) - .expect("should parse 'cleanup' alias for compact"); - assert!(matches!(cli.command, Some(Commands::Compact(_)))); - } - - #[test] - fn test_feedback_alias_report() { - let cli = Cli::try_parse_from(["cortex", "report"]) - .expect("should parse 'report' alias for feedback"); - assert!(matches!(cli.command, Some(Commands::Feedback(_)))); - } - - #[test] - fn test_lock_alias_protect() { - let cli = Cli::try_parse_from(["cortex", "protect"]) - .expect("should parse 'protect' alias for lock"); - assert!(matches!(cli.command, Some(Commands::Lock(_)))); - } - - // ========================================================================== - // FeaturesCommand tests - // ========================================================================== - - #[test] - fn test_features_list_subcommand() { - let cli = Cli::try_parse_from(["cortex", "features", "list"]) - .expect("should parse features list"); - if let Some(Commands::Features(features)) = cli.command { - assert!(matches!(features.sub, FeaturesSubcommand::List)); - } else { - panic!("Expected Features command"); - } - } -} +#[path = "args_parse_command_tests.rs"] +mod command_tests; +#[cfg(test)] +#[path = "args_parse_tests.rs"] +mod tests; diff --git a/src/cortex-cli/src/cli/args_parse_command_tests.rs b/src/cortex-cli/src/cli/args_parse_command_tests.rs new file mode 100644 index 00000000..480381f5 --- /dev/null +++ b/src/cortex-cli/src/cli/args_parse_command_tests.rs @@ -0,0 +1,617 @@ +use super::*; +use clap::Parser; + +// ========================================================================== +// SessionsCommand tests +// ========================================================================== + +#[test] +fn test_sessions_command_default() { + let cli = Cli::try_parse_from(["cortex", "sessions"]).expect("should parse sessions"); + if let Some(Commands::Sessions(sessions)) = cli.command { + assert!(!sessions.all); + assert!(sessions.days.is_none()); + assert!(sessions.since.is_none()); + assert!(sessions.until.is_none()); + assert!(!sessions.favorites); + assert!(sessions.search.is_none()); + assert!(sessions.limit.is_none()); + assert!(!sessions.json); + } else { + panic!("Expected Sessions command"); + } +} + +#[test] +fn test_sessions_command_with_flags() { + let cli = Cli::try_parse_from([ + "cortex", + "sessions", + "--all", + "--days", + "7", + "--favorites", + "--limit", + "10", + "--json", + ]) + .expect("should parse sessions with flags"); + if let Some(Commands::Sessions(sessions)) = cli.command { + assert!(sessions.all); + assert_eq!(sessions.days, Some(7)); + assert!(sessions.favorites); + assert_eq!(sessions.limit, Some(10)); + assert!(sessions.json); + } else { + panic!("Expected Sessions command"); + } +} + +#[test] +fn test_sessions_command_search() { + let cli = Cli::try_parse_from(["cortex", "sessions", "--search", "fix bug"]) + .expect("should parse sessions --search"); + if let Some(Commands::Sessions(sessions)) = cli.command { + assert_eq!(sessions.search, Some("fix bug".to_string())); + } else { + panic!("Expected Sessions command"); + } +} + +// ========================================================================== +// DeleteCommand tests +// ========================================================================== + +#[test] +fn test_delete_command() { + let cli = Cli::try_parse_from(["cortex", "delete", "abc12345"]) + .expect("should parse delete with session id"); + if let Some(Commands::Delete(delete)) = cli.command { + assert_eq!(delete.session_id, "abc12345"); + assert!(!delete.yes); + assert!(!delete.force); + } else { + panic!("Expected Delete command"); + } +} + +#[test] +fn test_delete_command_with_flags() { + let cli = Cli::try_parse_from(["cortex", "delete", "abc12345", "-y", "-f"]) + .expect("should parse delete with flags"); + if let Some(Commands::Delete(delete)) = cli.command { + assert_eq!(delete.session_id, "abc12345"); + assert!(delete.yes); + assert!(delete.force); + } else { + panic!("Expected Delete command"); + } +} + +// ========================================================================== +// ConfigCommand tests +// ========================================================================== + +#[test] +fn test_config_command_default() { + let cli = Cli::try_parse_from(["cortex", "config"]).expect("should parse config"); + if let Some(Commands::Config(config)) = cli.command { + assert!(!config.json); + assert!(!config.edit); + assert!(config.action.is_none()); + } else { + panic!("Expected Config command"); + } +} + +#[test] +fn test_config_command_json() { + let cli = + Cli::try_parse_from(["cortex", "config", "--json"]).expect("should parse config --json"); + if let Some(Commands::Config(config)) = cli.command { + assert!(config.json); + } else { + panic!("Expected Config command"); + } +} + +#[test] +fn test_config_command_edit() { + let cli = + Cli::try_parse_from(["cortex", "config", "--edit"]).expect("should parse config --edit"); + if let Some(Commands::Config(config)) = cli.command { + assert!(config.edit); + } else { + panic!("Expected Config command"); + } +} + +#[test] +fn test_config_get_subcommand() { + let cli = + Cli::try_parse_from(["cortex", "config", "get", "model"]).expect("should parse config get"); + if let Some(Commands::Config(config)) = cli.command { + if let Some(ConfigSubcommand::Get(get)) = config.action { + assert_eq!(get.key, "model"); + } else { + panic!("Expected Get subcommand"); + } + } else { + panic!("Expected Config command"); + } +} + +#[test] +fn test_config_set_subcommand() { + let cli = Cli::try_parse_from(["cortex", "config", "set", "model", "gpt-4o"]) + .expect("should parse config set"); + if let Some(Commands::Config(config)) = cli.command { + if let Some(ConfigSubcommand::Set(set)) = config.action { + assert_eq!(set.key, "model"); + assert_eq!(set.value, "gpt-4o"); + } else { + panic!("Expected Set subcommand"); + } + } else { + panic!("Expected Config command"); + } +} + +#[test] +fn test_config_unset_subcommand() { + let cli = Cli::try_parse_from(["cortex", "config", "unset", "api_key"]) + .expect("should parse config unset"); + if let Some(Commands::Config(config)) = cli.command { + if let Some(ConfigSubcommand::Unset(unset)) = config.action { + assert_eq!(unset.key, "api_key"); + } else { + panic!("Expected Unset subcommand"); + } + } else { + panic!("Expected Config command"); + } +} + +// ========================================================================== +// ServeCommand tests +// ========================================================================== + +#[test] +fn test_serve_command_default() { + let cli = Cli::try_parse_from(["cortex", "serve"]).expect("should parse serve"); + if let Some(Commands::Serve(serve)) = cli.command { + assert_eq!(serve.port, 3000); + assert_eq!(serve.host, "127.0.0.1"); + assert!(serve.auth_token.is_none()); + assert!(!serve.cors); + assert!(serve.cors_origins.is_empty()); + assert!(!serve.mdns); + assert!(!serve.no_mdns); + assert!(serve.mdns_name.is_none()); + } else { + panic!("Expected Serve command"); + } +} + +#[test] +fn test_serve_command_with_port() { + let cli = Cli::try_parse_from(["cortex", "serve", "--port", "8080"]) + .expect("should parse serve --port"); + if let Some(Commands::Serve(serve)) = cli.command { + assert_eq!(serve.port, 8080); + } else { + panic!("Expected Serve command"); + } +} + +#[test] +fn test_serve_command_with_host() { + let cli = Cli::try_parse_from(["cortex", "serve", "--host", "0.0.0.0"]) + .expect("should parse serve --host"); + if let Some(Commands::Serve(serve)) = cli.command { + assert_eq!(serve.host, "0.0.0.0"); + } else { + panic!("Expected Serve command"); + } +} + +#[test] +fn test_serve_command_cors() { + let cli = + Cli::try_parse_from(["cortex", "serve", "--cors"]).expect("should parse serve --cors"); + if let Some(Commands::Serve(serve)) = cli.command { + assert!(serve.cors); + } else { + panic!("Expected Serve command"); + } +} + +#[test] +fn test_serve_command_cors_origins() { + let cli = Cli::try_parse_from([ + "cortex", + "serve", + "--cors-origin", + "http://localhost:3000", + "--cors-origin", + "https://example.com", + ]) + .expect("should parse serve with cors origins"); + if let Some(Commands::Serve(serve)) = cli.command { + assert_eq!( + serve.cors_origins, + vec!["http://localhost:3000", "https://example.com"] + ); + } else { + panic!("Expected Serve command"); + } +} + +#[test] +fn test_serve_command_mdns() { + let cli = + Cli::try_parse_from(["cortex", "serve", "--mdns"]).expect("should parse serve --mdns"); + if let Some(Commands::Serve(serve)) = cli.command { + assert!(serve.mdns); + } else { + panic!("Expected Serve command"); + } +} + +// ========================================================================== +// InitCommand tests +// ========================================================================== + +#[test] +fn test_init_command_default() { + let cli = Cli::try_parse_from(["cortex", "init"]).expect("should parse init"); + if let Some(Commands::Init(init)) = cli.command { + assert!(!init.force); + assert!(!init.yes); + } else { + panic!("Expected Init command"); + } +} + +#[test] +fn test_init_command_force() { + let cli = Cli::try_parse_from(["cortex", "init", "-f"]).expect("should parse init -f"); + if let Some(Commands::Init(init)) = cli.command { + assert!(init.force); + } else { + panic!("Expected Init command"); + } +} + +#[test] +fn test_init_command_yes() { + let cli = Cli::try_parse_from(["cortex", "init", "-y"]).expect("should parse init -y"); + if let Some(Commands::Init(init)) = cli.command { + assert!(init.yes); + } else { + panic!("Expected Init command"); + } +} + +// ========================================================================== +// CompletionCommand tests +// ========================================================================== + +#[test] +fn test_completion_command_default() { + let cli = Cli::try_parse_from(["cortex", "completion"]).expect("should parse completion"); + if let Some(Commands::Completion(completion)) = cli.command { + assert!(completion.shell.is_none()); + assert!(!completion.install); + } else { + panic!("Expected Completion command"); + } +} + +#[test] +fn test_completion_command_bash() { + let cli = Cli::try_parse_from(["cortex", "completion", "bash"]) + .expect("should parse completion bash"); + if let Some(Commands::Completion(completion)) = cli.command { + assert_eq!(completion.shell, Some(clap_complete::Shell::Bash)); + } else { + panic!("Expected Completion command"); + } +} + +#[test] +fn test_completion_command_zsh() { + let cli = + Cli::try_parse_from(["cortex", "completion", "zsh"]).expect("should parse completion zsh"); + if let Some(Commands::Completion(completion)) = cli.command { + assert_eq!(completion.shell, Some(clap_complete::Shell::Zsh)); + } else { + panic!("Expected Completion command"); + } +} + +#[test] +fn test_completion_command_install() { + let cli = Cli::try_parse_from(["cortex", "completion", "--install"]) + .expect("should parse completion --install"); + if let Some(Commands::Completion(completion)) = cli.command { + assert!(completion.install); + } else { + panic!("Expected Completion command"); + } +} + +// ========================================================================== +// HistoryCommand tests +// ========================================================================== + +#[test] +fn test_history_command_default() { + let cli = Cli::try_parse_from(["cortex", "history"]).expect("should parse history"); + if let Some(Commands::History(history)) = cli.command { + assert!(history.action.is_none()); + assert_eq!(history.limit, 20); + assert!(!history.all); + assert!(!history.json); + } else { + panic!("Expected History command"); + } +} + +#[test] +fn test_history_command_with_limit() { + let cli = + Cli::try_parse_from(["cortex", "history", "-n", "50"]).expect("should parse history -n"); + if let Some(Commands::History(history)) = cli.command { + assert_eq!(history.limit, 50); + } else { + panic!("Expected History command"); + } +} + +#[test] +fn test_history_search_subcommand() { + let cli = Cli::try_parse_from(["cortex", "history", "search", "fix"]) + .expect("should parse history search"); + if let Some(Commands::History(history)) = cli.command { + if let Some(HistorySubcommand::Search(search)) = history.action { + assert_eq!(search.pattern, "fix"); + assert_eq!(search.limit, 20); + assert!(!search.json); + } else { + panic!("Expected Search subcommand"); + } + } else { + panic!("Expected History command"); + } +} + +#[test] +fn test_history_clear_subcommand() { + let cli = + Cli::try_parse_from(["cortex", "history", "clear"]).expect("should parse history clear"); + if let Some(Commands::History(history)) = cli.command { + if let Some(HistorySubcommand::Clear(clear)) = history.action { + assert!(!clear.yes); + } else { + panic!("Expected Clear subcommand"); + } + } else { + panic!("Expected History command"); + } +} + +#[test] +fn test_history_clear_yes() { + let cli = Cli::try_parse_from(["cortex", "history", "clear", "-y"]) + .expect("should parse history clear -y"); + if let Some(Commands::History(history)) = cli.command { + if let Some(HistorySubcommand::Clear(clear)) = history.action { + assert!(clear.yes); + } else { + panic!("Expected Clear subcommand"); + } + } else { + panic!("Expected History command"); + } +} + +// ========================================================================== +// ServersCommand tests +// ========================================================================== + +#[test] +fn test_servers_command_default() { + let cli = Cli::try_parse_from(["cortex", "servers"]).expect("should parse servers"); + if let Some(Commands::Servers(servers)) = cli.command { + assert!(servers.action.is_none()); + assert_eq!(servers.timeout, 3); + assert!(!servers.json); + } else { + panic!("Expected Servers command"); + } +} + +#[test] +fn test_servers_command_with_timeout() { + let cli = Cli::try_parse_from(["cortex", "servers", "--timeout", "10"]) + .expect("should parse servers --timeout"); + if let Some(Commands::Servers(servers)) = cli.command { + assert_eq!(servers.timeout, 10); + } else { + panic!("Expected Servers command"); + } +} + +#[test] +fn test_servers_refresh_subcommand() { + let cli = Cli::try_parse_from(["cortex", "servers", "refresh"]) + .expect("should parse servers refresh"); + if let Some(Commands::Servers(servers)) = cli.command { + if let Some(ServersSubcommand::Refresh(refresh)) = servers.action { + assert_eq!(refresh.timeout, 5); + assert!(!refresh.json); + } else { + panic!("Expected Refresh subcommand"); + } + } else { + panic!("Expected Servers command"); + } +} + +// ========================================================================== +// get_long_version tests +// ========================================================================== + +#[test] +fn test_get_long_version() { + let version = get_long_version(); + assert!(!version.is_empty()); + // Version should contain the package version + assert!( + version.contains(env!("CARGO_PKG_VERSION")), + "Version should contain CARGO_PKG_VERSION" + ); +} + +// ========================================================================== +// Conflict tests +// ========================================================================== + +#[test] +fn test_dangerously_bypass_conflicts_with_approval_policy() { + let result = Cli::try_parse_from([ + "cortex", + "--dangerously-bypass-approvals-and-sandbox", + "--ask-for-approval", + "always", + ]); + assert!( + result.is_err(), + "Should fail when dangerous flag conflicts with approval policy" + ); +} + +#[test] +fn test_dangerously_bypass_conflicts_with_full_auto() { + let result = Cli::try_parse_from([ + "cortex", + "--dangerously-bypass-approvals-and-sandbox", + "--full-auto", + ]); + assert!( + result.is_err(), + "Should fail when dangerous flag conflicts with full-auto" + ); +} + +#[test] +fn test_resume_session_id_conflicts_with_last() { + let result = Cli::try_parse_from(["cortex", "resume", "abc123", "--last"]); + assert!( + result.is_err(), + "Should fail when session_id conflicts with --last" + ); +} + +#[test] +fn test_resume_pick_conflicts_with_session_id() { + let result = Cli::try_parse_from(["cortex", "resume", "abc123", "--pick"]); + assert!( + result.is_err(), + "Should fail when --pick conflicts with session_id" + ); +} + +#[test] +fn test_resume_pick_conflicts_with_last() { + let result = Cli::try_parse_from(["cortex", "resume", "--pick", "--last"]); + assert!( + result.is_err(), + "Should fail when --pick conflicts with --last" + ); +} + +#[test] +fn test_login_token_conflicts_with_api_key() { + let result = Cli::try_parse_from(["cortex", "login", "--token", "xyz", "--with-api-key"]); + assert!( + result.is_err(), + "Should fail when --token conflicts with --with-api-key" + ); +} + +#[test] +fn test_serve_mdns_conflicts_with_no_mdns() { + let result = Cli::try_parse_from(["cortex", "serve", "--mdns", "--no-mdns"]); + assert!( + result.is_err(), + "Should fail when --mdns conflicts with --no-mdns" + ); +} + +// ========================================================================== +// Alias tests +// ========================================================================== + +#[test] +fn test_run_alias_r() { + let cli = Cli::try_parse_from(["cortex", "r"]).expect("should parse 'r' alias for run"); + assert!(matches!(cli.command, Some(Commands::Run(_)))); +} + +#[test] +fn test_exec_alias_e() { + let cli = Cli::try_parse_from(["cortex", "e"]).expect("should parse 'e' alias for exec"); + assert!(matches!(cli.command, Some(Commands::Exec(_)))); +} + +#[test] +fn test_github_alias_gh() { + // GitHub command requires a subcommand, so test with "status" + let cli = Cli::try_parse_from(["cortex", "gh", "status"]) + .expect("should parse 'gh status' alias for github"); + assert!(matches!(cli.command, Some(Commands::Github(_)))); +} + +#[test] +fn test_compact_alias_gc() { + let cli = Cli::try_parse_from(["cortex", "gc"]).expect("should parse 'gc' alias for compact"); + assert!(matches!(cli.command, Some(Commands::Compact(_)))); +} + +#[test] +fn test_compact_alias_cleanup() { + let cli = Cli::try_parse_from(["cortex", "cleanup"]) + .expect("should parse 'cleanup' alias for compact"); + assert!(matches!(cli.command, Some(Commands::Compact(_)))); +} + +#[test] +fn test_feedback_alias_report() { + let cli = Cli::try_parse_from(["cortex", "report"]) + .expect("should parse 'report' alias for feedback"); + assert!(matches!(cli.command, Some(Commands::Feedback(_)))); +} + +#[test] +fn test_lock_alias_protect() { + let cli = + Cli::try_parse_from(["cortex", "protect"]).expect("should parse 'protect' alias for lock"); + assert!(matches!(cli.command, Some(Commands::Lock(_)))); +} + +// ========================================================================== +// FeaturesCommand tests +// ========================================================================== + +#[test] +fn test_features_list_subcommand() { + let cli = + Cli::try_parse_from(["cortex", "features", "list"]).expect("should parse features list"); + if let Some(Commands::Features(features)) = cli.command { + assert!(matches!(features.sub, FeaturesSubcommand::List)); + } else { + panic!("Expected Features command"); + } +} diff --git a/src/cortex-cli/src/cli/args_parse_tests.rs b/src/cortex-cli/src/cli/args_parse_tests.rs new file mode 100644 index 00000000..43bd8a79 --- /dev/null +++ b/src/cortex-cli/src/cli/args_parse_tests.rs @@ -0,0 +1,624 @@ +use super::*; +use clap::Parser; + +// ========================================================================== +// LogLevel tests +// ========================================================================== + +#[test] +fn test_log_level_default() { + let default = LogLevel::default(); + assert_eq!(default, LogLevel::Info); +} + +#[test] +fn test_log_level_as_filter_str() { + assert_eq!(LogLevel::Error.as_filter_str(), "error"); + assert_eq!(LogLevel::Warn.as_filter_str(), "warn"); + assert_eq!(LogLevel::Info.as_filter_str(), "info"); + assert_eq!(LogLevel::Debug.as_filter_str(), "debug"); + assert_eq!(LogLevel::Trace.as_filter_str(), "trace"); +} + +#[test] +fn test_log_level_from_str_loose_valid() { + assert_eq!(LogLevel::from_str_loose("error"), Some(LogLevel::Error)); + assert_eq!(LogLevel::from_str_loose("warn"), Some(LogLevel::Warn)); + assert_eq!(LogLevel::from_str_loose("warning"), Some(LogLevel::Warn)); + assert_eq!(LogLevel::from_str_loose("info"), Some(LogLevel::Info)); + assert_eq!(LogLevel::from_str_loose("debug"), Some(LogLevel::Debug)); + assert_eq!(LogLevel::from_str_loose("trace"), Some(LogLevel::Trace)); +} + +#[test] +fn test_log_level_from_str_loose_case_insensitive() { + assert_eq!(LogLevel::from_str_loose("ERROR"), Some(LogLevel::Error)); + assert_eq!(LogLevel::from_str_loose("WARN"), Some(LogLevel::Warn)); + assert_eq!(LogLevel::from_str_loose("WARNING"), Some(LogLevel::Warn)); + assert_eq!(LogLevel::from_str_loose("INFO"), Some(LogLevel::Info)); + assert_eq!(LogLevel::from_str_loose("Debug"), Some(LogLevel::Debug)); + assert_eq!(LogLevel::from_str_loose("TrAcE"), Some(LogLevel::Trace)); +} + +#[test] +fn test_log_level_from_str_loose_invalid() { + assert_eq!(LogLevel::from_str_loose("invalid"), None); + assert_eq!(LogLevel::from_str_loose(""), None); + assert_eq!(LogLevel::from_str_loose("err"), None); + assert_eq!(LogLevel::from_str_loose("verbose"), None); +} + +#[test] +fn test_log_level_equality() { + assert_eq!(LogLevel::Error, LogLevel::Error); + assert_ne!(LogLevel::Error, LogLevel::Warn); + assert_ne!(LogLevel::Info, LogLevel::Debug); +} + +#[test] +fn test_log_level_clone() { + let level = LogLevel::Debug; + let cloned = level; + assert_eq!(level, cloned); +} + +// ========================================================================== +// ColorMode tests +// ========================================================================== + +#[test] +fn test_color_mode_default() { + let default = ColorMode::default(); + assert_eq!(default, ColorMode::Auto); +} + +#[test] +fn test_color_mode_equality() { + assert_eq!(ColorMode::Auto, ColorMode::Auto); + assert_eq!(ColorMode::Always, ColorMode::Always); + assert_eq!(ColorMode::Never, ColorMode::Never); + assert_ne!(ColorMode::Auto, ColorMode::Always); + assert_ne!(ColorMode::Always, ColorMode::Never); +} + +#[test] +fn test_color_mode_clone() { + let mode = ColorMode::Always; + let cloned = mode; + assert_eq!(mode, cloned); +} + +// ========================================================================== +// InteractiveArgs tests +// ========================================================================== + +#[test] +fn test_interactive_args_default() { + let args = InteractiveArgs::default(); + assert!(args.model.is_none()); + assert!(!args.oss); + assert!(args.config_profile.is_none()); + assert!(args.sandbox_mode.is_none()); + assert!(args.approval_policy.is_none()); + assert!(!args.full_auto); + assert!(!args.dangerously_bypass_approvals_and_sandbox); + assert!(args.cwd.is_none()); + assert!(args.add_dir.is_empty()); + assert!(args.images.is_empty()); + assert!(!args.web_search); + assert!(!args.alternate_screen); + assert!(!args.no_alternate_screen); + assert_eq!(args.log_level, LogLevel::Info); + assert!(!args.debug); + assert!(args.prompt.is_empty()); +} + +// ========================================================================== +// Cli parsing tests +// ========================================================================== + +#[test] +fn test_cli_no_args() { + let cli = Cli::try_parse_from(["cortex"]).expect("should parse with no args"); + assert!(cli.command.is_none()); + assert!(!cli.verbose); + assert!(!cli.trace); + assert_eq!(cli.color, ColorMode::Auto); +} + +#[test] +fn test_cli_verbose_flag() { + let cli = Cli::try_parse_from(["cortex", "--verbose"]).expect("should parse --verbose"); + assert!(cli.verbose); +} + +#[test] +fn test_cli_verbose_short_flag() { + let cli = Cli::try_parse_from(["cortex", "-v"]).expect("should parse -v"); + assert!(cli.verbose); +} + +#[test] +fn test_cli_trace_flag() { + let cli = Cli::try_parse_from(["cortex", "--trace"]).expect("should parse --trace"); + assert!(cli.trace); +} + +#[test] +fn test_cli_color_always() { + let cli = + Cli::try_parse_from(["cortex", "--color", "always"]).expect("should parse --color always"); + assert_eq!(cli.color, ColorMode::Always); +} + +#[test] +fn test_cli_color_never() { + let cli = + Cli::try_parse_from(["cortex", "--color", "never"]).expect("should parse --color never"); + assert_eq!(cli.color, ColorMode::Never); +} + +#[test] +fn test_cli_color_auto() { + let cli = + Cli::try_parse_from(["cortex", "--color", "auto"]).expect("should parse --color auto"); + assert_eq!(cli.color, ColorMode::Auto); +} + +#[test] +fn test_cli_model_short() { + let cli = Cli::try_parse_from(["cortex", "-m", "gpt-4o"]).expect("should parse -m"); + assert_eq!(cli.interactive.model, Some("gpt-4o".to_string())); +} + +#[test] +fn test_cli_model_long() { + let cli = Cli::try_parse_from(["cortex", "--model", "claude-sonnet-4-20250514"]) + .expect("should parse --model"); + assert_eq!( + cli.interactive.model, + Some("claude-sonnet-4-20250514".to_string()) + ); +} + +#[test] +fn test_cli_oss_flag() { + let cli = Cli::try_parse_from(["cortex", "--oss"]).expect("should parse --oss"); + assert!(cli.interactive.oss); +} + +#[test] +fn test_cli_profile_short() { + let cli = Cli::try_parse_from(["cortex", "-p", "work"]).expect("should parse -p"); + assert_eq!(cli.interactive.config_profile, Some("work".to_string())); +} + +#[test] +fn test_cli_profile_long() { + let cli = + Cli::try_parse_from(["cortex", "--profile", "production"]).expect("should parse --profile"); + assert_eq!( + cli.interactive.config_profile, + Some("production".to_string()) + ); +} + +#[test] +fn test_cli_sandbox_mode() { + let cli = + Cli::try_parse_from(["cortex", "--sandbox", "strict"]).expect("should parse --sandbox"); + assert_eq!(cli.interactive.sandbox_mode, Some("strict".to_string())); +} + +#[test] +fn test_cli_approval_policy() { + let cli = Cli::try_parse_from(["cortex", "--ask-for-approval", "always"]) + .expect("should parse --ask-for-approval"); + assert_eq!(cli.interactive.approval_policy, Some("always".to_string())); +} + +#[test] +fn test_cli_approval_policy_short() { + let cli = Cli::try_parse_from(["cortex", "-a", "never"]).expect("should parse -a"); + assert_eq!(cli.interactive.approval_policy, Some("never".to_string())); +} + +#[test] +fn test_cli_full_auto() { + let cli = Cli::try_parse_from(["cortex", "--full-auto"]).expect("should parse --full-auto"); + assert!(cli.interactive.full_auto); +} + +#[test] +fn test_cli_dangerously_bypass() { + let cli = Cli::try_parse_from(["cortex", "--dangerously-bypass-approvals-and-sandbox"]) + .expect("should parse dangerous flag"); + assert!(cli.interactive.dangerously_bypass_approvals_and_sandbox); +} + +#[test] +fn test_cli_dangerously_bypass_yolo_alias() { + let cli = Cli::try_parse_from(["cortex", "--yolo"]).expect("should parse --yolo alias"); + assert!(cli.interactive.dangerously_bypass_approvals_and_sandbox); +} + +#[test] +fn test_cli_cwd_short() { + let cli = Cli::try_parse_from(["cortex", "-C", "/workspace"]).expect("should parse -C"); + assert_eq!(cli.interactive.cwd, Some(PathBuf::from("/workspace"))); +} + +#[test] +fn test_cli_cwd_long() { + let cli = Cli::try_parse_from(["cortex", "--cd", "/tmp/project"]).expect("should parse --cd"); + assert_eq!(cli.interactive.cwd, Some(PathBuf::from("/tmp/project"))); +} + +#[test] +fn test_cli_add_dir() { + let cli = + Cli::try_parse_from(["cortex", "--add-dir", "/extra/dir"]).expect("should parse --add-dir"); + assert_eq!(cli.interactive.add_dir, vec![PathBuf::from("/extra/dir")]); +} + +#[test] +fn test_cli_add_dir_multiple() { + let cli = Cli::try_parse_from(["cortex", "--add-dir", "/dir1", "--add-dir", "/dir2"]) + .expect("should parse multiple --add-dir"); + assert_eq!( + cli.interactive.add_dir, + vec![PathBuf::from("/dir1"), PathBuf::from("/dir2")] + ); +} + +#[test] +fn test_cli_image() { + let cli = + Cli::try_parse_from(["cortex", "--image", "screenshot.png"]).expect("should parse --image"); + assert_eq!( + cli.interactive.images, + vec![PathBuf::from("screenshot.png")] + ); +} + +#[test] +fn test_cli_image_short() { + let cli = Cli::try_parse_from(["cortex", "-i", "photo.jpg"]).expect("should parse -i"); + assert_eq!(cli.interactive.images, vec![PathBuf::from("photo.jpg")]); +} + +#[test] +fn test_cli_image_comma_separated() { + let cli = Cli::try_parse_from(["cortex", "--image", "a.png,b.jpg,c.gif"]) + .expect("should parse comma-separated images"); + assert_eq!( + cli.interactive.images, + vec![ + PathBuf::from("a.png"), + PathBuf::from("b.jpg"), + PathBuf::from("c.gif") + ] + ); +} + +#[test] +fn test_cli_web_search() { + let cli = Cli::try_parse_from(["cortex", "--search"]).expect("should parse --search"); + assert!(cli.interactive.web_search); +} + +#[test] +fn test_cli_log_level() { + let cli = + Cli::try_parse_from(["cortex", "--log-level", "debug"]).expect("should parse --log-level"); + assert_eq!(cli.interactive.log_level, LogLevel::Debug); +} + +#[test] +fn test_cli_log_level_short() { + let cli = Cli::try_parse_from(["cortex", "-L", "trace"]).expect("should parse -L"); + assert_eq!(cli.interactive.log_level, LogLevel::Trace); +} + +#[test] +fn test_cli_debug_flag() { + let cli = Cli::try_parse_from(["cortex", "--debug"]).expect("should parse --debug"); + assert!(cli.interactive.debug); +} + +#[test] +fn test_cli_alternate_screen_flag_defaults_unset() { + let cli = Cli::try_parse_from(["cortex"]).expect("should parse"); + assert!( + !cli.interactive.alternate_screen, + "the flag is unset unless passed; config default is always" + ); + assert!( + !cli.interactive.no_alternate_screen, + "inline opt-out is unset unless --no-alternate-screen is passed" + ); + let cli = Cli::try_parse_from(["cortex", "--alternate-screen"]) + .expect("should parse --alternate-screen"); + assert!(cli.interactive.alternate_screen); + assert!(!cli.interactive.no_alternate_screen); + let cli = Cli::try_parse_from(["cortex", "--no-alternate-screen"]) + .expect("should parse --no-alternate-screen"); + assert!(cli.interactive.no_alternate_screen); + assert!(!cli.interactive.alternate_screen); +} + +#[test] +fn test_cli_alternate_screen_conflicts_with_no_alternate_screen() { + let result = Cli::try_parse_from(["cortex", "--alternate-screen", "--no-alternate-screen"]); + match result { + Err(err) => { + let rendered = err.to_string(); + assert!( + rendered.contains("cannot be used with") || rendered.contains("conflict"), + "unexpected clap error: {rendered}" + ); + } + Ok(_) => panic!("--alternate-screen and --no-alternate-screen must conflict"), + } +} + +#[test] +fn test_cli_prompt_trailing() { + let cli = Cli::try_parse_from(["cortex", "write", "a", "unit", "test"]) + .expect("should parse trailing prompt"); + assert_eq!( + cli.interactive.prompt, + vec!["write", "a", "unit", "test"] + .into_iter() + .map(String::from) + .collect::>() + ); +} + +#[test] +fn test_cli_prompt_with_hyphens() { + let cli = Cli::try_parse_from(["cortex", "create", "--", "test", "--with", "options"]) + .expect("should parse prompt with hyphens"); + assert!(!cli.interactive.prompt.is_empty()); +} + +#[test] +fn test_cli_harness_flags_and_attach_jobs() { + let cli = Cli::try_parse_from([ + "cortex", + "--plugin-dir", + "/tmp/plugins", + "--bash-edit-diff", + "--worktree", + ]) + .expect("should parse harness flags"); + assert_eq!( + cli.interactive.plugin_dir, + vec![std::path::PathBuf::from("/tmp/plugins")] + ); + assert!(cli.interactive.bash_edit_diff); + assert_eq!( + cli.interactive.worktree.as_deref(), + Some(std::path::Path::new("auto")) + ); + + let cli = Cli::try_parse_from(["cortex", "attach", "sess-9"]).expect("attach"); + assert!(matches!(cli.command, Some(Commands::Attach(_)))); + let cli = Cli::try_parse_from(["cortex", "jobs", "list"]).expect("jobs"); + assert!(matches!(cli.command, Some(Commands::Jobs(_)))); +} + +// ========================================================================== +// Subcommand tests +// ========================================================================== + +#[test] +fn test_cli_run_subcommand() { + let cli = Cli::try_parse_from(["cortex", "run"]).expect("should parse run subcommand"); + assert!(matches!(cli.command, Some(Commands::Run(_)))); +} + +#[test] +fn test_cli_exec_subcommand() { + let cli = Cli::try_parse_from(["cortex", "exec"]).expect("should parse exec subcommand"); + assert!(matches!(cli.command, Some(Commands::Exec(_)))); +} + +#[test] +fn test_cli_login_subcommand() { + let cli = Cli::try_parse_from(["cortex", "login"]).expect("should parse login subcommand"); + assert!(matches!(cli.command, Some(Commands::Login(_)))); +} + +#[test] +fn test_cli_logout_subcommand() { + let cli = Cli::try_parse_from(["cortex", "logout"]).expect("should parse logout subcommand"); + assert!(matches!(cli.command, Some(Commands::Logout(_)))); +} + +#[test] +fn test_cli_whoami_subcommand() { + let cli = Cli::try_parse_from(["cortex", "whoami"]).expect("should parse whoami subcommand"); + assert!(matches!(cli.command, Some(Commands::Whoami))); +} + +#[test] +fn test_cli_config_subcommand() { + let cli = Cli::try_parse_from(["cortex", "config"]).expect("should parse config subcommand"); + assert!(matches!(cli.command, Some(Commands::Config(_)))); +} + +#[test] +fn test_cli_models_subcommand() { + let cli = Cli::try_parse_from(["cortex", "models"]).expect("should parse models subcommand"); + assert!(matches!(cli.command, Some(Commands::Models(_)))); +} + +#[test] +fn test_cli_sessions_subcommand() { + let cli = + Cli::try_parse_from(["cortex", "sessions"]).expect("should parse sessions subcommand"); + assert!(matches!(cli.command, Some(Commands::Sessions(_)))); +} + +#[test] +fn test_cli_resume_subcommand() { + let cli = Cli::try_parse_from(["cortex", "resume"]).expect("should parse resume subcommand"); + assert!(matches!(cli.command, Some(Commands::Resume(_)))); +} + +#[test] +fn test_cli_resume_with_session_id() { + let cli = Cli::try_parse_from(["cortex", "resume", "abc123"]) + .expect("should parse resume with session id"); + if let Some(Commands::Resume(resume)) = cli.command { + assert_eq!(resume.session_id, Some("abc123".to_string())); + } else { + panic!("Expected Resume command"); + } +} + +#[test] +fn test_cli_resume_last_flag() { + let cli = + Cli::try_parse_from(["cortex", "resume", "--last"]).expect("should parse resume --last"); + if let Some(Commands::Resume(resume)) = cli.command { + assert!(resume.last); + } else { + panic!("Expected Resume command"); + } +} + +#[test] +fn test_cli_upgrade_subcommand() { + let cli = Cli::try_parse_from(["cortex", "upgrade"]).expect("should parse upgrade subcommand"); + assert!(matches!(cli.command, Some(Commands::Upgrade(_)))); +} + +#[test] +fn test_cli_agent_subcommand() { + // Agent command requires a subcommand, so test with "list" + let cli = Cli::try_parse_from(["cortex", "agent", "list"]) + .expect("should parse agent list subcommand"); + assert!(matches!(cli.command, Some(Commands::Agent(_)))); +} + +// NOTE: test_cli_mcp_subcommand is skipped due to a pre-existing bug in mcp_cmd +// where "ls" is defined as both a command name and an alias, causing clap to panic. +// This is tracked as a known issue in the mcp_cmd module (types.rs:27). + +#[test] +fn test_cli_acp_subcommand() { + let cli = Cli::try_parse_from(["cortex", "acp"]).expect("should parse acp subcommand"); + assert!(matches!(cli.command, Some(Commands::Acp(_)))); +} + +// ========================================================================== +// LoginCommand tests +// ========================================================================== + +#[test] +fn test_login_command_default() { + let cli = Cli::try_parse_from(["cortex", "login"]).expect("should parse login"); + if let Some(Commands::Login(login)) = cli.command { + assert!(!login.with_api_key); + assert!(login.token.is_none()); + assert!(!login.use_device_code); + assert!(!login.use_sso); + assert!(login.issuer_base_url.is_none()); + assert!(login.client_id.is_none()); + assert!(login.action.is_none()); + } else { + panic!("Expected Login command"); + } +} + +#[test] +fn test_login_command_with_api_key() { + let cli = Cli::try_parse_from(["cortex", "login", "--with-api-key"]) + .expect("should parse login --with-api-key"); + if let Some(Commands::Login(login)) = cli.command { + assert!(login.with_api_key); + } else { + panic!("Expected Login command"); + } +} + +#[test] +fn test_login_command_with_token() { + let cli = Cli::try_parse_from(["cortex", "login", "--token", "mytoken123"]) + .expect("should parse login --token"); + if let Some(Commands::Login(login)) = cli.command { + assert_eq!(login.token, Some("mytoken123".to_string())); + } else { + panic!("Expected Login command"); + } +} + +#[test] +fn test_login_command_device_auth() { + let cli = Cli::try_parse_from(["cortex", "login", "--device-auth"]) + .expect("should parse login --device-auth"); + if let Some(Commands::Login(login)) = cli.command { + assert!(login.use_device_code); + } else { + panic!("Expected Login command"); + } +} + +#[test] +fn test_login_command_sso() { + let cli = Cli::try_parse_from(["cortex", "login", "--sso"]).expect("should parse login --sso"); + if let Some(Commands::Login(login)) = cli.command { + assert!(login.use_sso); + } else { + panic!("Expected Login command"); + } +} + +#[test] +fn test_login_status_subcommand() { + let cli = + Cli::try_parse_from(["cortex", "login", "status"]).expect("should parse login status"); + if let Some(Commands::Login(login)) = cli.command { + assert!(matches!(login.action, Some(LoginSubcommand::Status))); + } else { + panic!("Expected Login command"); + } +} + +// ========================================================================== +// LogoutCommand tests +// ========================================================================== + +#[test] +fn test_logout_command_default() { + let cli = Cli::try_parse_from(["cortex", "logout"]).expect("should parse logout"); + if let Some(Commands::Logout(logout)) = cli.command { + assert!(!logout.yes); + assert!(!logout.all); + } else { + panic!("Expected Logout command"); + } +} + +#[test] +fn test_logout_command_yes() { + let cli = Cli::try_parse_from(["cortex", "logout", "-y"]).expect("should parse logout -y"); + if let Some(Commands::Logout(logout)) = cli.command { + assert!(logout.yes); + } else { + panic!("Expected Logout command"); + } +} + +#[test] +fn test_logout_command_all() { + let cli = + Cli::try_parse_from(["cortex", "logout", "--all"]).expect("should parse logout --all"); + if let Some(Commands::Logout(logout)) = cli.command { + assert!(logout.all); + } else { + panic!("Expected Logout command"); + } +} diff --git a/src/cortex-cli/src/cli/handlers.rs b/src/cortex-cli/src/cli/handlers.rs index d6eaa1ab..c1afb52a 100644 --- a/src/cortex-cli/src/cli/handlers.rs +++ b/src/cortex-cli/src/cli/handlers.rs @@ -40,6 +40,8 @@ pub async fn dispatch_command(cli: Cli) -> Result<()> { Some(Commands::Export(export_cli)) => export_cli.run().await, Some(Commands::Import(import_cli)) => import_cli.run().await, Some(Commands::Delete(delete_cli)) => run_delete(delete_cli).await, + Some(Commands::Attach(attach_cli)) => attach_cli.run().await, + Some(Commands::Jobs(jobs_cli)) => jobs_cli.run().await, Some(Commands::Config(config_cli)) => show_config(config_cli).await, Some(Commands::Features(features_cli)) => handle_features(features_cli).await, Some(Commands::Serve(serve_cli)) => run_serve(serve_cli).await, @@ -149,6 +151,15 @@ async fn run_tui(args: InteractiveArgs) -> Result<()> { config.cwd = cwd_path; } + crate::harness::apply_session_harness( + &mut config.cwd, + &config.cortex_home, + args.bash_edit_diff, + args.worktree.as_ref(), + &args.plugin_dir, + ) + .await?; + // Initialize custom command registry let project_root = std::env::current_dir().ok(); let _custom_registry = @@ -933,349 +944,5 @@ pub async fn run_history(history_cli: HistoryCommand) -> Result<()> { } #[cfg(test)] -mod tests { - use clap_complete::Shell; - use std::io::{self, ErrorKind, Write}; - - // ========================================================================= - // Shell name parsing tests (unit test the parsing logic directly) - // Note: We avoid testing detect_shell_from_env directly because it reads - // from env vars which causes race conditions in parallel tests. - // Instead, we test the shell name matching logic inline. - // ========================================================================= - - /// Helper function that mirrors the shell detection logic for testing - fn parse_shell_name(shell_name: &str) -> Shell { - let normalized = shell_name.to_lowercase(); - match normalized.as_str() { - "bash" => Shell::Bash, - "zsh" => Shell::Zsh, - "fish" => Shell::Fish, - "powershell" | "pwsh" => Shell::PowerShell, - "elvish" => Shell::Elvish, - _ => Shell::Bash, // Default to Bash for unknown shells - } - } - - /// Helper to extract shell name from path (like the real function does) - fn extract_shell_name_from_path(path: &str) -> &str { - std::path::Path::new(path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - } - - #[test] - fn test_shell_name_parsing_bash() { - assert!( - matches!(parse_shell_name("bash"), Shell::Bash), - "Should parse 'bash' as Bash" - ); - assert!( - matches!(parse_shell_name("BASH"), Shell::Bash), - "Should parse 'BASH' (uppercase) as Bash" - ); - assert!( - matches!(parse_shell_name("Bash"), Shell::Bash), - "Should parse 'Bash' (mixed case) as Bash" - ); - } - - #[test] - fn test_shell_name_parsing_zsh() { - assert!( - matches!(parse_shell_name("zsh"), Shell::Zsh), - "Should parse 'zsh' as Zsh" - ); - assert!( - matches!(parse_shell_name("ZSH"), Shell::Zsh), - "Should parse 'ZSH' (uppercase) as Zsh" - ); - } - - #[test] - fn test_shell_name_parsing_fish() { - assert!( - matches!(parse_shell_name("fish"), Shell::Fish), - "Should parse 'fish' as Fish" - ); - assert!( - matches!(parse_shell_name("FISH"), Shell::Fish), - "Should parse 'FISH' (uppercase) as Fish" - ); - } - - #[test] - fn test_shell_name_parsing_powershell() { - assert!( - matches!(parse_shell_name("powershell"), Shell::PowerShell), - "Should parse 'powershell' as PowerShell" - ); - assert!( - matches!(parse_shell_name("pwsh"), Shell::PowerShell), - "Should parse 'pwsh' as PowerShell" - ); - assert!( - matches!(parse_shell_name("PWSH"), Shell::PowerShell), - "Should parse 'PWSH' (uppercase) as PowerShell" - ); - } - - #[test] - fn test_shell_name_parsing_elvish() { - assert!( - matches!(parse_shell_name("elvish"), Shell::Elvish), - "Should parse 'elvish' as Elvish" - ); - assert!( - matches!(parse_shell_name("ELVISH"), Shell::Elvish), - "Should parse 'ELVISH' (uppercase) as Elvish" - ); - } - - #[test] - fn test_shell_name_parsing_unknown_defaults_to_bash() { - assert!( - matches!(parse_shell_name("unknown-shell"), Shell::Bash), - "Unknown shell should default to Bash" - ); - assert!( - matches!(parse_shell_name("tcsh"), Shell::Bash), - "tcsh should default to Bash" - ); - assert!( - matches!(parse_shell_name("csh"), Shell::Bash), - "csh should default to Bash" - ); - assert!( - matches!(parse_shell_name(""), Shell::Bash), - "Empty string should default to Bash" - ); - } - - #[test] - fn test_extract_shell_name_from_path() { - assert_eq!( - extract_shell_name_from_path("/bin/bash"), - "bash", - "Should extract 'bash' from /bin/bash" - ); - assert_eq!( - extract_shell_name_from_path("/usr/bin/zsh"), - "zsh", - "Should extract 'zsh' from /usr/bin/zsh" - ); - assert_eq!( - extract_shell_name_from_path("/usr/local/bin/fish"), - "fish", - "Should extract 'fish' from /usr/local/bin/fish" - ); - assert_eq!( - extract_shell_name_from_path("bash"), - "bash", - "Should handle shell name without path" - ); - } - - #[test] - fn test_full_path_to_shell_detection() { - // Test the full pipeline: path -> name extraction -> shell detection - let test_cases = vec![ - ("/bin/bash", Shell::Bash), - ("/usr/bin/bash", Shell::Bash), - ("/bin/zsh", Shell::Zsh), - ("/usr/local/bin/zsh", Shell::Zsh), - ("/usr/bin/fish", Shell::Fish), - ("/usr/bin/pwsh", Shell::PowerShell), - ("/usr/bin/elvish", Shell::Elvish), - ("/bin/BASH", Shell::Bash), // uppercase - ("/bin/ZSH", Shell::Zsh), // uppercase - ]; - - for (path, expected_shell) in test_cases { - let shell_name = extract_shell_name_from_path(path); - let detected = parse_shell_name(shell_name); - assert!( - std::mem::discriminant(&detected) == std::mem::discriminant(&expected_shell), - "Path '{}' should detect as {:?}, got {:?}", - path, - expected_shell, - detected - ); - } - } - - // ========================================================================= - // BrokenPipeIgnorer tests (testing the pattern from generate_completions) - // ========================================================================= - - /// Custom writer that silently ignores BrokenPipe errors (mirrors the one in generate_completions). - struct BrokenPipeIgnorer { - inner: W, - } - - impl Write for BrokenPipeIgnorer { - fn write(&mut self, buf: &[u8]) -> io::Result { - match self.inner.write(buf) { - Err(e) if e.kind() == ErrorKind::BrokenPipe => Ok(buf.len()), - other => other, - } - } - - fn flush(&mut self) -> io::Result<()> { - match self.inner.flush() { - Err(e) if e.kind() == ErrorKind::BrokenPipe => Ok(()), - other => other, - } - } - } - - /// A mock writer that can be configured to return specific errors. - struct MockWriter { - error_kind: Option, - data: Vec, - } - - impl MockWriter { - fn new() -> Self { - Self { - error_kind: None, - data: Vec::new(), - } - } - - fn with_error(error_kind: ErrorKind) -> Self { - Self { - error_kind: Some(error_kind), - data: Vec::new(), - } - } - } - - impl Write for MockWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - if let Some(kind) = self.error_kind { - return Err(io::Error::new(kind, "mock error")); - } - self.data.extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - if let Some(kind) = self.error_kind { - return Err(io::Error::new(kind, "mock flush error")); - } - Ok(()) - } - } - - #[test] - fn test_broken_pipe_ignorer_normal_write() { - let mock = MockWriter::new(); - let mut ignorer = BrokenPipeIgnorer { inner: mock }; - - let result = ignorer.write(b"hello"); - assert!(result.is_ok(), "Normal write should succeed"); - assert_eq!(result.unwrap(), 5, "Should return bytes written"); - } - - #[test] - fn test_broken_pipe_ignorer_swallows_broken_pipe() { - let mock = MockWriter::with_error(ErrorKind::BrokenPipe); - let mut ignorer = BrokenPipeIgnorer { inner: mock }; - - let result = ignorer.write(b"hello"); - assert!( - result.is_ok(), - "BrokenPipe error should be silently ignored" - ); - assert_eq!( - result.unwrap(), - 5, - "Should return buffer length even on BrokenPipe" - ); - } - - #[test] - fn test_broken_pipe_ignorer_propagates_other_errors() { - let mock = MockWriter::with_error(ErrorKind::PermissionDenied); - let mut ignorer = BrokenPipeIgnorer { inner: mock }; - - let result = ignorer.write(b"hello"); - assert!( - result.is_err(), - "Non-BrokenPipe errors should be propagated" - ); - assert_eq!( - result.unwrap_err().kind(), - ErrorKind::PermissionDenied, - "Should preserve the original error kind" - ); - } - - #[test] - fn test_broken_pipe_ignorer_flush_normal() { - let mock = MockWriter::new(); - let mut ignorer = BrokenPipeIgnorer { inner: mock }; - - let result = ignorer.flush(); - assert!(result.is_ok(), "Normal flush should succeed"); - } - - #[test] - fn test_broken_pipe_ignorer_flush_swallows_broken_pipe() { - let mock = MockWriter::with_error(ErrorKind::BrokenPipe); - let mut ignorer = BrokenPipeIgnorer { inner: mock }; - - let result = ignorer.flush(); - assert!( - result.is_ok(), - "BrokenPipe on flush should be silently ignored" - ); - } - - #[test] - fn test_broken_pipe_ignorer_flush_propagates_other_errors() { - let mock = MockWriter::with_error(ErrorKind::WriteZero); - let mut ignorer = BrokenPipeIgnorer { inner: mock }; - - let result = ignorer.flush(); - assert!( - result.is_err(), - "Non-BrokenPipe errors on flush should be propagated" - ); - assert_eq!( - result.unwrap_err().kind(), - ErrorKind::WriteZero, - "Should preserve the original error kind on flush" - ); - } - - // ========================================================================= - // Shell enum variant tests - // ========================================================================= - - #[test] - fn test_shell_variants_are_supported() { - // Verify that our shell detection covers all commonly used shells - let supported_shells = vec![ - (Shell::Bash, "bash"), - (Shell::Zsh, "zsh"), - (Shell::Fish, "fish"), - (Shell::PowerShell, "powershell"), - (Shell::Elvish, "elvish"), - ]; - - for (shell, name) in supported_shells { - // Verify each shell variant can be matched - match shell { - Shell::Bash => assert_eq!(name, "bash"), - Shell::Zsh => assert_eq!(name, "zsh"), - Shell::Fish => assert_eq!(name, "fish"), - Shell::PowerShell => assert_eq!(name, "powershell"), - Shell::Elvish => assert_eq!(name, "elvish"), - _ => {} // Other variants exist but we don't need to test them - } - } - } -} +#[path = "handlers_tests.rs"] +mod tests; diff --git a/src/cortex-cli/src/cli/handlers_tests.rs b/src/cortex-cli/src/cli/handlers_tests.rs new file mode 100644 index 00000000..4afe0ee0 --- /dev/null +++ b/src/cortex-cli/src/cli/handlers_tests.rs @@ -0,0 +1,373 @@ +use clap_complete::Shell; +use std::io::{self, ErrorKind, Write}; + +// ========================================================================= +// Shell name parsing tests (unit test the parsing logic directly) +// Note: We avoid testing detect_shell_from_env directly because it reads +// from env vars which causes race conditions in parallel tests. +// Instead, we test the shell name matching logic inline. +// ========================================================================= + +/// Helper function that mirrors the shell detection logic for testing +fn parse_shell_name(shell_name: &str) -> Shell { + let normalized = shell_name.to_lowercase(); + match normalized.as_str() { + "bash" => Shell::Bash, + "zsh" => Shell::Zsh, + "fish" => Shell::Fish, + "powershell" | "pwsh" => Shell::PowerShell, + "elvish" => Shell::Elvish, + _ => Shell::Bash, // Default to Bash for unknown shells + } +} + +/// Helper to extract shell name from path (like the real function does) +fn extract_shell_name_from_path(path: &str) -> &str { + std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") +} + +#[test] +fn test_shell_name_parsing_bash() { + assert!( + matches!(parse_shell_name("bash"), Shell::Bash), + "Should parse 'bash' as Bash" + ); + assert!( + matches!(parse_shell_name("BASH"), Shell::Bash), + "Should parse 'BASH' (uppercase) as Bash" + ); + assert!( + matches!(parse_shell_name("Bash"), Shell::Bash), + "Should parse 'Bash' (mixed case) as Bash" + ); +} + +#[test] +fn test_shell_name_parsing_zsh() { + assert!( + matches!(parse_shell_name("zsh"), Shell::Zsh), + "Should parse 'zsh' as Zsh" + ); + assert!( + matches!(parse_shell_name("ZSH"), Shell::Zsh), + "Should parse 'ZSH' (uppercase) as Zsh" + ); +} + +#[test] +fn test_shell_name_parsing_fish() { + assert!( + matches!(parse_shell_name("fish"), Shell::Fish), + "Should parse 'fish' as Fish" + ); + assert!( + matches!(parse_shell_name("FISH"), Shell::Fish), + "Should parse 'FISH' (uppercase) as Fish" + ); +} + +#[test] +fn test_shell_name_parsing_powershell() { + assert!( + matches!(parse_shell_name("powershell"), Shell::PowerShell), + "Should parse 'powershell' as PowerShell" + ); + assert!( + matches!(parse_shell_name("pwsh"), Shell::PowerShell), + "Should parse 'pwsh' as PowerShell" + ); + assert!( + matches!(parse_shell_name("PWSH"), Shell::PowerShell), + "Should parse 'PWSH' (uppercase) as PowerShell" + ); +} + +#[test] +fn test_shell_name_parsing_elvish() { + assert!( + matches!(parse_shell_name("elvish"), Shell::Elvish), + "Should parse 'elvish' as Elvish" + ); + assert!( + matches!(parse_shell_name("ELVISH"), Shell::Elvish), + "Should parse 'ELVISH' (uppercase) as Elvish" + ); +} + +#[test] +fn test_shell_name_parsing_unknown_defaults_to_bash() { + assert!( + matches!(parse_shell_name("unknown-shell"), Shell::Bash), + "Unknown shell should default to Bash" + ); + assert!( + matches!(parse_shell_name("tcsh"), Shell::Bash), + "tcsh should default to Bash" + ); + assert!( + matches!(parse_shell_name("csh"), Shell::Bash), + "csh should default to Bash" + ); + assert!( + matches!(parse_shell_name(""), Shell::Bash), + "Empty string should default to Bash" + ); +} + +#[test] +fn test_extract_shell_name_from_path() { + assert_eq!( + extract_shell_name_from_path("/bin/bash"), + "bash", + "Should extract 'bash' from /bin/bash" + ); + assert_eq!( + extract_shell_name_from_path("/usr/bin/zsh"), + "zsh", + "Should extract 'zsh' from /usr/bin/zsh" + ); + assert_eq!( + extract_shell_name_from_path("/usr/local/bin/fish"), + "fish", + "Should extract 'fish' from /usr/local/bin/fish" + ); + assert_eq!( + extract_shell_name_from_path("bash"), + "bash", + "Should handle shell name without path" + ); +} + +#[test] +fn test_full_path_to_shell_detection() { + // Test the full pipeline: path -> name extraction -> shell detection + let test_cases = vec![ + ("/bin/bash", Shell::Bash), + ("/usr/bin/bash", Shell::Bash), + ("/bin/zsh", Shell::Zsh), + ("/usr/local/bin/zsh", Shell::Zsh), + ("/usr/bin/fish", Shell::Fish), + ("/usr/bin/pwsh", Shell::PowerShell), + ("/usr/bin/elvish", Shell::Elvish), + ("/bin/BASH", Shell::Bash), // uppercase + ("/bin/ZSH", Shell::Zsh), // uppercase + ]; + + for (path, expected_shell) in test_cases { + let shell_name = extract_shell_name_from_path(path); + let detected = parse_shell_name(shell_name); + assert!( + std::mem::discriminant(&detected) == std::mem::discriminant(&expected_shell), + "Path '{}' should detect as {:?}, got {:?}", + path, + expected_shell, + detected + ); + } +} + +// ========================================================================= +// BrokenPipeIgnorer tests (testing the pattern from generate_completions) +// ========================================================================= + +/// Custom writer that silently ignores BrokenPipe errors (mirrors the one in generate_completions). +struct BrokenPipeIgnorer { + inner: W, +} + +impl Write for BrokenPipeIgnorer { + fn write(&mut self, buf: &[u8]) -> io::Result { + match self.inner.write(buf) { + Err(e) if e.kind() == ErrorKind::BrokenPipe => Ok(buf.len()), + other => other, + } + } + + fn flush(&mut self) -> io::Result<()> { + match self.inner.flush() { + Err(e) if e.kind() == ErrorKind::BrokenPipe => Ok(()), + other => other, + } + } +} + +/// A mock writer that can be configured to return specific errors. +struct MockWriter { + error_kind: Option, + data: Vec, +} + +impl MockWriter { + fn new() -> Self { + Self { + error_kind: None, + data: Vec::new(), + } + } + + fn with_error(error_kind: ErrorKind) -> Self { + Self { + error_kind: Some(error_kind), + data: Vec::new(), + } + } +} + +impl Write for MockWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + if let Some(kind) = self.error_kind { + return Err(io::Error::new(kind, "mock error")); + } + self.data.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + if let Some(kind) = self.error_kind { + return Err(io::Error::new(kind, "mock flush error")); + } + Ok(()) + } +} + +#[test] +fn test_broken_pipe_ignorer_normal_write() { + let mock = MockWriter::new(); + let mut ignorer = BrokenPipeIgnorer { inner: mock }; + + let result = ignorer.write(b"hello"); + assert!(result.is_ok(), "Normal write should succeed"); + assert_eq!(result.unwrap(), 5, "Should return bytes written"); +} + +#[test] +fn test_broken_pipe_ignorer_swallows_broken_pipe() { + let mock = MockWriter::with_error(ErrorKind::BrokenPipe); + let mut ignorer = BrokenPipeIgnorer { inner: mock }; + + let result = ignorer.write(b"hello"); + assert!( + result.is_ok(), + "BrokenPipe error should be silently ignored" + ); + assert_eq!( + result.unwrap(), + 5, + "Should return buffer length even on BrokenPipe" + ); +} + +#[test] +fn test_broken_pipe_ignorer_propagates_other_errors() { + let mock = MockWriter::with_error(ErrorKind::PermissionDenied); + let mut ignorer = BrokenPipeIgnorer { inner: mock }; + + let result = ignorer.write(b"hello"); + assert!( + result.is_err(), + "Non-BrokenPipe errors should be propagated" + ); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::PermissionDenied, + "Should preserve the original error kind" + ); +} + +#[test] +fn test_broken_pipe_ignorer_flush_normal() { + let mock = MockWriter::new(); + let mut ignorer = BrokenPipeIgnorer { inner: mock }; + + let result = ignorer.flush(); + assert!(result.is_ok(), "Normal flush should succeed"); +} + +#[test] +fn test_broken_pipe_ignorer_flush_swallows_broken_pipe() { + let mock = MockWriter::with_error(ErrorKind::BrokenPipe); + let mut ignorer = BrokenPipeIgnorer { inner: mock }; + + let result = ignorer.flush(); + assert!( + result.is_ok(), + "BrokenPipe on flush should be silently ignored" + ); +} + +#[test] +fn test_broken_pipe_ignorer_flush_propagates_other_errors() { + let mock = MockWriter::with_error(ErrorKind::WriteZero); + let mut ignorer = BrokenPipeIgnorer { inner: mock }; + + let result = ignorer.flush(); + assert!( + result.is_err(), + "Non-BrokenPipe errors on flush should be propagated" + ); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::WriteZero, + "Should preserve the original error kind on flush" + ); +} + +// ========================================================================= +// Shell enum variant tests +// ========================================================================= + +#[test] +fn test_shell_variants_are_supported() { + // Verify that our shell detection covers all commonly used shells + let supported_shells = vec![ + (Shell::Bash, "bash"), + (Shell::Zsh, "zsh"), + (Shell::Fish, "fish"), + (Shell::PowerShell, "powershell"), + (Shell::Elvish, "elvish"), + ]; + + for (shell, name) in supported_shells { + // Verify each shell variant can be matched + match shell { + Shell::Bash => assert_eq!(name, "bash"), + Shell::Zsh => assert_eq!(name, "zsh"), + Shell::Fish => assert_eq!(name, "fish"), + Shell::PowerShell => assert_eq!(name, "powershell"), + Shell::Elvish => assert_eq!(name, "elvish"), + _ => {} // Other variants exist but we don't need to test them + } + } +} + +use clap::Parser; +use serial_test::serial; + +#[tokio::test] +async fn dispatch_attach_url_fails_closed() { + let cli = crate::cli::Cli::try_parse_from(["cortex", "attach", "https://example.com/s"]) + .expect("parse attach"); + let err = crate::cli::dispatch_command(cli).await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("No local session was started"), "{msg}"); +} + +#[tokio::test] +#[serial] +async fn dispatch_jobs_list_requires_auth() { + let home = tempfile::tempdir().unwrap(); + unsafe { + std::env::remove_var("CORTEX_AUTH_TOKEN"); + std::env::remove_var("CORTEX_API_KEY"); + std::env::set_var("CORTEX_HOME", home.path()); + } + let cli = crate::cli::Cli::try_parse_from(["cortex", "jobs", "list"]).expect("parse jobs"); + let err = crate::cli::dispatch_command(cli).await.unwrap_err(); + assert!(err.to_string().contains("Not signed in"), "{err}"); + unsafe { + std::env::remove_var("CORTEX_HOME"); + } +} diff --git a/src/cortex-cli/src/harness.rs b/src/cortex-cli/src/harness.rs new file mode 100644 index 00000000..18d8daad --- /dev/null +++ b/src/cortex-cli/src/harness.rs @@ -0,0 +1,184 @@ +//! Shared interactive/`run` harness flags (worktree, plugin-dir, bash-edit-diff). + +use anyhow::Result; +use std::path::{Path, PathBuf}; + +/// Opt-in Execute/Bash file diffs via process env (read by the tool handler). +pub fn enable_bash_edit_diff(on: bool) { + if on { + // SAFETY: set once before the agent loop; tests that fork should set this first. + unsafe { + std::env::set_var("CORTEX_BASH_EDIT_DIFF", "1"); + } + } +} + +/// Isolate the session cwd into a git worktree when `--worktree` is present. +pub fn apply_worktree(cwd: &Path, worktree: Option<&PathBuf>) -> Result { + let Some(flag) = worktree else { + return Ok(cwd.to_path_buf()); + }; + let dest = if flag.as_os_str().is_empty() || flag == Path::new("auto") { + None + } else { + Some(flag.as_path()) + }; + let id = format!("{}", std::process::id()); + let isolated = cortex_engine::worktree::isolate_worktree(cwd, dest, &id)?; + std::env::set_current_dir(&isolated.path)?; + Ok(isolated.path) +} + +/// Load user/project plugins plus `--plugin-dir` folders. Failures are warnings. +pub async fn load_plugin_dirs( + cortex_home: &Path, + project_root: Option, + extra: &[PathBuf], +) { + if let Err(e) = cortex_engine::plugin::init_with_project_and_dirs( + cortex_home.to_path_buf(), + project_root.clone(), + extra.to_vec(), + ) + .await + { + tracing::warn!("Plugin manager was not started: {e}"); + } else if let Err(e) = cortex_engine::plugin::discover_and_load().await { + tracing::warn!("Plugin directory was not loaded: {e}"); + } + // Metadata PluginManager is not the session tool runtime. Extra dirs must + // also reach the executable (WASM/Node) runtime so tools/hooks/commands load. + let cwd = project_root.unwrap_or_else(|| cortex_home.to_path_buf()); + cortex_engine::plugin::start_executable_runtime(cwd, extra).await; +} + +/// Apply `--bash-edit-diff`, `--worktree`, and `--plugin-dir` to a session cwd. +pub async fn apply_session_harness( + cwd: &mut PathBuf, + cortex_home: &Path, + bash_edit_diff: bool, + worktree: Option<&PathBuf>, + plugin_dir: &[PathBuf], +) -> Result<()> { + enable_bash_edit_diff(bash_edit_diff); + if worktree.is_some() { + *cwd = apply_worktree(cwd, worktree)?; + } + load_plugin_dirs(cortex_home, Some(cwd.clone()), plugin_dir).await; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::process::Command; + + struct CwdGuard(PathBuf); + impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } + } + + fn init_git_repo(dir: &Path) { + assert!( + Command::new("git") + .args(["init"]) + .current_dir(dir) + .status() + .unwrap() + .success() + ); + assert!( + Command::new("git") + .args(["-c", "user.email=ci@example.com", "-c", "user.name=ci"]) + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(dir) + .status() + .unwrap() + .success() + ); + } + + #[test] + fn apply_worktree_without_flag_keeps_cwd() { + let cwd = PathBuf::from("/tmp"); + let out = apply_worktree(&cwd, None).unwrap(); + assert_eq!(out, cwd); + } + + #[test] + #[serial] + fn apply_worktree_auto_isolates_git_repo() { + let home = std::env::current_dir().unwrap(); + let _guard = CwdGuard(home); + let repo = tempfile::tempdir().unwrap(); + init_git_repo(repo.path()); + let dest = PathBuf::from("auto"); + let isolated = apply_worktree(repo.path(), Some(&dest)).unwrap(); + assert!(isolated.exists()); + assert_ne!(isolated, repo.path()); + } + + #[test] + #[serial] + fn apply_worktree_explicit_dir() { + let home = std::env::current_dir().unwrap(); + let _guard = CwdGuard(home); + let repo = tempfile::tempdir().unwrap(); + init_git_repo(repo.path()); + let dest = repo.path().join("isolated-wt"); + let isolated = apply_worktree(repo.path(), Some(&dest)).unwrap(); + assert_eq!(isolated, dest); + assert!(dest.is_dir()); + } + + #[test] + #[serial] + fn enable_bash_edit_diff_sets_env() { + unsafe { + std::env::remove_var("CORTEX_BASH_EDIT_DIFF"); + } + enable_bash_edit_diff(false); + assert!(std::env::var("CORTEX_BASH_EDIT_DIFF").is_err()); + enable_bash_edit_diff(true); + assert_eq!(std::env::var("CORTEX_BASH_EDIT_DIFF").unwrap(), "1"); + unsafe { + std::env::remove_var("CORTEX_BASH_EDIT_DIFF"); + } + } + + #[tokio::test] + #[serial] + async fn load_plugin_dirs_warns_instead_of_failing() { + let home = tempfile::tempdir().unwrap(); + let extra = tempfile::tempdir().unwrap(); + let plugin = extra.path().join("sample"); + std::fs::create_dir_all(&plugin).unwrap(); + std::fs::write(plugin.join("plugin.json"), "{}").unwrap(); + load_plugin_dirs( + home.path(), + Some(home.path().to_path_buf()), + &[extra.path().to_path_buf()], + ) + .await; + apply_session_harness( + &mut home.path().to_path_buf(), + home.path(), + false, + None, + &[], + ) + .await + .unwrap(); + let extra_path = extra.path().to_path_buf(); + let config = + cortex_engine::plugin::plugin_config_with_extra_dirs(std::slice::from_ref(&extra_path)); + assert!( + config.search_paths.iter().any(|path| path == &extra_path), + "{:?}", + config.search_paths + ); + } +} diff --git a/src/cortex-cli/src/jobs_cmd.rs b/src/cortex-cli/src/jobs_cmd.rs new file mode 100644 index 00000000..c1db07f1 --- /dev/null +++ b/src/cortex-cli/src/jobs_cmd.rs @@ -0,0 +1,193 @@ +//! Background agents: list, logs, stop, attach. + +use anyhow::Result; +use clap::Parser; + +use crate::attach_cmd::{AttachCli, load_attach_token}; +use cortex_engine::client::{CodeAgentClient, CodeSession}; +use cortex_engine::session_attach::parse_attach_target; + +/// Manage background / remote Code agents. +#[derive(Debug, Parser)] +pub struct JobsCli { + #[command(subcommand)] + pub subcommand: JobsSubcommand, +} + +#[derive(Debug, clap::Subcommand)] +pub enum JobsSubcommand { + /// List live Code sessions (cloud + attached). + #[command(visible_alias = "ls")] + List(JobsListArgs), + /// Print the transcript for a session. + Logs(JobsIdArgs), + /// Request stop; the session is not deleted. + Stop(JobsIdArgs), + /// Attach this terminal (same as `cortex attach`). + Attach(AttachCli), +} + +#[derive(Debug, Parser)] +pub struct JobsListArgs { + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, Parser)] +pub struct JobsIdArgs { + pub session_id: String, +} + +impl JobsCli { + pub async fn run(self) -> Result<()> { + match self.subcommand { + JobsSubcommand::List(args) => list_jobs(args).await, + JobsSubcommand::Logs(args) => logs_job(args).await, + JobsSubcommand::Stop(args) => stop_job(args).await, + JobsSubcommand::Attach(cli) => cli.run().await, + } + } +} + +async fn client() -> Result { + let token = load_attach_token()?; + Ok(CodeAgentClient::new(None, Some(token))) +} + +pub(crate) fn format_job_line(session: &CodeSession) -> String { + format!( + "{}\t{}\t{}\t{}", + session.id, + if session.state.is_empty() { + "live" + } else { + session.state.as_str() + }, + session.runtime, + session.title + ) +} + +async fn list_jobs(args: JobsListArgs) -> Result<()> { + let client = client().await?; + let sessions = client.list_sessions().await?; + if args.json { + println!("{}", serde_json::to_string_pretty(&sessions)?); + return Ok(()); + } + if sessions.is_empty() { + println!("No background Code sessions."); + return Ok(()); + } + for session in sessions { + println!("{}", format_job_line(&session)); + } + Ok(()) +} + +async fn logs_job(args: JobsIdArgs) -> Result<()> { + let id = parse_attach_target(&args.session_id)?; + let client = client().await?; + for message in client.list_messages(&id).await? { + if !message.text.is_empty() { + println!("{}: {}", message.role, message.text); + } + } + Ok(()) +} + +async fn stop_job(args: JobsIdArgs) -> Result<()> { + let id = parse_attach_target(&args.session_id)?; + let client = client().await?; + client.resume_session(&id).await?; + client.cancel_in_flight_checked().await?; + println!("Stop requested for {id}. Detach left the session; it is not deleted."); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use cortex_engine::client::AUTH_REQUIRED; + use serial_test::serial; + + #[test] + fn jobs_subcommands_parse() { + assert!(JobsCli::try_parse_from(["jobs", "list"]).is_ok()); + assert!(JobsCli::try_parse_from(["jobs", "list", "--json"]).is_ok()); + assert!(JobsCli::try_parse_from(["jobs", "logs", "sess-1"]).is_ok()); + assert!(JobsCli::try_parse_from(["jobs", "stop", "sess-1"]).is_ok()); + assert!(JobsCli::try_parse_from(["jobs", "attach", "sess-1"]).is_ok()); + assert!(JobsCli::try_parse_from(["jobs", "ls"]).is_ok()); + } + + #[test] + fn format_job_line_uses_live_when_state_empty() { + let session = CodeSession { + id: "sess-1".into(), + runtime: "cloud".into(), + kind: String::new(), + host_status: String::new(), + state: String::new(), + model_slug: String::new(), + model_ref: String::new(), + title: "rate limiter".into(), + created_at: String::new(), + host_id: String::new(), + stream: None, + }; + let line = format_job_line(&session); + assert_eq!(line, "sess-1\tlive\tcloud\trate limiter"); + let mut busy = session.clone(); + busy.state = "running".into(); + assert!(format_job_line(&busy).contains("running")); + } + + #[tokio::test] + #[serial] + async fn jobs_list_requires_auth() { + let home = tempfile::tempdir().unwrap(); + unsafe { + std::env::remove_var("CORTEX_AUTH_TOKEN"); + std::env::remove_var("CORTEX_API_KEY"); + std::env::set_var("CORTEX_HOME", home.path()); + } + let err = JobsCli::try_parse_from(["jobs", "list"]) + .unwrap() + .run() + .await + .unwrap_err(); + assert!(err.to_string().contains("Not signed in"), "{err}"); + assert!(err.to_string().contains(AUTH_REQUIRED)); + unsafe { + std::env::remove_var("CORTEX_HOME"); + } + } + + #[tokio::test] + async fn jobs_logs_rejects_url() { + let err = JobsCli::try_parse_from(["jobs", "logs", "https://example.com/x"]) + .unwrap() + .run() + .await + .unwrap_err(); + assert!( + err.to_string().contains("No local session was started"), + "{err}" + ); + } + + #[tokio::test] + async fn jobs_stop_rejects_url() { + let err = JobsCli::try_parse_from(["jobs", "stop", "http://127.0.0.1/s"]) + .unwrap() + .run() + .await + .unwrap_err(); + assert!( + err.to_string().contains("No local session was started"), + "{err}" + ); + } +} diff --git a/src/cortex-cli/src/lib.rs b/src/cortex-cli/src/lib.rs index c316f17d..436e4c28 100644 --- a/src/cortex-cli/src/lib.rs +++ b/src/cortex-cli/src/lib.rs @@ -175,6 +175,7 @@ pub fn restore_terminal() { pub mod acp_cmd; pub mod agent_cmd; pub mod alias_cmd; +pub mod attach_cmd; pub mod cache_cmd; pub mod compact_cmd; pub mod completion_setup; @@ -185,7 +186,9 @@ pub mod exec_cmd; pub mod export_cmd; pub mod feedback_cmd; pub mod github_cmd; +pub mod harness; pub mod import_cmd; +pub mod jobs_cmd; pub mod lock_cmd; pub mod login; pub mod logs_cmd; diff --git a/src/cortex-cli/src/run_cmd/cli.rs b/src/cortex-cli/src/run_cmd/cli.rs index dcc883db..d976e8b5 100644 --- a/src/cortex-cli/src/run_cmd/cli.rs +++ b/src/cortex-cli/src/run_cmd/cli.rs @@ -140,6 +140,18 @@ pub struct RunCli { #[arg(long = "cwd", value_name = "DIR")] pub cwd: Option, + /// Isolated git worktree for this run (`--worktree` or `--worktree DIR`). + #[arg(long = "worktree", value_name = "DIR", num_args = 0..=1, default_missing_value = "auto")] + pub worktree: Option, + + /// Extra plugin folder (folder-of-plugins or a single plugin). Repeatable. + #[arg(long = "plugin-dir", value_name = "DIR", action = clap::ArgAction::Append)] + pub plugin_dir: Vec, + + /// Attach unified diffs of files Execute/Bash changed. + #[arg(long = "bash-edit-diff", default_value_t = false)] + pub bash_edit_diff: bool, + /// Additional directories that should be writable. /// Can be specified multiple times to add multiple directories. #[arg(long = "add-dir", value_name = "DIR", action = clap::ArgAction::Append)] diff --git a/src/cortex-cli/src/run_cmd/execution.rs b/src/cortex-cli/src/run_cmd/execution.rs index 6f7f2b43..63582321 100644 --- a/src/cortex-cli/src/run_cmd/execution.rs +++ b/src/cortex-cli/src/run_cmd/execution.rs @@ -92,6 +92,14 @@ impl RunCli { self.system_prompt.clone(), ) .await?; + crate::harness::apply_session_harness( + &mut config.cwd, + &config.cortex_home, + self.bash_edit_diff, + self.worktree.as_ref(), + &self.plugin_dir, + ) + .await?; if let Some(agent) = &self.agent { config.current_agent = Some(agent.clone()); } @@ -125,64 +133,7 @@ impl RunCli { None => Session::new(config.clone())?, }; let session_id = handle.conversation_id.to_string(); - - // Build user input parts - let mut input_parts = Vec::new(); - - // Add file attachments - for attachment in attachments { - // Read file content - let content = std::fs::read_to_string(&attachment.path) - .with_context(|| format!("Failed to read file: {}", attachment.path.display()))?; - - input_parts.push(UserInput::Text { - text: format!( - "--- File: {} ---\n{}\n--- End of {} ---", - attachment.filename, content, attachment.filename - ), - }); - } - - // Add the main message - if !message.is_empty() { - input_parts.push(UserInput::Text { - text: message.to_string(), - }); - } - - // If using a command, try to expand it from custom commands registry - let final_input = if let Some(ref cmd) = self.command { - // Try to get the custom command registry - if let Some(registry) = cortex_engine::try_custom_command_registry() { - // Try to execute the custom command - let ctx = cortex_engine::TemplateContext::new(message.to_string()) - .with_cwd(config.cwd.to_string_lossy().to_string()); - - // Use blocking runtime to get the command - let prompt = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(async { registry.execute(cmd, &ctx).await }) - }); - - if let Some(result) = prompt { - vec![UserInput::Text { - text: result.prompt, - }] - } else { - // Fallback to simple format if command not found - vec![UserInput::Text { - text: format!("Execute command: {} with arguments: {}", cmd, message), - }] - } - } else { - // No registry, use simple format - vec![UserInput::Text { - text: format!("Execute command: {} with arguments: {}", cmd, message), - }] - } - } else { - input_parts - }; + let final_input = build_user_input(self, message, attachments, &config.cwd)?; let session_task = tokio::spawn(async move { session.run().await }); @@ -635,3 +586,48 @@ impl RunCli { Ok(()) } } + +fn build_user_input( + cli: &RunCli, + message: &str, + attachments: &[FileAttachment], + cwd: &std::path::Path, +) -> Result> { + let mut input_parts = Vec::new(); + for attachment in attachments { + let content = std::fs::read_to_string(&attachment.path) + .with_context(|| format!("Failed to read file: {}", attachment.path.display()))?; + input_parts.push(UserInput::Text { + text: format!( + "--- File: {} ---\n{}\n--- End of {} ---", + attachment.filename, content, attachment.filename + ), + }); + } + if !message.is_empty() { + input_parts.push(UserInput::Text { + text: message.to_string(), + }); + } + let Some(cmd) = cli.command.as_ref() else { + return Ok(input_parts); + }; + let fallback = vec![UserInput::Text { + text: format!("Execute command: {} with arguments: {}", cmd, message), + }]; + let Some(registry) = cortex_engine::try_custom_command_registry() else { + return Ok(fallback); + }; + let ctx = cortex_engine::TemplateContext::new(message.to_string()) + .with_cwd(cwd.to_string_lossy().to_string()); + let prompt = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { registry.execute(cmd, &ctx).await }) + }); + Ok(if let Some(result) = prompt { + vec![UserInput::Text { + text: result.prompt, + }] + } else { + fallback + }) +} diff --git a/src/cortex-cli/tests/cli_schema.rs b/src/cortex-cli/tests/cli_schema.rs index 6be9b1bd..0581c72a 100644 --- a/src/cortex-cli/tests/cli_schema.rs +++ b/src/cortex-cli/tests/cli_schema.rs @@ -17,6 +17,8 @@ fn test_development_commands_remain_available_without_admin_commands() { "export", "import", "delete", + "attach", + "jobs", "login", "logout", "whoami", diff --git a/src/cortex-engine/src/client/code_agent.rs b/src/cortex-engine/src/client/code_agent.rs index 2260d9f8..90530529 100644 --- a/src/cortex-engine/src/client/code_agent.rs +++ b/src/cortex-engine/src/client/code_agent.rs @@ -540,6 +540,28 @@ impl CodeAgentClient { Ok(()) } + /// Allow or deny a paused tool call on an attached session. + pub async fn approve_invocation( + &self, + session_id: &str, + invocation_id: &str, + approved: bool, + ) -> Result<()> { + self.ensure_auth().await?; + if session_id.is_empty() || invocation_id.is_empty() { + return Err(CortexError::InvalidInput( + "Session id and invocation id are required.".into(), + )); + } + let url = format!("{}/v1/code/sessions/{session_id}/approvals", self.base_url); + let body = serde_json::json!({ + "invocation_id": invocation_id, + "approved": approved, + }); + let _ = self.authed_post(&url, &body).await?; + Ok(()) + } + /// Transcript for a Code session (server-side persistence). pub async fn list_messages(&self, session_id: &str) -> Result> { self.ensure_auth().await?; @@ -856,250 +878,5 @@ use code_sse::map_sse_error_copy; use code_sse::pump_sse; #[cfg(test)] -mod tests { - use super::*; - use crate::client::Message; - use futures::StreamExt; - - #[test] - fn parses_tool_start_event() { - let raw = r#"{"type":"tool_start","invocation_id":"tci_1","tool_name":"python","label":"Ran Python"}"#; - let ev: CodeTurnEvent = serde_json::from_str(raw).unwrap(); - match ev { - CodeTurnEvent::ToolStart { tool_name, .. } => assert_eq!(tool_name, "python"), - other => panic!("unexpected {other:?}"), - } - } - - #[test] - fn parses_done_event() { - let raw = r#"{"type":"done","message_id":"msg_1","finish_reason":"stop"}"#; - let ev: CodeTurnEvent = serde_json::from_str(raw).unwrap(); - assert!(matches!(ev, CodeTurnEvent::Done { .. })); - } - - #[test] - fn last_user_message_picks_latest() { - let req = CompletionRequest { - messages: vec![ - Message::system("sys"), - Message::user("first"), - Message::assistant("ok"), - Message::user("second"), - ], - model: "cortex-1-mini".into(), - ..Default::default() - }; - assert_eq!(CodeAgentClient::last_user_message(&req), "second"); - } - - #[test] - fn guest_token_prefix_is_stable() { - assert_eq!(GUEST_TOKEN_PREFIX, "gt:"); - } - - #[test] - fn parses_tool_start_with_arguments() { - let raw = r#"{"type":"tool_start","invocation_id":"tci_2","tool_name":"Bash","arguments":{"command":"ls"}}"#; - let ev: CodeTurnEvent = serde_json::from_str(raw).unwrap(); - match ev { - CodeTurnEvent::ToolStart { - tool_name, - arguments, - .. - } => { - assert_eq!(tool_name, "Bash"); - assert_eq!(arguments.unwrap()["command"].as_str().unwrap(), "ls"); - } - other => panic!("unexpected {other:?}"), - } - } - - #[test] - fn parses_cancelled_and_question() { - let cancelled: CodeTurnEvent = - serde_json::from_str(r#"{"type":"cancelled","message_id":"m"}"#).unwrap(); - assert!(matches!(cancelled, CodeTurnEvent::Cancelled { .. })); - let q: CodeTurnEvent = serde_json::from_str( - r#"{"type":"question","invocation_id":"q1","questions":{"prompt":"Ship it?"}}"#, - ) - .unwrap(); - match q { - CodeTurnEvent::Question { invocation_id, .. } => assert_eq!(invocation_id, "q1"), - other => panic!("{other:?}"), - } - } - - #[test] - fn turn_mode_only_chat_or_code() { - assert_eq!(CodeTurnMode::Code.as_str(), "code"); - assert_eq!(CodeTurnMode::Chat.as_str(), "chat"); - } - - #[test] - fn api_errors_are_product_facing() { - let err = map_api_error( - reqwest::StatusCode::INTERNAL_SERVER_ERROR, - r#"{"detail":"nope"}"#, - ); - let msg = err.user_friendly_message(); - assert!( - msg.contains("temporarily unavailable"), - "expected product error, got {msg}" - ); - assert!(!msg.to_lowercase().contains("reqwest")); - assert!(!msg.contains("nope")); - } - - #[test] - fn api_401_asks_the_user_to_login() { - let err = map_api_error(reqwest::StatusCode::UNAUTHORIZED, r#"{"detail":"nope"}"#); - let msg = err.user_friendly_message(); - assert!( - msg.contains("cortex login") || msg.contains("CORTEX_API_KEY"), - "{msg}" - ); - assert!(!msg.contains("temporarily unavailable"), "{msg}"); - assert!(!msg.contains("nope")); - } - - #[test] - fn api_404_is_not_a_false_outage() { - let err = map_api_error(reqwest::StatusCode::NOT_FOUND, r#"{"detail":"missing"}"#); - let msg = err.to_string(); - assert!( - msg.contains("not found") || msg.contains("CORTEX_API_URL"), - "{msg}" - ); - assert!(!msg.contains("temporarily unavailable"), "{msg}"); - assert!(!msg.contains("missing")); - } - - #[test] - fn backend_error_display_keeps_actionable_copy() { - let err = CortexError::BackendError { - message: AUTH_REQUIRED.to_string(), - }; - assert_eq!(err.to_string(), AUTH_REQUIRED); - assert_eq!(err.user_friendly_message(), AUTH_REQUIRED); - } - - #[test] - fn normalize_api_base_strips_trailing_slash() { - assert_eq!( - normalize_api_base("https://api.cortex.foundation/"), - "https://api.cortex.foundation" - ); - assert_eq!( - CodeAgentClient::new(Some("https://api.cortex.foundation/".into()), None).base_url(), - "https://api.cortex.foundation" - ); - } - - #[test] - fn sse_error_maps_auth_without_outage_copy() { - assert_eq!(map_sse_error_copy("unauthorized"), AUTH_REQUIRED); - assert_eq!(map_sse_error_copy(""), SERVICE_UNAVAILABLE); - assert_eq!(map_sse_error_copy("rate limit 429"), TOO_MANY_REQUESTS); - } - - #[test] - fn persists_session_id_under_cortex_home() { - let dir = std::env::temp_dir().join(format!("cortex-sess-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).unwrap(); - let prev = std::env::var_os("CORTEX_HOME"); - unsafe { - std::env::set_var("CORTEX_HOME", &dir); - } - persist_session_id("/tmp/demo-ws", "sess_abc"); - assert_eq!( - cached_code_session_id("/tmp/demo-ws").as_deref(), - Some("sess_abc") - ); - match prev { - Some(v) => unsafe { std::env::set_var("CORTEX_HOME", v) }, - None => unsafe { std::env::remove_var("CORTEX_HOME") }, - } - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn tool_start_without_args_is_display_only() { - let args = serde_json::json!({"label": "Ran Python"}); - let has_exec_args = args.get("command").is_some() - || args.get("file_path").is_some() - || args.get("path").is_some() - || args - .as_object() - .is_some_and(|o| o.len() > 1 && !o.contains_key("label")); - assert!(!has_exec_args); - } - - fn live_api_enabled() -> bool { - std::env::var("CORTEX_LIVE_API").ok().as_deref() == Some("1") - } - - #[tokio::test] - #[ignore = "hits api.cortex.foundation; CORTEX_LIVE_API=1 cargo test -p cortex-engine -- --ignored"] - async fn live_guest_code_turn_streams_tokens() { - if !live_api_enabled() { - return; - } - let dir = tempfile::tempdir().unwrap(); - unsafe { - std::env::set_var("CORTEX_HOME", dir.path()); - } - let client = CodeAgentClient::new(None, None); - let guest = client - .begin_guest_session() - .await - .expect("guest session against the live API"); - assert_eq!(guest.kind, "guest"); - assert!(guest.user_id.starts_with("usr_")); - - client.set_turn_context(CodeTurnContext { - workspace: Some("/tmp".into()), - computer: ComputerKind::Cloud, - turn_mode: Some(CodeTurnMode::Chat), - ssh_target: None, - }); - - let mut stream = client - .stream_turn( - "Reply with the single word pong and nothing else.", - CodeTurnMode::Chat, - ) - .await - .expect("Code session turn"); - let mut text = String::new(); - let mut done = false; - while let Some(ev) = stream.next().await { - match ev.expect("SSE event") { - ResponseEvent::Delta(d) => text.push_str(&d), - ResponseEvent::Done(_) => { - done = true; - break; - } - ResponseEvent::Error(e) => panic!("turn error: {e}"), - _ => {} - } - } - assert!(done, "expected a done event from the Code turn stream"); - assert!( - text.to_lowercase().contains("pong"), - "expected streamed pong, got {text:?}" - ); - - let sid = client - .current_session_id() - .await - .expect("session id after turn"); - let messages = client.list_messages(&sid).await.expect("transcript"); - assert!( - messages.iter().any(|m| m.role == "user"), - "server transcript should persist the user message" - ); - - client.cancel_in_flight().await; - } -} +#[path = "code_agent_tests.rs"] +mod tests; diff --git a/src/cortex-engine/src/client/code_agent_tests.rs b/src/cortex-engine/src/client/code_agent_tests.rs new file mode 100644 index 00000000..7f1fc001 --- /dev/null +++ b/src/cortex-engine/src/client/code_agent_tests.rs @@ -0,0 +1,245 @@ +use super::*; +use crate::client::Message; +use futures::StreamExt; + +#[test] +fn parses_tool_start_event() { + let raw = r#"{"type":"tool_start","invocation_id":"tci_1","tool_name":"python","label":"Ran Python"}"#; + let ev: CodeTurnEvent = serde_json::from_str(raw).unwrap(); + match ev { + CodeTurnEvent::ToolStart { tool_name, .. } => assert_eq!(tool_name, "python"), + other => panic!("unexpected {other:?}"), + } +} + +#[test] +fn parses_done_event() { + let raw = r#"{"type":"done","message_id":"msg_1","finish_reason":"stop"}"#; + let ev: CodeTurnEvent = serde_json::from_str(raw).unwrap(); + assert!(matches!(ev, CodeTurnEvent::Done { .. })); +} + +#[test] +fn last_user_message_picks_latest() { + let req = CompletionRequest { + messages: vec![ + Message::system("sys"), + Message::user("first"), + Message::assistant("ok"), + Message::user("second"), + ], + model: "cortex-1-mini".into(), + ..Default::default() + }; + assert_eq!(CodeAgentClient::last_user_message(&req), "second"); +} + +#[test] +fn guest_token_prefix_is_stable() { + assert_eq!(GUEST_TOKEN_PREFIX, "gt:"); +} + +#[test] +fn parses_tool_start_with_arguments() { + let raw = r#"{"type":"tool_start","invocation_id":"tci_2","tool_name":"Bash","arguments":{"command":"ls"}}"#; + let ev: CodeTurnEvent = serde_json::from_str(raw).unwrap(); + match ev { + CodeTurnEvent::ToolStart { + tool_name, + arguments, + .. + } => { + assert_eq!(tool_name, "Bash"); + assert_eq!(arguments.unwrap()["command"].as_str().unwrap(), "ls"); + } + other => panic!("unexpected {other:?}"), + } +} + +#[test] +fn parses_cancelled_and_question() { + let cancelled: CodeTurnEvent = + serde_json::from_str(r#"{"type":"cancelled","message_id":"m"}"#).unwrap(); + assert!(matches!(cancelled, CodeTurnEvent::Cancelled { .. })); + let q: CodeTurnEvent = serde_json::from_str( + r#"{"type":"question","invocation_id":"q1","questions":{"prompt":"Ship it?"}}"#, + ) + .unwrap(); + match q { + CodeTurnEvent::Question { invocation_id, .. } => assert_eq!(invocation_id, "q1"), + other => panic!("{other:?}"), + } +} + +#[test] +fn turn_mode_only_chat_or_code() { + assert_eq!(CodeTurnMode::Code.as_str(), "code"); + assert_eq!(CodeTurnMode::Chat.as_str(), "chat"); +} + +#[test] +fn api_errors_are_product_facing() { + let err = map_api_error( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + r#"{"detail":"nope"}"#, + ); + let msg = err.user_friendly_message(); + assert!( + msg.contains("temporarily unavailable"), + "expected product error, got {msg}" + ); + assert!(!msg.to_lowercase().contains("reqwest")); + assert!(!msg.contains("nope")); +} + +#[test] +fn api_401_asks_the_user_to_login() { + let err = map_api_error(reqwest::StatusCode::UNAUTHORIZED, r#"{"detail":"nope"}"#); + let msg = err.user_friendly_message(); + assert!( + msg.contains("cortex login") || msg.contains("CORTEX_API_KEY"), + "{msg}" + ); + assert!(!msg.contains("temporarily unavailable"), "{msg}"); + assert!(!msg.contains("nope")); +} + +#[test] +fn api_404_is_not_a_false_outage() { + let err = map_api_error(reqwest::StatusCode::NOT_FOUND, r#"{"detail":"missing"}"#); + let msg = err.to_string(); + assert!( + msg.contains("not found") || msg.contains("CORTEX_API_URL"), + "{msg}" + ); + assert!(!msg.contains("temporarily unavailable"), "{msg}"); + assert!(!msg.contains("missing")); +} + +#[test] +fn backend_error_display_keeps_actionable_copy() { + let err = CortexError::BackendError { + message: AUTH_REQUIRED.to_string(), + }; + assert_eq!(err.to_string(), AUTH_REQUIRED); + assert_eq!(err.user_friendly_message(), AUTH_REQUIRED); +} + +#[test] +fn normalize_api_base_strips_trailing_slash() { + assert_eq!( + normalize_api_base("https://api.cortex.foundation/"), + "https://api.cortex.foundation" + ); + assert_eq!( + CodeAgentClient::new(Some("https://api.cortex.foundation/".into()), None).base_url(), + "https://api.cortex.foundation" + ); +} + +#[test] +fn sse_error_maps_auth_without_outage_copy() { + assert_eq!(map_sse_error_copy("unauthorized"), AUTH_REQUIRED); + assert_eq!(map_sse_error_copy(""), SERVICE_UNAVAILABLE); + assert_eq!(map_sse_error_copy("rate limit 429"), TOO_MANY_REQUESTS); +} + +#[test] +fn persists_session_id_under_cortex_home() { + let dir = std::env::temp_dir().join(format!("cortex-sess-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let prev = std::env::var_os("CORTEX_HOME"); + unsafe { + std::env::set_var("CORTEX_HOME", &dir); + } + persist_session_id("/tmp/demo-ws", "sess_abc"); + assert_eq!( + cached_code_session_id("/tmp/demo-ws").as_deref(), + Some("sess_abc") + ); + match prev { + Some(v) => unsafe { std::env::set_var("CORTEX_HOME", v) }, + None => unsafe { std::env::remove_var("CORTEX_HOME") }, + } + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn tool_start_without_args_is_display_only() { + let args = serde_json::json!({"label": "Ran Python"}); + let has_exec_args = args.get("command").is_some() + || args.get("file_path").is_some() + || args.get("path").is_some() + || args + .as_object() + .is_some_and(|o| o.len() > 1 && !o.contains_key("label")); + assert!(!has_exec_args); +} + +fn live_api_enabled() -> bool { + std::env::var("CORTEX_LIVE_API").ok().as_deref() == Some("1") +} + +#[tokio::test] +#[ignore = "hits api.cortex.foundation; CORTEX_LIVE_API=1 cargo test -p cortex-engine -- --ignored"] +async fn live_guest_code_turn_streams_tokens() { + if !live_api_enabled() { + return; + } + let dir = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("CORTEX_HOME", dir.path()); + } + let client = CodeAgentClient::new(None, None); + let guest = client + .begin_guest_session() + .await + .expect("guest session against the live API"); + assert_eq!(guest.kind, "guest"); + assert!(guest.user_id.starts_with("usr_")); + + client.set_turn_context(CodeTurnContext { + workspace: Some("/tmp".into()), + computer: ComputerKind::Cloud, + turn_mode: Some(CodeTurnMode::Chat), + ssh_target: None, + }); + + let mut stream = client + .stream_turn( + "Reply with the single word pong and nothing else.", + CodeTurnMode::Chat, + ) + .await + .expect("Code session turn"); + let mut text = String::new(); + let mut done = false; + while let Some(ev) = stream.next().await { + match ev.expect("SSE event") { + ResponseEvent::Delta(d) => text.push_str(&d), + ResponseEvent::Done(_) => { + done = true; + break; + } + ResponseEvent::Error(e) => panic!("turn error: {e}"), + _ => {} + } + } + assert!(done, "expected a done event from the Code turn stream"); + assert!( + text.to_lowercase().contains("pong"), + "expected streamed pong, got {text:?}" + ); + + let sid = client + .current_session_id() + .await + .expect("session id after turn"); + let messages = client.list_messages(&sid).await.expect("transcript"); + assert!( + messages.iter().any(|m| m.role == "user"), + "server transcript should persist the user message" + ); + + client.cancel_in_flight().await; +} diff --git a/src/cortex-engine/src/lib.rs b/src/cortex-engine/src/lib.rs index b44024d9..4c4deac9 100644 --- a/src/cortex-engine/src/lib.rs +++ b/src/cortex-engine/src/lib.rs @@ -219,6 +219,7 @@ pub mod retry; pub mod review; pub mod search; pub mod secrets; +pub mod session_attach; pub mod shell; pub mod streaming; pub mod tasks; @@ -232,6 +233,7 @@ pub mod unified_exec; pub mod validation; pub mod version_utils; pub mod workspace; +pub mod worktree; // Background terminal management (different from TUI terminal) pub mod terminal; diff --git a/src/cortex-engine/src/plugin/executable.rs b/src/cortex-engine/src/plugin/executable.rs index b7df98be..8d6328a3 100644 --- a/src/cortex-engine/src/plugin/executable.rs +++ b/src/cortex-engine/src/plugin/executable.rs @@ -3,13 +3,55 @@ //! Callers must preserve this order: before-hook -> final schema/path validation //! -> existing approval/execpolicy/sandbox gate -> tool -> observer after-hook. //! This API does not authorize execution, grant capabilities or contact a service. -use std::sync::Arc; +use std::path::PathBuf; +use std::sync::{Arc, OnceLock}; use cortex_plugins_ext::contract::{InvocationResult, Notification, ToolDeclaration}; use cortex_plugins_ext::{HookType, PluginConfig, PluginContext, PluginError, PluginManager}; use serde_json::{Value, json}; use tokio::sync::Mutex; +static EXECUTABLE_SESSION: OnceLock>>> = OnceLock::new(); + +fn executable_slot() -> &'static Mutex>> { + EXECUTABLE_SESSION.get_or_init(|| Mutex::new(None)) +} + +/// Build the executable runtime config, including `--plugin-dir` search paths. +pub fn plugin_config_with_extra_dirs(extra: &[PathBuf]) -> PluginConfig { + let mut config = PluginConfig::default(); + for dir in extra { + config.add_search_path(dir.clone()); + } + config +} + +/// Start the WASM/Node executable plugin runtime. Failures are warnings. +pub async fn start_executable_runtime(cwd: PathBuf, extra: &[PathBuf]) { + let mut slot = executable_slot().lock().await; + if slot.is_some() { + tracing::warn!( + "Executable plugin runtime was already started; extra plugin-dir paths were not reloaded" + ); + return; + } + let config = plugin_config_with_extra_dirs(extra); + let context = PluginContext::new(cwd); + match ExecutableSession::start(config, context).await { + Ok(session) => { + *slot = Some(Arc::new(session)); + } + Err(error) => { + tracing::warn!("Executable plugin runtime was not started: {error}"); + } + } +} + +/// Session-owned executable plugin runtime, if start succeeded. +pub async fn executable_session() -> Option> { + executable_slot().lock().await.clone() +} + pub struct ExecutableSession { manager: Arc, context: PluginContext, @@ -179,3 +221,20 @@ impl ExecutableSession { shutdown } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn extra_plugin_dirs_are_executable_search_paths() { + let extra = PathBuf::from("/tmp/cortex-extra-plugins"); + let config = plugin_config_with_extra_dirs(std::slice::from_ref(&extra)); + assert!( + config.search_paths.iter().any(|path| path == &extra), + "{:?}", + config.search_paths + ); + } +} diff --git a/src/cortex-engine/src/plugin/loader.rs b/src/cortex-engine/src/plugin/loader.rs index 73c4b803..fb3d0006 100644 --- a/src/cortex-engine/src/plugin/loader.rs +++ b/src/cortex-engine/src/plugin/loader.rs @@ -42,6 +42,8 @@ pub struct PluginLoader { discovered: Vec, /// Plugin configurations from config file. plugin_configs: HashMap, + /// Extra `--plugin-dir` folders (folder-of-plugins or a single plugin). + extra_dirs: Vec, } impl PluginLoader { @@ -52,6 +54,7 @@ impl PluginLoader { project_root: None, discovered: Vec::new(), plugin_configs: HashMap::new(), + extra_dirs: Vec::new(), } } @@ -61,6 +64,12 @@ impl PluginLoader { self } + /// Add a `--plugin-dir` folder (contained; children with manifests load). + pub fn with_plugin_dir(mut self, dir: impl Into) -> Self { + self.extra_dirs.push(dir.into()); + self + } + /// Add plugin configurations from config file. pub fn with_configs(mut self, configs: Vec) -> Self { for config in configs { @@ -93,6 +102,16 @@ impl PluginLoader { } } + for extra in &self.extra_dirs { + match super::plugin_dir::contain_plugin_dir(extra) { + Ok(path) => dirs.push(PluginDir { + path, + source: PluginSource::External, + }), + Err(e) => warn!("Skipping plugin-dir {}: {}", extra.display(), e), + } + } + dirs } @@ -103,6 +122,17 @@ impl PluginLoader { for dir in self.plugin_dirs() { debug!("Scanning plugin directory: {}", dir.path.display()); + if dir.source == PluginSource::External + && super::plugin_dir::has_plugin_manifest(&dir.path) + { + match self.discover_plugin(&dir.path, dir.source).await { + Ok(Some(plugin)) => plugins.push(plugin), + Ok(None) => {} + Err(e) => warn!("Failed to discover plugin at {}: {}", dir.path.display(), e), + } + continue; + } + let entries = match std::fs::read_dir(&dir.path) { Ok(entries) => entries, Err(e) => { @@ -125,6 +155,16 @@ impl PluginLoader { }; let path = entry.path(); + if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(true) { + continue; + } + if path.is_dir() { + if let Ok(canonical) = std::fs::canonicalize(&path) { + if !canonical.starts_with(&dir.path) { + continue; + } + } + } // Try to discover plugin match self.discover_plugin(&path, dir.source).await { diff --git a/src/cortex-engine/src/plugin/manager.rs b/src/cortex-engine/src/plugin/manager.rs index b1c0fd4a..77e6f9a2 100644 --- a/src/cortex-engine/src/plugin/manager.rs +++ b/src/cortex-engine/src/plugin/manager.rs @@ -50,6 +50,7 @@ pub struct PluginManager { cortex_home: PathBuf, /// Project root directory. project_root: Option, + extra_dirs: Vec, } impl PluginManager { @@ -63,6 +64,7 @@ impl PluginManager { configs: RwLock::new(HashMap::new()), cortex_home: cortex_home.into(), project_root: None, + extra_dirs: Vec::new(), } } @@ -72,6 +74,12 @@ impl PluginManager { self } + /// Add a `--plugin-dir` folder of plugins (or a single plugin folder). + pub fn with_plugin_dir(mut self, dir: impl Into) -> Self { + self.extra_dirs.push(dir.into()); + self + } + /// Add plugin configurations. pub async fn add_configs(&self, configs: Vec) { let mut cfg = self.configs.write().await; @@ -340,6 +348,9 @@ impl PluginManager { if let Some(ref project_root) = self.project_root { loader = loader.with_project_root(project_root); } + for dir in &self.extra_dirs { + loader = loader.with_plugin_dir(dir.clone()); + } // Discover plugins let discovered = loader.discover().await?; diff --git a/src/cortex-engine/src/plugin/mod.rs b/src/cortex-engine/src/plugin/mod.rs index 4efb789a..b467b0c8 100644 --- a/src/cortex-engine/src/plugin/mod.rs +++ b/src/cortex-engine/src/plugin/mod.rs @@ -75,9 +75,13 @@ pub mod hooks; pub mod integration; pub mod loader; pub mod manager; +pub mod plugin_dir; pub mod types; /// Authoritative executable plugin runtime; the older manager is metadata-only. pub use cortex_plugins_ext as runtime; +pub use executable::{ + ExecutableSession, executable_session, plugin_config_with_extra_dirs, start_executable_runtime, +}; // Re-exports for convenience pub use config::{PluginConfigBuilder, PluginConfigEntry, PluginSettings, PluginsConfig}; @@ -121,9 +125,23 @@ pub async fn init_with_project( cortex_home: impl Into, project_root: impl Into, ) -> crate::error::Result<()> { - let manager = - std::sync::Arc::new(PluginManager::new(cortex_home).with_project_root(project_root)); - init_global_manager(manager)?; + init_with_project_and_dirs(cortex_home, Some(project_root.into()), Vec::new()).await +} + +/// Initialize plugins, including extra `--plugin-dir` folders. +pub async fn init_with_project_and_dirs( + cortex_home: impl Into, + project_root: Option, + extra_dirs: Vec, +) -> crate::error::Result<()> { + let mut manager = PluginManager::new(cortex_home); + if let Some(root) = project_root { + manager = manager.with_project_root(root); + } + for dir in extra_dirs { + manager = manager.with_plugin_dir(dir); + } + init_global_manager(std::sync::Arc::new(manager))?; Ok(()) } diff --git a/src/cortex-engine/src/plugin/plugin_dir.rs b/src/cortex-engine/src/plugin/plugin_dir.rs new file mode 100644 index 00000000..d8d31e64 --- /dev/null +++ b/src/cortex-engine/src/plugin/plugin_dir.rs @@ -0,0 +1,245 @@ +//! Folder-of-plugins load path (`--plugin-dir`) with path containment. +//! +//! A plugin-dir may be: +//! - a **folder of plugins**: each child directory with a manifest is a plugin +//! - a **single plugin** directory that itself has a manifest +//! +//! Symlink children are skipped so a pack cannot escape the pointed-at folder. +//! Errors never include raw plugin source URLs. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use crate::error::{CortexError, Result}; + +/// Manifest filenames recognised as a plugin root. +pub const PLUGIN_MANIFESTS: &[&str] = &[ + "plugin.toml", + "cortex-plugin.json", + "plugin.json", + "cortex-plugin.toml", + ".cortex-plugin/plugin.json", +]; + +/// A child plugin folder discovered under a `--plugin-dir` path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginDirChild { + pub id: String, + pub path: PathBuf, +} + +/// Snapshot used to detect add/remove while a session is running. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginDirSnapshot { + pub root: PathBuf, + pub children: BTreeSet, +} + +/// Add/remove delta between two snapshots. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginDirDiff { + pub added: Vec, + pub removed: Vec, +} + +impl PluginDirDiff { + pub fn is_empty(&self) -> bool { + self.added.is_empty() && self.removed.is_empty() + } +} + +/// True when `dir` contains a plugin manifest (single-plugin path). +pub fn has_plugin_manifest(dir: &Path) -> bool { + PLUGIN_MANIFESTS.iter().any(|name| dir.join(name).is_file()) +} + +/// Canonicalize `path` as a directory. Fail closed on missing paths. +pub fn contain_plugin_dir(path: &Path) -> Result { + let canonical = std::fs::canonicalize(path).map_err(|_| { + CortexError::InvalidInput(format!( + "Plugin directory was not found: {}", + display_path(path) + )) + })?; + if !canonical.is_dir() { + return Err(CortexError::InvalidInput(format!( + "Plugin directory is not a folder: {}", + display_path(&canonical) + ))); + } + Ok(canonical) +} + +/// Discover plugin children under a contained folder. +/// +/// Skips symlink entries and children whose canonical path leaves `root`. +pub fn discover_plugin_dir_folder(root: &Path) -> Result> { + let root = contain_plugin_dir(root)?; + let mut children = Vec::new(); + + if has_plugin_manifest(&root) { + children.push(PluginDirChild { + id: folder_id(&root), + path: root, + }); + return Ok(children); + } + + let entries = std::fs::read_dir(&root).map_err(|_| { + CortexError::InvalidInput(format!( + "Plugin directory could not be read: {}", + display_path(&root) + )) + })?; + + for entry in entries.flatten() { + let path = entry.path(); + let file_type = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + if file_type.is_symlink() || !file_type.is_dir() { + continue; + } + let Ok(canonical) = std::fs::canonicalize(&path) else { + continue; + }; + if !canonical.starts_with(&root) { + continue; + } + if !has_plugin_manifest(&canonical) { + continue; + } + children.push(PluginDirChild { + id: folder_id(&canonical), + path: canonical, + }); + } + + children.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(children) +} + +/// Snapshot child ids for hot-reload. +pub fn snapshot_plugin_dir(root: &Path) -> Result { + let children = discover_plugin_dir_folder(root)?; + Ok(PluginDirSnapshot { + root: contain_plugin_dir(root)?, + children: children.into_iter().map(|c| c.id).collect(), + }) +} + +/// Diff two snapshots taken of the same folder. +pub fn diff_plugin_dir(before: &PluginDirSnapshot, after: &PluginDirSnapshot) -> PluginDirDiff { + PluginDirDiff { + added: after + .children + .difference(&before.children) + .cloned() + .collect(), + removed: before + .children + .difference(&after.children) + .cloned() + .collect(), + } +} + +/// Fingerprint several `--plugin-dir` roots. +pub fn snapshot_plugin_dirs(roots: &[PathBuf]) -> Result> { + let mut map = BTreeMap::new(); + for root in roots { + let snap = snapshot_plugin_dir(root)?; + map.insert(snap.root.clone(), snap); + } + Ok(map) +} + +fn folder_id(path: &Path) -> String { + path.file_name() + .and_then(|s| s.to_str()) + .unwrap_or("plugin") + .to_string() +} + +fn display_path(path: &Path) -> String { + path.display().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write_plugin(dir: &Path, name: &str) { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write( + dir.join("plugin.toml"), + format!("[plugin]\nid = \"{name}\"\nname = \"{name}\"\nversion = \"0.0.1\"\n"), + ) + .unwrap(); + } + + #[test] + fn folder_of_plugins_loads_children_with_manifests() { + let tmp = TempDir::new().unwrap(); + write_plugin(&tmp.path().join("alpha"), "alpha"); + write_plugin(&tmp.path().join("beta"), "beta"); + std::fs::create_dir_all(tmp.path().join("empty")).unwrap(); + + let found = discover_plugin_dir_folder(tmp.path()).unwrap(); + let ids: Vec<_> = found.iter().map(|c| c.id.as_str()).collect(); + assert_eq!(ids, vec!["alpha", "beta"]); + } + + #[test] + fn single_plugin_directory_loads_itself() { + let tmp = TempDir::new().unwrap(); + write_plugin(tmp.path(), "solo"); + let found = discover_plugin_dir_folder(tmp.path()).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!( + found[0].id, + tmp.path().file_name().unwrap().to_str().unwrap() + ); + } + + #[test] + fn symlink_children_are_skipped() { + let tmp = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + write_plugin(outside.path(), "escaped"); + write_plugin(&tmp.path().join("ok"), "ok"); + #[cfg(unix)] + { + std::os::unix::fs::symlink(outside.path(), tmp.path().join("link")).unwrap(); + } + let found = discover_plugin_dir_folder(tmp.path()).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].id, "ok"); + } + + #[test] + fn hot_reload_diff_reports_added_and_removed_children() { + let tmp = TempDir::new().unwrap(); + write_plugin(&tmp.path().join("keep"), "keep"); + write_plugin(&tmp.path().join("gone"), "gone"); + let before = snapshot_plugin_dir(tmp.path()).unwrap(); + + std::fs::remove_dir_all(tmp.path().join("gone")).unwrap(); + write_plugin(&tmp.path().join("new"), "new"); + let after = snapshot_plugin_dir(tmp.path()).unwrap(); + + let diff = diff_plugin_dir(&before, &after); + assert_eq!(diff.added, vec!["new".to_string()]); + assert_eq!(diff.removed, vec!["gone".to_string()]); + } + + #[test] + fn missing_folder_fails_closed() { + let err = contain_plugin_dir(Path::new("/no/such/plugin-dir-cortex")).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("not found"), "{msg}"); + assert!(!msg.contains("http"), "{msg}"); + } +} diff --git a/src/cortex-engine/src/session_attach.rs b/src/cortex-engine/src/session_attach.rs new file mode 100644 index 00000000..cb1326e9 --- /dev/null +++ b/src/cortex-engine/src/session_attach.rs @@ -0,0 +1,53 @@ +//! Live Code session attach (terminal follow + approvals). + +use crate::error::{CortexError, Result}; + +/// Parse a live-attach target. URLs are rejected so a local session is never +/// started as a fallback. +pub fn parse_attach_target(raw: &str) -> Result { + let id = raw.trim(); + if id.is_empty() { + return Err(CortexError::InvalidInput( + "Session id is required. No local session was started.".into(), + )); + } + if id.contains("://") { + return Err(CortexError::InvalidInput( + "Use `cortex attach ` for a live Code session. Connecting by URL is not supported. No local session was started.".into(), + )); + } + if !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Err(CortexError::InvalidInput( + "Invalid Code session ID. No local session was started.".into(), + )); + } + Ok(id.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_session_ids() { + assert_eq!(parse_attach_target("sess-1").unwrap(), "sess-1"); + assert_eq!(parse_attach_target(" abc_DEF-9 ").unwrap(), "abc_DEF-9"); + } + + #[test] + fn url_does_not_start_a_local_session() { + let err = parse_attach_target("http://127.0.0.1:1").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("No local session was started"), "{msg}"); + assert!(msg.contains("session-id"), "{msg}"); + } + + #[test] + fn empty_fails_closed() { + let err = parse_attach_target(" ").unwrap_err(); + assert!(err.to_string().contains("No local session was started")); + } +} diff --git a/src/cortex-engine/src/tools/handlers/bash_edit_diff.rs b/src/cortex-engine/src/tools/handlers/bash_edit_diff.rs new file mode 100644 index 00000000..8bea7f39 --- /dev/null +++ b/src/cortex-engine/src/tools/handlers/bash_edit_diff.rs @@ -0,0 +1,202 @@ +//! Opt-in diffs of files a Bash/Execute command wrote. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::diff::diff; + +const MAX_FILES: usize = 32; +const MAX_FILE_BYTES: usize = 256 * 1024; + +fn env_flag_on(value: &str) -> bool { + matches!(value.trim(), "1" | "true" | "TRUE" | "yes") +} + +/// Whether Bash/Execute should attach file diffs to the tool result. +pub fn bash_edit_diff_enabled(env: &HashMap) -> bool { + if let Some(value) = env.get("CORTEX_BASH_EDIT_DIFF") { + return env_flag_on(value); + } + std::env::var("CORTEX_BASH_EDIT_DIFF") + .ok() + .is_some_and(|v| env_flag_on(&v)) +} + +/// Snapshot of regular files under `cwd` (no `.git` / `target` / `node_modules`). +pub fn snapshot_workspace(cwd: &Path) -> HashMap> { + let mut files = HashMap::new(); + walk(cwd, &mut files); + files +} + +/// Unified diffs for files that changed between two snapshots. +pub fn diff_snapshots( + cwd: &Path, + before: &HashMap>, + after: &HashMap>, +) -> String { + let mut paths: Vec<_> = before.keys().chain(after.keys()).cloned().collect(); + paths.sort(); + paths.dedup(); + + let mut chunks = Vec::new(); + for path in paths { + if should_redact_path(&path) { + continue; + } + let old = before.get(&path).cloned().unwrap_or_default(); + let new = after.get(&path).cloned().unwrap_or_default(); + if old == new { + continue; + } + let old_s = String::from_utf8_lossy(&old); + let new_s = String::from_utf8_lossy(&new); + let unified = diff(&old_s, &new_s); + if unified.is_empty() { + continue; + } + let rel = path.strip_prefix(cwd).unwrap_or(&path); + chunks.push(format!( + "--- {}\n+++ {}\n{}", + rel.display(), + rel.display(), + unified + )); + if chunks.len() >= MAX_FILES { + break; + } + } + chunks.join("\n") +} + +/// Append a diff section to a tool result when files changed. +pub fn append_edit_diff(output: &str, diff_text: &str) -> String { + if diff_text.trim().is_empty() { + return output.to_string(); + } + if output.is_empty() { + format!("Files changed:\n{diff_text}") + } else { + format!("{output}\n\nFiles changed:\n{diff_text}") + } +} + +fn walk(dir: &Path, files: &mut HashMap>) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with('.') + || name == "target" + || name == "node_modules" + || name == "dist" + || name == "__pycache__" + { + continue; + } + let Ok(ft) = entry.file_type() else { + continue; + }; + if ft.is_symlink() { + continue; + } + if ft.is_dir() { + walk(&path, files); + continue; + } + if !ft.is_file() { + continue; + } + if should_redact_path(&path) { + continue; + } + let Ok(meta) = entry.metadata() else { + continue; + }; + if meta.len() as usize > MAX_FILE_BYTES { + continue; + } + if let Ok(bytes) = std::fs::read(&path) { + files.insert(path, bytes); + } + } +} + +fn should_redact_path(path: &Path) -> bool { + let name = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + name == ".env" + || name.ends_with(".pem") + || name.ends_with(".key") + || name.contains("credential") + || name.contains("secret") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn enabled_reads_tool_env() { + let mut env = HashMap::new(); + env.insert("CORTEX_BASH_EDIT_DIFF".into(), "1".into()); + assert!(bash_edit_diff_enabled(&env)); + } + + #[test] + fn disabled_by_default() { + let env = HashMap::new(); + // Process env may be set in the agent host; treat missing map key as the + // local default when the process var is unset. + if std::env::var("CORTEX_BASH_EDIT_DIFF").is_err() { + assert!(!bash_edit_diff_enabled(&env)); + } + } + + #[test] + fn snapshot_diff_includes_new_and_edited_files() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("a.txt"), "one\n").unwrap(); + let before = snapshot_workspace(tmp.path()); + std::fs::write(tmp.path().join("a.txt"), "two\n").unwrap(); + std::fs::write(tmp.path().join("b.txt"), "new\n").unwrap(); + let after = snapshot_workspace(tmp.path()); + let text = diff_snapshots(tmp.path(), &before, &after); + assert!(text.contains("a.txt"), "{text}"); + assert!(text.contains("b.txt"), "{text}"); + let combined = append_edit_diff("ok", &text); + assert!(combined.contains("Files changed"), "{combined}"); + } + + #[test] + fn secret_filenames_are_omitted() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join(".env"), "SECRET=1\n").unwrap(); + let before = snapshot_workspace(tmp.path()); + std::fs::write(tmp.path().join(".env"), "SECRET=2\n").unwrap(); + let after = snapshot_workspace(tmp.path()); + let text = diff_snapshots(tmp.path(), &before, &after); + assert!(text.is_empty(), "{text}"); + } + + #[test] + fn snapshot_discovers_edits_past_display_cap() { + let tmp = TempDir::new().unwrap(); + for i in 0..33 { + std::fs::write(tmp.path().join(format!("f{i:02}.txt")), "before\n").unwrap(); + } + let before = snapshot_workspace(tmp.path()); + assert_eq!(before.len(), 33); + std::fs::write(tmp.path().join("f32.txt"), "after\n").unwrap(); + let after = snapshot_workspace(tmp.path()); + let text = diff_snapshots(tmp.path(), &before, &after); + assert!(text.contains("f32.txt"), "{text}"); + } +} diff --git a/src/cortex-engine/src/tools/handlers/local_shell.rs b/src/cortex-engine/src/tools/handlers/local_shell.rs index 7744c27a..e2445e70 100644 --- a/src/cortex-engine/src/tools/handlers/local_shell.rs +++ b/src/cortex-engine/src/tools/handlers/local_shell.rs @@ -76,14 +76,19 @@ impl ToolHandler for LocalShellHandler { )); } - // Build the command (handles shell metacharacters) let command = Self::build_command(&args); + let capture_edits = super::bash_edit_diff::bash_edit_diff_enabled(&context.env); // Resolve working directory let cwd = match context.resolve_and_validate_path(args.workdir.as_deref().unwrap_or(".")) { Ok(cwd) => cwd, Err(message) => return Ok(ToolResult::error(message)), }; + let before = if capture_edits { + Some(super::bash_edit_diff::snapshot_workspace(&cwd)) + } else { + None + }; // Build execution options let timeout = args @@ -92,7 +97,7 @@ impl ToolHandler for LocalShellHandler { .unwrap_or(Duration::from_secs(60)); let options = ExecOptions { - cwd, + cwd: cwd.clone(), timeout, env: context.env.clone(), capture_output: true, @@ -133,7 +138,23 @@ impl ToolHandler for LocalShellHandler { // Convert ExecOutput to ToolResult match result { - Ok(output) => Ok(exec_output_to_tool_result(output)), + Ok(output) => { + let mut tool = exec_output_to_tool_result(output); + if let Some(before) = before { + let after = super::bash_edit_diff::snapshot_workspace(&cwd); + let diff = super::bash_edit_diff::diff_snapshots(&cwd, &before, &after); + if let Some(meta) = tool.metadata.as_mut() { + let changed: Vec = after + .keys() + .filter(|p| before.get(*p) != after.get(*p)) + .map(|p| p.display().to_string()) + .collect(); + meta.files_modified = changed; + } + tool.output = super::bash_edit_diff::append_edit_diff(&tool.output, &diff); + } + Ok(tool) + } Err(e) => Ok(ToolResult::error(format!("Execution failed: {e}"))), } } diff --git a/src/cortex-engine/src/tools/handlers/mod.rs b/src/cortex-engine/src/tools/handlers/mod.rs index ae95c822..b469e41d 100644 --- a/src/cortex-engine/src/tools/handlers/mod.rs +++ b/src/cortex-engine/src/tools/handlers/mod.rs @@ -1,6 +1,7 @@ //! Tool handlers. pub mod apply_patch; +mod bash_edit_diff; pub mod batch; mod create_agent; mod edit_file; diff --git a/src/cortex-engine/src/worktree.rs b/src/cortex-engine/src/worktree.rs new file mode 100644 index 00000000..5db2c83e --- /dev/null +++ b/src/cortex-engine/src/worktree.rs @@ -0,0 +1,313 @@ +//! Git worktree isolation for parallel Code agent runs. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde::{Deserialize, Serialize}; + +use crate::error::{CortexError, Result}; + +/// Isolated worktree created for a Code session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IsolatedWorktree { + /// Path the agent should use as cwd. + pub path: PathBuf, + /// Branch checked out in the worktree. + pub branch: String, + /// Parent repository root. + pub repo: PathBuf, + /// True when this process created the worktree (cleanup may remove it). + pub created: bool, +} + +/// Session metadata recorded next to an isolated worktree. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorktreeSessionMeta { + pub path: PathBuf, + pub branch: String, + pub repo: PathBuf, +} + +/// Create or reuse an isolated git worktree. +/// +/// `dest` is an optional target path. When omitted, a directory named +/// `.cortex-worktrees/` is created beside the repository. +pub fn isolate_worktree(cwd: &Path, dest: Option<&Path>, id: &str) -> Result { + let repo = git_root(cwd)?; + let path = match dest { + Some(p) if p.as_os_str().is_empty() || p == Path::new("auto") => { + default_worktree_path(&repo, id) + } + Some(p) => { + if p.is_absolute() { + p.to_path_buf() + } else { + cwd.join(p) + } + } + None => default_worktree_path(&repo, id), + }; + + if path.exists() { + if !path.is_dir() { + return Err(CortexError::InvalidInput( + "Worktree path exists and is not a directory".into(), + )); + } + return reuse_linked_worktree(&repo, &path); + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(CortexError::Io)?; + } + + let branch = format!("cortex/{}", sanitize_id(id)); + let output = Command::new("git") + .arg("worktree") + .arg("add") + .arg("-b") + .arg(&branch) + .arg(&path) + .arg("HEAD") + .current_dir(&repo) + .output() + .map_err(|e| CortexError::Internal(format!("Could not start git worktree: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(CortexError::InvalidInput(format!( + "Could not create an isolated worktree: {}", + first_line(&stderr) + ))); + } + + let isolated = IsolatedWorktree { + path: path.clone(), + branch, + repo, + created: true, + }; + write_session_meta(&isolated)?; + Ok(isolated) +} + +/// Remove a worktree this process created. Leaves user trees untouched. +pub fn cleanup_worktree(worktree: &IsolatedWorktree) -> Result<()> { + if !worktree.created { + return Ok(()); + } + let output = Command::new("git") + .arg("worktree") + .arg("remove") + .arg("--force") + .arg(&worktree.path) + .current_dir(&worktree.repo) + .output() + .map_err(|e| CortexError::Internal(format!("Could not remove git worktree: {e}")))?; + if !output.status.success() { + return Err(CortexError::InvalidInput( + "Could not remove the isolated worktree".into(), + )); + } + Ok(()) +} + +fn reuse_linked_worktree(repo: &Path, path: &Path) -> Result { + if same_dir(path, repo) { + return Err(CortexError::InvalidInput( + "Worktree path is the primary checkout, not a linked worktree".into(), + )); + } + let listed = git_worktree_paths(repo)?; + if !listed.iter().any(|listed_path| same_dir(listed_path, path)) { + return Err(CortexError::InvalidInput( + "Worktree path is not a linked worktree of this repository".into(), + )); + } + Ok(IsolatedWorktree { + path: path.to_path_buf(), + branch: current_branch(path).unwrap_or_else(|_| "HEAD".into()), + repo: repo.to_path_buf(), + created: false, + }) +} + +fn git_worktree_paths(repo: &Path) -> Result> { + let output = Command::new("git") + .args(["worktree", "list", "--porcelain"]) + .current_dir(repo) + .output() + .map_err(|e| CortexError::Internal(format!("Could not list git worktrees: {e}")))?; + if !output.status.success() { + return Err(CortexError::InvalidInput( + "Could not list git worktrees for this repository".into(), + )); + } + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.strip_prefix("worktree ")) + .map(PathBuf::from) + .collect()) +} + +fn same_dir(a: &Path, b: &Path) -> bool { + match (a.canonicalize(), b.canonicalize()) { + (Ok(left), Ok(right)) => left == right, + _ => a == b, + } +} + +fn default_worktree_path(repo: &Path, id: &str) -> PathBuf { + repo.join(".cortex-worktrees").join(sanitize_id(id)) +} + +fn git_root(cwd: &Path) -> Result { + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(cwd) + .output() + .map_err(|_| CortexError::InvalidInput("Not a git repository".into()))?; + if !output.status.success() { + return Err(CortexError::InvalidInput( + "Worktree isolation requires a git repository".into(), + )); + } + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(PathBuf::from(path)) +} + +fn current_branch(repo: &Path) -> Result { + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(repo) + .output() + .map_err(CortexError::Io)?; + if !output.status.success() { + return Err(CortexError::InvalidInput( + "Could not read git branch".into(), + )); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn write_session_meta(worktree: &IsolatedWorktree) -> Result<()> { + let meta = WorktreeSessionMeta { + path: worktree.path.clone(), + branch: worktree.branch.clone(), + repo: worktree.repo.clone(), + }; + let path = worktree.path.join(".cortex-worktree.json"); + std::fs::write(&path, serde_json::to_vec_pretty(&meta)?).map_err(CortexError::Io)?; + Ok(()) +} + +fn sanitize_id(id: &str) -> String { + let cleaned: String = id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '-' + } + }) + .take(32) + .collect(); + if cleaned.is_empty() { + "session".into() + } else { + cleaned + } +} + +fn first_line(s: &str) -> String { + s.lines() + .next() + .unwrap_or("git worktree failed") + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn git(cwd: &Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(cwd) + .status() + .unwrap(); + assert!(status.success(), "git {args:?}"); + } + + fn init_repo() -> TempDir { + let tmp = TempDir::new().unwrap(); + git(tmp.path(), &["init"]); + git(tmp.path(), &["checkout", "-b", "main"]); + git( + tmp.path(), + &["config", "user.email", "dev@cortex.foundation"], + ); + git(tmp.path(), &["config", "user.name", "Cortex"]); + std::fs::write(tmp.path().join("README.md"), "hello\n").unwrap(); + git(tmp.path(), &["add", "README.md"]); + git(tmp.path(), &["commit", "-m", "init"]); + tmp + } + + #[test] + fn isolate_creates_a_separate_worktree() { + let repo = init_repo(); + let isolated = isolate_worktree(repo.path(), None, "sess-1").unwrap(); + assert!(isolated.created); + assert!(isolated.path.join("README.md").exists()); + assert_ne!(isolated.path, repo.path()); + assert!(isolated.path.join(".cortex-worktree.json").exists()); + cleanup_worktree(&isolated).unwrap(); + assert!(!isolated.path.exists()); + } + + #[test] + fn missing_git_repo_fails_closed() { + let tmp = TempDir::new().unwrap(); + let err = isolate_worktree(tmp.path(), None, "x").unwrap_err(); + assert!(err.to_string().to_lowercase().contains("git"), "{}", err); + } + + #[test] + fn existing_unrelated_directory_is_rejected() { + let repo = init_repo(); + let other = TempDir::new().unwrap(); + let err = isolate_worktree(repo.path(), Some(other.path()), "sess-other").unwrap_err(); + assert!(err.to_string().contains("linked worktree"), "{err}"); + } + + #[test] + fn existing_linked_worktree_is_reused() { + let repo = init_repo(); + let linked = repo.path().join("linked-wt"); + git( + repo.path(), + &[ + "worktree", + "add", + "-b", + "cortex-reuse", + linked.to_str().unwrap(), + "HEAD", + ], + ); + let isolated = isolate_worktree(repo.path(), Some(&linked), "sess-reuse").unwrap(); + assert!(!isolated.created); + assert!(same_dir(&isolated.path, &linked)); + assert_eq!(isolated.branch, "cortex-reuse"); + } + + #[test] + fn primary_checkout_is_not_a_linked_worktree() { + let repo = init_repo(); + let err = isolate_worktree(repo.path(), Some(repo.path()), "sess-main").unwrap_err(); + assert!(err.to_string().contains("primary checkout"), "{err}"); + } +} diff --git a/src/cortex-tui/src/interactive/builders/jobs.rs b/src/cortex-tui/src/interactive/builders/jobs.rs new file mode 100644 index 00000000..bbadbfe3 --- /dev/null +++ b/src/cortex-tui/src/interactive/builders/jobs.rs @@ -0,0 +1,165 @@ +//! `/jobs` picker — background agents and subagents (lock `jobs`). + +use crate::app::SubagentTaskDisplay; +use crate::interactive::state::{InteractiveAction, InteractiveItem, InteractiveState}; + +/// One row in the jobs picker. +#[derive(Debug, Clone)] +pub struct JobRow { + pub id: String, + pub kind: String, + pub title: String, + pub status: String, +} + +impl JobRow { + pub fn from_subagent(task: &SubagentTaskDisplay) -> Self { + Self { + id: task.session_id.clone(), + kind: if task.agent_type.is_empty() { + "subagent".into() + } else { + task.agent_type.clone() + }, + title: task.description.clone(), + status: task.status.description(), + } + } +} + +/// Build `/jobs` interactive picker. Numbered `>` / `·` rows — not radios. +pub fn build_jobs_picker(jobs: &[JobRow]) -> InteractiveState { + let running = jobs.iter().filter(|j| job_is_running(&j.status)).count(); + let title = if jobs.is_empty() { + "Agents & jobs · none running".into() + } else { + format!("Agents & jobs · {running} running") + }; + + let items = if jobs.is_empty() { + vec![ + InteractiveItem::new("empty", "No background tasks running") + .with_description("Ctrl+B runs the current prompt in the background") + .with_disabled(true), + ] + } else { + jobs.iter() + .map(|job| { + InteractiveItem::new(job.id.clone(), format!("{} · {}", job.kind, job.title)) + .with_description(job.status.clone()) + }) + .collect() + }; + + InteractiveState::new( + title, + items, + InteractiveAction::Custom("jobs-picker".into()), + ) + .with_hints(vec![ + ("Enter".into(), "open".into()), + ("a".into(), "attach".into()), + ("x".into(), "cancel job".into()), + ("Esc".into(), "close".into()), + ]) +} + +pub(crate) fn job_is_running(status: &str) -> bool { + let s = status.to_ascii_lowercase(); + !(s.contains("done") + || s.contains("fail") + || s.contains("cancel") + || s.contains("wait") + || s.contains("queue") + || s.contains("stop")) +} + +pub const JOBS_ATTACH_PREFIX: &str = "attach:"; +pub const JOBS_STOP_PREFIX: &str = "stop:"; + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{SubagentDisplayStatus, SubagentTaskDisplay}; + + #[test] + fn empty_jobs_picker_is_honest() { + let state = build_jobs_picker(&[]); + assert!(state.title.contains("none running")); + assert!(state.items[0].disabled); + assert!(matches!( + state.action, + InteractiveAction::Custom(ref id) if id == "jobs-picker" + )); + } + + #[test] + fn jobs_from_subagents_keep_ids() { + let mut task = SubagentTaskDisplay::new("sub-1", "tool-1", "rate-limiter", "subagent"); + task.status = SubagentDisplayStatus::ExecutingTool("edit".into()); + let rows = vec![JobRow::from_subagent(&task)]; + let state = build_jobs_picker(&rows); + assert_eq!(state.items[0].id, "sub-1"); + assert!(state.title.contains("1 running")); + assert!(state.hints.as_ref().unwrap().iter().any(|(k, _)| k == "a")); + } + + #[test] + fn queued_jobs_are_not_counted_as_running() { + let rows = vec![JobRow { + id: "q".into(), + kind: "queued".into(), + title: "later".into(), + status: "waiting".into(), + }]; + let state = build_jobs_picker(&rows); + assert!(state.title.contains("0 running")); + assert!(!job_is_running("waiting")); + assert!(job_is_running("running")); + } + + #[test] + fn jobs_picker_uses_numbered_chevron_chrome_not_radios() { + use crate::interactive::renderer::InteractiveWidget; + use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; + + let rows = vec![ + JobRow { + id: "cloud".into(), + kind: "cloud agent".into(), + title: "bc-4f2a".into(), + status: "running".into(), + }, + JobRow { + id: "q".into(), + kind: "queued".into(), + title: "later".into(), + status: "waiting".into(), + }, + ]; + let state = build_jobs_picker(&rows); + let area = Rect::new(0, 0, 72, 10); + let mut buf = Buffer::empty(area); + InteractiveWidget::new(&state).render(area, &mut buf); + let mut text = String::new(); + for y in 0..area.height { + for x in 0..area.width { + text.push_str(buf[(x, y)].symbol()); + } + text.push('\n'); + } + assert!( + text.lines() + .any(|line| line.trim_start().starts_with('>') || line.starts_with("> ")), + "selected row must use `>` chrome, got {text}" + ); + assert!( + text.contains("· "), + "unselected rows must use middot chrome, got {text}" + ); + assert!( + !text.contains('●') && !text.contains('○'), + "jobs picker must not use radio glyphs, got {text}" + ); + } +} diff --git a/src/cortex-tui/src/interactive/builders/mod.rs b/src/cortex-tui/src/interactive/builders/mod.rs index bbaa2130..e5c1d94b 100644 --- a/src/cortex-tui/src/interactive/builders/mod.rs +++ b/src/cortex-tui/src/interactive/builders/mod.rs @@ -8,6 +8,7 @@ pub mod approval; pub mod billing; pub mod export; pub mod files; +pub mod jobs; pub mod login; pub mod mcp; pub mod model; @@ -37,6 +38,7 @@ pub use approval::{ pub use billing::{BillingFlowState, BillingStatus, build_billing_selector}; pub use export::build_export_selector; pub use files::{build_context_list, build_context_remove, build_file_browser}; +pub use jobs::{JobRow, build_jobs_picker}; pub use login::{ LoginFlowState, LoginStatus, build_already_logged_in_selector, build_login_selector, }; diff --git a/src/cortex-tui/src/interactive/handlers.rs b/src/cortex-tui/src/interactive/handlers.rs index 86d81b3f..7f92d691 100644 --- a/src/cortex-tui/src/interactive/handlers.rs +++ b/src/cortex-tui/src/interactive/handlers.rs @@ -236,12 +236,39 @@ fn handle_non_search_char(state: &mut InteractiveState, c: char) -> InteractiveR if let Some(result) = try_permission_prompt_edit(state, c) { return result; } + if let Some(result) = try_jobs_picker_key(state, c) { + return result; + } if let Some(result) = try_shortcut(state, c) { return result; } InteractiveResult::Continue } +fn try_jobs_picker_key(state: &mut InteractiveState, c: char) -> Option { + if !matches!( + &state.action, + InteractiveAction::Custom(id) if id == "jobs-picker" + ) { + return None; + } + let prefix = match c { + 'a' => super::builders::jobs::JOBS_ATTACH_PREFIX, + 'x' => super::builders::jobs::JOBS_STOP_PREFIX, + _ => return None, + }; + let item = state.selected_item()?; + if item.disabled { + return None; + } + let item_id = format!("{prefix}{}", item.id); + Some(InteractiveResult::Selected { + action: state.action.clone(), + item_id: item_id.clone(), + item_ids: vec![item_id], + }) +} + /// `e` selects Edit on the §3.10 permission prompt (in addition to digit `3`). fn try_permission_prompt_edit(state: &mut InteractiveState, c: char) -> Option { if c != 'e' @@ -431,4 +458,31 @@ mod tests { assert!(matches!(result, InteractiveResult::Continue)); assert!(state.effort.is_none()); } + + #[test] + fn jobs_picker_a_and_x_are_attach_and_stop() { + let rows = vec![crate::interactive::builders::JobRow { + id: "sess-1".into(), + kind: "cloud".into(), + title: "agent".into(), + status: "running".into(), + }]; + let mut state = crate::interactive::builders::build_jobs_picker(&rows); + let attach = handle_interactive_key( + &mut state, + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + ); + assert!(matches!( + attach, + InteractiveResult::Selected { ref item_id, .. } if item_id == "attach:sess-1" + )); + let stop = handle_interactive_key( + &mut state, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ); + assert!(matches!( + stop, + InteractiveResult::Selected { ref item_id, .. } if item_id == "stop:sess-1" + )); + } } diff --git a/src/cortex-tui/src/lib.rs b/src/cortex-tui/src/lib.rs index dcd51655..64f493c3 100644 --- a/src/cortex-tui/src/lib.rs +++ b/src/cortex-tui/src/lib.rs @@ -104,8 +104,10 @@ pub mod lock_boards; pub mod lock_palette; pub mod lock_proof; pub mod lock_v2; +mod lock_v2_boards; mod lock_v2_goal; mod lock_v2_ids; +mod lock_v2_scenes; pub mod readme_hero; pub mod readme_hero_boards; pub mod splash_chrome; diff --git a/src/cortex-tui/src/lock_v2.rs b/src/cortex-tui/src/lock_v2.rs index 6f6b98ad..d6c1dadb 100644 --- a/src/cortex-tui/src/lock_v2.rs +++ b/src/cortex-tui/src/lock_v2.rs @@ -4,38 +4,18 @@ //! is one live MockTerminal state — never an alias of another frame. use anyhow::{Context, Result}; -use cortex_core::widgets::Message; use cortex_tui_capture::{CaptureConfig, MockTerminal, StyleRendering}; use ratatui::widgets::Clear; use serde::Serialize; use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; -use crate::app::{ - AppState, AutocompleteItem, AutocompleteTrigger, SubagentDisplayStatus, SubagentTaskDisplay, - SubagentTodoItem, SubagentTodoStatus, -}; -use crate::commands::{CommandRegistry, CompletionEngine, PALETTE_HOME_LIMIT}; -use crate::interactive::builders::{ - SkillListItem, build_clear_confirm, build_mcp_selector, build_model_selector, - build_permissions_picker, build_plan_confirm, build_question_prompt, build_sandbox_deny_prompt, -}; -use crate::interactive::state::{InteractiveAction, InteractiveItem, InteractiveState}; use crate::lock_proof::{LOCK_SPLASH_VERSION, LockFrame}; -use crate::modal::mcp_manager::{McpServerInfo, McpStatus}; use crate::runner::login_screen::LoginScreen; -use crate::session::SessionSummary; -use crate::ui::consts::SERVICE_UNAVAILABLE_NEXT_STEP; use crate::views::minimal_session::MinimalSessionView; -use crate::views::tool_call::{ToolCallDisplay, ToolResultDisplay, ToolStatus}; -use crate::widgets::SettingsModalState; -use crate::widgets::settings_modal::SettingsRowKind; - -use crate::lock_v2_goal::{apply_goal_chip_scene, show_goal_in_narrow_palette}; pub use crate::lock_v2_ids::{LOCK_V2_NARROW_IDS, LOCK_V2_WIDE_IDS, lock_v2_scene_ids}; -const PRODUCT_ERROR: &str = "The coding service is temporarily unavailable"; +pub(crate) const PRODUCT_ERROR: &str = "The coding service is temporarily unavailable"; #[derive(Debug, Clone, Serialize)] struct Manifest { @@ -107,7 +87,7 @@ pub fn render_lock_v2_scene(id: &str, width: u16, height: u16) -> Result { - let state = scene_state(id, width, height); + let state = crate::lock_v2_boards::scene_state(id, width, height); let view = MinimalSessionView::new(&state); frame.render_widget(view, area); } @@ -122,1010 +102,10 @@ pub fn render_lock_v2_scene(id: &str, width: u16, height: u16) -> Result AppState { - let mut state = AppState::default(); - state.cli_version = env!("CARGO_PKG_VERSION").to_string(); - state.model = "cortex-1-mini".into(); - state.agent_mode_label = "Agent".into(); - state.thinking_budget = Some("medium".into()); - state.tokens_used = 0; - state.context_window = 500_000; - state.opt_in_banner = false; - state.timestamps_enabled = true; - state.caret_visible = true; - state -} - -fn resumed(state: &mut AppState) { - state.show_launch_splash = false; - state.tokens_used = 14_000; -} - -fn conversation(state: &mut AppState) { - resumed(state); - state.add_message( - Message::user("hey") - .with_timestamp("12:49 AM") - .with_thought_secs(0.4) - .with_worked_secs(1.8), - ); - if let Some(last) = state.messages.last_mut() { - last.thought_secs = None; - last.worked_secs = None; - } - state.add_message( - Message::assistant("Hey — what do you want to work on?") - .with_timestamp("12:49 AM") - .with_thought_secs(0.4) - .with_worked_secs(1.8), - ); - state.add_message(Message::user("tell me about yourself").with_timestamp("12:49 AM")); - state.add_message( - Message::assistant( - "I'm **Cortex**, a coding agent that runs in your terminal.\n\n\ -I mostly help you **build and debug software**: code, architecture, debugging, reviews, docs, and a bit of research.\n\ -Here I run in an interactive terminal, so I can read your files, run commands, and change the project.\n\n\ -In practice:\n\ -• I get straight to the point\n\ -• I prefer concrete work over long explanations\n\ -• I can also discuss, explain, or help you plan\n\n\ -Tell me what you'd like to do.", - ) - .with_timestamp("12:49 AM") - .with_thought_secs(0.4) - .with_worked_secs(4.6), - ); -} - -fn palette_state(query: &str) -> AppState { - let mut state = lock_app(); - conversation(&mut state); - state.input.set_text(query); - state.autocomplete.show(AutocompleteTrigger::Command, 0); - let registry = CommandRegistry::default(); - let engine = CompletionEngine::new(®istry); - let completions = engine.complete(query); - let items: Vec = completions - .into_iter() - .map(|c| AutocompleteItem::new(&c.command, &c.display, &c.description)) - .collect(); - state.autocomplete.set_items(items); - state.autocomplete.max_visible = PALETTE_HOME_LIMIT; - if query.len() > 1 { - state.autocomplete.set_query(&query[1..]); - } - state -} - -fn dummy_model(id: &str, name: &str) -> crate::providers::models::ModelInfo { - crate::providers::models::ModelInfo::new(id, name, "cortex") -} - -fn model_picker(state: &mut AppState, hovered: Option) { - conversation(state); - state.input.set_text("/model"); - let mut interactive = build_model_selector( - vec![ - dummy_model("cortex-1-mini", "Cortex Mini 1"), - dummy_model("cortex-1", "Cortex 1"), - dummy_model("cortex-1-max", "Cortex Max 1"), - ], - Some("cortex-1-mini"), - Some("medium"), - ); - interactive.hovered = hovered; - state.enter_interactive_mode(interactive); -} - -fn effort_picker(state: &mut AppState, effort: crate::interactive::EffortLevel, hover_low: bool) { - conversation(state); - state.input.set_text("/model Cortex Mini 1"); - let mut interactive = build_model_selector( - vec![dummy_model("cortex-1-mini", "Cortex Mini 1")], - Some("cortex-1-mini"), - Some(effort.as_str()), - ); - interactive.effort = Some(effort); - interactive.effort_focused = true; - if hover_low { - interactive.hovered = Some(1000 + 2); - } - state.thinking_budget = Some(effort.as_str().to_ascii_lowercase()); - state.enter_interactive_mode(interactive); -} - -fn open_settings(state: &mut AppState, tune: impl FnOnce(&mut SettingsModalState)) { - conversation(state); - let mut modal = SettingsModalState::default(); - modal.values = state.settings_values(); - tune(&mut modal); - state.settings_modal = Some(modal); -} - -/// Production permission prompt for lock `permission-prompt*`. -fn lock_permission_prompt(state: &mut AppState, hovered: Option) { - resumed(state); - state.add_message( - Message::user("add ioredis and a mock for the tests").with_timestamp("09:40 AM"), - ); - state.add_message( - Message::assistant( - "Cortex wants to run\n`$ npm install ioredis && npm install -D ioredis-mock`", - ) - .with_timestamp("09:40 AM") - .with_thought_secs(1.4), - ); - state.request_tool_approval( - "lock-perm".into(), - "shell".into(), - serde_json::json!({ - "command": "npm install ioredis && npm install -D ioredis-mock" - }), - None, - ); - if let Some(hover) = hovered - && let Some(interactive) = state.get_interactive_state_mut() - { - interactive.hovered = Some(hover); - } -} - -fn radios( - title: &str, - rows: &[(&str, &str, &str)], - selected: usize, - hovered: Option, -) -> InteractiveState { - let items = rows - .iter() - .map(|(id, label, desc)| InteractiveItem::new(*id, *label).with_description(*desc)) - .collect(); - let mut interactive = - InteractiveState::new(title, items, InteractiveAction::Custom(title.into())); - interactive.selected = selected.min(rows.len().saturating_sub(1)); - interactive.hovered = hovered; - interactive -} - -fn tool( - id: &str, - name: &str, - args: serde_json::Value, - status: ToolStatus, - output: &str, - summary: &str, - sequence: u64, -) -> ToolCallDisplay { - let mut call = ToolCallDisplay::new(id.into(), name.into(), args, sequence); - call.status = status; - call.collapsed = false; - if !output.is_empty() || !summary.is_empty() { - call.result = Some(ToolResultDisplay { - output: output.into(), - success: status != ToolStatus::Failed, - summary: summary.into(), - }); - } - call -} - -const DIFF_HUNK: &str = r#"@@ -20,6 +20,10 @@ - import Redis from "ioredis"; - import type { FastifyRequest } from "fastify"; --const limit = 30; -+const limit = 60; -+const windowSec = 60; - - export function rateLimit(opts: RateLimitOpts) { -- const redis = new Redis(); -+ const redis = new Redis(process.env.REDIS_URL); -"#; - -const MD_TABLE: &str = r#"Here is how the three models compare: - -| Model | Effort | Billing | -|---|---|---| -| Mini 1 | Medium | per request | -| Cortex 1 | High | per request | -| Max 1 | MAX | per token | - -Mini 1 is the default; switch with /model when a change needs deeper reasoning."#; - -const MD_FENCE: &str = r#"The limiter is a sliding window over a Redis sorted set: - -```ts -export async function rateLimit(key: string, limit = 60) { - const now = Date.now(); - await redis.zadd(key, now, String(now)); - return count <= limit; -} -``` - -It fails open when Redis is unreachable."#; - -fn scene_state(id: &str, width: u16, height: u16) -> AppState { - let mut state = lock_app(); - state.terminal_size = (width, height); - match id { - "welcome-cortex" => {} - "welcome-agent" => { - state.agent_entrypoint = true; - } - "first-run-tips" => { - state.settings.insert("first_run_tips".into(), "1".into()); - } - "session-empty" => { - resumed(&mut state); - } - "session-user-bars" => { - conversation(&mut state); - state.opt_in_banner = true; - } - "session-thought" => { - resumed(&mut state); - state.add_message( - Message::user("why does the composer lose focus after /model?") - .with_timestamp("10:02 AM"), - ); - state.add_message( - Message::assistant( - "The picker steals focus and never hands it back. `close_picker()` returns early when the effort radios are open.", - ) - .with_timestamp("10:02 AM") - .with_thought_secs(3.2), - ); - } - "session-thought-expanded" => { - resumed(&mut state); - state.show_thinking_blocks = true; - state.add_message( - Message::user("why does the composer lose focus after /model?") - .with_timestamp("10:02 AM"), - ); - state.add_message( - Message::assistant( - "**Thinking**\nThe picker steals focus and never hands it back because `close_picker()` returns early when the effort radios are open.\n\nThe picker steals focus and never hands it back. `close_picker()` returns early when the effort radios are open.", - ) - .with_timestamp("10:02 AM") - .with_thought_secs(3.2) - .with_worked_secs(6.0), - ); - } - "session-thinking-live" => { - resumed(&mut state); - state.add_message( - Message::user("why does the composer lose focus after /model?") - .with_timestamp("10:02 AM"), - ); - state.start_streaming(None, true); - state.streaming.thinking = true; - state.streaming.prompt_started_at = Some(Instant::now() - Duration::from_secs(3)); - } - "session-assistant" => { - resumed(&mut state); - state.add_message(Message::user("tell me about yourself").with_timestamp("12:49 AM")); - state.add_message( - Message::assistant( - "I'm **Cortex**, a coding agent that runs in your terminal.\n\n\ -In practice:\n\ -• I get straight to the point\n\ -• I prefer concrete work over long explanations\n\ -• I can also discuss, explain, or help you plan\n\n\ -Tell me what you'd like to do.", - ) - .with_timestamp("12:49 AM"), - ); - } - "session-worked" => { - resumed(&mut state); - state - .add_message(Message::user("summarize the auth module").with_timestamp("12:51 AM")); - state.add_message( - Message::assistant("Auth looks consistent — tokens land in the keyring and `/login` is the only entry.") - .with_timestamp("12:51 AM") - .with_worked_secs(4.6), - ); - } - "session-optin" => { - resumed(&mut state); - state.opt_in_banner = true; - state.add_message( - Message::user("can Cortex retain traces to improve the product?") - .with_timestamp("08:15 AM"), - ); - state.add_message( - Message::assistant( - "Off by default. Opt in from the banner, or later in /settings → Privacy.", - ) - .with_timestamp("08:15 AM"), - ); - } - "session-optin-hover" => { - resumed(&mut state); - state.opt_in_banner = true; - state.opt_in_hover = Some(1); - state.add_message( - Message::user("can Cortex retain traces to improve the product?") - .with_timestamp("08:15 AM"), - ); - state.add_message( - Message::assistant( - "Off by default. Opt in from the banner, or later in /settings → Privacy.", - ) - .with_timestamp("08:15 AM"), - ); - } - "composer-empty" => { - state.show_launch_splash = false; - state.tokens_used = 0; - } - "composer-typing" => { - resumed(&mut state); - state.input.set_text("hello"); - } - "composer-typing-blink" => { - resumed(&mut state); - state.input.set_text("hello"); - state.caret_visible = false; - } - "composer-hover" => { - resumed(&mut state); - state.composer_hovered = true; - } - "composer-multiline" => { - resumed(&mut state); - state.input.set_text("first line\nsecond line\nthird line"); - } - "footer-shortcuts" => { - resumed(&mut state); - state.input.set_text("list every shortcut"); - } - "footer-hover" => { - resumed(&mut state); - state.footer_hover = Some(1); - } - "tokens-topright" => { - resumed(&mut state); - state.tokens_used = 142_000; - state.add_message( - Message::user("run the tui tests and fix whatever fails") - .with_timestamp("09:14 AM"), - ); - state.add_message( - Message::assistant("I'll run the suite and patch failures.") - .with_timestamp("09:14 AM") - .with_thought_secs(2.1), - ); - state.tool_calls = vec![ - tool( - "sh", - "shell", - serde_json::json!({"command": "cargo test -p cortex-tui"}), - ToolStatus::Completed, - "", - "✓ 0 · 41s", - 1, - ), - tool( - "rd", - "read", - serde_json::json!({"path": "src/cortex-tui/src/composer.rs"}), - ToolStatus::Completed, - "", - "212 lines", - 2, - ), - tool( - "gr", - "grep", - serde_json::json!({"pattern": "alternate_screen", "path": "src/"}), - ToolStatus::Completed, - "", - "6 hits in 4 files", - 3, - ), - ]; - } - "tokens-topright-warn" => { - resumed(&mut state); - state.tokens_used = 460_000; - state.add_message( - Message::user("keep going on the rate limiter").with_timestamp("11:08 AM"), - ); - state.add_message( - Message::assistant("Context is nearly full — /compact will reclaim room.") - .with_timestamp("11:08 AM"), - ); - } - "compact-chat" => { - resumed(&mut state); - state.compact_mode = true; - state.timestamps_enabled = false; - state.add_message(Message::user("hey")); - state.add_message(Message::assistant("Hey — what do you want to work on?")); - state.add_message(Message::user("tell me about yourself")); - state.add_message(Message::assistant( - "I'm Cortex. Edge-to-edge bars, no timestamps.", - )); - } - "slash-palette" => { - let mut s = palette_state("/"); - if width <= 40 { - show_goal_in_narrow_palette(&mut s); - } else { - s.autocomplete.hovered = Some(3); - } - s.terminal_size = (width, height); - return s; - } - "slash-model-typed" => { - let mut s = palette_state("/mod"); - s.terminal_size = (width, height); - return s; - } - "model-list" => model_picker(&mut state, None), - "model-list-hover" => model_picker(&mut state, Some(2)), - "model-effort-high" => { - effort_picker(&mut state, crate::interactive::EffortLevel::High, false) - } - "model-effort-medium" => { - effort_picker(&mut state, crate::interactive::EffortLevel::Medium, false) - } - "model-effort-low" => { - effort_picker(&mut state, crate::interactive::EffortLevel::Low, false) - } - "model-effort-hover" => { - effort_picker(&mut state, crate::interactive::EffortLevel::Medium, true) - } - "settings-appearance" => open_settings(&mut state, |_| {}), - "settings-mouse" => open_settings(&mut state, |modal| { - if let Some(i) = modal - .visible_rows() - .iter() - .position(|r| r.id == "mouse_capture") - { - modal.selected = i; - modal.scroll = modal - .visible_rows() - .iter() - .position(|r| r.id == "mouse" || r.label == "Mouse") - .unwrap_or(i.saturating_sub(1)); - } - }), - "settings-row-hover" => open_settings(&mut state, |modal| { - modal.selected = 1; // Compact mode - if let Some(i) = modal - .visible_rows() - .iter() - .position(|r| r.id == "timestamps") - { - modal.hovered = Some(i); - } - }), - "settings-search" => open_settings(&mut state, |modal| { - modal.search = "scro".into(); - modal.search_focused = true; - modal.selected = 0; - if let Some(i) = modal - .visible_rows() - .iter() - .position(|r| r.kind != SettingsRowKind::Category) - { - modal.selected = i; - } - }), - "settings-theme-submenu" => open_settings(&mut state, |modal| { - modal.theme_open = true; - modal.theme_selected = 0; - }), - "mode-agent" => { - resumed(&mut state); - state.agent_mode_label = "Agent".into(); - state.add_message(Message::user("ship the lock v2 chrome").with_timestamp("09:00 AM")); - state.add_message( - Message::assistant("On it — Agent mode, edits allowed.").with_timestamp("09:00 AM"), - ); - } - "mode-plan" => { - resumed(&mut state); - state.agent_mode_label = "Plan".into(); - state.add_message( - Message::user("how should we ship lock v2?").with_timestamp("09:04 AM"), - ); - state.add_message( - Message::assistant( - "**Plan**\n1. Recapture every SPEC §7 board from the live session.\n2. Keep banner green on keyboard focus only.\n3. Do not merge until Designer signs off.", - ) - .with_timestamp("09:04 AM"), - ); - } - "mode-ask" => { - resumed(&mut state); - state.agent_mode_label = "Ask".into(); - state.add_message( - Message::user("where does the composer pin?").with_timestamp("09:06 AM"), - ); - state.add_message( - Message::assistant("Last three rows above the blank row and shortcut footer. Ask mode is read-only.") - .with_timestamp("09:06 AM"), - ); - } - "mode-bash" => { - resumed(&mut state); - state.agent_mode_label = "Bash".into(); - state.input.set_text("git status"); - } - "permission-prompt" => { - lock_permission_prompt(&mut state, None); - } - "permission-prompt-hover" => { - lock_permission_prompt(&mut state, Some(1)); - } - "permissions-picker" => { - resumed(&mut state); - state.input.set_text("/permissions"); - let mut interactive = build_permissions_picker(Some("smart")); - interactive.selected = 1; - state.enter_interactive_mode(interactive); - } - "mcp-servers" => { - resumed(&mut state); - state.input.set_text("/mcp"); - let servers = vec![ - McpServerInfo { - name: "github".into(), - status: McpStatus::Running, - tool_count: 12, - error: None, - requires_auth: false, - }, - McpServerInfo { - name: "linear".into(), - status: McpStatus::Starting, - tool_count: 0, - error: None, - requires_auth: true, - }, - McpServerInfo { - name: "jira".into(), - status: McpStatus::Error, - tool_count: 0, - error: Some("auth failed".into()), - requires_auth: true, - }, - ]; - state.enter_interactive_mode(build_mcp_selector(&servers)); - } - "mcp-drop" => { - resumed(&mut state); - state.add_message(Message::user("list open PRs").with_timestamp("10:11 AM")); - state.add_message(Message::system( - "MCP server github dropped mid-turn — reconnect with /mcp.", - )); - } - "plugins" => { - resumed(&mut state); - state.input.set_text("/plugins"); - state.enter_interactive_mode(radios( - "Plugins", - &[ - ("review", "cortex-review", "enabled"), - ("mermaid", "mermaid-preview", "enabled"), - ("jira", "jira", "disabled"), - ], - 0, - None, - )); - } - "usage" => { - resumed(&mut state); - state.input.set_text("/usage"); - state.enter_interactive_mode(radios( - "Usage", - &[ - ("plan", "Cortex Pro", "current plan"), - ("agent", "Agent requests", "42 / 500 this period"), - ("tokens", "Tokens", "8.4M / 12M"), - ("billing", "Billing", "cortex.foundation/billing"), - ], - 0, - None, - )); - } - "quota-exhausted" => { - resumed(&mut state); - state.quota_held = true; - state.add_message(Message::user("keep going").with_timestamp("04:12 PM")); - state.add_message(Message::system( - "Agent quota exhausted — 500 / 500. Follow-ups stay in the composer until quota resets.", - )); - } - "sandbox" => { - resumed(&mut state); - state.input.set_text("/sandbox"); - state.enter_interactive_mode(radios( - "Sandbox", - &[ - ("fs", "Filesystem", "workspace only"), - ("net", "Network", "ask before leaving"), - ("esc", "Escalation", "Smart"), - ], - 0, - None, - )); - } - "sandbox-deny" => { - resumed(&mut state); - state.add_message( - Message::user("curl https://example.invalid").with_timestamp("10:22 AM"), - ); - state.add_message(Message::system( - "Sandbox denied: network egress is blocked for this command.", - )); - state.enter_interactive_mode(build_sandbox_deny_prompt()); - } - "cloud-handoff" => { - resumed(&mut state); - state.add_message( - Message::user("& ship this on a cloud agent").with_timestamp("02:18 PM"), - ); - state.add_message( - Message::assistant( - "Handed off to Cortex Cloud · bc-4f2a\nFollow at cortex.foundation/agents/bc-4f2a · or /jobs right here.", - ) - .with_timestamp("02:18 PM"), - ); - } - "diagnostics" => { - resumed(&mut state); - state.add_message(Message::user("check the workspace").with_timestamp("08:03 AM")); - state.tool_calls = vec![tool( - "diag", - "diagnostics", - serde_json::json!({"path": "src/"}), - ToolStatus::Failed, - "error: type mismatch in lock_v2.rs\nwarning: unused import in chrome.rs", - "1 error · 1 warning", - 1, - )]; - state.add_message(Message::system( - "Diagnostics · 1 error, 1 warning — Check your types before capturing.", - )); - } - "interrupt-stopped" => { - resumed(&mut state); - state.add_message(Message::user("rewrite the whole crate").with_timestamp("09:33 AM")); - state.add_message(Message::system("× Stopped")); - } - "error-unavailable" => { - resumed(&mut state); - state.add_message(Message::user("review the auth module").with_timestamp("01:02 PM")); - state.add_message(Message::system(PRODUCT_ERROR)); - state.add_message(Message::system(SERVICE_UNAVAILABLE_NEXT_STEP)); - } - "tool-tiles" => { - resumed(&mut state); - state.group_tool_calls = true; - state.add_message(Message::user("inspect the composer").with_timestamp("09:14 AM")); - state.tool_calls = vec![ - tool( - "rd", - "read", - serde_json::json!({"path": "src/cortex-tui/src/ui/chrome.rs"}), - ToolStatus::Completed, - "pub fn paint_composer_box(\n area: Rect,\n buf: &mut Buffer,\n) {", - "88 lines", - 1, - ), - tool( - "gr", - "grep", - serde_json::json!({"pattern": "paint_composer", "path": "src/"}), - ToolStatus::Completed, - "src/cortex-tui/src/ui/chrome.rs\nsrc/cortex-tui/src/views/minimal_session/view.rs", - "6 hits in 2 files", - 2, - ), - tool( - "sh", - "shell", - serde_json::json!({"command": "rg paint_composer_box"}), - ToolStatus::Completed, - "chrome.rs:127:pub fn paint_composer_box", - "1 match", - 3, - ), - ]; - } - "tool-tiles-collapsed" => { - resumed(&mut state); - state.group_tool_calls = true; - state.add_message(Message::user("inspect the composer").with_timestamp("09:14 AM")); - state.tool_calls = vec![ - tool( - "rd", - "read", - serde_json::json!({"path": "src/cortex-tui/src/ui/chrome.rs"}), - ToolStatus::Completed, - "", - "88 lines", - 1, - ), - tool( - "gr", - "grep", - serde_json::json!({"pattern": "paint_composer"}), - ToolStatus::Completed, - "", - "6 hits", - 2, - ), - tool( - "sh", - "shell", - serde_json::json!({"command": "rg paint_composer_box"}), - ToolStatus::Completed, - "", - "1 match", - 3, - ), - ]; - } - "shell-running" => { - resumed(&mut state); - state.add_message( - Message::user("run cargo test -p cortex-tui").with_timestamp("09:20 AM"), - ); - state.start_streaming(Some("shell".into()), true); - let mut sh = tool( - "sh", - "shell", - serde_json::json!({"command": "cargo test -p cortex-tui"}), - ToolStatus::Running, - "", - "", - 1, - ); - sh.live_output = vec![ - "running 1025 tests".into(), - "test lock_v2::tests::lock_v2_wide_frames_are_unique ... ok".into(), - "test lock_v2::tests::welcome_paints_inky_and_token_counter ... ok".into(), - ]; - state.tool_calls = vec![sh]; - } - "diff-hunk" => { - resumed(&mut state); - state.add_message( - Message::user("raise the rate limit to 60").with_timestamp("10:40 AM"), - ); - state.tool_calls = vec![tool( - "ed", - "edit", - serde_json::json!({"path": "src/config/rateLimits.ts"}), - ToolStatus::Completed, - DIFF_HUNK, - "Edit src/config/rateLimits.ts · +4 -2", - 1, - )]; - } - "edit-collapsed" => { - resumed(&mut state); - state.collapsed_edit_blocks = true; - state.add_message( - Message::user("raise the rate limit to 60").with_timestamp("10:40 AM"), - ); - state.tool_calls = vec![tool( - "ed", - "edit", - serde_json::json!({"path": "src/config/rateLimits.ts"}), - ToolStatus::Completed, - "", - "Edit src/config/rateLimits.ts · +4 -2", - 1, - )]; - } - "md-table" => { - resumed(&mut state); - state.add_message( - Message::user("Compare the three models for this project") - .with_timestamp("03:11 PM"), - ); - state.add_message(Message::assistant(MD_TABLE).with_timestamp("03:11 PM")); - } - "code-fence" => { - resumed(&mut state); - state.add_message( - Message::user("Show me the middleware you wrote").with_timestamp("03:12 PM"), - ); - state.add_message(Message::assistant(MD_FENCE).with_timestamp("03:12 PM")); - } - "shortcuts-overlay" => { - resumed(&mut state); - state.shortcuts_open = true; - } - "resume-picker" => { - resumed(&mut state); - let now = chrono::Utc::now(); - let sessions = vec![ - SessionSummary { - id: "sess-lock-v2".into(), - title: "lock v2 runtime chrome".into(), - model: "cortex-1-mini".into(), - provider: "cortex".into(), - created_at: now - chrono::Duration::hours(2), - updated_at: now - chrono::Duration::minutes(12), - message_count: 18, - archived: false, - }, - SessionSummary { - id: "sess-rate-limit".into(), - title: "rate limiter redis window".into(), - model: "cortex-1".into(), - provider: "cortex".into(), - created_at: now - chrono::Duration::days(1), - updated_at: now - chrono::Duration::hours(5), - message_count: 42, - archived: false, - }, - ]; - state.enter_interactive_mode(crate::interactive::builders::build_resume_picker( - &sessions, false, - )); - } - "clear-confirm" => { - resumed(&mut state); - conversation(&mut state); - state.enter_interactive_mode(build_clear_confirm()); - } - "plan-confirm" => { - resumed(&mut state); - state.agent_mode_label = "Plan".into(); - state.add_message( - Message::assistant( - "**Plan**\nRecapture every SPEC §7 board, then wait for Designer.", - ) - .with_timestamp("09:05 AM"), - ); - state.enter_interactive_mode(build_plan_confirm()); - } - "queue" => { - resumed(&mut state); - state.add_message(Message::user("rewrite chrome.rs").with_timestamp("09:30 AM")); - state.start_streaming(None, true); - state.streaming.thinking = true; - state.queue_message("also recapture the 40x12 set".into()); - } - "files-picker" => { - resumed(&mut state); - let mut interactive = radios( - "Files", - &[ - ("rl", "src/config/rateLimits.json", "2 days ago"), - ("ch", "src/cortex-tui/src/ui/chrome.rs", "today"), - ("lv", "src/cortex-tui/src/lock_v2.rs", "today"), - ], - 0, - Some(1), - ) - .with_search(); - interactive.search_query = "rate".into(); - state.enter_interactive_mode(interactive); - } - "jobs" => { - resumed(&mut state); - state.input.set_text("/jobs"); - state.enter_interactive_mode(radios( - "Jobs", - &[ - ("cloud", "cloud agent · bc-4f2a", "running"), - ("sub", "subagent · rate-limiter", "running"), - ("q", "queued · recapture PNGs", "waiting"), - ], - 0, - None, - )); - } - "skills" => { - resumed(&mut state); - let skills = [ - SkillListItem { - name: "review".into(), - description: "Review a diff against the lock".into(), - }, - SkillListItem { - name: "capture".into(), - description: "Recapture TUI lock boards".into(), - }, - ]; - state.enter_interactive_mode(crate::interactive::builders::build_skills_selector( - &skills, - )); - } - "todos" => { - resumed(&mut state); - state.add_message( - Message::user("work through the capture checklist").with_timestamp("09:41 AM"), - ); - let mut task = SubagentTaskDisplay::new("sub-1", "tool-1", "lock v2 captures", "code"); - task.status = SubagentDisplayStatus::ExecutingTool("edit".into()); - task.todos = vec![ - SubagentTodoItem::new("Expand lock_v2 scene ids", SubagentTodoStatus::Completed), - SubagentTodoItem::new( - "Make every frame a unique state", - SubagentTodoStatus::Completed, - ), - SubagentTodoItem::new("Recapture 120×40 and 40×12", SubagentTodoStatus::InProgress), - SubagentTodoItem::new("Verify sha256 uniqueness", SubagentTodoStatus::Pending), - SubagentTodoItem::new("Keep the PR drafted", SubagentTodoStatus::Pending), - ]; - state.active_subagents = vec![task]; - } - "question" => { - resumed(&mut state); - state.add_message( - Message::assistant("Which capture size should we lock first?") - .with_timestamp("09:44 AM"), - ); - state.enter_interactive_mode(build_question_prompt( - "Question", - &[ - ("wide", "1 120×40 first", "wide boards"), - ("narrow", "2 40×12 first", "narrow boards"), - ("both", "3 Both together", "full SPEC §7 set"), - ], - 2, - )); - } - "sudo" => { - resumed(&mut state); - state.add_message( - Message::user("restart the sandbox daemon").with_timestamp("11:12 AM"), - ); - state.enter_interactive_mode(radios( - "Elevated Shell", - &[ - ("pw", "Password", "••••••••"), - ("once", "1 Run once", "elevated"), - ("cancel", "2 Cancel", "do not run"), - ], - 0, - None, - )); - } - "config-tree" => { - resumed(&mut state); - state.input.set_text("/config"); - state.enter_interactive_mode(radios( - "Config", - &[ - ("path", "~/.cortex/config.json", "read-only"), - ("model", "model", "Cortex Mini 1"), - ("effort", "effort", "medium"), - ("tui", "tui.theme", "dark → Cortex Night"), - ], - 0, - None, - )); - } - "btw" => { - resumed(&mut state); - state.add_message(Message::user("keep rewriting chrome.rs").with_timestamp("09:50 AM")); - state.start_streaming(None, true); - state.streaming.thinking = false; - state.streaming.is_actively_streaming = true; - state.add_message( - Message::user("/btw keep the composer dual-hairline").with_timestamp("09:51 AM"), - ); - } - id if apply_goal_chip_scene(id, &mut state) => {} - other => panic!("unknown lock v2 scene {other}"), - } - state -} - #[cfg(test)] mod tests { use super::*; + use crate::lock_v2_scenes::*; use cortex_core::style::{ACCENT, BAR_HOVER, SELECTION_BG, VOID}; use ratatui::widgets::Widget; use std::collections::HashMap; diff --git a/src/cortex-tui/src/lock_v2_boards.rs b/src/cortex-tui/src/lock_v2_boards.rs new file mode 100644 index 00000000..49305366 --- /dev/null +++ b/src/cortex-tui/src/lock_v2_boards.rs @@ -0,0 +1,813 @@ +//! Lock v2 session board states (`scene_state`). +//! +//! Permission-prompt lock chrome is unchanged; this module only relocates +//! existing scene construction so `lock_v2.rs` stays under 1000 lines. + +use cortex_core::widgets::Message; +use std::time::{Duration, Instant}; + +use crate::app::{ + AppState, SubagentDisplayStatus, SubagentTaskDisplay, SubagentTodoItem, SubagentTodoStatus, +}; +use crate::interactive::builders::{ + JobRow, SkillListItem, build_clear_confirm, build_jobs_picker, build_mcp_selector, + build_permissions_picker, build_plan_confirm, build_question_prompt, build_sandbox_deny_prompt, +}; +use crate::lock_v2::PRODUCT_ERROR; +use crate::lock_v2_goal::{apply_goal_chip_scene, show_goal_in_narrow_palette}; +use crate::lock_v2_scenes::*; +use crate::modal::mcp_manager::{McpServerInfo, McpStatus}; +use crate::session::SessionSummary; +use crate::ui::consts::SERVICE_UNAVAILABLE_NEXT_STEP; +use crate::views::tool_call::ToolStatus; +use crate::widgets::settings_modal::SettingsRowKind; + +pub(crate) fn scene_state(id: &str, width: u16, height: u16) -> AppState { + let mut state = lock_app(); + state.terminal_size = (width, height); + match id { + "welcome-cortex" => {} + "welcome-agent" => { + state.agent_entrypoint = true; + } + "first-run-tips" => { + state.settings.insert("first_run_tips".into(), "1".into()); + } + "session-empty" => { + resumed(&mut state); + } + "session-user-bars" => { + conversation(&mut state); + state.opt_in_banner = true; + } + "session-thought" => { + resumed(&mut state); + state.add_message( + Message::user("why does the composer lose focus after /model?") + .with_timestamp("10:02 AM"), + ); + state.add_message( + Message::assistant( + "The picker steals focus and never hands it back. `close_picker()` returns early when the effort radios are open.", + ) + .with_timestamp("10:02 AM") + .with_thought_secs(3.2), + ); + } + "session-thought-expanded" => { + resumed(&mut state); + state.show_thinking_blocks = true; + state.add_message( + Message::user("why does the composer lose focus after /model?") + .with_timestamp("10:02 AM"), + ); + state.add_message( + Message::assistant( + "**Thinking**\nThe picker steals focus and never hands it back because `close_picker()` returns early when the effort radios are open.\n\nThe picker steals focus and never hands it back. `close_picker()` returns early when the effort radios are open.", + ) + .with_timestamp("10:02 AM") + .with_thought_secs(3.2) + .with_worked_secs(6.0), + ); + } + "session-thinking-live" => { + resumed(&mut state); + state.add_message( + Message::user("why does the composer lose focus after /model?") + .with_timestamp("10:02 AM"), + ); + state.start_streaming(None, true); + state.streaming.thinking = true; + state.streaming.prompt_started_at = Some(Instant::now() - Duration::from_secs(3)); + } + "session-assistant" => { + resumed(&mut state); + state.add_message(Message::user("tell me about yourself").with_timestamp("12:49 AM")); + state.add_message( + Message::assistant( + "I'm **Cortex**, a coding agent that runs in your terminal.\n\n\ +In practice:\n\ +• I get straight to the point\n\ +• I prefer concrete work over long explanations\n\ +• I can also discuss, explain, or help you plan\n\n\ +Tell me what you'd like to do.", + ) + .with_timestamp("12:49 AM"), + ); + } + "session-worked" => { + resumed(&mut state); + state + .add_message(Message::user("summarize the auth module").with_timestamp("12:51 AM")); + state.add_message( + Message::assistant("Auth looks consistent — tokens land in the keyring and `/login` is the only entry.") + .with_timestamp("12:51 AM") + .with_worked_secs(4.6), + ); + } + "session-optin" => { + resumed(&mut state); + state.opt_in_banner = true; + state.add_message( + Message::user("can Cortex retain traces to improve the product?") + .with_timestamp("08:15 AM"), + ); + state.add_message( + Message::assistant( + "Off by default. Opt in from the banner, or later in /settings → Privacy.", + ) + .with_timestamp("08:15 AM"), + ); + } + "session-optin-hover" => { + resumed(&mut state); + state.opt_in_banner = true; + state.opt_in_hover = Some(1); + state.add_message( + Message::user("can Cortex retain traces to improve the product?") + .with_timestamp("08:15 AM"), + ); + state.add_message( + Message::assistant( + "Off by default. Opt in from the banner, or later in /settings → Privacy.", + ) + .with_timestamp("08:15 AM"), + ); + } + "composer-empty" => { + state.show_launch_splash = false; + state.tokens_used = 0; + } + "composer-typing" => { + resumed(&mut state); + state.input.set_text("hello"); + } + "composer-typing-blink" => { + resumed(&mut state); + state.input.set_text("hello"); + state.caret_visible = false; + } + "composer-hover" => { + resumed(&mut state); + state.composer_hovered = true; + } + "composer-multiline" => { + resumed(&mut state); + state.input.set_text("first line\nsecond line\nthird line"); + } + "footer-shortcuts" => { + resumed(&mut state); + state.input.set_text("list every shortcut"); + } + "footer-hover" => { + resumed(&mut state); + state.footer_hover = Some(1); + } + "tokens-topright" => { + resumed(&mut state); + state.tokens_used = 142_000; + state.add_message( + Message::user("run the tui tests and fix whatever fails") + .with_timestamp("09:14 AM"), + ); + state.add_message( + Message::assistant("I'll run the suite and patch failures.") + .with_timestamp("09:14 AM") + .with_thought_secs(2.1), + ); + state.tool_calls = vec![ + tool( + "sh", + "shell", + serde_json::json!({"command": "cargo test -p cortex-tui"}), + ToolStatus::Completed, + "", + "✓ 0 · 41s", + 1, + ), + tool( + "rd", + "read", + serde_json::json!({"path": "src/cortex-tui/src/composer.rs"}), + ToolStatus::Completed, + "", + "212 lines", + 2, + ), + tool( + "gr", + "grep", + serde_json::json!({"pattern": "alternate_screen", "path": "src/"}), + ToolStatus::Completed, + "", + "6 hits in 4 files", + 3, + ), + ]; + } + "tokens-topright-warn" => { + resumed(&mut state); + state.tokens_used = 460_000; + state.add_message( + Message::user("keep going on the rate limiter").with_timestamp("11:08 AM"), + ); + state.add_message( + Message::assistant("Context is nearly full — /compact will reclaim room.") + .with_timestamp("11:08 AM"), + ); + } + "compact-chat" => { + resumed(&mut state); + state.compact_mode = true; + state.timestamps_enabled = false; + state.add_message(Message::user("hey")); + state.add_message(Message::assistant("Hey — what do you want to work on?")); + state.add_message(Message::user("tell me about yourself")); + state.add_message(Message::assistant( + "I'm Cortex. Edge-to-edge bars, no timestamps.", + )); + } + "slash-palette" => { + let mut s = palette_state("/"); + if width <= 40 { + show_goal_in_narrow_palette(&mut s); + } else { + s.autocomplete.hovered = Some(3); + } + s.terminal_size = (width, height); + return s; + } + "slash-model-typed" => { + let mut s = palette_state("/mod"); + s.terminal_size = (width, height); + return s; + } + "model-list" => model_picker(&mut state, None), + "model-list-hover" => model_picker(&mut state, Some(2)), + "model-effort-high" => { + effort_picker(&mut state, crate::interactive::EffortLevel::High, false) + } + "model-effort-medium" => { + effort_picker(&mut state, crate::interactive::EffortLevel::Medium, false) + } + "model-effort-low" => { + effort_picker(&mut state, crate::interactive::EffortLevel::Low, false) + } + "model-effort-hover" => { + effort_picker(&mut state, crate::interactive::EffortLevel::Medium, true) + } + "settings-appearance" => open_settings(&mut state, |_| {}), + "settings-mouse" => open_settings(&mut state, |modal| { + if let Some(i) = modal + .visible_rows() + .iter() + .position(|r| r.id == "mouse_capture") + { + modal.selected = i; + modal.scroll = modal + .visible_rows() + .iter() + .position(|r| r.id == "mouse" || r.label == "Mouse") + .unwrap_or(i.saturating_sub(1)); + } + }), + "settings-row-hover" => open_settings(&mut state, |modal| { + modal.selected = 1; // Compact mode + if let Some(i) = modal + .visible_rows() + .iter() + .position(|r| r.id == "timestamps") + { + modal.hovered = Some(i); + } + }), + "settings-search" => open_settings(&mut state, |modal| { + modal.search = "scro".into(); + modal.search_focused = true; + modal.selected = 0; + if let Some(i) = modal + .visible_rows() + .iter() + .position(|r| r.kind != SettingsRowKind::Category) + { + modal.selected = i; + } + }), + "settings-theme-submenu" => open_settings(&mut state, |modal| { + modal.theme_open = true; + modal.theme_selected = 0; + }), + "mode-agent" => { + resumed(&mut state); + state.agent_mode_label = "Agent".into(); + state.add_message(Message::user("ship the lock v2 chrome").with_timestamp("09:00 AM")); + state.add_message( + Message::assistant("On it — Agent mode, edits allowed.").with_timestamp("09:00 AM"), + ); + } + "mode-plan" => { + resumed(&mut state); + state.agent_mode_label = "Plan".into(); + state.add_message( + Message::user("how should we ship lock v2?").with_timestamp("09:04 AM"), + ); + state.add_message( + Message::assistant( + "**Plan**\n1. Recapture every SPEC §7 board from the live session.\n2. Keep banner green on keyboard focus only.\n3. Do not merge until Designer signs off.", + ) + .with_timestamp("09:04 AM"), + ); + } + "mode-ask" => { + resumed(&mut state); + state.agent_mode_label = "Ask".into(); + state.add_message( + Message::user("where does the composer pin?").with_timestamp("09:06 AM"), + ); + state.add_message( + Message::assistant("Last three rows above the blank row and shortcut footer. Ask mode is read-only.") + .with_timestamp("09:06 AM"), + ); + } + "mode-bash" => { + resumed(&mut state); + state.agent_mode_label = "Bash".into(); + state.input.set_text("git status"); + } + "permission-prompt" => { + lock_permission_prompt(&mut state, None); + } + "permission-prompt-hover" => { + lock_permission_prompt(&mut state, Some(1)); + } + "permissions-picker" => { + resumed(&mut state); + state.input.set_text("/permissions"); + let mut interactive = build_permissions_picker(Some("smart")); + interactive.selected = 1; + state.enter_interactive_mode(interactive); + } + "mcp-servers" => { + resumed(&mut state); + state.input.set_text("/mcp"); + let servers = vec![ + McpServerInfo { + name: "github".into(), + status: McpStatus::Running, + tool_count: 12, + error: None, + requires_auth: false, + }, + McpServerInfo { + name: "linear".into(), + status: McpStatus::Starting, + tool_count: 0, + error: None, + requires_auth: true, + }, + McpServerInfo { + name: "jira".into(), + status: McpStatus::Error, + tool_count: 0, + error: Some("auth failed".into()), + requires_auth: true, + }, + ]; + state.enter_interactive_mode(build_mcp_selector(&servers)); + } + "mcp-drop" => { + resumed(&mut state); + state.add_message(Message::user("list open PRs").with_timestamp("10:11 AM")); + state.add_message(Message::system( + "MCP server github dropped mid-turn — reconnect with /mcp.", + )); + } + "plugins" => { + resumed(&mut state); + state.input.set_text("/plugins"); + state.enter_interactive_mode(radios( + "Plugins", + &[ + ("review", "cortex-review", "enabled"), + ("mermaid", "mermaid-preview", "enabled"), + ("jira", "jira", "disabled"), + ], + 0, + None, + )); + } + "usage" => { + resumed(&mut state); + state.input.set_text("/usage"); + state.enter_interactive_mode(radios( + "Usage", + &[ + ("plan", "Cortex Pro", "current plan"), + ("agent", "Agent requests", "42 / 500 this period"), + ("tokens", "Tokens", "8.4M / 12M"), + ("billing", "Billing", "cortex.foundation/billing"), + ], + 0, + None, + )); + } + "quota-exhausted" => { + resumed(&mut state); + state.quota_held = true; + state.add_message(Message::user("keep going").with_timestamp("04:12 PM")); + state.add_message(Message::system( + "Agent quota exhausted — 500 / 500. Follow-ups stay in the composer until quota resets.", + )); + } + "sandbox" => { + resumed(&mut state); + state.input.set_text("/sandbox"); + state.enter_interactive_mode(radios( + "Sandbox", + &[ + ("fs", "Filesystem", "workspace only"), + ("net", "Network", "ask before leaving"), + ("esc", "Escalation", "Smart"), + ], + 0, + None, + )); + } + "sandbox-deny" => { + resumed(&mut state); + state.add_message( + Message::user("curl https://example.invalid").with_timestamp("10:22 AM"), + ); + state.add_message(Message::system( + "Sandbox denied: network egress is blocked for this command.", + )); + state.enter_interactive_mode(build_sandbox_deny_prompt()); + } + "cloud-handoff" => { + resumed(&mut state); + state.add_message( + Message::user("& ship this on a cloud agent").with_timestamp("02:18 PM"), + ); + state.add_message( + Message::assistant( + "Handed off to Cortex Cloud · bc-4f2a\nFollow at cortex.foundation/agents/bc-4f2a · or /jobs right here.", + ) + .with_timestamp("02:18 PM"), + ); + } + "diagnostics" => { + resumed(&mut state); + state.add_message(Message::user("check the workspace").with_timestamp("08:03 AM")); + state.tool_calls = vec![tool( + "diag", + "diagnostics", + serde_json::json!({"path": "src/"}), + ToolStatus::Failed, + "error: type mismatch in lock_v2.rs\nwarning: unused import in chrome.rs", + "1 error · 1 warning", + 1, + )]; + state.add_message(Message::system( + "Diagnostics · 1 error, 1 warning — Check your types before capturing.", + )); + } + "interrupt-stopped" => { + resumed(&mut state); + state.add_message(Message::user("rewrite the whole crate").with_timestamp("09:33 AM")); + state.add_message(Message::system("× Stopped")); + } + "error-unavailable" => { + resumed(&mut state); + state.add_message(Message::user("review the auth module").with_timestamp("01:02 PM")); + state.add_message(Message::system(PRODUCT_ERROR)); + state.add_message(Message::system(SERVICE_UNAVAILABLE_NEXT_STEP)); + } + "tool-tiles" => { + resumed(&mut state); + state.group_tool_calls = true; + state.add_message(Message::user("inspect the composer").with_timestamp("09:14 AM")); + state.tool_calls = vec![ + tool( + "rd", + "read", + serde_json::json!({"path": "src/cortex-tui/src/ui/chrome.rs"}), + ToolStatus::Completed, + "pub fn paint_composer_box(\n area: Rect,\n buf: &mut Buffer,\n) {", + "88 lines", + 1, + ), + tool( + "gr", + "grep", + serde_json::json!({"pattern": "paint_composer", "path": "src/"}), + ToolStatus::Completed, + "src/cortex-tui/src/ui/chrome.rs\nsrc/cortex-tui/src/views/minimal_session/view.rs", + "6 hits in 2 files", + 2, + ), + tool( + "sh", + "shell", + serde_json::json!({"command": "rg paint_composer_box"}), + ToolStatus::Completed, + "chrome.rs:127:pub fn paint_composer_box", + "1 match", + 3, + ), + ]; + } + "tool-tiles-collapsed" => { + resumed(&mut state); + state.group_tool_calls = true; + state.add_message(Message::user("inspect the composer").with_timestamp("09:14 AM")); + state.tool_calls = vec![ + tool( + "rd", + "read", + serde_json::json!({"path": "src/cortex-tui/src/ui/chrome.rs"}), + ToolStatus::Completed, + "", + "88 lines", + 1, + ), + tool( + "gr", + "grep", + serde_json::json!({"pattern": "paint_composer"}), + ToolStatus::Completed, + "", + "6 hits", + 2, + ), + tool( + "sh", + "shell", + serde_json::json!({"command": "rg paint_composer_box"}), + ToolStatus::Completed, + "", + "1 match", + 3, + ), + ]; + } + "shell-running" => { + resumed(&mut state); + state.add_message( + Message::user("run cargo test -p cortex-tui").with_timestamp("09:20 AM"), + ); + state.start_streaming(Some("shell".into()), true); + let mut sh = tool( + "sh", + "shell", + serde_json::json!({"command": "cargo test -p cortex-tui"}), + ToolStatus::Running, + "", + "", + 1, + ); + sh.live_output = vec![ + "running 1025 tests".into(), + "test lock_v2::tests::lock_v2_wide_frames_are_unique ... ok".into(), + "test lock_v2::tests::welcome_paints_inky_and_token_counter ... ok".into(), + ]; + state.tool_calls = vec![sh]; + } + "diff-hunk" => { + resumed(&mut state); + state.add_message( + Message::user("raise the rate limit to 60").with_timestamp("10:40 AM"), + ); + state.tool_calls = vec![tool( + "ed", + "edit", + serde_json::json!({"path": "src/config/rateLimits.ts"}), + ToolStatus::Completed, + DIFF_HUNK, + "Edit src/config/rateLimits.ts · +4 -2", + 1, + )]; + } + "edit-collapsed" => { + resumed(&mut state); + state.collapsed_edit_blocks = true; + state.add_message( + Message::user("raise the rate limit to 60").with_timestamp("10:40 AM"), + ); + state.tool_calls = vec![tool( + "ed", + "edit", + serde_json::json!({"path": "src/config/rateLimits.ts"}), + ToolStatus::Completed, + "", + "Edit src/config/rateLimits.ts · +4 -2", + 1, + )]; + } + "md-table" => { + resumed(&mut state); + state.add_message( + Message::user("Compare the three models for this project") + .with_timestamp("03:11 PM"), + ); + state.add_message(Message::assistant(MD_TABLE).with_timestamp("03:11 PM")); + } + "code-fence" => { + resumed(&mut state); + state.add_message( + Message::user("Show me the middleware you wrote").with_timestamp("03:12 PM"), + ); + state.add_message(Message::assistant(MD_FENCE).with_timestamp("03:12 PM")); + } + "shortcuts-overlay" => { + resumed(&mut state); + state.shortcuts_open = true; + } + "resume-picker" => { + resumed(&mut state); + let now = chrono::Utc::now(); + let sessions = vec![ + SessionSummary { + id: "sess-lock-v2".into(), + title: "lock v2 runtime chrome".into(), + model: "cortex-1-mini".into(), + provider: "cortex".into(), + created_at: now - chrono::Duration::hours(2), + updated_at: now - chrono::Duration::minutes(12), + message_count: 18, + archived: false, + }, + SessionSummary { + id: "sess-rate-limit".into(), + title: "rate limiter redis window".into(), + model: "cortex-1".into(), + provider: "cortex".into(), + created_at: now - chrono::Duration::days(1), + updated_at: now - chrono::Duration::hours(5), + message_count: 42, + archived: false, + }, + ]; + state.enter_interactive_mode(crate::interactive::builders::build_resume_picker( + &sessions, false, + )); + } + "clear-confirm" => { + resumed(&mut state); + conversation(&mut state); + state.enter_interactive_mode(build_clear_confirm()); + } + "plan-confirm" => { + resumed(&mut state); + state.agent_mode_label = "Plan".into(); + state.add_message( + Message::assistant( + "**Plan**\nRecapture every SPEC §7 board, then wait for Designer.", + ) + .with_timestamp("09:05 AM"), + ); + state.enter_interactive_mode(build_plan_confirm()); + } + "queue" => { + resumed(&mut state); + state.add_message(Message::user("rewrite chrome.rs").with_timestamp("09:30 AM")); + state.start_streaming(None, true); + state.streaming.thinking = true; + state.queue_message("also recapture the 40x12 set".into()); + } + "files-picker" => { + resumed(&mut state); + let mut interactive = radios( + "Files", + &[ + ("rl", "src/config/rateLimits.json", "2 days ago"), + ("ch", "src/cortex-tui/src/ui/chrome.rs", "today"), + ("lv", "src/cortex-tui/src/lock_v2.rs", "today"), + ], + 0, + Some(1), + ) + .with_search(); + interactive.search_query = "rate".into(); + state.enter_interactive_mode(interactive); + } + "jobs" => { + resumed(&mut state); + state.input.set_text("/jobs"); + state.enter_interactive_mode(build_jobs_picker(&[ + JobRow { + id: "cloud".into(), + kind: "cloud agent".into(), + title: "bc-4f2a".into(), + status: "running".into(), + }, + JobRow { + id: "sub".into(), + kind: "subagent".into(), + title: "rate-limiter".into(), + status: "running".into(), + }, + JobRow { + id: "q".into(), + kind: "queued".into(), + title: "recapture PNGs".into(), + status: "waiting".into(), + }, + ])); + } + "skills" => { + resumed(&mut state); + let skills = [ + SkillListItem { + name: "review".into(), + description: "Review a diff against the lock".into(), + }, + SkillListItem { + name: "capture".into(), + description: "Recapture TUI lock boards".into(), + }, + ]; + state.enter_interactive_mode(crate::interactive::builders::build_skills_selector( + &skills, + )); + } + "todos" => { + resumed(&mut state); + state.add_message( + Message::user("work through the capture checklist").with_timestamp("09:41 AM"), + ); + let mut task = SubagentTaskDisplay::new("sub-1", "tool-1", "lock v2 captures", "code"); + task.status = SubagentDisplayStatus::ExecutingTool("edit".into()); + task.todos = vec![ + SubagentTodoItem::new("Expand lock_v2 scene ids", SubagentTodoStatus::Completed), + SubagentTodoItem::new( + "Make every frame a unique state", + SubagentTodoStatus::Completed, + ), + SubagentTodoItem::new("Recapture 120×40 and 40×12", SubagentTodoStatus::InProgress), + SubagentTodoItem::new("Verify sha256 uniqueness", SubagentTodoStatus::Pending), + SubagentTodoItem::new("Keep the PR drafted", SubagentTodoStatus::Pending), + ]; + state.active_subagents = vec![task]; + } + "question" => { + resumed(&mut state); + state.add_message( + Message::assistant("Which capture size should we lock first?") + .with_timestamp("09:44 AM"), + ); + state.enter_interactive_mode(build_question_prompt( + "Question", + &[ + ("wide", "1 120×40 first", "wide boards"), + ("narrow", "2 40×12 first", "narrow boards"), + ("both", "3 Both together", "full SPEC §7 set"), + ], + 2, + )); + } + "sudo" => { + resumed(&mut state); + state.add_message( + Message::user("restart the sandbox daemon").with_timestamp("11:12 AM"), + ); + state.enter_interactive_mode(radios( + "Elevated Shell", + &[ + ("pw", "Password", "••••••••"), + ("once", "1 Run once", "elevated"), + ("cancel", "2 Cancel", "do not run"), + ], + 0, + None, + )); + } + "config-tree" => { + resumed(&mut state); + state.input.set_text("/config"); + state.enter_interactive_mode(radios( + "Config", + &[ + ("path", "~/.cortex/config.json", "read-only"), + ("model", "model", "Cortex Mini 1"), + ("effort", "effort", "medium"), + ("tui", "tui.theme", "dark → Cortex Night"), + ], + 0, + None, + )); + } + "btw" => { + resumed(&mut state); + state.add_message(Message::user("keep rewriting chrome.rs").with_timestamp("09:50 AM")); + state.start_streaming(None, true); + state.streaming.thinking = false; + state.streaming.is_actively_streaming = true; + state.add_message( + Message::user("/btw keep the composer dual-hairline").with_timestamp("09:51 AM"), + ); + } + id if apply_goal_chip_scene(id, &mut state) => {} + other => panic!("unknown lock v2 scene {other}"), + } + state +} diff --git a/src/cortex-tui/src/lock_v2_scenes.rs b/src/cortex-tui/src/lock_v2_scenes.rs new file mode 100644 index 00000000..c5b723bc --- /dev/null +++ b/src/cortex-tui/src/lock_v2_scenes.rs @@ -0,0 +1,238 @@ +//! Lock v2 session scene helpers (not login screens). +//! +//! Permission-prompt lock chrome is unchanged; this module only relocates +//! existing scene construction so `lock_v2.rs` stays under 1000 lines. + +use cortex_core::widgets::Message; + +use crate::app::{AppState, AutocompleteItem, AutocompleteTrigger}; +use crate::commands::{CommandRegistry, CompletionEngine, PALETTE_HOME_LIMIT}; +use crate::interactive::builders::build_model_selector; +use crate::interactive::state::{InteractiveAction, InteractiveItem, InteractiveState}; +use crate::views::tool_call::{ToolCallDisplay, ToolResultDisplay, ToolStatus}; +use crate::widgets::SettingsModalState; + +pub(crate) fn lock_app() -> AppState { + let mut state = AppState::default(); + state.cli_version = env!("CARGO_PKG_VERSION").to_string(); + state.model = "cortex-1-mini".into(); + state.agent_mode_label = "Agent".into(); + state.thinking_budget = Some("medium".into()); + state.tokens_used = 0; + state.context_window = 500_000; + state.opt_in_banner = false; + state.timestamps_enabled = true; + state.caret_visible = true; + state +} + +pub(crate) fn resumed(state: &mut AppState) { + state.show_launch_splash = false; + state.tokens_used = 14_000; +} + +pub(crate) fn conversation(state: &mut AppState) { + resumed(state); + state.add_message( + Message::user("hey") + .with_timestamp("12:49 AM") + .with_thought_secs(0.4) + .with_worked_secs(1.8), + ); + if let Some(last) = state.messages.last_mut() { + last.thought_secs = None; + last.worked_secs = None; + } + state.add_message( + Message::assistant("Hey — what do you want to work on?") + .with_timestamp("12:49 AM") + .with_thought_secs(0.4) + .with_worked_secs(1.8), + ); + state.add_message(Message::user("tell me about yourself").with_timestamp("12:49 AM")); + state.add_message( + Message::assistant( + "I'm **Cortex**, a coding agent that runs in your terminal.\n\n\ +I mostly help you **build and debug software**: code, architecture, debugging, reviews, docs, and a bit of research.\n\ +Here I run in an interactive terminal, so I can read your files, run commands, and change the project.\n\n\ +In practice:\n\ +• I get straight to the point\n\ +• I prefer concrete work over long explanations\n\ +• I can also discuss, explain, or help you plan\n\n\ +Tell me what you'd like to do.", + ) + .with_timestamp("12:49 AM") + .with_thought_secs(0.4) + .with_worked_secs(4.6), + ); +} + +pub(crate) fn palette_state(query: &str) -> AppState { + let mut state = lock_app(); + conversation(&mut state); + state.input.set_text(query); + state.autocomplete.show(AutocompleteTrigger::Command, 0); + let registry = CommandRegistry::default(); + let engine = CompletionEngine::new(®istry); + let completions = engine.complete(query); + let items: Vec = completions + .into_iter() + .map(|c| AutocompleteItem::new(&c.command, &c.display, &c.description)) + .collect(); + state.autocomplete.set_items(items); + state.autocomplete.max_visible = PALETTE_HOME_LIMIT; + if query.len() > 1 { + state.autocomplete.set_query(&query[1..]); + } + state +} + +pub(crate) fn dummy_model(id: &str, name: &str) -> crate::providers::models::ModelInfo { + crate::providers::models::ModelInfo::new(id, name, "cortex") +} + +pub(crate) fn model_picker(state: &mut AppState, hovered: Option) { + conversation(state); + state.input.set_text("/model"); + let mut interactive = build_model_selector( + vec![ + dummy_model("cortex-1-mini", "Cortex Mini 1"), + dummy_model("cortex-1", "Cortex 1"), + dummy_model("cortex-1-max", "Cortex Max 1"), + ], + Some("cortex-1-mini"), + Some("medium"), + ); + interactive.hovered = hovered; + state.enter_interactive_mode(interactive); +} + +pub(crate) fn effort_picker( + state: &mut AppState, + effort: crate::interactive::EffortLevel, + hover_low: bool, +) { + conversation(state); + state.input.set_text("/model Cortex Mini 1"); + let mut interactive = build_model_selector( + vec![dummy_model("cortex-1-mini", "Cortex Mini 1")], + Some("cortex-1-mini"), + Some(effort.as_str()), + ); + interactive.effort = Some(effort); + interactive.effort_focused = true; + if hover_low { + interactive.hovered = Some(1000 + 2); + } + state.thinking_budget = Some(effort.as_str().to_ascii_lowercase()); + state.enter_interactive_mode(interactive); +} + +pub(crate) fn open_settings(state: &mut AppState, tune: impl FnOnce(&mut SettingsModalState)) { + conversation(state); + let mut modal = SettingsModalState::default(); + modal.values = state.settings_values(); + tune(&mut modal); + state.settings_modal = Some(modal); +} + +/// Production permission prompt for lock `permission-prompt*`. +pub(crate) fn lock_permission_prompt(state: &mut AppState, hovered: Option) { + resumed(state); + state.add_message( + Message::user("add ioredis and a mock for the tests").with_timestamp("09:40 AM"), + ); + state.add_message( + Message::assistant( + "Cortex wants to run\n`$ npm install ioredis && npm install -D ioredis-mock`", + ) + .with_timestamp("09:40 AM") + .with_thought_secs(1.4), + ); + state.request_tool_approval( + "lock-perm".into(), + "shell".into(), + serde_json::json!({ + "command": "npm install ioredis && npm install -D ioredis-mock" + }), + None, + ); + if let Some(hover) = hovered + && let Some(interactive) = state.get_interactive_state_mut() + { + interactive.hovered = Some(hover); + } +} + +pub(crate) fn radios( + title: &str, + rows: &[(&str, &str, &str)], + selected: usize, + hovered: Option, +) -> InteractiveState { + let items = rows + .iter() + .map(|(id, label, desc)| InteractiveItem::new(*id, *label).with_description(*desc)) + .collect(); + let mut interactive = + InteractiveState::new(title, items, InteractiveAction::Custom(title.into())); + interactive.selected = selected.min(rows.len().saturating_sub(1)); + interactive.hovered = hovered; + interactive +} + +pub(crate) fn tool( + id: &str, + name: &str, + args: serde_json::Value, + status: ToolStatus, + output: &str, + summary: &str, + sequence: u64, +) -> ToolCallDisplay { + let mut call = ToolCallDisplay::new(id.into(), name.into(), args, sequence); + call.status = status; + call.collapsed = false; + if !output.is_empty() || !summary.is_empty() { + call.result = Some(ToolResultDisplay { + output: output.into(), + success: status != ToolStatus::Failed, + summary: summary.into(), + }); + } + call +} + +pub(crate) const DIFF_HUNK: &str = r#"@@ -20,6 +20,10 @@ + import Redis from "ioredis"; + import type { FastifyRequest } from "fastify"; +-const limit = 30; ++const limit = 60; ++const windowSec = 60; + + export function rateLimit(opts: RateLimitOpts) { +- const redis = new Redis(); ++ const redis = new Redis(process.env.REDIS_URL); +"#; + +pub(crate) const MD_TABLE: &str = r#"Here is how the three models compare: + +| Model | Effort | Billing | +|---|---|---| +| Mini 1 | Medium | per request | +| Cortex 1 | High | per request | +| Max 1 | MAX | per token | + +Mini 1 is the default; switch with /model when a change needs deeper reasoning."#; + +pub(crate) const MD_FENCE: &str = r#"The limiter is a sliding window over a Redis sorted set: + +```ts +export async function rateLimit(key: string, limit = 60) { + const now = Date.now(); + await redis.zadd(key, now, String(now)); + return count <= limit; +} +``` + +It fails open when Redis is unreachable."#; diff --git a/src/cortex-tui/src/runner/event_loop/commands.rs b/src/cortex-tui/src/runner/event_loop/commands.rs index 8f953a09..9e0354a4 100644 --- a/src/cortex-tui/src/runner/event_loop/commands.rs +++ b/src/cortex-tui/src/runner/event_loop/commands.rs @@ -351,10 +351,15 @@ impl EventLoop { let interactive = self.build_skills_picker().await; self.app_state.enter_interactive_mode(interactive); } - _ => { - self.app_state - .toasts - .error(format!("Not implemented: {:?}", modal_type)); + ModalType::Tasks => { + let rows: Vec<_> = self + .app_state + .active_subagents + .iter() + .map(crate::interactive::builders::JobRow::from_subagent) + .collect(); + let interactive = crate::interactive::builders::build_jobs_picker(&rows); + self.app_state.enter_interactive_mode(interactive); } } } diff --git a/src/cortex-tui/src/runner/event_loop/mod.rs b/src/cortex-tui/src/runner/event_loop/mod.rs index d0b3c150..305b68ca 100644 --- a/src/cortex-tui/src/runner/event_loop/mod.rs +++ b/src/cortex-tui/src/runner/event_loop/mod.rs @@ -34,6 +34,7 @@ mod input; mod local_workflows; mod me; mod modal; +mod modal_jobs; mod mouse; mod rendering; mod sessions; diff --git a/src/cortex-tui/src/runner/event_loop/modal.rs b/src/cortex-tui/src/runner/event_loop/modal.rs index c43d088b..07ff39fb 100644 --- a/src/cortex-tui/src/runner/event_loop/modal.rs +++ b/src/cortex-tui/src/runner/event_loop/modal.rs @@ -729,6 +729,9 @@ impl EventLoop { .await; return false; } + "jobs-picker" => { + return self.handle_jobs_picker_choice(&item_id); + } "permission-prompt" => { return self.handle_permission_prompt_choice(&item_id).await; } @@ -988,13 +991,4 @@ impl EventLoop { } false } - - /// Process pending actions from the card handler. - pub(super) fn process_card_actions(&mut self) { - let actions = self.card_handler.take_actions(); - for _action in actions { - tracing::debug!("Card action received"); - // Handle card actions - implementation in the main module - } - } } diff --git a/src/cortex-tui/src/runner/event_loop/modal_jobs.rs b/src/cortex-tui/src/runner/event_loop/modal_jobs.rs new file mode 100644 index 00000000..33a1d353 --- /dev/null +++ b/src/cortex-tui/src/runner/event_loop/modal_jobs.rs @@ -0,0 +1,175 @@ +//! `/jobs` picker Enter handling (numbered `>` / `·` chrome; not radios). + +use super::core::EventLoop; +use crate::app::SubagentDisplayStatus; + +impl EventLoop { + pub(super) fn handle_jobs_picker_choice(&mut self, item_id: &str) -> bool { + if item_id == "empty" || item_id.is_empty() { + return false; + } + let attach = crate::interactive::builders::jobs::JOBS_ATTACH_PREFIX; + let stop = crate::interactive::builders::jobs::JOBS_STOP_PREFIX; + if let Some(id) = item_id.strip_prefix(attach) { + self.app_state.toasts.info(format!( + "Attach from this terminal: cortex attach {id} — empty line detaches, session keeps running" + )); + return false; + } + if let Some(id) = item_id.strip_prefix(stop) { + self.stop_job(id); + return false; + } + self.open_job(item_id); + false + } + + fn abort_job_task(&mut self, display_id: &str) { + let tool_id = display_id.strip_prefix("subagent_").unwrap_or(display_id); + for key in [tool_id, display_id] { + if let Some(handle) = self.running_tool_tasks.remove(key) { + handle.abort(); + } + if let Some(handle) = self.running_subagents.remove(key) { + handle.abort(); + } + } + } + + fn stop_job(&mut self, id: &str) { + self.abort_job_task(id); + let tool_id = id.strip_prefix("subagent_").unwrap_or(id); + self.app_state.add_pending_tool_result( + tool_id.to_string(), + "Task".into(), + "The job was stopped.".into(), + false, + ); + let found = self + .app_state + .active_subagents + .iter() + .any(|t| t.session_id == id); + if found { + self.app_state.update_subagent(id, |task| { + task.status = SubagentDisplayStatus::Failed; + task.error_message = Some("The job was stopped.".into()); + task.current_activity = "Stopped".into(); + }); + if !self.app_state.has_active_subagents() { + self.app_state.streaming.stop_delegation(); + } + self.app_state + .toasts + .info(format!("Stop requested for {id}. The job is not deleted.")); + } else { + self.app_state.toasts.info(format!( + "Stop from the CLI: cortex jobs stop {id}. The session is not deleted." + )); + } + } + + fn open_job(&mut self, item_id: &str) { + if let Some(task) = self + .app_state + .active_subagents + .iter() + .find(|task| task.session_id == item_id) + { + let preview = if task.output_preview.is_empty() { + "(no output yet)".to_string() + } else { + task.output_preview.clone() + }; + self.add_system_message(&format!( + "Job {} · {}\nStatus: {}\n{preview}", + task.session_id, + task.description, + task.status.description() + )); + return; + } + self.add_system_message(&format!( + "Job {item_id} is not in this session. Live attach: cortex attach {item_id}" + )); + } + + /// Process pending actions from the card handler. + pub(super) fn process_card_actions(&mut self) { + let actions = self.card_handler.take_actions(); + for _action in actions { + tracing::debug!("Card action received"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{AppState, SubagentTaskDisplay}; + use crate::runner::event_loop::EventLoop; + use std::time::Duration; + + #[tokio::test] + async fn x_aborts_running_job_task() { + let mut event_loop = EventLoop::new(AppState::new()); + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + let handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(30)).await; + let _ = tx.send(()); + }); + event_loop + .running_tool_tasks + .insert("tool-1".into(), handle); + event_loop + .app_state + .add_subagent_task(SubagentTaskDisplay::new( + "subagent_tool-1", + "tool-1", + "rate-limiter", + "subagent", + )); + event_loop.handle_jobs_picker_choice("stop:subagent_tool-1"); + assert!(event_loop.running_tool_tasks.is_empty()); + let outcome = tokio::time::timeout(Duration::from_millis(400), rx).await; + assert!( + matches!(outcome, Ok(Err(_)) | Err(_)), + "stopped job must not keep running to completion" + ); + let task = event_loop + .app_state + .active_subagents + .iter() + .find(|task| task.session_id == "subagent_tool-1") + .expect("stopped job stays listed"); + assert!(matches!(task.status, SubagentDisplayStatus::Failed)); + assert!( + event_loop + .app_state + .pending_tool_results + .iter() + .any(|result| result.tool_call_id == "tool-1" && !result.success) + ); + } + + #[test] + fn enter_opens_job_details() { + let mut event_loop = EventLoop::new(AppState::new()); + let mut task = SubagentTaskDisplay::new( + "subagent_tool-2", + "tool-2", + "summarize lockfile", + "subagent", + ); + task.output_preview = "diff hunk".into(); + event_loop.app_state.add_subagent_task(task); + event_loop.handle_jobs_picker_choice("subagent_tool-2"); + let last = event_loop + .app_state + .messages + .last() + .expect("open posts job details"); + assert!(last.content.contains("summarize lockfile"), "{last:?}"); + assert!(last.content.contains("diff hunk"), "{last:?}"); + } +} diff --git a/src/cortex-tui/src/views/tasks.rs b/src/cortex-tui/src/views/tasks.rs index b9af50d5..d4098dcf 100644 --- a/src/cortex-tui/src/views/tasks.rs +++ b/src/cortex-tui/src/views/tasks.rs @@ -8,6 +8,7 @@ use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table}; use cortex_agents::background::{AgentStatus, RunningAgentInfo}; use cortex_common::{format_duration, truncate_first_line, truncate_id_default}; +use cortex_core::style::{ACCENT, ERROR, HAIRLINE, SUCCESS, TEXT, TEXT_DIM, WARNING}; /// View for displaying background tasks/agents. pub struct TasksView { @@ -85,18 +86,18 @@ impl TasksView { // Create outer block let block = Block::default() - .title(" Background Tasks ") - .title_style(Style::default().fg(Color::Cyan).bold()) + .title(" Agents & jobs ") + .title_style(Style::default().fg(ACCENT).bold()) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)); + .border_style(Style::default().fg(HAIRLINE)); let inner = block.inner(area); frame.render_widget(block, area); if self.agents.is_empty() { // Show empty state - let empty_text = Paragraph::new("No background tasks running.\n\nPress Ctrl+B to run the current prompt in background.") - .style(Style::default().fg(Color::DarkGray)) + let empty_text = Paragraph::new("No background tasks running.\n\nCtrl+B runs the current prompt in the background. /jobs lists live agents.") + .style(Style::default().fg(TEXT_DIM)) .alignment(Alignment::Center); // Center vertically @@ -126,11 +127,11 @@ impl TasksView { // Create table header let header = Row::new(vec![ - Cell::from("ID").style(Style::default().fg(Color::Yellow).bold()), - Cell::from("Status").style(Style::default().fg(Color::Yellow).bold()), - Cell::from("Task").style(Style::default().fg(Color::Yellow).bold()), - Cell::from("Duration").style(Style::default().fg(Color::Yellow).bold()), - Cell::from("Tokens").style(Style::default().fg(Color::Yellow).bold()), + Cell::from("ID").style(Style::default().fg(TEXT).bold()), + Cell::from("Status").style(Style::default().fg(TEXT).bold()), + Cell::from("Task").style(Style::default().fg(TEXT).bold()), + Cell::from("Duration").style(Style::default().fg(TEXT).bold()), + Cell::from("Tokens").style(Style::default().fg(TEXT).bold()), ]) .height(1) .bottom_margin(1); @@ -145,9 +146,9 @@ impl TasksView { .map(|(idx, agent)| { let is_selected = idx == self.selected; let style = if is_selected { - Style::default().bg(Color::DarkGray) + Style::default().bg(HAIRLINE).fg(TEXT) } else { - Style::default() + Style::default().fg(TEXT) }; Row::new(vec![ @@ -184,9 +185,10 @@ impl TasksView { height: 1, }; - let help_text = Paragraph::new("↑↓ Navigate Enter: Details c: Cancel Esc: Close") - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); + let help_text = + Paragraph::new("↑↓ Navigate Enter: open a: attach x: cancel Esc: close") + .style(Style::default().fg(TEXT_DIM)) + .alignment(Alignment::Center); frame.render_widget(help_text, help_area); } } @@ -194,12 +196,12 @@ impl TasksView { /// Formats a status as a styled badge. fn status_badge(status: &AgentStatus) -> Span<'static> { match status { - AgentStatus::Initializing => Span::styled("○ Init", Style::default().fg(Color::Gray)), - AgentStatus::Running => Span::styled("● Running", Style::default().fg(Color::Yellow)), - AgentStatus::Completed => Span::styled("✓ Done", Style::default().fg(Color::Green)), - AgentStatus::Failed => Span::styled("✗ Failed", Style::default().fg(Color::Red)), - AgentStatus::Cancelled => Span::styled("○ Cancelled", Style::default().fg(Color::DarkGray)), - AgentStatus::TimedOut => Span::styled("⏱ Timeout", Style::default().fg(Color::Magenta)), + AgentStatus::Initializing => Span::styled("Init", Style::default().fg(TEXT_DIM)), + AgentStatus::Running => Span::styled("Running", Style::default().fg(WARNING)), + AgentStatus::Completed => Span::styled("Done", Style::default().fg(SUCCESS)), + AgentStatus::Failed => Span::styled("Failed", Style::default().fg(ERROR)), + AgentStatus::Cancelled => Span::styled("Cancelled", Style::default().fg(TEXT_DIM)), + AgentStatus::TimedOut => Span::styled("Timeout", Style::default().fg(WARNING)), } } @@ -309,4 +311,47 @@ mod tests { assert_eq!(truncate_id_default("bg-1").as_ref(), "bg-1"); } + + fn render_tasks(view: &TasksView, w: u16, h: u16) -> String { + let backend = ratatui::backend::TestBackend::new(w, h); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| view.render(frame, frame.area())) + .unwrap(); + let buf = terminal.backend().buffer(); + let mut out = String::new(); + for y in 0..h { + for x in 0..w { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn tasks_view_snapshots_narrow_and_wide() { + let mut view = TasksView::new(); + view.set_agents(vec![ + make_test_agent("bg-1", "rate-limiter", AgentStatus::Running), + make_test_agent("bc-4f2a", "cloud agent", AgentStatus::Running), + ]); + for (w, h) in [(40, 12), (120, 40)] { + let text = render_tasks(&view, w, h); + assert!( + text.contains("Agents & jobs"), + "missing title at {w}x{h}:\n{text}" + ); + assert!( + text.contains("Running"), + "missing status at {w}x{h}:\n{text}" + ); + assert!( + !text.contains('●') && !text.contains('○'), + "jobs table must not use radio glyphs at {w}x{h}:\n{text}" + ); + } + let empty = render_tasks(&TasksView::new(), 40, 12); + assert!(empty.contains("No background tasks running")); + } }