返回 CodeWhale
whales.rs
根目录 / crates / tui / src / tui / whales.rs
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
728 lines RUST