From 30743863a5c722114b57bd86b16ebe4088fd2897 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Tue, 17 Mar 2026 09:08:04 -0400 Subject: [PATCH 01/11] fix: int32 deserialize for MCP count fields and notify socket ref mut - Add custom option_i32/option_u8 serde deserializers so MCP count fields accept numbers sent as floats (e.g. 3.0) from Claude Code - Fix notify_rx ref mut in factory lifecycle to allow mutable drain/recv - Refactor DaemonNotifier socket to lazy tokio conversion (std socket bound before Tokio runtime, converted on first async use) - Use gcc linker instead of clang for local build --- .cargo/config.toml | 2 +- .../ui/factory/daemon/runtime/lifecycle.rs | 4 +- crates/cas-factory/src/notify.rs | 38 +++++-- crates/cas-mcp/src/types.rs | 5 +- crates/cas-mcp/src/types/deser.rs | 107 ++++++++++++++++++ crates/cas-mcp/src/types/ops_secondary.rs | 4 +- crates/cas-mcp/src/types_tests/tests.rs | 78 +++++++++++++ 7 files changed, 224 insertions(+), 14 deletions(-) create mode 100644 crates/cas-mcp/src/types/deser.rs 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/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/crates/cas-factory/src/notify.rs b/crates/cas-factory/src/notify.rs index 4b1f3004..37876b55 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). diff --git a/crates/cas-mcp/src/types.rs b/crates/cas-mcp/src/types.rs index 3a5e5a78..f8dddeed 100644 --- a/crates/cas-mcp/src/types.rs +++ b/crates/cas-mcp/src/types.rs @@ -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 @@ -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) @@ -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..a989d0ea --- /dev/null +++ b/crates/cas-mcp/src/types/deser.rs @@ -0,0 +1,107 @@ +//! 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; + +/// Deserialize `Option` accepting both numeric and string-encoded values. +/// +/// Handles: `null` → `None`, absent field (via `#[serde(default)]`) → `None`, +/// `3` → `Some(3)`, `"3"` → `Some(3)`. +pub fn option_u8<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct V; + impl<'de> de::Visitor<'de> for V { + type Value = Option; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("an integer 0-255, a string containing an integer, or null") + } + + 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 { + u8::try_from(v).map(Some).map_err(|_| { + de::Error::invalid_value(de::Unexpected::Unsigned(v), &"a u8 value (0-255)") + }) + } + fn visit_i64(self, v: i64) -> Result { + u8::try_from(v).map(Some).map_err(|_| { + de::Error::invalid_value(de::Unexpected::Signed(v), &"a u8 value (0-255)") + }) + } + fn visit_str(self, v: &str) -> Result { + let t = v.trim(); + if t.is_empty() { + return Ok(None); + } + t.parse::().map(Some).map_err(|_| { + de::Error::invalid_value(de::Unexpected::Str(v), &"a string encoding a u8 (0-255)") + }) + } + } + + deserializer.deserialize_option(V) +} + +/// Deserialize `Option` accepting both numeric and string-encoded values. +/// +/// Handles: `null` → `None`, absent field (via `#[serde(default)]`) → `None`, +/// `3` → `Some(3)`, `"3"` → `Some(3)`. +pub fn option_i32<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct V; + impl<'de> de::Visitor<'de> for V { + type Value = Option; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("an integer, a string containing an integer, or null") + } + + 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 { + i32::try_from(v).map(Some).map_err(|_| { + de::Error::invalid_value(de::Unexpected::Unsigned(v), &"an i32 value") + }) + } + fn visit_i64(self, v: i64) -> Result { + i32::try_from(v).map(Some).map_err(|_| { + de::Error::invalid_value(de::Unexpected::Signed(v), &"an i32 value") + }) + } + fn visit_str(self, v: &str) -> Result { + let t = v.trim(); + if t.is_empty() { + return Ok(None); + } + t.parse::().map(Some).map_err(|_| { + de::Error::invalid_value(de::Unexpected::Str(v), &"a string encoding an i32") + }) + } + } + + deserializer.deserialize_option(V) +} diff --git a/crates/cas-mcp/src/types/ops_secondary.rs b/crates/cas-mcp/src/types/ops_secondary.rs index bb23447c..67ac42c3 100644 --- a/crates/cas-mcp/src/types/ops_secondary.rs +++ b/crates/cas-mcp/src/types/ops_secondary.rs @@ -361,7 +361,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 = "super::deser::option_i32")] pub count: Option, /// Specific worker names (comma-separated) @@ -584,7 +584,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 = "super::deser::option_i32")] pub count: Option, /// Comma-separated worker names diff --git a/crates/cas-mcp/src/types_tests/tests.rs b/crates/cas-mcp/src/types_tests/tests.rs index 73a3888e..ab1a1bd8 100644 --- a/crates/cas-mcp/src/types_tests/tests.rs +++ b/crates/cas-mcp/src/types_tests/tests.rs @@ -139,3 +139,81 @@ 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); +} From 7dd1c1f6d7426aecb0967cf6fee32d5dc8305b7a Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Tue, 17 Mar 2026 09:24:12 -0400 Subject: [PATCH 02/11] feat(config_gen): inject mcp__cas__* permissions into worker settings.json Workers were only getting Bash(cas :*) permissions in their generated settings.json, leaving mcp__cas__* tools unallowed. This required unreliable fallback to the global ~/.claude/settings.json. Add get_cas_mcp_permissions() alongside get_cas_bash_permissions() and merge both into the permissions.allow array in get_cas_hooks_config(). The 10 MCP tools added: task, coordination, memory, search, rule, skill, spec, verification, system, pattern. Co-Authored-By: Claude Sonnet 4.6 --- cas-cli/src/cli/hook/config_gen.rs | 23 +++++++++++++++++- cas-cli/src/cli/hook_tests/tests.rs | 37 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) 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] From 7356722523c0ba35ebe042780b8a58f281717a98 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Tue, 17 Mar 2026 09:32:25 -0400 Subject: [PATCH 03/11] fix(factory): add .mcp.json committed-file pre-flight check for workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When workers use isolated git worktrees, .mcp.json must be committed to git or it won't appear in their checkout — leaving them with no MCP tools and silently idle. Changes: - preflight_factory_launch(): add git ls-files check for .mcp.json. Hard failure when workers > 0; advisory notice when supervisor-only. Quick-start steps show 'git add .mcp.json && git commit' when only .mcp.json is missing (not duplicated if .claude/ is also missing). - queue_codex_worker_intro_prompt(): inject MCP fallback instructions for Claude workers at startup. Tells workers to check .mcp.json, run 'cas init -y' to regenerate it, and notify supervisor via 'cas factory message' if MCP tools are unavailable — preventing silent idle loops. Closes cas-8397 (based on spike cas-4567). Co-Authored-By: Claude Sonnet 4.6 --- cas-cli/src/cli/factory/mod.rs | 32 +++++++++++++++++++++++++++++++ cas-cli/src/ui/factory/app/mod.rs | 27 +++++++++++++++++++++----- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/cas-cli/src/cli/factory/mod.rs b/cas-cli/src/cli/factory/mod.rs index e6b2edee..710237d5 100644 --- a/cas-cli/src/cli/factory/mod.rs +++ b/cas-cli/src/cli/factory/mod.rs @@ -826,6 +826,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 +962,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 +1014,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/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); + } + } } } From 119139507c96d9d1664d05a06a1449c2ae840d1c Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 08:33:21 -0400 Subject: [PATCH 04/11] fix(mcp): complete numeric string-coercion for all MCP Option fields Replace hand-written option_u8/option_i32 deserializers with a single option_numeric_deser! macro generating 6 variants (u8, i32, i64, u32, usize, u64). Apply to all ~30 Option fields across MCP request types so Claude Code's string-encoded numbers ("3") are accepted alongside native JSON numbers (3). Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cas-mcp/src/types.rs | 22 +- crates/cas-mcp/src/types/deser.rs | 153 ++++------ crates/cas-mcp/src/types/ops_secondary.rs | 49 ++-- crates/cas-mcp/src/types_tests/tests.rs | 343 ++++++++++++++++++++++ 4 files changed, 442 insertions(+), 125 deletions(-) diff --git a/crates/cas-mcp/src/types.rs b/crates/cas-mcp/src/types.rs index f8dddeed..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 @@ -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) ========== @@ -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) diff --git a/crates/cas-mcp/src/types/deser.rs b/crates/cas-mcp/src/types/deser.rs index a989d0ea..e59dc913 100644 --- a/crates/cas-mcp/src/types/deser.rs +++ b/crates/cas-mcp/src/types/deser.rs @@ -8,100 +8,73 @@ use serde::{de, Deserializer}; use std::fmt; -/// Deserialize `Option` accepting both numeric and string-encoded values. +/// Generate an `Option<$target>` deserializer that accepts numbers, strings, and null. /// -/// Handles: `null` → `None`, absent field (via `#[serde(default)]`) → `None`, -/// `3` → `Some(3)`, `"3"` → `Some(3)`. -pub fn option_u8<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - struct V; - impl<'de> de::Visitor<'de> for V { - type Value = Option; +/// 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 { - f.write_str("an integer 0-255, a string containing an integer, or null") - } + 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 { - u8::try_from(v).map(Some).map_err(|_| { - de::Error::invalid_value(de::Unexpected::Unsigned(v), &"a u8 value (0-255)") - }) - } - fn visit_i64(self, v: i64) -> Result { - u8::try_from(v).map(Some).map_err(|_| { - de::Error::invalid_value(de::Unexpected::Signed(v), &"a u8 value (0-255)") - }) - } - fn visit_str(self, v: &str) -> Result { - let t = v.trim(); - if t.is_empty() { - return Ok(None); + 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)), + ) + }) + } } - t.parse::().map(Some).map_err(|_| { - de::Error::invalid_value(de::Unexpected::Str(v), &"a string encoding a u8 (0-255)") - }) - } - } - - deserializer.deserialize_option(V) -} - -/// Deserialize `Option` accepting both numeric and string-encoded values. -/// -/// Handles: `null` → `None`, absent field (via `#[serde(default)]`) → `None`, -/// `3` → `Some(3)`, `"3"` → `Some(3)`. -pub fn option_i32<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - struct V; - impl<'de> de::Visitor<'de> for V { - type Value = Option; - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("an integer, a string containing an integer, or null") + deserializer.deserialize_option(V) } - - 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 { - i32::try_from(v).map(Some).map_err(|_| { - de::Error::invalid_value(de::Unexpected::Unsigned(v), &"an i32 value") - }) - } - fn visit_i64(self, v: i64) -> Result { - i32::try_from(v).map(Some).map_err(|_| { - de::Error::invalid_value(de::Unexpected::Signed(v), &"an i32 value") - }) - } - fn visit_str(self, v: &str) -> Result { - let t = v.trim(); - if t.is_empty() { - return Ok(None); - } - t.parse::().map(Some).map_err(|_| { - de::Error::invalid_value(de::Unexpected::Str(v), &"a string encoding an i32") - }) - } - } - - 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 67ac42c3..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, deserialize_with = "super::deser::option_i32")] + #[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, deserialize_with = "super::deser::option_i32")] + #[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 ab1a1bd8..36cfda02 100644 --- a/crates/cas-mcp/src/types_tests/tests.rs +++ b/crates/cas-mcp/src/types_tests/tests.rs @@ -217,3 +217,346 @@ fn test_coordination_request_count_null() { 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)); +} From 4d2078e763d746ef34eaf94c5c5bbcb247ddb1a9 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 09:37:06 -0400 Subject: [PATCH 05/11] fix(tui): OSC 52 clipboard copy and auto-inject on image paste - handle_mouse_up() returns selected text instead of calling arboard directly (daemon is headless, can't access system clipboard) - Daemon relays selected text to client terminal via OSC 52 escape sequence for native clipboard write - Auto-enter inject mode after image drop so user can immediately type context for pasted images Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/factory/app/sidecar_and_selection.rs | 21 ++++++------- .../ui/factory/daemon/runtime/client_input.rs | 31 ++++++++++++++++++- 2 files changed, 40 insertions(+), 12 deletions(-) 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/daemon/runtime/client_input.rs b/cas-cli/src/ui/factory/daemon/runtime/client_input.rs index 276fa4bc..67de7bb8 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,19 @@ 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) +} + fn bracketed_paste_bytes(payload: &str) -> Vec { let mut out = Vec::with_capacity(payload.len() + 12); out.extend_from_slice(b"\x1b[200~"); From 7ee61e2391466167ab0f1fd84996de4b3dc505cd Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 09:41:21 -0400 Subject: [PATCH 06/11] feat(theme): add opt-in Minions theme for factory workers and boot screen Adds a "minions" theme variant selectable via `[theme] variant = "minions"` in config.toml. When active: workers get minion names (kevin, stuart, bob), supervisor is named gru/dru/nefario, the color palette shifts to yellow primary with denim blue accents, and the boot screen shows a minion ASCII art logo with "BANANA!" ready message and "Bee-do Bee-do" launch animation. Co-Authored-By: Claude Opus 4.6 (1M context) --- cas-cli/src/cli/factory/daemon.rs | 9 + cas-cli/src/cli/factory/mod.rs | 25 ++- cas-cli/src/orchestration/mod.rs | 2 +- cas-cli/src/orchestration/names.rs | 103 ++++++++++ cas-cli/src/ui/factory/app/init.rs | 25 ++- cas-cli/src/ui/factory/boot.rs | 4 +- cas-cli/src/ui/factory/boot/screen.rs | 264 +++++++++++++++++++++----- cas-cli/src/ui/theme/colors.rs | 24 +++ cas-cli/src/ui/theme/config.rs | 57 +++++- cas-cli/src/ui/theme/icons.rs | 14 ++ cas-cli/src/ui/theme/mod.rs | 4 +- crates/cas-factory/src/config.rs | 3 + 12 files changed, 467 insertions(+), 67 deletions(-) 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 710237d5..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; 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..046ec66a 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", "larry", "tom", "chris", "john", "paul", "mike", "ken", "mel", + "lance", "donny", +]; + +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/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..02061bb3 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,26 @@ const LOGO_SMALL: &str = r#" ╚═══════════════════════════════════════════════════════╝ "#; +/// Minion ASCII art logo +const MINION_LOGO: &str = r#" + ╭──────────╮ + ╭┤ ╭────╮ ├╮ + │╰──┤ ●● ├──╯│ + │ ╰────╯ │ + │ ╭────╮ │ + │ │ │ │ + ╰───┤ ├───╯ + ╰────╯ +"#; + +/// Smaller minion for narrow/short terminals +const MINION_LOGO_SMALL: &str = r#" + ╭──────╮ + │ (●●) │ + │ ╭──╮ │ + ╰─┤ ├─╯ +"#; + /// Braille spinner frames for smooth animation const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -131,11 +236,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 +270,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 +334,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 +353,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 +365,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(padding as u16, row), - SetForegroundColor(colors::LOGO), + SetForegroundColor(logo_color), Print(line) )?; self.stdout.flush()?; @@ -224,7 +375,7 @@ impl BootScreen { execute!( self.stdout, MoveTo(padding as u16, row), - SetForegroundColor(colors::LOGO), + SetForegroundColor(logo_color), Print(line) )?; } @@ -237,19 +388,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 +444,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 +503,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 +521,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 +532,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 +553,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 +566,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 +580,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 +605,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 +618,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 +660,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 +668,7 @@ impl BootScreen { // Draw empty portion execute!( self.stdout, - SetForegroundColor(colors::PROGRESS_EMPTY), + SetForegroundColor(empty_color), Print("░".repeat(empty_chars)) )?; @@ -528,12 +682,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 +696,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 +723,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 +735,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 +746,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/theme/colors.rs b/cas-cli/src/ui/theme/colors.rs index 5d5fe5ec..91305bae 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 { diff --git a/cas-cli/src/ui/theme/config.rs b/cas-cli/src/ui/theme/config.rs index 4acee496..adbab9d7 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 { diff --git a/cas-cli/src/ui/theme/icons.rs b/cas-cli/src/ui/theme/icons.rs index a25a7dc5..2a2ec22f 100644 --- a/cas-cli/src/ui/theme/icons.rs +++ b/cas-cli/src/ui/theme/icons.rs @@ -111,3 +111,17 @@ 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) +} 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, } } } From c734005dd1ba07bade5fe5202dcaeb33a48abfd1 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 09:52:22 -0400 Subject: [PATCH 07/11] fix(theme): improve minion ASCII art, wire MinionsIcons into TUI, fix test compilation - Replace generic box ASCII art with recognizable minion character (pill body, goggles with eyes, overalls, "BANANA" text) - Wire MinionsIcons (banana/eye/sleep emoji) into agent_status_icon() and agent_status_icon_simple() via is_minions() theme flag - Add missing minions_theme field to test_config() in core.rs and factory_integration.rs - Fix pre-existing mut warnings in notify.rs tests (lines 132, 155) Co-Authored-By: Claude Opus 4.6 (1M context) --- cas-cli/src/ui/factory/boot/screen.rs | 37 +++++++++++------- .../src/ui/factory/director/agent_helpers.rs | 38 ++++++++++++++----- .../src/ui/factory/director/factory_radar.rs | 3 +- .../ui/factory/director/mission_workers.rs | 3 +- crates/cas-factory/src/core.rs | 1 + crates/cas-factory/src/notify.rs | 4 +- .../cas-factory/tests/factory_integration.rs | 1 + 7 files changed, 61 insertions(+), 26 deletions(-) diff --git a/cas-cli/src/ui/factory/boot/screen.rs b/cas-cli/src/ui/factory/boot/screen.rs index 02061bb3..3b4e59bf 100644 --- a/cas-cli/src/ui/factory/boot/screen.rs +++ b/cas-cli/src/ui/factory/boot/screen.rs @@ -199,24 +199,35 @@ const LOGO_SMALL: &str = r#" ╚═══════════════════════════════════════════════════════╝ "#; -/// Minion ASCII art logo +/// 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 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/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 37876b55..3aeda670 100644 --- a/crates/cas-factory/src/notify.rs +++ b/crates/cas-factory/src/notify.rs @@ -129,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(); @@ -152,7 +152,7 @@ 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(); // Send multiple notifications for _ in 0..5 { 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, } } From c630a920dbd507a7e3134dde88503a519f4980bf Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 10:00:42 -0400 Subject: [PATCH 08/11] fix(theme): improve minion ASCII art and use canonical character names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redesign MINION_LOGO with better pill-shaped body silhouette, symmetric goggle strap, arm stubs (─┤/├─), and cleaner feet - Replace 8 non-canonical worker names (larry, tom, chris, john, paul, mike, ken, donny) with actual Minion film characters (jorge, otto, steve, herb, pete, donnie, abel, walter) - Add otto (main character from Minions: The Rise of Gru) Co-Authored-By: Claude Opus 4.6 (1M context) --- cas-cli/src/orchestration/names.rs | 4 ++-- cas-cli/src/ui/factory/boot/screen.rs | 26 ++++++++++++-------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/cas-cli/src/orchestration/names.rs b/cas-cli/src/orchestration/names.rs index 046ec66a..83431fd6 100644 --- a/cas-cli/src/orchestration/names.rs +++ b/cas-cli/src/orchestration/names.rs @@ -11,8 +11,8 @@ use std::collections::HashSet; const MINION_WORKERS: &[&str] = &[ "kevin", "stuart", "bob", "dave", "jerry", "tim", "mark", "phil", "carl", - "norbert", "larry", "tom", "chris", "john", "paul", "mike", "ken", "mel", - "lance", "donny", + "norbert", "jorge", "otto", "steve", "herb", "pete", "donnie", "mel", + "abel", "lance", "walter", ]; const MINION_SUPERVISORS: &[&str] = &["gru", "dru", "nefario"]; diff --git a/cas-cli/src/ui/factory/boot/screen.rs b/cas-cli/src/ui/factory/boot/screen.rs index 3b4e59bf..0b59212c 100644 --- a/cas-cli/src/ui/factory/boot/screen.rs +++ b/cas-cli/src/ui/factory/boot/screen.rs @@ -201,23 +201,21 @@ 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 From dfc86b5091b71db29b7bd19d8d94593c8a94358a Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 10:08:37 -0400 Subject: [PATCH 09/11] fix(theme): replace non-canonical minion name 'lance' with 'tony' Co-Authored-By: Claude Opus 4.6 (1M context) --- cas-cli/src/orchestration/names.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cas-cli/src/orchestration/names.rs b/cas-cli/src/orchestration/names.rs index 83431fd6..a064b218 100644 --- a/cas-cli/src/orchestration/names.rs +++ b/cas-cli/src/orchestration/names.rs @@ -12,7 +12,7 @@ use std::collections::HashSet; const MINION_WORKERS: &[&str] = &[ "kevin", "stuart", "bob", "dave", "jerry", "tim", "mark", "phil", "carl", "norbert", "jorge", "otto", "steve", "herb", "pete", "donnie", "mel", - "abel", "lance", "walter", + "abel", "tony", "walter", ]; const MINION_SUPERVISORS: &[&str] = &["gru", "dru", "nefario"]; From e11e1f80d1412a7be9d5eb30f9dc40c68ae4e289 Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 10:20:22 -0400 Subject: [PATCH 10/11] fix(factory): fix drain_clears_pending_notifications test The test was failing because try_recv on a Tokio UnixDatagram requires the socket to be registered with the reactor first. Added an initial recv().await call before the drain test sequence to ensure the Tokio socket is properly registered. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cas-factory/src/notify.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/cas-factory/src/notify.rs b/crates/cas-factory/src/notify.rs index 3aeda670..e020ca04 100644 --- a/crates/cas-factory/src/notify.rs +++ b/crates/cas-factory/src/notify.rs @@ -154,12 +154,15 @@ mod tests { let dir = TempDir::new().unwrap(); let mut notifier = DaemonNotifier::bind(dir.path()).unwrap(); - // Send multiple notifications + // 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; + + // 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 From 913626bd11e34c52c8378e91a367a046f5b2769b Mon Sep 17 00:00:00 2001 From: Daniel Villafranca Date: Fri, 20 Mar 2026 10:25:13 -0400 Subject: [PATCH 11/11] test: add coverage for OSC 52, theme variant, minions palette and icons - OSC 52: base64 encoding, empty string, unicode, multiline round-trip - ThemeVariant: default value, Display, FromStr, serde round-trip - ActiveTheme: minions config wiring, is_minions() check - ColorPalette::minions(): yellow primary, denim blue info, differs from dark, preserves base bg - MinionsIcons: non-empty constants, differ from default circles Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/factory/daemon/runtime/client_input.rs | 36 ++++++++++++++ cas-cli/src/ui/theme/colors.rs | 45 +++++++++++++++++ cas-cli/src/ui/theme/config.rs | 49 +++++++++++++++++++ cas-cli/src/ui/theme/icons.rs | 22 +++++++++ 4 files changed, 152 insertions(+) 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 67de7bb8..22c77891 100644 --- a/cas-cli/src/ui/factory/daemon/runtime/client_input.rs +++ b/cas-cli/src/ui/factory/daemon/runtime/client_input.rs @@ -872,6 +872,42 @@ fn osc52_copy_sequence(text: &str) -> String { 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/theme/colors.rs b/cas-cli/src/ui/theme/colors.rs index 91305bae..1d625e3a 100644 --- a/cas-cli/src/ui/theme/colors.rs +++ b/cas-cli/src/ui/theme/colors.rs @@ -189,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 adbab9d7..aaf7cda7 100644 --- a/cas-cli/src/ui/theme/config.rs +++ b/cas-cli/src/ui/theme/config.rs @@ -172,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 2a2ec22f..b982a1be 100644 --- a/cas-cli/src/ui/theme/icons.rs +++ b/cas-cli/src/ui/theme/icons.rs @@ -125,3 +125,25 @@ impl MinionsIcons { 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); + } +}