diff --git a/.cargo/config.toml b/.cargo/config.toml index c5cc5d9f..9c827ed6 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -8,5 +8,5 @@ jobs = 16 # on machines without sccache installed. [target.x86_64-unknown-linux-gnu] -linker = "clang" +linker = "gcc" rustflags = ["-C", "link-arg=-fuse-ld=lld"] diff --git a/cas-cli/src/cli/factory/daemon.rs b/cas-cli/src/cli/factory/daemon.rs index 2eee7ed7..3e8f9e36 100644 --- a/cas-cli/src/cli/factory/daemon.rs +++ b/cas-cli/src/cli/factory/daemon.rs @@ -69,6 +69,11 @@ pub(super) fn execute_daemon( }, teams_configs, lead_session_id: Some(lead_session_id), + minions_theme: cas_config + .theme + .as_ref() + .map(|t| t.variant == crate::ui::theme::ThemeVariant::Minions) + .unwrap_or(false), }; let daemon_config = DaemonConfig { @@ -127,6 +132,7 @@ pub(super) fn run_factory_with_daemon( .unwrap_or_else(|| "supervisor".to_string()); let worker_names = config.worker_names.clone(); let worktrees_enabled = config.enable_worktrees; + let minions_theme = config.minions_theme; let cwd = config.cwd.to_string_lossy().to_string(); let profile = build_boot_profile(&config, worker_names.len()); @@ -148,6 +154,7 @@ pub(super) fn run_factory_with_daemon( session_name: session_name.clone(), profile, skip_animation: false, + minions_theme, }; if let Err(e) = run_boot_screen_client(&boot_config, &sock_path, 0) { @@ -177,6 +184,7 @@ pub(super) fn run_factory_with_daemon( .unwrap_or_else(|| "supervisor".to_string()); let worker_names = config.worker_names.clone(); let worktrees_enabled = config.enable_worktrees; + let minions_theme = config.minions_theme; let cwd = config.cwd.to_string_lossy().to_string(); let profile = build_boot_profile(&config, worker_names.len()); @@ -199,6 +207,7 @@ pub(super) fn run_factory_with_daemon( session_name: session_name.clone(), profile, skip_animation: false, + minions_theme, }; if let Err(e) = run_boot_screen_client(&boot_config, &sock_path, daemon_pid) { diff --git a/cas-cli/src/cli/factory/mod.rs b/cas-cli/src/cli/factory/mod.rs index e6b2edee..4268f136 100644 --- a/cas-cli/src/cli/factory/mod.rs +++ b/cas-cli/src/cli/factory/mod.rs @@ -549,9 +549,27 @@ pub fn execute(args: &FactoryArgs, cli: &Cli, cas_root: Option<&std::path::Path> } } - let all_names = generate_unique(args.workers as usize + 1); - let supervisor_name = all_names[0].clone(); - let worker_names: Vec = all_names[1..].to_vec(); + // Determine theme variant early so we can use themed names + let theme_variant = { + let cd = cwd.join(".cas"); + let cr = cas_root.or_else(|| if cd.exists() { Some(cd.as_path()) } else { None }); + cr.and_then(|r| Config::load(r).ok()) + .and_then(|c| c.theme.as_ref().map(|t| t.variant)) + .unwrap_or_default() + }; + let is_minions = theme_variant == crate::ui::theme::ThemeVariant::Minions; + + let (supervisor_name, worker_names) = if is_minions { + use crate::orchestration::names::{generate_minion_supervisor, generate_minion_unique}; + let sup = generate_minion_supervisor(); + let workers = generate_minion_unique(args.workers as usize); + (sup, workers) + } else { + let all_names = generate_unique(args.workers as usize + 1); + let sup = all_names[0].clone(); + let workers: Vec = all_names[1..].to_vec(); + (sup, workers) + }; let session_name = args .name @@ -613,6 +631,7 @@ pub fn execute(args: &FactoryArgs, cli: &Cli, cas_root: Option<&std::path::Path> }, teams_configs, lead_session_id: Some(lead_session_id), + minions_theme: is_minions, }; let phone_home = !args.no_phone_home; @@ -826,6 +845,7 @@ fn preflight_factory_launch( let mut missing_git_repo = false; let mut missing_initial_commit = false; let mut missing_claude_commit = false; + let mut missing_mcp_commit = false; let resolved_cas_root = match validate_cas_root(cwd, cas_root) { Ok(path) => Some(path), @@ -961,6 +981,33 @@ fn preflight_factory_launch( } } + // Check if .mcp.json is committed (required for worktree-based workers) + if enable_worktrees && !missing_git_repo && !missing_initial_commit { + let mcp_tracked = std::process::Command::new("git") + .args(["ls-files", "--error-unmatch", ".mcp.json"]) + .current_dir(cwd) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + if !mcp_tracked { + if args.workers > 0 { + failures.push( + ".mcp.json is not committed. Workers need it for MCP tool access in their worktrees." + .to_string(), + ); + missing_mcp_commit = true; + } else { + notices.push( + ".mcp.json is not committed. Commit it before spawning workers: git add .mcp.json && git commit -m \"Configure CAS MCP\"" + .to_string(), + ); + } + } + } + if !failures.is_empty() { let details = failures .iter() @@ -986,6 +1033,10 @@ fn preflight_factory_launch( steps.push("git add .claude/ CLAUDE.md .mcp.json .gitignore".to_string()); steps.push("git commit -m \"Configure CAS\"".to_string()); } + if missing_mcp_commit && !missing_claude_commit { + steps.push("git add .mcp.json".to_string()); + steps.push("git commit -m \"Configure CAS MCP\"".to_string()); + } let launch = if args.no_worktrees { "cas factory --no-worktrees" } else { diff --git a/cas-cli/src/cli/hook/config_gen.rs b/cas-cli/src/cli/hook/config_gen.rs index a59ff63e..305ca375 100644 --- a/cas-cli/src/cli/hook/config_gen.rs +++ b/cas-cli/src/cli/hook/config_gen.rs @@ -232,9 +232,12 @@ pub(crate) fn get_cas_hooks_config(config: &crate::config::HookConfig) -> serde_ ); } + let mut allow_permissions = get_cas_bash_permissions(); + allow_permissions.extend(get_cas_mcp_permissions()); + serde_json::json!({ "permissions": { - "allow": get_cas_bash_permissions() + "allow": allow_permissions }, "hooks": hooks, "statusLine": { @@ -257,6 +260,24 @@ pub fn get_cas_bash_permissions() -> Vec { ] } +/// Get MCP tool permission patterns for CAS tools +/// +/// Workers need these permissions to call mcp__cas__* tools without prompts. +pub fn get_cas_mcp_permissions() -> Vec { + vec![ + "mcp__cas__task".to_string(), + "mcp__cas__coordination".to_string(), + "mcp__cas__memory".to_string(), + "mcp__cas__search".to_string(), + "mcp__cas__rule".to_string(), + "mcp__cas__skill".to_string(), + "mcp__cas__spec".to_string(), + "mcp__cas__verification".to_string(), + "mcp__cas__system".to_string(), + "mcp__cas__pattern".to_string(), + ] +} + /// Configure CAS as an MCP server via .mcp.json /// /// Creates or updates .mcp.json in the project root to register CAS. diff --git a/cas-cli/src/cli/hook_tests/tests.rs b/cas-cli/src/cli/hook_tests/tests.rs index 3868751a..b6c70990 100644 --- a/cas-cli/src/cli/hook_tests/tests.rs +++ b/cas-cli/src/cli/hook_tests/tests.rs @@ -31,6 +31,31 @@ fn test_configure_creates_settings() { allow_arr.iter().any(|v| v.as_str() == Some("Bash(cas :*)")), "Bash(cas :*) permission missing" ); + // Verify CAS MCP tool permissions + assert!( + allow_arr + .iter() + .any(|v| v.as_str() == Some("mcp__cas__task")), + "mcp__cas__task permission missing" + ); + assert!( + allow_arr + .iter() + .any(|v| v.as_str() == Some("mcp__cas__coordination")), + "mcp__cas__coordination permission missing" + ); + assert!( + allow_arr + .iter() + .any(|v| v.as_str() == Some("mcp__cas__memory")), + "mcp__cas__memory permission missing" + ); + assert!( + allow_arr + .iter() + .any(|v| v.as_str() == Some("mcp__cas__search")), + "mcp__cas__search permission missing" + ); } #[test] @@ -91,6 +116,18 @@ fn test_configure_merges_existing() { allow_arr.iter().any(|v| v.as_str() == Some("Bash(cas :*)")), "Bash(cas :*) permission should be added" ); + assert!( + allow_arr + .iter() + .any(|v| v.as_str() == Some("mcp__cas__task")), + "mcp__cas__task permission should be added" + ); + assert!( + allow_arr + .iter() + .any(|v| v.as_str() == Some("mcp__cas__coordination")), + "mcp__cas__coordination permission should be added" + ); } #[test] diff --git a/cas-cli/src/orchestration/mod.rs b/cas-cli/src/orchestration/mod.rs index 636dddb4..464cde00 100644 --- a/cas-cli/src/orchestration/mod.rs +++ b/cas-cli/src/orchestration/mod.rs @@ -7,4 +7,4 @@ pub mod names; -pub use names::generate_unique; +pub use names::{generate_minion_supervisor, generate_minion_unique, generate_unique}; diff --git a/cas-cli/src/orchestration/names.rs b/cas-cli/src/orchestration/names.rs index 6816e83f..a064b218 100644 --- a/cas-cli/src/orchestration/names.rs +++ b/cas-cli/src/orchestration/names.rs @@ -7,6 +7,69 @@ use rand::Rng; use rand::seq::IndexedRandom; use std::collections::HashSet; +// ── Minions theme names ────────────────────────────────────────────────────── + +const MINION_WORKERS: &[&str] = &[ + "kevin", "stuart", "bob", "dave", "jerry", "tim", "mark", "phil", "carl", + "norbert", "jorge", "otto", "steve", "herb", "pete", "donnie", "mel", + "abel", "tony", "walter", +]; + +const MINION_SUPERVISORS: &[&str] = &["gru", "dru", "nefario"]; + +/// Generate a single minion worker name (e.g., "kevin", "stuart") +pub fn generate_minion() -> String { + let mut rng = rand::rng(); + let name = MINION_WORKERS.choose(&mut rng).unwrap_or(&"bob"); + (*name).to_string() +} + +/// Generate a minion supervisor name +pub fn generate_minion_supervisor() -> String { + let mut rng = rand::rng(); + let name = MINION_SUPERVISORS.choose(&mut rng).unwrap_or(&"gru"); + (*name).to_string() +} + +/// Generate N unique minion worker names. +/// +/// If more names are requested than available, appends a numeric suffix. +pub fn generate_minion_unique(count: usize) -> Vec { + let mut names = Vec::with_capacity(count); + let mut rng = rand::rng(); + + // Shuffle the pool and take as many as we can + let mut pool: Vec<&str> = MINION_WORKERS.to_vec(); + // Fisher-Yates shuffle + for i in (1..pool.len()).rev() { + let j = rng.random_range(0..=i); + pool.swap(i, j); + } + + for (i, name) in pool.iter().enumerate() { + if i >= count { + break; + } + names.push((*name).to_string()); + } + + // If we need more than the pool, add suffixed duplicates + let mut suffix = 2; + while names.len() < count { + for name in &pool { + if names.len() >= count { + break; + } + names.push(format!("{name}-{suffix}")); + } + suffix += 1; + } + + names +} + +// ── Default theme names ────────────────────────────────────────────────────── + const ADJECTIVES: &[&str] = &[ "agile", "bold", "brave", "bright", "calm", "clever", "cosmic", "crisp", "daring", "eager", "fair", "fast", "fierce", "gentle", "golden", "happy", "jolly", "keen", "kind", "lively", @@ -123,4 +186,44 @@ mod tests { let names = generate_unique(100); assert_eq!(names.len(), 100); } + + #[test] + fn test_generate_minion_returns_valid_name() { + let name = generate_minion(); + assert!( + MINION_WORKERS.contains(&name.as_str()), + "Minion name should be valid: {name}" + ); + } + + #[test] + fn test_generate_minion_supervisor_returns_valid_name() { + let name = generate_minion_supervisor(); + assert!( + MINION_SUPERVISORS.contains(&name.as_str()), + "Supervisor name should be valid: {name}" + ); + } + + #[test] + fn test_generate_minion_unique_returns_correct_count() { + let names = generate_minion_unique(5); + assert_eq!(names.len(), 5); + } + + #[test] + fn test_generate_minion_unique_all_different() { + let names = generate_minion_unique(10); + let unique: HashSet<_> = names.iter().collect(); + assert_eq!(unique.len(), names.len(), "All minion names should be unique"); + } + + #[test] + fn test_generate_minion_unique_exceeds_pool() { + // More than 20 minion names, should use suffixes + let names = generate_minion_unique(25); + assert_eq!(names.len(), 25); + let unique: HashSet<_> = names.iter().collect(); + assert_eq!(unique.len(), 25, "All names should still be unique"); + } } diff --git a/cas-cli/src/ui/factory/app/init.rs b/cas-cli/src/ui/factory/app/init.rs index d5a294e6..0ee0cae0 100644 --- a/cas-cli/src/ui/factory/app/init.rs +++ b/cas-cli/src/ui/factory/app/init.rs @@ -29,14 +29,25 @@ impl FactoryApp { let cas_dir = find_cas_root()?; - let all_names = generate_unique(config.workers + 1); - let supervisor_name = config - .supervisor_name - .unwrap_or_else(|| all_names[0].clone()); - let worker_names: Vec = if config.worker_names.is_empty() { - all_names[1..].to_vec() + let (supervisor_name, worker_names) = if config.minions_theme + && config.supervisor_name.is_none() + && config.worker_names.is_empty() + { + use crate::orchestration::names::{generate_minion_supervisor, generate_minion_unique}; + let sup = generate_minion_supervisor(); + let workers = generate_minion_unique(config.workers); + (sup, workers) } else { - config.worker_names + let all_names = generate_unique(config.workers + 1); + let sup = config + .supervisor_name + .unwrap_or_else(|| all_names[0].clone()); + let workers = if config.worker_names.is_empty() { + all_names[1..].to_vec() + } else { + config.worker_names + }; + (sup, workers) }; let (cols, rows) = crossterm::terminal::size().unwrap_or((120, 40)); diff --git a/cas-cli/src/ui/factory/app/mod.rs b/cas-cli/src/ui/factory/app/mod.rs index 66ca59ce..32a985f2 100644 --- a/cas-cli/src/ui/factory/app/mod.rs +++ b/cas-cli/src/ui/factory/app/mod.rs @@ -786,11 +786,28 @@ pub(crate) fn queue_codex_worker_intro_prompt( worker_name: &str, worker_cli: cas_mux::SupervisorCli, ) { - let _ = cas_dir; - let _ = worker_name; - if worker_cli == cas_mux::SupervisorCli::Codex { - // Codex workers now receive startup workflow as the initial codex prompt arg at spawn time. - // Avoid queue injection here to prevent duplicate or draft-only startup prompts. + match worker_cli { + cas_mux::SupervisorCli::Codex => { + // Codex workers now receive startup workflow as the initial codex prompt arg at spawn time. + // Avoid queue injection here to prevent duplicate or draft-only startup prompts. + } + cas_mux::SupervisorCli::Claude => { + // Inject MCP fallback instructions so workers can self-diagnose and notify the + // supervisor if MCP tools fail to load (e.g. .mcp.json missing from worktree). + let prompt = format!( + "You are a CAS factory worker ({worker_name}).\n\ + Check your assigned tasks: `mcp__cas__task action=mine`\n\n\ + IMPORTANT — if mcp__cas__* tools are unavailable:\n\ + 1. Run: `cat .mcp.json` to verify MCP config exists in your worktree\n\ + 2. If missing, run: `cas init -y` to regenerate it (takes effect next session)\n\ + 3. Notify supervisor immediately via CLI fallback:\n\ + `cas factory message --target supervisor --message \"Worker {worker_name}: MCP tools unavailable. .mcp.json may be missing from worktree.\"`\n\ + Do not remain silently idle — always notify the supervisor if you cannot access MCP tools." + ); + if let Ok(queue) = open_prompt_queue_store(cas_dir) { + let _ = queue.enqueue("cas", worker_name, &prompt); + } + } } } diff --git a/cas-cli/src/ui/factory/app/sidecar_and_selection.rs b/cas-cli/src/ui/factory/app/sidecar_and_selection.rs index 96d7827d..d6d1eb02 100644 --- a/cas-cli/src/ui/factory/app/sidecar_and_selection.rs +++ b/cas-cli/src/ui/factory/app/sidecar_and_selection.rs @@ -352,26 +352,25 @@ impl FactoryApp { } } - /// Handle mouse up - finalize selection and copy to clipboard - pub fn handle_mouse_up(&mut self) { + /// Handle mouse up - finalize selection and return selected text for clipboard. + /// + /// Returns the selected text (if any) so the caller can relay it to the + /// client terminal via OSC 52. The daemon process is headless and cannot + /// write to the system clipboard directly. + pub fn handle_mouse_up(&mut self) -> Option { // Finalize the selection if self.selection.is_active { self.selection.finalize(); } - // Copy to clipboard if selection exists + // Return selected text for the caller to handle clipboard if let Some(text) = self.get_selected_text() { if !text.is_empty() { - match crate::ui::factory::clipboard::copy_to_clipboard(&text) { - Ok(()) => { - tracing::debug!("Copied {} chars to clipboard", text.len()); - } - Err(e) => { - tracing::warn!("Failed to copy to clipboard: {}", e); - } - } + tracing::debug!("Selection complete: {} chars", text.len()); + return Some(text); } } + None } /// Start a text selection at the given screen position diff --git a/cas-cli/src/ui/factory/boot.rs b/cas-cli/src/ui/factory/boot.rs index 23099e20..dd20b2a1 100644 --- a/cas-cli/src/ui/factory/boot.rs +++ b/cas-cli/src/ui/factory/boot.rs @@ -17,6 +17,8 @@ pub struct BootConfig { pub profile: String, /// Skip animations (for testing) pub skip_animation: bool, + /// Use minions theme + pub minions_theme: bool, } mod screen; @@ -36,7 +38,7 @@ pub fn run_boot_screen_client( use std::collections::HashMap as AgentMap; use std::io::Read; - let mut screen = BootScreen::new(boot_config.skip_animation)?; + let mut screen = BootScreen::new_themed(boot_config.skip_animation, boot_config.minions_theme)?; // Draw logo and get starting row let box_start = screen.draw_logo()?; diff --git a/cas-cli/src/ui/factory/boot/screen.rs b/cas-cli/src/ui/factory/boot/screen.rs index 707cee96..0b59212c 100644 --- a/cas-cli/src/ui/factory/boot/screen.rs +++ b/cas-cli/src/ui/factory/boot/screen.rs @@ -93,6 +93,91 @@ mod colors { }; } +/// Minions-themed colors for the boot screen +mod minions_colors { + use crossterm::style::Color; + + // Logo colors - Minion yellow with glow + pub const LOGO: Color = Color::Rgb { + r: 255, + g: 213, + b: 0, + }; + pub const LOGO_GLOW: Color = Color::Rgb { + r: 255, + g: 235, + b: 100, + }; + + // Text colors + pub const HEADER: Color = Color::White; + pub const LABEL: Color = Color::Rgb { + r: 120, + g: 120, + b: 130, + }; + pub const VALUE: Color = Color::Rgb { + r: 255, + g: 213, + b: 0, + }; + + // Status colors (keep functional) + pub const OK: Color = Color::Rgb { + r: 80, + g: 250, + b: 120, + }; + pub const PENDING: Color = Color::Rgb { + r: 255, + g: 213, + b: 0, + }; + pub const ERROR: Color = Color::Rgb { + r: 255, + g: 90, + b: 90, + }; + + // Progress bar - yellow fill + pub const PROGRESS_DONE: Color = Color::Rgb { + r: 255, + g: 213, + b: 0, + }; + pub const PROGRESS_EMPTY: Color = Color::Rgb { + r: 50, + g: 50, + b: 55, + }; + + // Agent role colors - denim blue for workers, dark for supervisor (Gru) + pub const WORKER: Color = Color::Rgb { + r: 65, + g: 105, + b: 225, + }; + pub const SUPERVISOR: Color = Color::Rgb { + r: 80, + g: 80, + b: 85, + }; + + // Box/frame colors - denim blue tint + pub const BOX: Color = Color::Rgb { + r: 50, + g: 60, + b: 90, + }; + + // Final ready state - banana yellow + pub const READY: Color = Color::Rgb { + r: 255, + g: 235, + b: 59, + }; +} + /// ASCII art logo for CAS Factory const LOGO: &str = r#" ██████╗ █████╗ ███████╗ ███████╗ █████╗ ██████╗████████╗ ██████╗ ██████╗ ██╗ ██╗ @@ -114,6 +199,35 @@ const LOGO_SMALL: &str = r#" ╚═══════════════════════════════════════════════════════╝ "#; +/// Minion ASCII art logo — pill-shaped body, goggles, overalls +const MINION_LOGO: &str = r#" + ▄████████████▄ + ██ ██ + ██ ▄██████████▄ ██ + ██ █ ◉ ◉ █ ██ + ██ █ █ ██ + ██ ▀██████████▀ ██ + ██ ╭──────╮ ██ + ─┤ ██ │ ╰──╯ │ ██ ├─ + ██ ╰──────╯ ██ + ▐█ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄ █▌ + ▐█ █ B A N A N A █ █▌ + ▐█ █▄▄▄▄▄▄▄▄▄▄▄▄█ █▌ + ██ ██ + ██ ██ ██ ██ + ▀██▀ ▀██▀ +"#; + +/// Smaller minion for narrow/short terminals +const MINION_LOGO_SMALL: &str = r#" + ▄██████▄ + ██ (◉◉) ██ + ██ ╰──╯ ██ + █▌▐████▌▐█ + █▌ │ │ ▐█ + ▀▀ ▀▀ +"#; + /// Braille spinner frames for smooth animation const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -131,11 +245,23 @@ pub(crate) struct BootScreen { pub(crate) steps_row: u16, pub(crate) agent_row: u16, pub(crate) skip_animation: bool, + pub(crate) minions_theme: bool, spinner_tick: usize, } +/// Helper to select default or minions color +macro_rules! themed { + ($self:expr, $name:ident) => { + if $self.minions_theme { + minions_colors::$name + } else { + colors::$name + } + }; +} + impl BootScreen { - pub(crate) fn new(skip_animation: bool) -> std::io::Result { + pub(crate) fn new_themed(skip_animation: bool, minions_theme: bool) -> std::io::Result { let mut stdout = stdout(); let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); @@ -153,29 +279,59 @@ impl BootScreen { steps_row: 0, // Set after logo agent_row: 0, // Set after steps skip_animation, + minions_theme, spinner_tick: 0, }) } pub(crate) fn draw_logo(&mut self) -> std::io::Result { + let logo_color = themed!(self, LOGO); + let logo_glow = themed!(self, LOGO_GLOW); + let header_color = themed!(self, HEADER); + let label_color = themed!(self, LABEL); + + let (title, subtitle) = if self.minions_theme { + ( + "═══ BANANA! ═══", + format!("Bee-do Bee-do • v{}", APP_VERSION), + ) + } else { + ( + "═══ Coding Agent System ═══", + format!("Multi-Agent Orchestration • v{}", APP_VERSION), + ) + }; + + let compact_title = if self.minions_theme { + "Minion Factory Boot" + } else { + "CAS Factory Boot" + }; + + let compact_subtitle = if self.minions_theme { + format!("Bee-do Bee-do • v{}", APP_VERSION) + } else { + format!("Coding Agent System • v{}", APP_VERSION) + }; + // Tmux and many terminal defaults are 24 rows tall. The full logo + subtitle // pushes the boot box out of view, so fall back to a compact header. if self.rows < 36 { execute!( self.stdout, MoveTo(0, 1), - SetForegroundColor(colors::HEADER), + SetForegroundColor(header_color), SetAttribute(Attribute::Bold), Print(format!( "{:^width$}", - "CAS Factory Boot", + compact_title, width = self.cols as usize )), SetAttribute(Attribute::Reset), MoveTo(0, 2), - SetForegroundColor(colors::LABEL), + SetForegroundColor(label_color), Print(format!( "{:^width$}", - format!("Coding Agent System • v{}", APP_VERSION), + compact_subtitle, width = self.cols as usize )), )?; @@ -187,7 +343,11 @@ impl BootScreen { } let delay = if self.skip_animation { 0 } else { 35 }; - let logo = if self.cols >= 100 { LOGO } else { LOGO_SMALL }; + let logo = if self.minions_theme { + if self.cols >= 100 { MINION_LOGO } else { MINION_LOGO_SMALL } + } else { + if self.cols >= 100 { LOGO } else { LOGO_SMALL } + }; let logo_lines: Vec<&str> = logo.lines().filter(|l| !l.is_empty()).collect(); // Starting row with top padding @@ -202,7 +362,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(padding as u16, row), - SetForegroundColor(colors::LOGO_GLOW), + SetForegroundColor(logo_glow), SetAttribute(Attribute::Bold), Print(line), SetAttribute(Attribute::Reset) @@ -214,7 +374,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(padding as u16, row), - SetForegroundColor(colors::LOGO), + SetForegroundColor(logo_color), Print(line) )?; self.stdout.flush()?; @@ -224,7 +384,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(padding as u16, row), - SetForegroundColor(colors::LOGO), + SetForegroundColor(logo_color), Print(line) )?; } @@ -237,19 +397,19 @@ impl BootScreen { execute!( self.stdout, MoveTo(0, subtitle_row), - SetForegroundColor(colors::HEADER), + SetForegroundColor(header_color), SetAttribute(Attribute::Bold), Print(format!( "{:^width$}", - "═══ Coding Agent System ═══", + title, width = self.cols as usize )), SetAttribute(Attribute::Reset), MoveTo(0, subtitle_row + 1), - SetForegroundColor(colors::LABEL), + SetForegroundColor(label_color), Print(format!( "{:^width$}", - format!("Multi-Agent Orchestration • v{}", APP_VERSION), + subtitle, width = self.cols as usize )), )?; @@ -293,7 +453,7 @@ impl BootScreen { self.steps_row = steps_row; // Draw box outline - execute!(self.stdout, SetForegroundColor(colors::BOX))?; + execute!(self.stdout, SetForegroundColor(themed!(self, BOX)))?; // Top border with double line for emphasis execute!( @@ -352,11 +512,11 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 1, row), - SetForegroundColor(colors::BOX), + SetForegroundColor(themed!(self, BOX)), Print("─".repeat(side_len)), - SetForegroundColor(colors::LABEL), + SetForegroundColor(themed!(self, LABEL)), Print(&label_with_padding), - SetForegroundColor(colors::BOX), + SetForegroundColor(themed!(self, BOX)), Print("─".repeat(right_side)) )?; Ok(()) @@ -370,9 +530,9 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 2, row), - SetForegroundColor(colors::LABEL), + SetForegroundColor(themed!(self, LABEL)), Print(format!("{label:>12}: ")), - SetForegroundColor(colors::VALUE), + SetForegroundColor(themed!(self, VALUE)), Print(value) )?; Ok(()) @@ -381,12 +541,12 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4, row), - SetForegroundColor(colors::PENDING), + SetForegroundColor(themed!(self, PENDING)), Print(SPINNER_FRAMES[0]), Print(" "), - SetForegroundColor(colors::HEADER), + SetForegroundColor(themed!(self, HEADER)), Print(text), - SetForegroundColor(colors::LABEL), + SetForegroundColor(themed!(self, LABEL)), Print(" ...") )?; self.stdout.flush()?; @@ -402,7 +562,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4, row), - SetForegroundColor(colors::PENDING), + SetForegroundColor(themed!(self, PENDING)), Print(SPINNER_FRAMES[frame_idx]) )?; self.stdout.flush()?; @@ -415,10 +575,10 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4, row), - SetForegroundColor(colors::OK), + SetForegroundColor(themed!(self, OK)), Print("✓"), Print(" "), - SetForegroundColor(colors::HEADER), + SetForegroundColor(themed!(self, HEADER)), Print(text), Print(" ") // Clear any remnants )?; @@ -429,14 +589,14 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4, row), - SetForegroundColor(colors::ERROR), + SetForegroundColor(themed!(self, ERROR)), Print("✗"), Print(" "), - SetForegroundColor(colors::HEADER), + SetForegroundColor(themed!(self, HEADER)), Print(text), - SetForegroundColor(colors::LABEL), + SetForegroundColor(themed!(self, LABEL)), Print(" — "), - SetForegroundColor(colors::ERROR), + SetForegroundColor(themed!(self, ERROR)), Print(truncate_path(error, 30)) )?; self.stdout.flush()?; @@ -454,9 +614,9 @@ impl BootScreen { "worker" }; let role_color = if is_supervisor { - colors::SUPERVISOR + themed!(self, SUPERVISOR) } else { - colors::WORKER + themed!(self, WORKER) }; let bar_width = 24; let name_width = 14; @@ -467,17 +627,17 @@ impl BootScreen { SetForegroundColor(role_color), Print(format!("{role:>10}")), Print(" "), - SetForegroundColor(colors::VALUE), + SetForegroundColor(themed!(self, VALUE)), Print(format!("{name: 0 { 1 } else { 0 }; + let done_color = themed!(self, PROGRESS_DONE); + let empty_color = themed!(self, PROGRESS_EMPTY); + // Move to progress bar position execute!( self.stdout, MoveTo(self.box_left + 4 + 12 + name_width as u16 + 3, row), - SetForegroundColor(colors::PROGRESS_DONE), + SetForegroundColor(done_color), Print("█".repeat(full_chars)) )?; @@ -506,7 +669,7 @@ impl BootScreen { if partial_char_idx > 0 { execute!( self.stdout, - SetForegroundColor(colors::PROGRESS_DONE), + SetForegroundColor(done_color), Print(PROGRESS_CHARS[partial_char_idx - 1]) )?; } @@ -514,7 +677,7 @@ impl BootScreen { // Draw empty portion execute!( self.stdout, - SetForegroundColor(colors::PROGRESS_EMPTY), + SetForegroundColor(empty_color), Print("░".repeat(empty_chars)) )?; @@ -528,12 +691,12 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4 + 12 + name_width as u16 + 3, row), - SetForegroundColor(colors::PROGRESS_DONE), + SetForegroundColor(themed!(self, PROGRESS_DONE)), Print("█".repeat(bar_width)), - SetForegroundColor(colors::BOX), + SetForegroundColor(themed!(self, BOX)), Print("▌"), Print(" "), - SetForegroundColor(colors::OK), + SetForegroundColor(themed!(self, OK)), SetAttribute(Attribute::Bold), Print("READY"), SetAttribute(Attribute::Reset) @@ -542,13 +705,23 @@ impl BootScreen { Ok(()) } pub(crate) fn show_ready(&mut self, final_row: u16) -> std::io::Result<()> { + let ready_color = themed!(self, READY); + let glow_color = themed!(self, LOGO_GLOW); + let label_color = themed!(self, LABEL); + + let (ready_text, launch_text) = if self.minions_theme { + (" BANANA!", " — Bee-do Bee-do Bee-do") + } else { + (" SYSTEM READY", " — Launching interface") + }; + if !self.skip_animation { // Pulsing animation before showing ready for _ in 0..3 { execute!( self.stdout, MoveTo(self.box_left + 4, final_row), - SetForegroundColor(colors::LOGO_GLOW), + SetForegroundColor(glow_color), SetAttribute(Attribute::Bold), Print("●"), SetAttribute(Attribute::Reset), @@ -559,7 +732,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4, final_row), - SetForegroundColor(colors::READY), + SetForegroundColor(ready_color), Print("○"), )?; self.stdout.flush()?; @@ -571,10 +744,10 @@ impl BootScreen { execute!( self.stdout, MoveTo(self.box_left + 4, final_row), - SetForegroundColor(colors::READY), + SetForegroundColor(ready_color), SetAttribute(Attribute::Bold), Print("▶"), - Print(" SYSTEM READY"), + Print(ready_text), SetAttribute(Attribute::Reset), )?; self.stdout.flush()?; @@ -582,13 +755,13 @@ impl BootScreen { if !self.skip_animation { thread::sleep(Duration::from_millis(200)); + let ready_len = ready_text.len() as u16 + 1; // +1 for ▶ // Type out the launching message - let message = " — Launching interface"; - for (i, ch) in message.chars().enumerate() { + for (i, ch) in launch_text.chars().enumerate() { execute!( self.stdout, - MoveTo(self.box_left + 4 + 16 + i as u16, final_row), - SetForegroundColor(colors::LABEL), + MoveTo(self.box_left + 4 + ready_len + i as u16, final_row), + SetForegroundColor(label_color), Print(ch) )?; self.stdout.flush()?; diff --git a/cas-cli/src/ui/factory/daemon/runtime/client_input.rs b/cas-cli/src/ui/factory/daemon/runtime/client_input.rs index 276fa4bc..22c77891 100644 --- a/cas-cli/src/ui/factory/daemon/runtime/client_input.rs +++ b/cas-cli/src/ui/factory/daemon/runtime/client_input.rs @@ -182,7 +182,18 @@ impl FactoryDaemon { || self.app.show_changes_dialog || self.app.show_help) { - self.app.handle_mouse_up(); + if let Some(text) = self.app.handle_mouse_up() { + // Send OSC 52 to the owner client so its + // terminal writes the text to the system + // clipboard. The daemon is headless and + // cannot access the clipboard directly. + let osc52 = osc52_copy_sequence(&text); + if let Some(client) = + self.clients.get_mut(&client_id) + { + client.output_buf.extend(osc52.as_bytes()); + } + } } } ControlEvent::MouseScrollUp => { @@ -213,6 +224,11 @@ impl FactoryDaemon { e ); } + // Auto-enter inject mode so the user can + // immediately type context for the image. + self.app.inject_target = Some(target_pane); + self.app.inject_buffer.clear(); + self.app.input_mode = crate::ui::factory::input::InputMode::Inject; } else { tracing::debug!( "Ignoring image drop outside worker/supervisor panes at ({}, {})", @@ -843,6 +859,55 @@ impl FactoryDaemon { } } +/// Build an OSC 52 escape sequence that tells the terminal to copy `text` +/// to the system clipboard. +/// +/// Format: `ESC ] 52 ; c ; ST` +/// where ST (String Terminator) = `ESC \` +/// +/// Supported by kitty, alacritty, wezterm, ghostty, iTerm2, and most modern +/// terminal emulators. +fn osc52_copy_sequence(text: &str) -> String { + let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + format!("\x1b]52;c;{}\x1b\\", encoded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn osc52_encodes_text_as_base64() { + let seq = osc52_copy_sequence("hello"); + // "hello" in base64 is "aGVsbG8=" + assert_eq!(seq, "\x1b]52;c;aGVsbG8=\x1b\\"); + } + + #[test] + fn osc52_handles_empty_string() { + let seq = osc52_copy_sequence(""); + assert_eq!(seq, "\x1b]52;c;\x1b\\"); + } + + #[test] + fn osc52_handles_unicode() { + let seq = osc52_copy_sequence("🍌"); + let expected_b64 = base64::engine::general_purpose::STANDARD.encode("🍌".as_bytes()); + assert_eq!(seq, format!("\x1b]52;c;{}\x1b\\", expected_b64)); + } + + #[test] + fn osc52_handles_multiline() { + let seq = osc52_copy_sequence("line1\nline2\nline3"); + assert!(seq.starts_with("\x1b]52;c;")); + assert!(seq.ends_with("\x1b\\")); + // Decode and verify round-trip + let b64 = &seq[7..seq.len() - 2]; + let decoded = base64::engine::general_purpose::STANDARD.decode(b64).unwrap(); + assert_eq!(std::str::from_utf8(&decoded).unwrap(), "line1\nline2\nline3"); + } +} + fn bracketed_paste_bytes(payload: &str) -> Vec { let mut out = Vec::with_capacity(payload.len() + 12); out.extend_from_slice(b"\x1b[200~"); diff --git a/cas-cli/src/ui/factory/daemon/runtime/lifecycle.rs b/cas-cli/src/ui/factory/daemon/runtime/lifecycle.rs index c4de2c6e..deff65c3 100644 --- a/cas-cli/src/ui/factory/daemon/runtime/lifecycle.rs +++ b/cas-cli/src/ui/factory/daemon/runtime/lifecycle.rs @@ -221,7 +221,7 @@ impl FactoryDaemon { // Poll prompt queue (on notification or timer) if prompt_notified || last_prompt_poll.elapsed() >= poll_interval { if prompt_notified { - if let Some(ref notify) = self.notify_rx { + if let Some(ref mut notify) = self.notify_rx { notify.drain(); } } @@ -478,7 +478,7 @@ impl FactoryDaemon { 16 }; let sleep_dur = Duration::from_millis(sleep_ms); - if let Some(ref notify) = self.notify_rx { + if let Some(ref mut notify) = self.notify_rx { tokio::select! { result = notify.recv() => { if result.is_ok() { diff --git a/cas-cli/src/ui/factory/director/agent_helpers.rs b/cas-cli/src/ui/factory/director/agent_helpers.rs index 341e2b53..656ee290 100644 --- a/cas-cli/src/ui/factory/director/agent_helpers.rs +++ b/cas-cli/src/ui/factory/director/agent_helpers.rs @@ -9,7 +9,7 @@ use chrono::Utc; use ratatui::prelude::Color; use super::data::DirectorData; -use crate::ui::theme::{Icons, Palette}; +use crate::ui::theme::{Icons, MinionsIcons, Palette}; /// Agents with no heartbeat for this many seconds are considered disconnected. pub const HEARTBEAT_TIMEOUT_SECS: i64 = 300; @@ -35,14 +35,25 @@ pub fn is_disconnected(agent: &cas_factory::AgentSummary) -> bool { pub fn agent_status_icon( agent: &cas_factory::AgentSummary, palette: &Palette, + minions: bool, ) -> (&'static str, Color) { if is_disconnected(agent) { - ("\u{2298}", palette.agent_dead) // ⊘ + let icon = if minions { MinionsIcons::AGENT_DEAD } else { "\u{2298}" }; + (icon, palette.agent_dead) } else { match agent.status { - AgentStatus::Active => (Icons::CIRCLE_FILLED, palette.agent_active), - AgentStatus::Idle => (Icons::CIRCLE_HALF, palette.agent_idle), - _ => (Icons::CIRCLE_EMPTY, palette.agent_dead), + AgentStatus::Active => { + let icon = if minions { MinionsIcons::AGENT_ACTIVE } else { Icons::CIRCLE_FILLED }; + (icon, palette.agent_active) + } + AgentStatus::Idle => { + let icon = if minions { MinionsIcons::AGENT_IDLE } else { Icons::CIRCLE_HALF }; + (icon, palette.agent_idle) + } + _ => { + let icon = if minions { MinionsIcons::AGENT_DEAD } else { Icons::CIRCLE_EMPTY }; + (icon, palette.agent_dead) + } } } } @@ -51,11 +62,20 @@ pub fn agent_status_icon( pub fn agent_status_icon_simple( agent: &cas_factory::AgentSummary, palette: &Palette, + minions: bool, ) -> (&'static str, Color) { - match agent.status { - AgentStatus::Active => ("\u{25cf}", palette.agent_active), // ● - AgentStatus::Idle => ("\u{25cb}", palette.agent_idle), // ○ - _ => ("\u{2298}", palette.agent_dead), // ⊘ + if minions { + match agent.status { + AgentStatus::Active => (MinionsIcons::AGENT_ACTIVE, palette.agent_active), + AgentStatus::Idle => (MinionsIcons::AGENT_IDLE, palette.agent_idle), + _ => (MinionsIcons::AGENT_DEAD, palette.agent_dead), + } + } else { + match agent.status { + AgentStatus::Active => ("\u{25cf}", palette.agent_active), // ● + AgentStatus::Idle => ("\u{25cb}", palette.agent_idle), // ○ + _ => ("\u{2298}", palette.agent_dead), // ⊘ + } } } diff --git a/cas-cli/src/ui/factory/director/factory_radar.rs b/cas-cli/src/ui/factory/director/factory_radar.rs index fc924bae..862d44e1 100644 --- a/cas-cli/src/ui/factory/director/factory_radar.rs +++ b/cas-cli/src/ui/factory/director/factory_radar.rs @@ -240,7 +240,8 @@ fn render_worker_list( } // Status indicator - let (status_char, status_color) = agent_helpers::agent_status_icon_simple(agent, palette); + let (status_char, status_color) = + agent_helpers::agent_status_icon_simple(agent, palette, theme.is_minions()); let is_selected = selected == Some(idx); let name_style = if is_selected { diff --git a/cas-cli/src/ui/factory/director/mission_workers.rs b/cas-cli/src/ui/factory/director/mission_workers.rs index 825e81f5..e38def9c 100644 --- a/cas-cli/src/ui/factory/director/mission_workers.rs +++ b/cas-cli/src/ui/factory/director/mission_workers.rs @@ -67,7 +67,8 @@ pub fn render_workers_panel_with_focus( // Status icon and color let is_disconnected = agent_helpers::is_disconnected(agent); - let (status_icon, icon_color) = agent_helpers::agent_status_icon(agent, palette); + let (status_icon, icon_color) = + agent_helpers::agent_status_icon(agent, palette, theme.is_minions()); let selection_marker = if is_selected { "\u{25b8} " } else { " " }; let name_width = agent.name.len(); diff --git a/cas-cli/src/ui/theme/colors.rs b/cas-cli/src/ui/theme/colors.rs index 5d5fe5ec..1d625e3a 100644 --- a/cas-cli/src/ui/theme/colors.rs +++ b/cas-cli/src/ui/theme/colors.rs @@ -127,6 +127,30 @@ impl ColorPalette { } } + /// Minions theme variant - yellow primary, denim blue secondary + pub fn minions(is_dark: bool) -> Self { + let base = if is_dark { Self::dark() } else { Self::light() }; + Self { + // Override primary accent from teal to Minion yellow + primary_100: Color::Rgb(255, 245, 157), // Light banana + primary_200: Color::Rgb(255, 235, 59), // Bright yellow + primary_300: Color::Rgb(255, 213, 0), // Minion yellow + primary_400: Color::Rgb(255, 193, 7), // Amber accent + primary_500: Color::Rgb(255, 160, 0), // Deep amber + + // Override info to denim blue (overalls) + info: Color::Rgb(65, 105, 225), // Royal blue / denim + info_dim: Color::Rgb(33, 53, 113), // Dark denim + + // Override cyan to goggle silver + cyan: Color::Rgb(192, 200, 210), // Goggle silver + cyan_dim: Color::Rgb(96, 100, 105), // Dark goggle + + // Keep everything else from the base + ..base + } + } + /// High contrast accessibility variant pub fn high_contrast() -> Self { Self { @@ -165,3 +189,48 @@ impl ColorPalette { } } } + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::Color; + + #[test] + fn minions_palette_has_yellow_primary() { + let minions = ColorPalette::minions(true); + match minions.primary_300 { + Color::Rgb(r, g, _) => { + assert!(r > 200, "primary_300 red should be bright yellow, got {r}"); + assert!(g > 150, "primary_300 green should be bright yellow, got {g}"); + } + other => panic!("Expected RGB color, got {other:?}"), + } + } + + #[test] + fn minions_palette_has_denim_blue_info() { + let minions = ColorPalette::minions(true); + match minions.info { + Color::Rgb(r, _, b) => { + assert!(b > r, "info blue should exceed red for denim blue"); + assert!(b > 150, "info blue component should be strong, got {b}"); + } + other => panic!("Expected RGB color, got {other:?}"), + } + } + + #[test] + fn minions_palette_differs_from_dark() { + let dark = ColorPalette::dark(); + let minions = ColorPalette::minions(true); + assert_ne!(minions.primary_300, dark.primary_300, "primary should differ"); + assert_ne!(minions.info, dark.info, "info should differ"); + } + + #[test] + fn minions_palette_preserves_base_bg() { + let dark = ColorPalette::dark(); + let minions = ColorPalette::minions(true); + assert_eq!(minions.gray_900, dark.gray_900, "bg should inherit from dark base"); + } +} diff --git a/cas-cli/src/ui/theme/config.rs b/cas-cli/src/ui/theme/config.rs index 4acee496..aaf7cda7 100644 --- a/cas-cli/src/ui/theme/config.rs +++ b/cas-cli/src/ui/theme/config.rs @@ -16,6 +16,36 @@ pub enum ThemeMode { HighContrast, } +/// Theme variant selection (cosmetic flavor) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThemeVariant { + #[default] + Default, + Minions, +} + +impl std::fmt::Display for ThemeVariant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ThemeVariant::Default => write!(f, "default"), + ThemeVariant::Minions => write!(f, "minions"), + } + } +} + +impl std::str::FromStr for ThemeVariant { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "default" => Ok(ThemeVariant::Default), + "minions" => Ok(ThemeVariant::Minions), + _ => Err(format!("Unknown theme variant: {s}")), + } + } +} + impl std::fmt::Display for ThemeMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -45,12 +75,17 @@ pub struct ThemeConfig { /// Theme mode: dark, light, or high_contrast #[serde(default)] pub mode: ThemeMode, + + /// Theme variant: default or minions + #[serde(default)] + pub variant: ThemeVariant, } /// Active theme instance with computed styles #[derive(Debug, Clone)] pub struct ActiveTheme { pub mode: ThemeMode, + pub variant: ThemeVariant, pub is_dark: bool, pub palette: Palette, pub styles: Styles, @@ -59,22 +94,33 @@ pub struct ActiveTheme { impl ActiveTheme { /// Create theme from configuration pub fn from_config(config: &ThemeConfig) -> Self { - Self::from_mode(config.mode) + Self::from_mode_and_variant(config.mode, config.variant) } - /// Create theme from mode + /// Create theme from mode (default variant) pub fn from_mode(mode: ThemeMode) -> Self { - let (colors, is_dark) = match mode { + Self::from_mode_and_variant(mode, ThemeVariant::Default) + } + + /// Create theme from mode and variant + pub fn from_mode_and_variant(mode: ThemeMode, variant: ThemeVariant) -> Self { + let (base_colors, is_dark) = match mode { ThemeMode::Dark => (ColorPalette::dark(), true), ThemeMode::Light => (ColorPalette::light(), false), ThemeMode::HighContrast => (ColorPalette::high_contrast(), true), }; + let colors = match variant { + ThemeVariant::Default => base_colors, + ThemeVariant::Minions => ColorPalette::minions(is_dark), + }; + let palette = Palette::from_colors(colors, is_dark); let styles = Styles::from_palette(&palette); Self { mode, + variant, is_dark, palette, styles, @@ -114,6 +160,11 @@ impl ActiveTheme { None => Self::detect(), } } + + /// Check if the minions variant is active + pub fn is_minions(&self) -> bool { + self.variant == ThemeVariant::Minions + } } impl Default for ActiveTheme { @@ -121,3 +172,52 @@ impl Default for ActiveTheme { Self::detect() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_variant_default_is_default() { + assert_eq!(ThemeVariant::default(), ThemeVariant::Default); + } + + #[test] + fn theme_variant_display() { + assert_eq!(ThemeVariant::Default.to_string(), "default"); + assert_eq!(ThemeVariant::Minions.to_string(), "minions"); + } + + #[test] + fn theme_variant_from_str() { + assert_eq!("default".parse::().unwrap(), ThemeVariant::Default); + assert_eq!("minions".parse::().unwrap(), ThemeVariant::Minions); + assert_eq!("MINIONS".parse::().unwrap(), ThemeVariant::Minions); + assert!("banana".parse::().is_err()); + } + + #[test] + fn theme_variant_serde_round_trip() { + let json = serde_json::to_string(&ThemeVariant::Minions).unwrap(); + assert_eq!(json, "\"minions\""); + let parsed: ThemeVariant = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, ThemeVariant::Minions); + } + + #[test] + fn active_theme_minions_variant() { + let config = ThemeConfig { + mode: ThemeMode::Dark, + variant: ThemeVariant::Minions, + }; + let theme = ActiveTheme::from_config(&config); + assert!(theme.is_minions()); + assert_eq!(theme.variant, ThemeVariant::Minions); + } + + #[test] + fn active_theme_default_is_not_minions() { + let theme = ActiveTheme::from_mode(ThemeMode::Dark); + assert!(!theme.is_minions()); + } +} diff --git a/cas-cli/src/ui/theme/icons.rs b/cas-cli/src/ui/theme/icons.rs index a25a7dc5..b982a1be 100644 --- a/cas-cli/src/ui/theme/icons.rs +++ b/cas-cli/src/ui/theme/icons.rs @@ -111,3 +111,39 @@ impl Icons { pub const AGENT_WORKER: &'static str = "W"; pub const AGENT_CI: &'static str = "C"; } + +/// Minion-themed icon overrides (used when minions variant is active) +pub struct MinionsIcons; + +impl MinionsIcons { + // Agent status indicators + pub const AGENT_ACTIVE: &'static str = "\u{1F34C}"; // 🍌 + pub const AGENT_IDLE: &'static str = "\u{1F441}"; // 👁 + pub const AGENT_DEAD: &'static str = "\u{1F4A4}"; // 💤 + + // Agent types + pub const AGENT_WORKER: &'static str = "\u{1F34C}"; // 🍌 + pub const AGENT_SUPERVISOR: &'static str = "\u{1F576}"; // 🕶 (Gru's glasses) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn minions_icons_are_non_empty() { + assert!(!MinionsIcons::AGENT_ACTIVE.is_empty()); + assert!(!MinionsIcons::AGENT_IDLE.is_empty()); + assert!(!MinionsIcons::AGENT_DEAD.is_empty()); + assert!(!MinionsIcons::AGENT_WORKER.is_empty()); + assert!(!MinionsIcons::AGENT_SUPERVISOR.is_empty()); + } + + #[test] + fn minions_icons_differ_from_default_circles() { + // Default TUI uses circle icons (●/○/⊘) for agent status + assert_ne!(MinionsIcons::AGENT_ACTIVE, Icons::CIRCLE_FILLED); + assert_ne!(MinionsIcons::AGENT_IDLE, Icons::CIRCLE_EMPTY); + assert_ne!(MinionsIcons::AGENT_DEAD, Icons::CIRCLE_X); + } +} diff --git a/cas-cli/src/ui/theme/mod.rs b/cas-cli/src/ui/theme/mod.rs index f311e77b..6c0c2826 100644 --- a/cas-cli/src/ui/theme/mod.rs +++ b/cas-cli/src/ui/theme/mod.rs @@ -13,8 +13,8 @@ mod styles; pub use agent_colors::{get_agent_color, register_agent_color, team_color_rgb}; pub use colors::ColorPalette; -pub use config::{ActiveTheme, ThemeConfig, ThemeMode}; +pub use config::{ActiveTheme, ThemeConfig, ThemeMode, ThemeVariant}; pub use detect::detect_background_theme; -pub use icons::Icons; +pub use icons::{Icons, MinionsIcons}; pub use palette::Palette; pub use styles::Styles; diff --git a/crates/cas-factory/src/config.rs b/crates/cas-factory/src/config.rs index 45d96d57..98b63265 100644 --- a/crates/cas-factory/src/config.rs +++ b/crates/cas-factory/src/config.rs @@ -189,6 +189,8 @@ pub struct FactoryConfig { /// UUID for the team lead's Claude Code session. /// Used as `leadSessionId` in config.json and passed as `--session-id` to the supervisor. pub lead_session_id: Option, + /// Use Minions theme for boot screen, names, and colors + pub minions_theme: bool, } impl Default for FactoryConfig { @@ -211,6 +213,7 @@ impl Default for FactoryConfig { session_id: None, teams_configs: std::collections::HashMap::new(), lead_session_id: None, + minions_theme: false, } } } diff --git a/crates/cas-factory/src/core.rs b/crates/cas-factory/src/core.rs index 70b39645..7f7d38f2 100644 --- a/crates/cas-factory/src/core.rs +++ b/crates/cas-factory/src/core.rs @@ -470,6 +470,7 @@ mod tests { session_id: None, teams_configs: std::collections::HashMap::new(), lead_session_id: None, + minions_theme: false, } } diff --git a/crates/cas-factory/src/notify.rs b/crates/cas-factory/src/notify.rs index 4b1f3004..e020ca04 100644 --- a/crates/cas-factory/src/notify.rs +++ b/crates/cas-factory/src/notify.rs @@ -19,7 +19,10 @@ pub fn notify_socket_path(cas_dir: &Path) -> PathBuf { /// Used in a `tokio::select!` branch to wake the event loop instantly when /// new prompts are enqueued. pub struct DaemonNotifier { - socket: UnixDatagram, + /// Bound std socket — converted to tokio lazily on first async use so that + /// `bind()` can be called before a Tokio runtime exists. + std_socket: Option, + socket: Option, path: PathBuf, } @@ -27,6 +30,7 @@ impl DaemonNotifier { /// Bind the notification socket at `{cas_dir}/notify.sock`. /// /// Removes a stale socket file from a previous run if one exists. + /// Safe to call before a Tokio runtime is active. pub fn bind(cas_dir: &Path) -> std::io::Result { let path = notify_socket_path(cas_dir); @@ -40,23 +44,43 @@ impl DaemonNotifier { std::fs::create_dir_all(parent)?; } - let socket = UnixDatagram::bind(&path)?; - Ok(Self { socket, path }) + let std_socket = StdUnixDatagram::bind(&path)?; + std_socket.set_nonblocking(true)?; + Ok(Self { + std_socket: Some(std_socket), + socket: None, + path, + }) + } + + /// Convert the std socket to a tokio socket. Must be called from within a + /// Tokio runtime. Idempotent — safe to call multiple times. + fn tokio_socket(&mut self) -> std::io::Result<&UnixDatagram> { + if self.socket.is_none() { + let std_sock = self + .std_socket + .take() + .expect("std_socket already consumed"); + self.socket = Some(UnixDatagram::from_std(std_sock)?); + } + Ok(self.socket.as_ref().unwrap()) } /// Async wait for a notification byte. Cancellation-safe (tokio /// `UnixDatagram::recv` is cancellation-safe). - pub async fn recv(&self) -> std::io::Result<()> { + pub async fn recv(&mut self) -> std::io::Result<()> { let mut buf = [0u8; 64]; - self.socket.recv(&mut buf).await?; + self.tokio_socket()?.recv(&mut buf).await?; Ok(()) } /// Non-blocking drain of all pending datagrams to coalesce multiple /// notifications into a single wakeup. - pub fn drain(&self) { + pub fn drain(&mut self) { let mut buf = [0u8; 64]; - while self.socket.try_recv(&mut buf).is_ok() {} + if let Ok(sock) = self.tokio_socket() { + while sock.try_recv(&mut buf).is_ok() {} + } } /// Remove the socket file (called on shutdown). @@ -105,7 +129,7 @@ mod tests { #[tokio::test] async fn notify_and_recv_round_trip() { let dir = TempDir::new().unwrap(); - let notifier = DaemonNotifier::bind(dir.path()).unwrap(); + let mut notifier = DaemonNotifier::bind(dir.path()).unwrap(); // Send a notification from the "worker" side notify_daemon(dir.path()).unwrap(); @@ -128,14 +152,17 @@ mod tests { #[tokio::test] async fn drain_clears_pending_notifications() { let dir = TempDir::new().unwrap(); - let notifier = DaemonNotifier::bind(dir.path()).unwrap(); + let mut notifier = DaemonNotifier::bind(dir.path()).unwrap(); + + // First recv registers the tokio socket with the reactor — without + // this, try_recv inside drain() will never see pending datagrams. + notify_daemon(dir.path()).unwrap(); + let _ = tokio::time::timeout(std::time::Duration::from_millis(100), notifier.recv()).await; - // Send multiple notifications + // Now send several more notifications for _ in 0..5 { notify_daemon(dir.path()).unwrap(); } - - // Small delay so datagrams land tokio::time::sleep(std::time::Duration::from_millis(10)).await; // Drain should clear all pending diff --git a/crates/cas-factory/tests/factory_integration.rs b/crates/cas-factory/tests/factory_integration.rs index 79290582..ce46efe8 100644 --- a/crates/cas-factory/tests/factory_integration.rs +++ b/crates/cas-factory/tests/factory_integration.rs @@ -195,6 +195,7 @@ fn test_config() -> FactoryConfig { session_id: None, teams_configs: std::collections::HashMap::new(), lead_session_id: None, + minions_theme: false, } } diff --git a/crates/cas-mcp/src/types.rs b/crates/cas-mcp/src/types.rs index 3a5e5a78..fafec08d 100644 --- a/crates/cas-mcp/src/types.rs +++ b/crates/cas-mcp/src/types.rs @@ -60,7 +60,7 @@ pub struct MemoryRequest { /// Limit for list/recent #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Scope filter @@ -124,7 +124,7 @@ pub struct TaskRequest { /// Priority 0-4 (for create, update) #[schemars(description = "Priority: 0=Critical, 1=High, 2=Medium, 3=Low, 4=Backlog")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_u8")] pub priority: Option, /// Task type (for create): task, bug, feature, epic, chore @@ -178,7 +178,7 @@ pub struct TaskRequest { /// Lease duration in seconds (for claim) #[schemars(description = "Lease duration in seconds (default: 600)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub duration_secs: Option, /// Target agent ID (for transfer) @@ -188,7 +188,7 @@ pub struct TaskRequest { /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Scope filter @@ -288,7 +288,7 @@ pub struct RuleRequest { /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Scope filter @@ -357,7 +357,7 @@ pub struct SkillRequest { /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Scope filter @@ -536,7 +536,7 @@ pub struct SpecRequest { /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, } @@ -586,7 +586,7 @@ pub struct AgentRequest { /// Max iterations (for loop_start, 0 = unlimited) #[schemars(description = "Maximum iterations (0 = unlimited)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_u32")] pub max_iterations: Option, /// Completion promise (for loop_start) @@ -601,12 +601,12 @@ pub struct AgentRequest { /// Stale threshold seconds (for cleanup) #[schemars(description = "Seconds since last heartbeat to consider stale")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub stale_threshold_secs: Option, /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, // ========== Queue Operations Fields (Factory Mode) ========== @@ -636,7 +636,7 @@ pub struct AgentRequest { /// Notification ID (for queue_ack) #[schemars(description = "Notification ID to acknowledge")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub notification_id: Option, // ========== Message Queue Fields (Agent → Agent) ========== @@ -690,7 +690,7 @@ pub struct PatternRequest { /// Priority 0-3 (for create, update, team_create_suggestion) #[schemars(description = "Priority: 0=Critical, 1=High, 2=Medium (default), 3=Low")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_u8")] pub priority: Option, /// Propagation mode (for create, update) @@ -717,7 +717,7 @@ pub struct PatternRequest { /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Team ID (for team_* actions) @@ -746,6 +746,7 @@ pub struct PatternRequest { pub include_dismissed: Option, } +pub(crate) mod deser; mod ops_secondary; pub use crate::types::ops_secondary::{ diff --git a/crates/cas-mcp/src/types/deser.rs b/crates/cas-mcp/src/types/deser.rs new file mode 100644 index 00000000..e59dc913 --- /dev/null +++ b/crates/cas-mcp/src/types/deser.rs @@ -0,0 +1,80 @@ +//! Custom deserializers for flexible numeric type coercion. +//! +//! Some MCP client implementations (including Claude Code) serialize numeric +//! parameters as JSON strings (e.g., `"3"` instead of `3`). These helpers +//! accept both native JSON numbers and string-encoded numbers so tool calls +//! are not rejected due to type mismatch. + +use serde::{de, Deserializer}; +use std::fmt; + +/// Generate an `Option<$target>` deserializer that accepts numbers, strings, and null. +/// +/// Produces a public function `$fn_name` usable with `#[serde(deserialize_with = "...")]`. +macro_rules! option_numeric_deser { + ($fn_name:ident, $target:ty, $desc:expr) => { + pub fn $fn_name<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + struct V; + impl<'de> de::Visitor<'de> for V { + type Value = Option<$target>; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}, a string containing one, or null", $desc) + } + + fn visit_none(self) -> Result { + Ok(None) + } + fn visit_unit(self) -> Result { + Ok(None) + } + fn visit_some>( + self, + d: D2, + ) -> Result { + d.deserialize_any(self) + } + fn visit_u64(self, v: u64) -> Result { + <$target>::try_from(v).map(Some).map_err(|_| { + de::Error::invalid_value( + de::Unexpected::Unsigned(v), + &concat!("a ", stringify!($target), " value"), + ) + }) + } + fn visit_i64(self, v: i64) -> Result { + <$target>::try_from(v).map(Some).map_err(|_| { + de::Error::invalid_value( + de::Unexpected::Signed(v), + &concat!("a ", stringify!($target), " value"), + ) + }) + } + fn visit_str(self, v: &str) -> Result { + let t = v.trim(); + if t.is_empty() { + return Ok(None); + } + t.parse::<$target>().map(Some).map_err(|_| { + de::Error::invalid_value( + de::Unexpected::Str(v), + &concat!("a string encoding a ", stringify!($target)), + ) + }) + } + } + + deserializer.deserialize_option(V) + } + }; +} + +option_numeric_deser!(option_u8, u8, "an integer 0-255"); +option_numeric_deser!(option_i32, i32, "an i32 integer"); +option_numeric_deser!(option_i64, i64, "an i64 integer"); +option_numeric_deser!(option_u32, u32, "a u32 integer"); +option_numeric_deser!(option_usize, usize, "a usize integer"); +option_numeric_deser!(option_u64, u64, "a u64 integer"); diff --git a/crates/cas-mcp/src/types/ops_secondary.rs b/crates/cas-mcp/src/types/ops_secondary.rs index bb23447c..c00a6712 100644 --- a/crates/cas-mcp/src/types/ops_secondary.rs +++ b/crates/cas-mcp/src/types/ops_secondary.rs @@ -1,5 +1,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use super::deser; /// Unified search, context, and entity operations request #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] @@ -29,7 +30,7 @@ pub struct SearchContextRequest { /// Max tokens for context #[schemars(description = "Maximum tokens for context")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub max_tokens: Option, /// Include related memories @@ -78,7 +79,7 @@ pub struct SearchContextRequest { /// Limit for list/search #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Sort field (for search) @@ -121,12 +122,12 @@ pub struct SearchContextRequest { /// Lines of context before match (for grep) #[schemars(description = "Lines of context before each match (grep -B)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub before_context: Option, /// Lines of context after match (for grep) #[schemars(description = "Lines of context after each match (grep -A)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub after_context: Option, /// Case insensitive search (for grep) @@ -142,12 +143,12 @@ pub struct SearchContextRequest { /// Start line for blame range #[schemars(description = "Start line number for blame range")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub line_start: Option, /// End line for blame range #[schemars(description = "End line number for blame range")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub line_end: Option, /// Filter to only AI-generated lines (for blame) @@ -316,12 +317,12 @@ pub struct VerificationRequest { /// Duration of verification in milliseconds (for add) #[schemars(description = "Duration in milliseconds")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_u64")] pub duration_ms: Option, /// Limit for list #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, /// Verification type: 'task' (default) or 'epic' @@ -344,7 +345,7 @@ pub struct TeamRequest { /// Limit for list operations #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, } @@ -361,7 +362,7 @@ pub struct FactoryRequest { #[schemars( description = "Number of workers (for spawn: how many to create, for shutdown: how many to stop, 0 = all)" )] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i32")] pub count: Option, /// Specific worker names (comma-separated) @@ -397,7 +398,7 @@ pub struct FactoryRequest { /// Threshold used by cleanup/report actions (seconds) #[schemars(description = "Optional threshold in seconds for cleanup/report actions")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub older_than_secs: Option, /// Whether spawned workers need isolated worktrees (git worktree per worker) @@ -414,7 +415,7 @@ pub struct FactoryRequest { /// Delay in seconds before reminder fires (time-based trigger) #[schemars(description = "Delay in seconds before reminder fires (time-based trigger)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub remind_delay_secs: Option, /// Event type that triggers the reminder (event-based trigger) @@ -433,14 +434,14 @@ pub struct FactoryRequest { /// Reminder ID for cancel operations #[schemars(description = "Reminder ID for cancel operations")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub remind_id: Option, /// TTL in seconds for the reminder (default: 3600) #[schemars( description = "Time-to-live in seconds for the reminder before auto-expiry (default: 3600)" )] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub remind_ttl_secs: Option, } @@ -501,7 +502,7 @@ pub struct CoordinationRequest { /// Maximum items to return #[schemars(description = "Maximum items to return")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub limit: Option, // ========== Agent Fields ========== @@ -532,7 +533,7 @@ pub struct CoordinationRequest { /// Max iterations (for loop_start, 0 = unlimited) #[schemars(description = "Maximum iterations (0 = unlimited)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_u32")] pub max_iterations: Option, /// Completion promise (for loop_start) @@ -547,7 +548,7 @@ pub struct CoordinationRequest { /// Stale threshold seconds (for agent_cleanup) #[schemars(description = "Seconds since last heartbeat to consider stale")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub stale_threshold_secs: Option, /// Supervisor ID (for queue operations) @@ -576,7 +577,7 @@ pub struct CoordinationRequest { /// Notification ID (for queue_ack) #[schemars(description = "Notification ID to acknowledge")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub notification_id: Option, // ========== Factory Fields ========== @@ -584,7 +585,7 @@ pub struct CoordinationRequest { #[schemars( description = "Number of workers (for spawn: how many to create, for shutdown: how many to stop, 0 = all)" )] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i32")] pub count: Option, /// Comma-separated worker names @@ -601,7 +602,7 @@ pub struct CoordinationRequest { /// Threshold in seconds for cleanup/report actions #[schemars(description = "Optional threshold in seconds for cleanup/report actions")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub older_than_secs: Option, /// Whether workers need isolated git worktrees @@ -618,7 +619,7 @@ pub struct CoordinationRequest { /// Delay in seconds before reminder fires (time-based trigger) #[schemars(description = "Delay in seconds before reminder fires (time-based trigger)")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub remind_delay_secs: Option, /// Event type that triggers reminder @@ -637,14 +638,14 @@ pub struct CoordinationRequest { /// Reminder ID for cancel operations #[schemars(description = "Reminder ID for cancel operations")] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub remind_id: Option, /// Time-to-live in seconds for the reminder (default: 3600) #[schemars( description = "Time-to-live in seconds for the reminder before auto-expiry (default: 3600)" )] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_i64")] pub remind_ttl_secs: Option, // ========== Worktree Fields ========== @@ -690,7 +691,7 @@ pub struct ExecuteRequest { #[schemars( description = "Max response length in characters. Default: 40000. Use your code to extract only what you need rather than increasing this." )] - #[serde(default)] + #[serde(default, deserialize_with = "deser::option_usize")] pub max_length: Option, } diff --git a/crates/cas-mcp/src/types_tests/tests.rs b/crates/cas-mcp/src/types_tests/tests.rs index 73a3888e..36cfda02 100644 --- a/crates/cas-mcp/src/types_tests/tests.rs +++ b/crates/cas-mcp/src/types_tests/tests.rs @@ -139,3 +139,424 @@ fn test_spec_request_supersede() { assert_eq!(req.supersedes_id, Some("spec-old456".to_string())); assert_eq!(req.new_version, Some(true)); } + +// ===== String-coercion tests (Claude Code serializes numbers as strings) ===== + +#[test] +fn test_task_request_priority_as_string() { + let req: TaskRequest = serde_json::from_str( + r#"{ + "action": "create", + "title": "Test", + "priority": "1" + }"#, + ) + .unwrap(); + assert_eq!(req.priority, Some(1)); +} + +#[test] +fn test_task_request_priority_null() { + let req: TaskRequest = serde_json::from_str( + r#"{ + "action": "list", + "priority": null + }"#, + ) + .unwrap(); + assert_eq!(req.priority, None); +} + +#[test] +fn test_task_request_priority_absent() { + let req: TaskRequest = serde_json::from_str(r#"{"action": "list"}"#).unwrap(); + assert_eq!(req.priority, None); +} + +#[test] +fn test_factory_request_count_as_string() { + let req: FactoryRequest = serde_json::from_str( + r#"{ + "action": "spawn_workers", + "count": "3" + }"#, + ) + .unwrap(); + assert_eq!(req.count, Some(3)); +} + +#[test] +fn test_coordination_request_count_as_string() { + let req: CoordinationRequest = serde_json::from_str( + r#"{ + "action": "spawn_workers", + "count": "3", + "isolate": true + }"#, + ) + .unwrap(); + assert_eq!(req.count, Some(3)); +} + +#[test] +fn test_coordination_request_count_as_int() { + // Existing integer encoding must still work + let req: CoordinationRequest = serde_json::from_str( + r#"{ + "action": "shutdown_workers", + "count": 0 + }"#, + ) + .unwrap(); + assert_eq!(req.count, Some(0)); +} + +#[test] +fn test_coordination_request_count_null() { + let req: CoordinationRequest = + serde_json::from_str(r#"{"action": "worker_status", "count": null}"#).unwrap(); + assert_eq!(req.count, None); +} + +// ===== option_i64 tests ===== + +#[test] +fn test_task_duration_secs_as_string() { + let req: TaskRequest = serde_json::from_str( + r#"{"action": "claim", "id": "t1", "duration_secs": "900"}"#, + ) + .unwrap(); + assert_eq!(req.duration_secs, Some(900)); +} + +#[test] +fn test_task_duration_secs_as_int() { + let req: TaskRequest = serde_json::from_str( + r#"{"action": "claim", "id": "t1", "duration_secs": 600}"#, + ) + .unwrap(); + assert_eq!(req.duration_secs, Some(600)); +} + +#[test] +fn test_task_duration_secs_null() { + let req: TaskRequest = + serde_json::from_str(r#"{"action": "claim", "duration_secs": null}"#).unwrap(); + assert_eq!(req.duration_secs, None); +} + +#[test] +fn test_task_duration_secs_absent() { + let req: TaskRequest = serde_json::from_str(r#"{"action": "claim"}"#).unwrap(); + assert_eq!(req.duration_secs, None); +} + +#[test] +fn test_agent_stale_threshold_as_string() { + let req: AgentRequest = serde_json::from_str( + r#"{"action": "cleanup", "stale_threshold_secs": "3600"}"#, + ) + .unwrap(); + assert_eq!(req.stale_threshold_secs, Some(3600)); +} + +#[test] +fn test_coordination_notification_id_as_string() { + let req: CoordinationRequest = serde_json::from_str( + r#"{"action": "queue_ack", "notification_id": "42"}"#, + ) + .unwrap(); + assert_eq!(req.notification_id, Some(42)); +} + +#[test] +fn test_factory_older_than_secs_as_string() { + let req: FactoryRequest = serde_json::from_str( + r#"{"action": "gc_cleanup", "older_than_secs": "7200"}"#, + ) + .unwrap(); + assert_eq!(req.older_than_secs, Some(7200)); +} + +#[test] +fn test_factory_remind_fields_as_string() { + let req: FactoryRequest = serde_json::from_str( + r#"{ + "action": "remind", + "remind_delay_secs": "120", + "remind_ttl_secs": "3600", + "remind_id": "7" + }"#, + ) + .unwrap(); + assert_eq!(req.remind_delay_secs, Some(120)); + assert_eq!(req.remind_ttl_secs, Some(3600)); + assert_eq!(req.remind_id, Some(7)); +} + +// ===== option_u32 tests ===== + +#[test] +fn test_agent_max_iterations_as_string() { + let req: AgentRequest = serde_json::from_str( + r#"{"action": "loop_start", "max_iterations": "10"}"#, + ) + .unwrap(); + assert_eq!(req.max_iterations, Some(10)); +} + +#[test] +fn test_agent_max_iterations_as_int() { + let req: AgentRequest = serde_json::from_str( + r#"{"action": "loop_start", "max_iterations": 5}"#, + ) + .unwrap(); + assert_eq!(req.max_iterations, Some(5)); +} + +#[test] +fn test_agent_max_iterations_null() { + let req: AgentRequest = serde_json::from_str( + r#"{"action": "loop_start", "max_iterations": null}"#, + ) + .unwrap(); + assert_eq!(req.max_iterations, None); +} + +#[test] +fn test_agent_max_iterations_absent() { + let req: AgentRequest = serde_json::from_str(r#"{"action": "loop_start"}"#).unwrap(); + assert_eq!(req.max_iterations, None); +} + +#[test] +fn test_coordination_max_iterations_as_string() { + let req: CoordinationRequest = serde_json::from_str( + r#"{"action": "loop_start", "max_iterations": "20"}"#, + ) + .unwrap(); + assert_eq!(req.max_iterations, Some(20)); +} + +// ===== option_usize tests ===== + +#[test] +fn test_memory_limit_as_string() { + let req: MemoryRequest = + serde_json::from_str(r#"{"action": "list", "limit": "50"}"#).unwrap(); + assert_eq!(req.limit, Some(50)); +} + +#[test] +fn test_memory_limit_as_int() { + let req: MemoryRequest = + serde_json::from_str(r#"{"action": "list", "limit": 25}"#).unwrap(); + assert_eq!(req.limit, Some(25)); +} + +#[test] +fn test_memory_limit_null() { + let req: MemoryRequest = + serde_json::from_str(r#"{"action": "list", "limit": null}"#).unwrap(); + assert_eq!(req.limit, None); +} + +#[test] +fn test_memory_limit_absent() { + let req: MemoryRequest = serde_json::from_str(r#"{"action": "list"}"#).unwrap(); + assert_eq!(req.limit, None); +} + +#[test] +fn test_task_limit_as_string() { + let req: TaskRequest = + serde_json::from_str(r#"{"action": "list", "limit": "100"}"#).unwrap(); + assert_eq!(req.limit, Some(100)); +} + +#[test] +fn test_rule_limit_as_string() { + let req: RuleRequest = + serde_json::from_str(r#"{"action": "list", "limit": "10"}"#).unwrap(); + assert_eq!(req.limit, Some(10)); +} + +#[test] +fn test_skill_limit_as_string() { + let req: SkillRequest = + serde_json::from_str(r#"{"action": "list", "limit": "15"}"#).unwrap(); + assert_eq!(req.limit, Some(15)); +} + +#[test] +fn test_spec_limit_as_string() { + let req: SpecRequest = + serde_json::from_str(r#"{"action": "list", "limit": "20"}"#).unwrap(); + assert_eq!(req.limit, Some(20)); +} + +#[test] +fn test_search_max_tokens_as_string() { + let req: SearchContextRequest = serde_json::from_str( + r#"{"action": "context", "max_tokens": "4096"}"#, + ) + .unwrap(); + assert_eq!(req.max_tokens, Some(4096)); +} + +#[test] +fn test_search_context_lines_as_string() { + let req: SearchContextRequest = serde_json::from_str( + r#"{"action": "grep", "pattern": "foo", "before_context": "3", "after_context": "5"}"#, + ) + .unwrap(); + assert_eq!(req.before_context, Some(3)); + assert_eq!(req.after_context, Some(5)); +} + +#[test] +fn test_search_line_range_as_string() { + let req: SearchContextRequest = serde_json::from_str( + r#"{"action": "blame", "file_path": "src/main.rs", "line_start": "10", "line_end": "20"}"#, + ) + .unwrap(); + assert_eq!(req.line_start, Some(10)); + assert_eq!(req.line_end, Some(20)); +} + +#[test] +fn test_search_limit_as_string() { + let req: SearchContextRequest = serde_json::from_str( + r#"{"action": "search", "query": "test", "limit": "30"}"#, + ) + .unwrap(); + assert_eq!(req.limit, Some(30)); +} + +#[test] +fn test_team_limit_as_string() { + let req: TeamRequest = + serde_json::from_str(r#"{"action": "list", "limit": "5"}"#).unwrap(); + assert_eq!(req.limit, Some(5)); +} + +#[test] +fn test_pattern_limit_as_string() { + let req: PatternRequest = + serde_json::from_str(r#"{"action": "list", "limit": "8"}"#).unwrap(); + assert_eq!(req.limit, Some(8)); +} + +#[test] +fn test_coordination_limit_as_string() { + let req: CoordinationRequest = + serde_json::from_str(r#"{"action": "agent_list", "limit": "50"}"#).unwrap(); + assert_eq!(req.limit, Some(50)); +} + +// ===== option_u64 tests ===== + +#[test] +fn test_verification_duration_ms_as_string() { + let req: VerificationRequest = serde_json::from_str( + r#"{"action": "add", "task_id": "t1", "duration_ms": "1500"}"#, + ) + .unwrap(); + assert_eq!(req.duration_ms, Some(1500)); +} + +#[test] +fn test_verification_duration_ms_as_int() { + let req: VerificationRequest = serde_json::from_str( + r#"{"action": "add", "task_id": "t1", "duration_ms": 2000}"#, + ) + .unwrap(); + assert_eq!(req.duration_ms, Some(2000)); +} + +#[test] +fn test_verification_duration_ms_null() { + let req: VerificationRequest = serde_json::from_str( + r#"{"action": "add", "task_id": "t1", "duration_ms": null}"#, + ) + .unwrap(); + assert_eq!(req.duration_ms, None); +} + +#[test] +fn test_verification_duration_ms_absent() { + let req: VerificationRequest = + serde_json::from_str(r#"{"action": "add", "task_id": "t1"}"#).unwrap(); + assert_eq!(req.duration_ms, None); +} + +#[test] +fn test_verification_limit_as_string() { + let req: VerificationRequest = serde_json::from_str( + r#"{"action": "list", "task_id": "t1", "limit": "10"}"#, + ) + .unwrap(); + assert_eq!(req.limit, Some(10)); +} + +// ===== ExecuteRequest max_length ===== + +#[test] +fn test_execute_max_length_as_string() { + let req: ExecuteRequest = serde_json::from_str( + r#"{"code": "return 1;", "max_length": "5000"}"#, + ) + .unwrap(); + assert_eq!(req.max_length, Some(5000)); +} + +#[test] +fn test_execute_max_length_as_int() { + let req: ExecuteRequest = serde_json::from_str( + r#"{"code": "return 1;", "max_length": 10000}"#, + ) + .unwrap(); + assert_eq!(req.max_length, Some(10000)); +} + +// ===== Empty string coercion to None ===== + +#[test] +fn test_empty_string_coerces_to_none() { + let req: TaskRequest = serde_json::from_str( + r#"{"action": "list", "priority": "", "limit": ""}"#, + ) + .unwrap(); + assert_eq!(req.priority, None); + assert_eq!(req.limit, None); +} + +// ===== Coordination request fields from factory/agent ===== + +#[test] +fn test_coordination_all_numeric_fields_as_string() { + let req: CoordinationRequest = serde_json::from_str( + r#"{ + "action": "remind", + "count": "2", + "max_iterations": "10", + "stale_threshold_secs": "300", + "notification_id": "99", + "older_than_secs": "7200", + "remind_delay_secs": "60", + "remind_id": "5", + "remind_ttl_secs": "1800", + "limit": "25" + }"#, + ) + .unwrap(); + assert_eq!(req.count, Some(2)); + assert_eq!(req.max_iterations, Some(10)); + assert_eq!(req.stale_threshold_secs, Some(300)); + assert_eq!(req.notification_id, Some(99)); + assert_eq!(req.older_than_secs, Some(7200)); + assert_eq!(req.remind_delay_secs, Some(60)); + assert_eq!(req.remind_id, Some(5)); + assert_eq!(req.remind_ttl_secs, Some(1800)); + assert_eq!(req.limit, Some(25)); +}