| 1 | //! Whale Teams — the Signal Cut whale identities in the terminal. |
| 2 | //! |
| 3 | //! CWC's "Whale Teams / Signal Cut" identity (2026-08-15) gives each agent |
| 4 | //! role a species-led whale and every whale one of six runtime states. This |
| 5 | //! module carries that identity into the TUI as species badges and state |
| 6 | //! words. The hand-drawn portrait art that used to live here (the three-row |
| 7 | //! glyph renditions and the hand-drawn crown fluke) was deleted per the |
| 8 | //! 2026-08-29 founder directive; the only sanctioned terminal mark is the one |
| 9 | //! generated from the brand master path. Colors are Codewhale palette tokens |
| 10 | //! resolved through the live [`UiTheme`], so the whales follow Blue Stage |
| 11 | //! dark/light, the Terminal theme, and ANSI-16 adaptation like every other |
| 12 | //! surface. |
| 13 | //! |
| 14 | //! Contract: |
| 15 | //! - **Role → species is one table** ([`WhaleSpecies::for_role_id`]). Fleet |
| 16 | //! roles map onto the six species; roles without a species (`worker`, |
| 17 | //! `general`, `custom`, unknown) render the plain Codewhale whale. |
| 18 | //! - **State is evidence, never decoration.** [`WhaleState`] is derived only |
| 19 | //! from real runtime facts ([`WhaleState::for_subagent`], |
| 20 | //! [`WhaleState::for_shell_phase`]). *Working* is asserted only for a child |
| 21 | //! or turn that is actually running. |
| 22 | //! - **Every state pairs a glyph cue with a word** ([`WhaleState::word`]), so |
| 23 | //! state never depends on color alone (same rule as `menu_style`). |
| 24 | //! - **ASCII-safe by construction.** Every authored glyph has a |
| 25 | //! [`glyphs::ascii_fallback`] entry, and [`badge_ascii`] exposes the |
| 26 | //! narrowed badge for tests and text surfaces. |
| 27 | //! |
| 28 | //! Only the *signal-classic* colorway is represented. The three alternate CWC |
| 29 | //! colorways exist only as resting rasters upstream and are not modelled here. |
| 30 | |
| 31 | use std::borrow::Cow; |
| 32 | |
| 33 | use ratatui::style::{Color, Modifier, Style}; |
| 34 | use ratatui::text::Span; |
| 35 | |
| 36 | use crate::tools::subagent::{AgentWorkerStatus, FleetRole, SubAgentResult, SubAgentStatus}; |
| 37 | use crate::tui::glyphs; |
| 38 | use crate::tui::motion::mode::MotionMode; |
| 39 | use crate::tui::underwater::ShellPhase; |
| 40 | use codewhale_localization::{Locale, MessageId, tr}; |
| 41 | use codewhale_palette::{self as palette, UiTheme}; |
| 42 | |
| 43 | /// Cells occupied by a badge (species mark + body). |
| 44 | #[cfg(test)] |
| 45 | const BADGE_WIDTH: usize = 2; |
| 46 | /// Working wake loop: four frames over 720 ms, as in the CWC GIFs. |
| 47 | pub const WORKING_FRAME_MS: u64 = 180; |
| 48 | pub const WORKING_FRAMES: usize = 4; |
| 49 | |
| 50 | /// The six Signal Cut species plus the plain Codewhale whale for roles that |
| 51 | /// have no species of their own. |
| 52 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
| 53 | pub enum WhaleSpecies { |
| 54 | /// Beaked whale — research. The signature whale of the Signal Current mark. |
| 55 | Scout, |
| 56 | /// Harbor porpoise — coding. |
| 57 | Patch, |
| 58 | /// Humpback whale — coordination. |
| 59 | Harbor, |
| 60 | /// Pilot whale — communications. |
| 61 | Echo, |
| 62 | /// Sperm whale — operations. |
| 63 | Keel, |
| 64 | /// Orca — review. |
| 65 | Lantern, |
| 66 | /// The plain Codewhale whale: no species, no accent. Used for `worker`, |
| 67 | /// `general`, `custom`, and unknown roles rather than guessing. |
| 68 | Plain, |
| 69 | } |
| 70 | |
| 71 | impl WhaleSpecies { |
| 72 | /// Every species, for exhaustive checks and the test gallery. |
| 73 | #[cfg(test)] |
| 74 | pub const ALL: [WhaleSpecies; 7] = [ |
| 75 | Self::Scout, |
| 76 | Self::Patch, |
| 77 | Self::Harbor, |
| 78 | Self::Echo, |
| 79 | Self::Keel, |
| 80 | Self::Lantern, |
| 81 | Self::Plain, |
| 82 | ]; |
| 83 | |
| 84 | /// The single role → species table. |
| 85 | /// |
| 86 | /// Accepts Fleet profile ids / role hints (`manager`, `explore`, |
| 87 | /// `implement`, `reviewer`, `test`, `advisor`, `synthesizer`, `general`, |
| 88 | /// `custom`) and legacy compatibility aliases. |
| 89 | /// Anything else is [`WhaleSpecies::Plain`] — never a guess. |
| 90 | #[must_use] |
| 91 | pub fn for_role_id(role: &str) -> Self { |
| 92 | match role.trim().to_ascii_lowercase().as_str() { |
| 93 | "scout" | "explore" => Self::Scout, |
| 94 | "builder" | "implement" => Self::Patch, |
| 95 | "manager" | "planner" => Self::Harbor, |
| 96 | "reviewer" => Self::Lantern, |
| 97 | "verifier" | "test" => Self::Keel, |
| 98 | "consultant" | "advisor" | "synthesizer" => Self::Echo, |
| 99 | _ => Self::Plain, |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | /// Species for a runtime Fleet role. |
| 104 | #[must_use] |
| 105 | pub fn for_fleet_role(role: &FleetRole) -> Self { |
| 106 | Self::for_role_id(role.as_str()) |
| 107 | } |
| 108 | |
| 109 | /// Product name (a proper noun; not localized). |
| 110 | #[must_use] |
| 111 | pub const fn name(self) -> &'static str { |
| 112 | match self { |
| 113 | Self::Scout => "Scout", |
| 114 | Self::Patch => "Patch", |
| 115 | Self::Harbor => "Harbor", |
| 116 | Self::Echo => "Echo", |
| 117 | Self::Keel => "Keel", |
| 118 | Self::Lantern => "Lantern", |
| 119 | Self::Plain => "codewhale", |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | /// Localized species (animal) label. |
| 124 | #[must_use] |
| 125 | pub fn animal(self, locale: Locale) -> Cow<'static, str> { |
| 126 | tr( |
| 127 | locale, |
| 128 | match self { |
| 129 | Self::Scout => MessageId::WhaleAnimalScout, |
| 130 | Self::Patch => MessageId::WhaleAnimalPatch, |
| 131 | Self::Harbor => MessageId::WhaleAnimalHarbor, |
| 132 | Self::Echo => MessageId::WhaleAnimalEcho, |
| 133 | Self::Keel => MessageId::WhaleAnimalKeel, |
| 134 | Self::Lantern => MessageId::WhaleAnimalLantern, |
| 135 | Self::Plain => MessageId::WhaleAnimalPlain, |
| 136 | }, |
| 137 | ) |
| 138 | } |
| 139 | |
| 140 | /// Localized job label. |
| 141 | #[must_use] |
| 142 | pub fn job(self, locale: Locale) -> Cow<'static, str> { |
| 143 | tr( |
| 144 | locale, |
| 145 | match self { |
| 146 | Self::Scout => MessageId::WhaleJobScout, |
| 147 | Self::Patch => MessageId::WhaleJobPatch, |
| 148 | Self::Harbor => MessageId::WhaleJobHarbor, |
| 149 | Self::Echo => MessageId::WhaleJobEcho, |
| 150 | Self::Keel => MessageId::WhaleJobKeel, |
| 151 | Self::Lantern => MessageId::WhaleJobLantern, |
| 152 | Self::Plain => MessageId::WhaleJobPlain, |
| 153 | }, |
| 154 | ) |
| 155 | } |
| 156 | |
| 157 | /// Species-distinct 1-row mark: a feature glyph plus a body cell. Every |
| 158 | /// pair narrows to a distinct ASCII pair (`<#`, `#]`, `#\`, `:#`, `#-`, |
| 159 | /// `*#`, `.#`). |
| 160 | #[must_use] |
| 161 | pub const fn badge_glyphs(self) -> (&'static str, &'static str, bool) { |
| 162 | // (feature, body, feature_first) |
| 163 | match self { |
| 164 | Self::Scout => ("◂", "▰", true), // long beak |
| 165 | Self::Patch => ("]", "▰", false), // bracket patch |
| 166 | Self::Harbor => ("▚", "▰", false), // long winglike flipper |
| 167 | Self::Echo => (":", "▰", true), // sonar ticks |
| 168 | Self::Keel => ("━", "▰", false), // keel stripe |
| 169 | Self::Lantern => ("◇", "▰", true), // review lens (Reviewer charter glyph) |
| 170 | Self::Plain => ("·", "▰", true), // neutral dot |
| 171 | } |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | /// The six-state grammar. Priority (highest first) mirrors CWC: |
| 176 | /// waiting > blocked > working > thinking > offline > resting. |
| 177 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] |
| 178 | pub enum WhaleState { |
| 179 | Resting, |
| 180 | Offline, |
| 181 | Thinking, |
| 182 | Working, |
| 183 | Blocked, |
| 184 | /// "Waiting for you": a human/parent action is required. |
| 185 | Waiting, |
| 186 | } |
| 187 | |
| 188 | impl WhaleState { |
| 189 | /// Every state, for exhaustive checks and the test gallery. |
| 190 | #[cfg(test)] |
| 191 | pub const ALL: [WhaleState; 6] = [ |
| 192 | Self::Resting, |
| 193 | Self::Thinking, |
| 194 | Self::Working, |
| 195 | Self::Waiting, |
| 196 | Self::Blocked, |
| 197 | Self::Offline, |
| 198 | ]; |
| 199 | |
| 200 | /// CWC state priority; higher wins when several facts apply. Public |
| 201 | /// contract for surfaces that fold several children into one whale. |
| 202 | #[cfg(test)] |
| 203 | #[must_use] |
| 204 | pub const fn priority(self) -> u8 { |
| 205 | match self { |
| 206 | Self::Waiting => 60, |
| 207 | Self::Blocked => 50, |
| 208 | Self::Working => 40, |
| 209 | Self::Thinking => 30, |
| 210 | Self::Offline => 20, |
| 211 | Self::Resting => 10, |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | /// Localized state word — always rendered next to the glyph cue. |
| 216 | #[must_use] |
| 217 | pub fn word(self, locale: Locale) -> Cow<'static, str> { |
| 218 | tr( |
| 219 | locale, |
| 220 | match self { |
| 221 | Self::Resting => MessageId::WhaleStateResting, |
| 222 | Self::Thinking => MessageId::WhaleStateThinking, |
| 223 | Self::Working => MessageId::WhaleStateWorking, |
| 224 | Self::Waiting => MessageId::WhaleStateWaiting, |
| 225 | Self::Blocked => MessageId::WhaleStateBlocked, |
| 226 | Self::Offline => MessageId::WhaleStateOffline, |
| 227 | }, |
| 228 | ) |
| 229 | } |
| 230 | |
| 231 | /// State from a real child record. Evidence rules: |
| 232 | /// - a pending question for the parent/user → Waiting; |
| 233 | /// - the live worker status when present (`WaitingForUser` → Waiting, |
| 234 | /// `ModelWait`/`Queued`/`Starting` → Thinking, `Running`/`RunningTool` |
| 235 | /// → Working, `Failed` → Blocked, `Interrupted` → Waiting, `Cancelled` |
| 236 | /// → Offline, `Completed` → Resting); |
| 237 | /// - otherwise the durable status (`Running` → Working, `Completed` → |
| 238 | /// Resting, `Interrupted` → Waiting, `Failed`/`BudgetExhausted` → |
| 239 | /// Blocked, `Cancelled` → Offline). |
| 240 | /// |
| 241 | /// Working is therefore only ever asserted for a child the runtime says |
| 242 | /// is running — never inferred from timestamps. |
| 243 | #[must_use] |
| 244 | pub fn for_subagent(agent: &SubAgentResult) -> Self { |
| 245 | if agent.needs_input.is_some() { |
| 246 | return Self::Waiting; |
| 247 | } |
| 248 | if let Some(status) = agent.worker_status { |
| 249 | return match status { |
| 250 | AgentWorkerStatus::WaitingForUser | AgentWorkerStatus::Interrupted => Self::Waiting, |
| 251 | AgentWorkerStatus::Queued |
| 252 | | AgentWorkerStatus::Starting |
| 253 | | AgentWorkerStatus::ModelWait => Self::Thinking, |
| 254 | AgentWorkerStatus::Running | AgentWorkerStatus::RunningTool => Self::Working, |
| 255 | AgentWorkerStatus::Failed => Self::Blocked, |
| 256 | AgentWorkerStatus::Cancelled => Self::Offline, |
| 257 | AgentWorkerStatus::Completed => Self::Resting, |
| 258 | }; |
| 259 | } |
| 260 | match agent.status { |
| 261 | SubAgentStatus::Running => Self::Working, |
| 262 | SubAgentStatus::Completed => Self::Resting, |
| 263 | SubAgentStatus::Interrupted(_) => Self::Waiting, |
| 264 | SubAgentStatus::Failed(_) | SubAgentStatus::BudgetExhausted => Self::Blocked, |
| 265 | SubAgentStatus::Cancelled => Self::Offline, |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | /// State from the operator session phase. Public contract for the shell |
| 270 | /// header / Fleet setup role pane (no consumer in this lane yet). |
| 271 | #[must_use] |
| 272 | #[cfg_attr(not(test), expect(dead_code))] |
| 273 | pub const fn for_shell_phase(phase: ShellPhase) -> Self { |
| 274 | match phase { |
| 275 | ShellPhase::Idle | ShellPhase::Done => Self::Resting, |
| 276 | ShellPhase::Typing => Self::Thinking, |
| 277 | ShellPhase::Working | ShellPhase::Verifying => Self::Working, |
| 278 | ShellPhase::Waiting | ShellPhase::Approval => Self::Waiting, |
| 279 | ShellPhase::Failed => Self::Blocked, |
| 280 | } |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | /// Resolved inks for one theme. Accents are contrast-enforced against the |
| 285 | /// theme surface (≥ 3:1, the secondary-chrome floor) so a Blue Stage Light or |
| 286 | /// custom surface never swallows a role mark. |
| 287 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 288 | pub struct WhaleInk { |
| 289 | /// Signal Gold body (the theme's `accent_action` slot, as the idle mark). |
| 290 | pub body: Color, |
| 291 | /// Orca body: ink/white patches read as muted text ink, saddle stays gold. |
| 292 | pub lantern_body: Color, |
| 293 | /// Bounded cyan: thinking ticks and the working wake. |
| 294 | pub current: Color, |
| 295 | /// Waiting ring — Signal Gold, the human-attention role. |
| 296 | pub human: Color, |
| 297 | /// Blocked obstruction bar. |
| 298 | pub bar: Color, |
| 299 | /// Offline outline. |
| 300 | pub dim: Color, |
| 301 | pub scout: Color, |
| 302 | pub patch: Color, |
| 303 | pub harbor: Color, |
| 304 | pub echo: Color, |
| 305 | pub keel: Color, |
| 306 | pub lantern: Color, |
| 307 | } |
| 308 | |
| 309 | impl WhaleInk { |
| 310 | #[must_use] |
| 311 | pub fn from_theme(theme: &UiTheme) -> Self { |
| 312 | let surface = theme.surface_bg; |
| 313 | let lift = |color: Color| { |
| 314 | palette::enforce_contrast(color, surface, palette::SECONDARY_CHROME_CONTRAST) |
| 315 | }; |
| 316 | let rgb = |(r, g, b): (u8, u8, u8)| Color::Rgb(r, g, b); |
| 317 | Self { |
| 318 | body: lift(theme.accent_action), |
| 319 | lantern_body: lift(theme.text_muted), |
| 320 | current: lift(rgb(palette::WHALE_CYAN_RGB)), |
| 321 | human: lift(theme.accent_action), |
| 322 | bar: lift(theme.text_muted), |
| 323 | dim: theme.text_dim, |
| 324 | scout: lift(rgb(palette::WHALE_CYAN_RGB)), |
| 325 | patch: lift(theme.accent_secondary), |
| 326 | harbor: lift(rgb(palette::WHALE_BRAND_ORANGE_RGB)), |
| 327 | echo: lift(rgb(palette::WHALE_BRAND_MAGENTA_RGB)), |
| 328 | keel: lift(theme.warning), |
| 329 | lantern: lift(theme.mode_operate), |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | #[must_use] |
| 334 | pub const fn accent(&self, species: WhaleSpecies) -> Color { |
| 335 | match species { |
| 336 | WhaleSpecies::Scout => self.scout, |
| 337 | WhaleSpecies::Patch => self.patch, |
| 338 | WhaleSpecies::Harbor => self.harbor, |
| 339 | WhaleSpecies::Echo => self.echo, |
| 340 | WhaleSpecies::Keel => self.keel, |
| 341 | WhaleSpecies::Lantern => self.lantern, |
| 342 | WhaleSpecies::Plain => self.body, |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | #[must_use] |
| 347 | pub const fn body_for(&self, species: WhaleSpecies) -> Color { |
| 348 | match species { |
| 349 | WhaleSpecies::Lantern => self.lantern_body, |
| 350 | _ => self.body, |
| 351 | } |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | /// Which working frame to show now. Anything but [`MotionMode::Full`] holds |
| 356 | /// the frame-A poster; callers must also only pass `Some(Working)` for a |
| 357 | /// child that is really running. |
| 358 | #[must_use] |
| 359 | pub const fn working_frame(now_ms: u64, mode: MotionMode) -> usize { |
| 360 | match mode { |
| 361 | MotionMode::Full => ((now_ms / WORKING_FRAME_MS) % WORKING_FRAMES as u64) as usize, |
| 362 | MotionMode::Reduced | MotionMode::Still => 0, |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | /// Two-cell species badge: feature glyph in the role accent, body in Signal |
| 367 | /// Gold (Lantern's body in orca ink). |
| 368 | #[must_use] |
| 369 | pub fn badge(species: WhaleSpecies, theme: &UiTheme) -> Vec<Span<'static>> { |
| 370 | let ink = WhaleInk::from_theme(theme); |
| 371 | let (feature, body, feature_first) = species.badge_glyphs(); |
| 372 | let feature_span = Span::styled( |
| 373 | feature, |
| 374 | Style::default() |
| 375 | .fg(ink.accent(species)) |
| 376 | .add_modifier(Modifier::BOLD), |
| 377 | ); |
| 378 | let body_span = Span::styled(body, Style::default().fg(ink.body_for(species))); |
| 379 | if feature_first { |
| 380 | vec![feature_span, body_span] |
| 381 | } else { |
| 382 | vec![body_span, feature_span] |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | /// Badge followed by the state word (glyph + word: never color alone). The |
| 387 | /// word takes the state's tone; when `state` is `None` only the badge renders. |
| 388 | #[cfg(test)] |
| 389 | #[must_use] |
| 390 | pub fn badge_with_state( |
| 391 | species: WhaleSpecies, |
| 392 | state: Option<WhaleState>, |
| 393 | theme: &UiTheme, |
| 394 | locale: Locale, |
| 395 | ) -> Vec<Span<'static>> { |
| 396 | badge_with_state_frame(species, state, 0, theme, locale) |
| 397 | } |
| 398 | |
| 399 | /// [`badge_with_state`] with an explicit working-wake frame (see |
| 400 | /// [`working_frame`]); every non-working state ignores `frame`. |
| 401 | #[must_use] |
| 402 | pub fn badge_with_state_frame( |
| 403 | species: WhaleSpecies, |
| 404 | state: Option<WhaleState>, |
| 405 | frame: usize, |
| 406 | theme: &UiTheme, |
| 407 | locale: Locale, |
| 408 | ) -> Vec<Span<'static>> { |
| 409 | let mut spans = badge(species, theme); |
| 410 | if let Some(state) = state { |
| 411 | let ink = WhaleInk::from_theme(theme); |
| 412 | let (cue, tone) = state_cue(state, frame, &ink, theme); |
| 413 | spans.push(Span::raw(" ")); |
| 414 | if !cue.is_empty() { |
| 415 | spans.push(Span::styled(format!("{cue} "), Style::default().fg(tone))); |
| 416 | } |
| 417 | spans.push(Span::styled( |
| 418 | state.word(locale).into_owned(), |
| 419 | Style::default().fg(tone), |
| 420 | )); |
| 421 | } |
| 422 | spans |
| 423 | } |
| 424 | |
| 425 | /// One-cell state cue paired with its tone: the state grammar folded to a |
| 426 | /// single glyph for badge rows. |
| 427 | fn state_cue( |
| 428 | state: WhaleState, |
| 429 | frame: usize, |
| 430 | ink: &WhaleInk, |
| 431 | theme: &UiTheme, |
| 432 | ) -> (&'static str, Color) { |
| 433 | /// One-cell wake: the four-beat working loop, never blank. |
| 434 | const WAKE_CUE: [&str; WORKING_FRAMES] = ["·", "˚", "·", "˚"]; |
| 435 | match state { |
| 436 | WhaleState::Resting => ("", theme.text_muted), |
| 437 | WhaleState::Thinking => ("˚", ink.current), |
| 438 | WhaleState::Working => (WAKE_CUE[frame % WORKING_FRAMES], ink.current), |
| 439 | WhaleState::Waiting => (glyphs::ATTENTION, ink.human), |
| 440 | WhaleState::Blocked => ("▌", ink.bar), |
| 441 | WhaleState::Offline => ("░", ink.dim), |
| 442 | } |
| 443 | } |
| 444 | |
| 445 | /// The badge as ASCII text (`<#`, `#]`, ...), for tests and text surfaces. |
| 446 | #[cfg(test)] |
| 447 | #[must_use] |
| 448 | pub fn badge_ascii(species: WhaleSpecies) -> String { |
| 449 | let (feature, body, feature_first) = species.badge_glyphs(); |
| 450 | let narrow = |glyph: &'static str| -> &'static str { |
| 451 | if glyph.is_ascii() { |
| 452 | glyph |
| 453 | } else { |
| 454 | glyphs::ascii_fallback(glyph).unwrap_or("?") |
| 455 | } |
| 456 | }; |
| 457 | if feature_first { |
| 458 | format!("{}{}", narrow(feature), narrow(body)) |
| 459 | } else { |
| 460 | format!("{}{}", narrow(body), narrow(feature)) |
| 461 | } |
| 462 | } |
| 463 | |
| 464 | #[cfg(test)] |
| 465 | mod tests { |
| 466 | use super::*; |
| 467 | use codewhale_palette::contrast_ratio; |
| 468 | |
| 469 | fn theme_dark() -> UiTheme { |
| 470 | palette::UI_THEME |
| 471 | } |
| 472 | |
| 473 | #[test] |
| 474 | fn role_table_is_total_and_never_guesses() { |
| 475 | assert_eq!(WhaleSpecies::for_role_id("scout"), WhaleSpecies::Scout); |
| 476 | assert_eq!(WhaleSpecies::for_role_id("builder"), WhaleSpecies::Patch); |
| 477 | assert_eq!(WhaleSpecies::for_role_id("manager"), WhaleSpecies::Harbor); |
| 478 | assert_eq!(WhaleSpecies::for_role_id("planner"), WhaleSpecies::Harbor); |
| 479 | assert_eq!(WhaleSpecies::for_role_id("reviewer"), WhaleSpecies::Lantern); |
| 480 | assert_eq!(WhaleSpecies::for_role_id("verifier"), WhaleSpecies::Keel); |
| 481 | assert_eq!(WhaleSpecies::for_role_id("consultant"), WhaleSpecies::Echo); |
| 482 | assert_eq!(WhaleSpecies::for_role_id("synthesizer"), WhaleSpecies::Echo); |
| 483 | for plain in [ |
| 484 | "worker", |
| 485 | "general", |
| 486 | "custom", |
| 487 | "", |
| 488 | "mystery-role", |
| 489 | "Scouting", |
| 490 | ] { |
| 491 | assert_eq!( |
| 492 | WhaleSpecies::for_role_id(plain), |
| 493 | WhaleSpecies::Plain, |
| 494 | "{plain}" |
| 495 | ); |
| 496 | } |
| 497 | assert_eq!( |
| 498 | WhaleSpecies::for_role_id(" Reviewer "), |
| 499 | WhaleSpecies::Lantern |
| 500 | ); |
| 501 | for role in [ |
| 502 | FleetRole::Worker, |
| 503 | FleetRole::Scout, |
| 504 | FleetRole::Planner, |
| 505 | FleetRole::Reviewer, |
| 506 | FleetRole::Builder, |
| 507 | FleetRole::Verifier, |
| 508 | FleetRole::Consultant, |
| 509 | FleetRole::Custom, |
| 510 | ] { |
| 511 | // Every runtime role resolves without panicking. |
| 512 | let _ = WhaleSpecies::for_fleet_role(&role); |
| 513 | } |
| 514 | assert_eq!( |
| 515 | WhaleSpecies::for_fleet_role(&FleetRole::Custom), |
| 516 | WhaleSpecies::Plain |
| 517 | ); |
| 518 | } |
| 519 | |
| 520 | #[test] |
| 521 | fn state_priority_orders_attention_before_work() { |
| 522 | assert!( |
| 523 | WhaleState::Waiting.priority() > WhaleState::Working.priority() |
| 524 | && WhaleState::Working.priority() > WhaleState::Resting.priority() |
| 525 | ); |
| 526 | } |
| 527 | |
| 528 | #[test] |
| 529 | fn badge_glyphs_have_ascii_fallbacks_and_stay_distinct() { |
| 530 | for species in WhaleSpecies::ALL { |
| 531 | let badge = badge_ascii(species); |
| 532 | assert!( |
| 533 | badge.is_ascii() && badge.chars().count() == BADGE_WIDTH, |
| 534 | "{badge:?}" |
| 535 | ); |
| 536 | } |
| 537 | // Distinct species read distinctly even without Unicode. |
| 538 | let mut badges: Vec<String> = WhaleSpecies::ALL.iter().map(|s| badge_ascii(*s)).collect(); |
| 539 | badges.sort(); |
| 540 | badges.dedup(); |
| 541 | assert_eq!(badges.len(), WhaleSpecies::ALL.len(), "{badges:?}"); |
| 542 | } |
| 543 | |
| 544 | #[test] |
| 545 | fn working_wake_only_animates_under_full_motion() { |
| 546 | assert_eq!(working_frame(0, MotionMode::Full), 0); |
| 547 | assert_eq!(working_frame(180, MotionMode::Full), 1); |
| 548 | assert_eq!(working_frame(540, MotionMode::Full), 3); |
| 549 | assert_eq!(working_frame(720, MotionMode::Full), 0); |
| 550 | for now in [0, 180, 360, 540, 90_000] { |
| 551 | assert_eq!(working_frame(now, MotionMode::Reduced), 0); |
| 552 | assert_eq!(working_frame(now, MotionMode::Still), 0); |
| 553 | } |
| 554 | } |
| 555 | |
| 556 | #[test] |
| 557 | fn every_state_pairs_a_cue_with_a_word_in_every_shipped_locale() { |
| 558 | let theme = theme_dark(); |
| 559 | let ink = WhaleInk::from_theme(&theme); |
| 560 | for state in WhaleState::ALL { |
| 561 | for locale in Locale::shipped_complete() { |
| 562 | let word = state.word(*locale); |
| 563 | assert!(!word.trim().is_empty(), "{state:?} {locale:?}"); |
| 564 | let spans = badge_with_state(WhaleSpecies::Scout, Some(state), &theme, *locale); |
| 565 | let text: String = spans.iter().map(|s| s.content.as_ref()).collect(); |
| 566 | assert!(text.contains(word.as_ref()), "{state:?} {locale:?}: {text}"); |
| 567 | } |
| 568 | let (cue, _) = state_cue(state, 0, &ink, &theme); |
| 569 | if state != WhaleState::Resting { |
| 570 | assert!(!cue.is_empty(), "{state:?} needs a glyph cue"); |
| 571 | } |
| 572 | } |
| 573 | // Identity-only rows carry the badge and nothing that reads as state. |
| 574 | let spans = badge_with_state(WhaleSpecies::Patch, None, &theme, Locale::En); |
| 575 | assert_eq!(spans.len(), BADGE_WIDTH); |
| 576 | } |
| 577 | |
| 578 | #[test] |
| 579 | fn subagent_state_is_derived_from_runtime_facts_only() { |
| 580 | let mut agent = SubAgentResult { |
| 581 | usage: None, |
| 582 | name: "child-1".into(), |
| 583 | agent_id: "child-1".into(), |
| 584 | context_mode: "fresh".into(), |
| 585 | fork_context: false, |
| 586 | workspace: None, |
| 587 | git_branch: None, |
| 588 | agent_type: FleetRole::Builder, |
| 589 | assignment: crate::tools::subagent::SubAgentAssignment { |
| 590 | objective: "objective".into(), |
| 591 | role: None, |
| 592 | }, |
| 593 | model: String::new(), |
| 594 | nickname: None, |
| 595 | status: SubAgentStatus::Running, |
| 596 | worker_status: None, |
| 597 | runtime_permissions: None, |
| 598 | parent_run_id: None, |
| 599 | spawn_depth: 0, |
| 600 | child_route: None, |
| 601 | result: None, |
| 602 | steps_taken: 0, |
| 603 | checkpoint: None, |
| 604 | needs_input: None, |
| 605 | duration_ms: 0, |
| 606 | started_at: None, |
| 607 | from_prior_session: false, |
| 608 | }; |
| 609 | assert_eq!(WhaleState::for_subagent(&agent), WhaleState::Working); |
| 610 | agent.status = SubAgentStatus::Completed; |
| 611 | assert_eq!(WhaleState::for_subagent(&agent), WhaleState::Resting); |
| 612 | agent.status = SubAgentStatus::Interrupted("parent".into()); |
| 613 | assert_eq!(WhaleState::for_subagent(&agent), WhaleState::Waiting); |
| 614 | agent.status = SubAgentStatus::Failed("boom".into()); |
| 615 | assert_eq!(WhaleState::for_subagent(&agent), WhaleState::Blocked); |
| 616 | agent.status = SubAgentStatus::BudgetExhausted; |
| 617 | assert_eq!(WhaleState::for_subagent(&agent), WhaleState::Blocked); |
| 618 | agent.status = SubAgentStatus::Cancelled; |
| 619 | assert_eq!(WhaleState::for_subagent(&agent), WhaleState::Offline); |
| 620 | // Live worker status refines the durable status. |
| 621 | agent.status = SubAgentStatus::Running; |
| 622 | agent.worker_status = Some(AgentWorkerStatus::ModelWait); |
| 623 | assert_eq!(WhaleState::for_subagent(&agent), WhaleState::Thinking); |
| 624 | agent.worker_status = Some(AgentWorkerStatus::RunningTool); |
| 625 | assert_eq!(WhaleState::for_subagent(&agent), WhaleState::Working); |
| 626 | agent.worker_status = Some(AgentWorkerStatus::WaitingForUser); |
| 627 | assert_eq!(WhaleState::for_subagent(&agent), WhaleState::Waiting); |
| 628 | // A pending question for the parent wins over everything. |
| 629 | agent.worker_status = Some(AgentWorkerStatus::Running); |
| 630 | agent.needs_input = Some(crate::tools::subagent::SubAgentNeedsInput { |
| 631 | question: "which branch?".into(), |
| 632 | }); |
| 633 | assert_eq!(WhaleState::for_subagent(&agent), WhaleState::Waiting); |
| 634 | // Priorities match CWC. |
| 635 | assert!(WhaleState::Waiting.priority() > WhaleState::Blocked.priority()); |
| 636 | assert!(WhaleState::Blocked.priority() > WhaleState::Working.priority()); |
| 637 | assert!(WhaleState::Working.priority() > WhaleState::Thinking.priority()); |
| 638 | assert!(WhaleState::Thinking.priority() > WhaleState::Offline.priority()); |
| 639 | assert!(WhaleState::Offline.priority() > WhaleState::Resting.priority()); |
| 640 | } |
| 641 | |
| 642 | #[test] |
| 643 | fn shell_phase_maps_without_inventing_work() { |
| 644 | assert_eq!( |
| 645 | WhaleState::for_shell_phase(ShellPhase::Idle), |
| 646 | WhaleState::Resting |
| 647 | ); |
| 648 | assert_eq!( |
| 649 | WhaleState::for_shell_phase(ShellPhase::Done), |
| 650 | WhaleState::Resting |
| 651 | ); |
| 652 | assert_eq!( |
| 653 | WhaleState::for_shell_phase(ShellPhase::Typing), |
| 654 | WhaleState::Thinking |
| 655 | ); |
| 656 | assert_eq!( |
| 657 | WhaleState::for_shell_phase(ShellPhase::Working), |
| 658 | WhaleState::Working |
| 659 | ); |
| 660 | assert_eq!( |
| 661 | WhaleState::for_shell_phase(ShellPhase::Verifying), |
| 662 | WhaleState::Working |
| 663 | ); |
| 664 | assert_eq!( |
| 665 | WhaleState::for_shell_phase(ShellPhase::Waiting), |
| 666 | WhaleState::Waiting |
| 667 | ); |
| 668 | assert_eq!( |
| 669 | WhaleState::for_shell_phase(ShellPhase::Approval), |
| 670 | WhaleState::Waiting |
| 671 | ); |
| 672 | assert_eq!( |
| 673 | WhaleState::for_shell_phase(ShellPhase::Failed), |
| 674 | WhaleState::Blocked |
| 675 | ); |
| 676 | } |
| 677 | |
| 678 | #[test] |
| 679 | fn badge_accents_meet_secondary_chrome_contrast_on_dark_and_light() { |
| 680 | // Flat Whale shells are terminal-owned and therefore deliberately |
| 681 | // unresolvable. Contrast is enforced against the concrete colors the |
| 682 | // explicit Deepsea treatment paints behind these badges. |
| 683 | let mut dark = palette::UI_THEME; |
| 684 | dark.surface_bg = palette::WHALE_BG; |
| 685 | let mut light = palette::LIGHT_UI_THEME; |
| 686 | light.surface_bg = palette::LIGHT_SURFACE; |
| 687 | for theme in [dark, light] { |
| 688 | let ink = WhaleInk::from_theme(&theme); |
| 689 | for species in WhaleSpecies::ALL { |
| 690 | for color in [ink.accent(species), ink.body_for(species)] { |
| 691 | let ratio = contrast_ratio(color, theme.surface_bg) |
| 692 | .unwrap_or_else(|| panic!("{species:?} unresolvable on {}", theme.name)); |
| 693 | assert!( |
| 694 | ratio >= palette::SECONDARY_CHROME_CONTRAST, |
| 695 | "{species:?} {color:?} on {} = {ratio:.2}", |
| 696 | theme.name |
| 697 | ); |
| 698 | } |
| 699 | } |
| 700 | for color in [ink.current, ink.human, ink.bar] { |
| 701 | let ratio = contrast_ratio(color, theme.surface_bg).unwrap(); |
| 702 | assert!( |
| 703 | ratio >= palette::SECONDARY_CHROME_CONTRAST, |
| 704 | "{color:?} {ratio:.2}" |
| 705 | ); |
| 706 | } |
| 707 | } |
| 708 | // Terminal theme surfaces are terminal-owned; enforcement must pass |
| 709 | // named colors through untouched rather than inventing RGB. |
| 710 | let terminal = palette::TERMINAL_UI_THEME; |
| 711 | let ink = WhaleInk::from_theme(&terminal); |
| 712 | assert_eq!(ink.body, terminal.accent_action); |
| 713 | assert_eq!(ink.keel, terminal.warning); |
| 714 | } |
| 715 | |
| 716 | #[test] |
| 717 | fn species_labels_are_localized_in_every_shipped_pack() { |
| 718 | for species in WhaleSpecies::ALL { |
| 719 | for locale in Locale::shipped_complete() { |
| 720 | assert!(!species.animal(*locale).trim().is_empty()); |
| 721 | assert!(!species.job(*locale).trim().is_empty()); |
| 722 | } |
| 723 | } |
| 724 | assert_eq!(WhaleSpecies::Scout.name(), "Scout"); |
| 725 | assert_eq!(WhaleSpecies::Plain.name(), "codewhale"); |
| 726 | } |
| 727 | } |
| 728 |