diff --git a/docs/familiars.md b/docs/familiars.md index 51a1c30..270b173 100644 --- a/docs/familiars.md +++ b/docs/familiars.md @@ -42,9 +42,9 @@ Workspace Agents ← .coven-code/agents/*.md • my-custom-agent default · user ✨ Coven Familiars ← ~/.coven/familiars.toml - ★ Nova ✨ Orchestrator — Your personal AI ... - ★ Sage 🧙 Research — Deep reasoning and ... - ★ Cody 🤖 Code — Focused implementation ... + ★ Dev 🤖 Code — Focused implementation ... + ★ Research 🧙 Research — Deep reasoning and ... + ★ Writer ✍️ Writing — Docs and communication ... ``` Select a familiar to see its full detail view, including persona preview and the suggested `--agent` invocation. @@ -170,8 +170,8 @@ The `access` field controls **which tools** a familiar may invoke once you selec | Tier | What the familiar can do | Typical role | |---|---|---| -| `full` | Read, write, and execute — full tool set (Edit/Write/Bash/etc.) | Build-tier familiars: `cody`, `nova`, `kitty` | -| `read-only` | Read & search the workspace plus `AskUserQuestion`, no writes or shell. **Default.** | Research/strategy familiars: `sage`, `astra`, `echo` | +| `full` | Read, write, and execute — full tool set (Edit/Write/Bash/etc.) | Build-tier familiars that edit and run code | +| `read-only` | Read & search the workspace plus `AskUserQuestion`, no writes or shell. **Default.** | Research / strategy familiars | | `search-only` | Narrow read+search whitelist: `Grep`, `Glob`, `Read`, `WebSearch`, `WebFetch`. No writes or shell. | Pure-research personas with minimal codebase footprint | > **Unknown values fail closed.** Case and surrounding whitespace are normalized silently — `"READ-ONLY"`, `"Read-Only"`, and `" full "` all map to their canonical tier. Anything else (a typo like `"readonly"`, an invented tier like `"super-admin"`, an empty string) is treated as `"read-only"` and a warning is printed to stderr. Typos cannot silently grant write/exec power. @@ -197,15 +197,15 @@ The `access` field controls **which tools** a familiar may invoke once you selec ```toml # Build-tier — can edit and run. [[familiar]] -id = "cody" -display_name = "Cody" +id = "dev" +display_name = "Dev" role = "Code" access = "full" # Research-tier — read-only by default (no `access` line needed). [[familiar]] -id = "sage" -display_name = "Sage" +id = "research" +display_name = "Research" role = "Research" ``` @@ -255,7 +255,7 @@ Every saved familiar from `~/.coven/familiars.toml` renders as a **static themed 2. The **F2 switcher popup**: one row per saved familiar, each painted in that familiar's accent palette with a coloured tier dot. 3. The **`/agents` detail view**: the card appears above the persona preview when you select a familiar-sourced agent. -The glyph itself does **not** animate. The only motion is a quarter-block eye spinner that kicks in when the assistant has gone quiet for ~3 seconds, so you still get a "thinking" signal without a walking mascot pulling attention from the work area. +The glyph is a procedural sigil framing the familiar's emoji. Its accent colour pulses gently while idle and pulses faster when the assistant has gone quiet for ~3 seconds, so you get a "thinking" signal without a walking mascot pulling attention from the work area. Cards adapt to available room: @@ -263,21 +263,15 @@ Cards adapt to available room: - **Standard** (default): glyph + name + tier dot inside a rounded border. - **Large** (wide terminals): adds the role line and an accent rule under the glyph. -### Built-in glyphs - -| ID | Concept | Accent | -|---|---|---| -| `kitty` | Cat head — ears, whiskers, square eyes (default) | violet | -| `nova` | Crowned sorceress with star sparkles | gold | -| `cody` | Robot face — antenna, bracket eyes, code body | cyan | -| `charm` | Heart with sparkle dots | pink | -| `sage` | Wizard hat + star + open book | emerald | -| `astra` | Crescent moon + compass star + orbit | indigo | -| `echo` | Round ghost + bracket eyes + echo dots | teal | - -### User-defined familiars +### Procedural glyphs -Any familiar declared in `~/.coven/familiars.toml` automatically gets a procedurally-generated card. The accent palette and sigil frame (crystal, hexagon, rune, or seal) are picked deterministically from the familiar's `id`, so the same familiar looks the same across sessions and machines without storing extra config. The familiar's `emoji` is rendered inside the frame. +There is **no built-in roster** — nothing ships with a named familiar, so a +fresh install never inherits one. Every familiar declared in +`~/.coven/familiars.toml` automatically gets a procedurally-generated card. The +accent palette and sigil frame (crystal, hexagon, rune, or seal) are picked +deterministically from the familiar's `id`, so the same familiar looks the same +across sessions and machines without storing extra config. The familiar's +`emoji` is rendered inside the frame. If you want a hand-crafted image instead of the procedural sigil, drop a PNG/JPG/WebP at `~/.coven/assets/familiars/.`. When the terminal supports Kitty or Sixel inline graphics, that image takes precedence over the card. @@ -287,14 +281,14 @@ Set `familiar` in your settings: ```json { - "familiar": "nova" + "familiar": "dev" } ``` Or run: ``` -coven-code config set familiar nova +coven-code config set familiar dev ``` When `~/.coven/familiars.toml` contains saved familiars, you can also press @@ -317,8 +311,8 @@ agent markdown files, and saved agent/familiar settings. After reset the welcome panel renders `Familiar: none`, the footer shows no familiar label, and the F2 familiar switcher does not open until a saved familiar roster exists again. The `/familiar` command only selects familiars from -`~/.coven/familiars.toml`; stale settings or built-in names are ignored when -the roster file is absent. +`~/.coven/familiars.toml`; stale settings are ignored when the roster file is +absent. --- diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index 9a9d50c..3ed7610 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -5323,15 +5323,15 @@ mod tests { #[test] fn startup_agent_defaults_to_configured_familiar() { assert_eq!( - startup_agent_name(None, Some("Echo")), - Some("echo".to_string()) + startup_agent_name(None, Some("Wisp")), + Some("wisp".to_string()) ); } #[test] fn startup_agent_prefers_explicit_agent_over_familiar() { assert_eq!( - startup_agent_name(Some("Explore"), Some("Echo")), + startup_agent_name(Some("Explore"), Some("Wisp")), Some("explore".to_string()) ); } @@ -5424,8 +5424,8 @@ mod tests { "issue_body": "Tokens expire early." }, "familiar": { - "id": "cody", - "display_name": "Cody", + "id": "ember", + "display_name": "Ember", "model": "anthropic/claude-sonnet-4-6", "skills": ["systematic-debugging"] }, @@ -5459,7 +5459,7 @@ mod tests { #[test] fn github_output_envelope_includes_git_summary() { let git = GitResultSummary { - branch: Some("cody/fix-auth-refresh".to_string()), + branch: Some("ember/fix-auth-refresh".to_string()), commits: vec![GitCommitSummary { sha: "abc123".to_string(), message: "fix auth refresh".to_string(), @@ -5470,7 +5470,7 @@ mod tests { let envelope = github_output_envelope(true, &git); assert_eq!(envelope["status"], "success"); - assert_eq!(envelope["branch"], "cody/fix-auth-refresh"); + assert_eq!(envelope["branch"], "ember/fix-auth-refresh"); assert_eq!(envelope["commits"][0]["sha"], "abc123"); assert_eq!(envelope["files_changed"][0], "src/auth.rs"); assert_eq!(envelope["exit_reason"], serde_json::Value::Null); diff --git a/src-rust/crates/tui/src/agents_view.rs b/src-rust/crates/tui/src/agents_view.rs index 6302ddd..8421f3e 100644 --- a/src-rust/crates/tui/src/agents_view.rs +++ b/src-rust/crates/tui/src/agents_view.rs @@ -730,8 +730,8 @@ mod tests { fn status(status: &str, active_sessions: u32) -> coven_shared::FamiliarStatus { coven_shared::FamiliarStatus { - id: "sage".to_string(), - display_name: "Sage".to_string(), + id: "wisp".to_string(), + display_name: "Wisp".to_string(), emoji: String::new(), status: status.to_string(), active_sessions, @@ -763,12 +763,12 @@ mod tests { #[test] fn familiar_id_from_source_parses_coven_familiar_prefix() { assert_eq!( - familiar_id_from_source("coven:familiar:cody"), - Some("cody".to_string()) + familiar_id_from_source("coven:familiar:ember"), + Some("ember".to_string()) ); assert_eq!( - familiar_id_from_source("coven:familiar:Nova"), - Some("nova".to_string()) + familiar_id_from_source("coven:familiar:Onyx"), + Some("onyx".to_string()) ); assert_eq!(familiar_id_from_source("user"), None); assert_eq!(familiar_id_from_source("plugin:foo"), None); @@ -807,8 +807,8 @@ mod tests { #[test] fn familiar_as_agent_def_matches_core_conversion() { let fam = coven_shared::CovenFamiliar { - id: "Cody".to_string(), - display_name: Some("Cody".to_string()), + id: "Ember".to_string(), + display_name: Some("Ember".to_string()), emoji: Some("⚡".to_string()), role: Some("Code".to_string()), description: Some("Builds and ships.".to_string()), @@ -827,7 +827,7 @@ mod tests { #[test] fn familiar_as_agent_def_defaults_access_to_read_only() { let fam = coven_shared::CovenFamiliar { - id: "sage".to_string(), + id: "wisp".to_string(), display_name: None, emoji: None, role: None, @@ -842,12 +842,12 @@ mod tests { #[test] fn confirm_selection_returns_familiar_id_from_list_route() { let mut state = AgentsMenuState::new(); - state.definitions = vec![user_def("review"), familiar_def("cody", "Cody")]; + state.definitions = vec![user_def("review"), familiar_def("ember", "Ember")]; state.route = AgentsRoute::List; // selected_row 0 = "Create new"; row 1 = reset; row 2 = first def; row 3 = familiar. state.selected_row = 3; let result = state.confirm_selection(); - assert_eq!(result, Some(("cody".to_string(), "Cody".to_string()))); + assert_eq!(result, Some(("ember".to_string(), "Ember".to_string()))); // List route is unchanged — caller is responsible for closing the menu. assert!(matches!(state.route, AgentsRoute::List)); } @@ -876,10 +876,10 @@ mod tests { #[test] fn confirm_selection_returns_familiar_id_from_detail_route() { let mut state = AgentsMenuState::new(); - state.definitions = vec![familiar_def("nova", "Nova")]; + state.definitions = vec![familiar_def("onyx", "Onyx")]; state.route = AgentsRoute::Detail(0); let result = state.confirm_selection(); - assert_eq!(result, Some(("nova".to_string(), "Nova".to_string()))); + assert_eq!(result, Some(("onyx".to_string(), "Onyx".to_string()))); } #[test] @@ -914,7 +914,7 @@ mod tests { #[test] fn delete_selected_definition_rejects_coven_familiar() { let mut state = AgentsMenuState::new(); - state.definitions = vec![familiar_def("nova", "Nova")]; + state.definitions = vec![familiar_def("onyx", "Onyx")]; state.route = AgentsRoute::List; state.selected_row = 2; diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 834c166..786a886 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -4832,16 +4832,18 @@ impl App { Some("No saved familiars. Add ~/.coven/familiars.toml first.".to_string()); } else { self.familiar_switcher_open = true; - let current = self.config.familiar.as_deref().unwrap_or("kitty"); - if let Some(idx) = self - .familiar_switcher_list - .iter() - .position(|id| id == current) - { - self.familiar_switcher_idx = idx; - } else { - self.familiar_switcher_idx = 0; - } + // Highlight the active familiar if one is set; otherwise + // start at the top. No built-in default is assumed. + self.familiar_switcher_idx = self + .config + .familiar + .as_deref() + .and_then(|current| { + self.familiar_switcher_list + .iter() + .position(|id| id == current) + }) + .unwrap_or(0); } } KeyCode::Char('?') @@ -7426,8 +7428,8 @@ mod tests { #[test] fn cycle_agent_mode_clears_visible_familiar() { let mut app = make_app(); - app.config.familiar = Some("echo".to_string()); - app.agent_mode = Some("echo".to_string()); + app.config.familiar = Some("wisp".to_string()); + app.agent_mode = Some("wisp".to_string()); app.cycle_agent_mode(); @@ -7447,7 +7449,7 @@ mod tests { std::fs::create_dir_all(&project).expect("project dir"); std::fs::write( coven_home.join("familiars.toml"), - "[[familiar]]\nid = \"nova\"\n", + "[[familiar]]\nid = \"ember\"\n", ) .expect("familiar roster"); let guard = EnvGuard::set(&home, &coven_home); @@ -7457,16 +7459,16 @@ mod tests { app.config .agents .insert("custom".to_string(), test_agent_definition()); - app.config.familiar = Some("nova".to_string()); + app.config.familiar = Some("ember".to_string()); app.config.managed_agents = Some(test_managed_agents()); app.config.permission_mode = claurst_core::config::PermissionMode::Plan; - app.agent_mode = Some("nova".to_string()); + app.agent_mode = Some("ember".to_string()); app.agent_mode_changed = false; app.accent_color = ACCENT_PLAN; app.plan_mode = true; app.managed_agents_active = true; app.familiar_switcher_open = true; - app.familiar_switcher_list = vec!["nova".to_string(), "kitty".to_string()]; + app.familiar_switcher_list = vec!["ember".to_string(), "onyx".to_string()]; app.familiar_switcher_idx = 1; app.reset_agents_and_familiars(); @@ -7510,7 +7512,7 @@ mod tests { let coven_home = temp.path().join("coven"); std::fs::create_dir_all(&home).expect("home dir"); std::fs::create_dir_all(&coven_home).expect("coven home dir"); - let _guard = EnvGuard::set_with_user(&home, &coven_home, Some("sage")); + let _guard = EnvGuard::set_with_user(&home, &coven_home, Some("ember")); let app = make_app(); @@ -7529,18 +7531,18 @@ mod tests { coven_home.join("familiars.toml"), r#" [[familiar]] -id = "sage" -display_name = "Sage" +id = "wisp" +display_name = "Wisp" role = "Research" "#, ) .expect("familiars file"); - let _guard = EnvGuard::set_with_user(&home, &coven_home, Some("sage")); + let _guard = EnvGuard::set_with_user(&home, &coven_home, Some("wisp")); let app = make_app(); - assert_eq!(app.config.familiar.as_deref(), Some("sage")); - assert_eq!(app.familiar_switcher_list, vec!["sage".to_string()]); + assert_eq!(app.config.familiar.as_deref(), Some("wisp")); + assert_eq!(app.familiar_switcher_list, vec!["wisp".to_string()]); } #[test] diff --git a/src-rust/crates/tui/src/familiar_card.rs b/src-rust/crates/tui/src/familiar_card.rs index 6ef8fd1..f7f118b 100644 --- a/src-rust/crates/tui/src/familiar_card.rs +++ b/src-rust/crates/tui/src/familiar_card.rs @@ -7,13 +7,12 @@ //! eye row while the assistant is in the `Loading` state; other surfaces pass //! `CompanionPose::Static` and stay still. //! -//! Built-in archetypes dispatch to the pixel-art builders in -//! [`crate::mascot`]. Procedural archetypes ([`Archetype::SigilCrystal`] etc.) -//! draw a colored frame around the familiar's emoji so any user-defined entry -//! from `~/.coven/familiars.toml` gets first-class visual identity. +//! Every archetype ([`Archetype::SigilCrystal`] etc.) draws a colored frame +//! around the familiar's emoji, so any entry from `~/.coven/familiars.toml` +//! gets first-class visual identity and nothing inherits a built-in persona. use crate::familiar_theme::{Archetype, FamiliarPalette, FamiliarTheme}; -use crate::mascot::{archetype_lines, CompanionPose}; +use crate::mascot::CompanionPose; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -240,24 +239,38 @@ fn access_line(theme: &FamiliarTheme, primary: Color, inner_w: u16) -> Line<'sta fn glyph_lines(theme: &FamiliarTheme, pose: &CompanionPose) -> Vec> { match theme.archetype { - Archetype::SigilCrystal => sigil_crystal(&theme.palette, &theme.emoji), - Archetype::SigilHex => sigil_hex(&theme.palette, &theme.emoji), - Archetype::SigilRune => sigil_rune(&theme.palette, &theme.emoji), - Archetype::SigilSeal => sigil_seal(&theme.palette, &theme.emoji), - _ => archetype_lines(theme.archetype, &theme.palette, pose).to_vec(), + Archetype::SigilCrystal => sigil_crystal(&theme.palette, &theme.emoji, pose), + Archetype::SigilHex => sigil_hex(&theme.palette, &theme.emoji, pose), + Archetype::SigilRune => sigil_rune(&theme.palette, &theme.emoji, pose), + Archetype::SigilSeal => sigil_seal(&theme.palette, &theme.emoji, pose), } } -// ── Procedural sigils for user-defined familiars ───────────────────────────── +/// Accent color for a sigil's decorative row, pulsed by the companion pose. +/// +/// The glyph text never changes width — only the accent row's color shifts +/// between the bright accent and the dimmer primary — so an `Idle` card +/// breathes softly and a stalled `Loading` card pulses faster, while a +/// `Static` card stays bright. This is the only animation; it is identical for +/// every familiar and tied to no named persona. +fn pulse_accent(p: &FamiliarPalette, pose: &CompanionPose) -> Color { + match pose { + CompanionPose::Idle { frame } if matches!(frame % 120, 90..=95) => p.primary, + CompanionPose::Loading { frame } if (frame / 5) % 2 == 1 => p.primary, + _ => p.accent, + } +} + +// ── Procedural sigils ──────────────────────────────────────────────────────── // -// Each sigil is 11 visible cells wide × 4 rows so it slots in next to the -// hand-crafted built-ins without changing the bordered layout. The emoji -// (2 cells wide on most terminals) is rendered as its own span; the -// surrounding frame characters are colored in the resolved palette. +// Each sigil is 11 visible cells wide × 4 rows so it slots into the bordered +// layout consistently. The emoji (2 cells wide on most terminals) is rendered +// as its own span; the surrounding frame characters are colored in the +// resolved palette. -fn sigil_crystal(p: &FamiliarPalette, emoji: &str) -> Vec> { +fn sigil_crystal(p: &FamiliarPalette, emoji: &str, pose: &CompanionPose) -> Vec> { let frame = Style::default().fg(p.primary).add_modifier(Modifier::BOLD); - let accent = Style::default().fg(p.accent); + let accent = Style::default().fg(pulse_accent(p, pose)); vec![ Line::from(Span::styled( " \u{2581}\u{2580}\u{2581} ".to_string(), @@ -275,9 +288,9 @@ fn sigil_crystal(p: &FamiliarPalette, emoji: &str) -> Vec> { ] } -fn sigil_hex(p: &FamiliarPalette, emoji: &str) -> Vec> { +fn sigil_hex(p: &FamiliarPalette, emoji: &str, pose: &CompanionPose) -> Vec> { let frame = Style::default().fg(p.primary).add_modifier(Modifier::BOLD); - let accent = Style::default().fg(p.accent); + let accent = Style::default().fg(pulse_accent(p, pose)); vec![ Line::from(Span::styled( " \u{256d}\u{2500}\u{2500}\u{2500}\u{256e} ".to_string(), @@ -295,9 +308,9 @@ fn sigil_hex(p: &FamiliarPalette, emoji: &str) -> Vec> { ] } -fn sigil_rune(p: &FamiliarPalette, emoji: &str) -> Vec> { +fn sigil_rune(p: &FamiliarPalette, emoji: &str, pose: &CompanionPose) -> Vec> { let frame = Style::default().fg(p.primary).add_modifier(Modifier::BOLD); - let accent = Style::default().fg(p.accent); + let accent = Style::default().fg(pulse_accent(p, pose)); vec![ Line::from(Span::styled( " \u{258e} \u{258e} ".to_string(), @@ -315,9 +328,9 @@ fn sigil_rune(p: &FamiliarPalette, emoji: &str) -> Vec> { ] } -fn sigil_seal(p: &FamiliarPalette, emoji: &str) -> Vec> { +fn sigil_seal(p: &FamiliarPalette, emoji: &str, pose: &CompanionPose) -> Vec> { let frame = Style::default().fg(p.primary).add_modifier(Modifier::BOLD); - let accent = Style::default().fg(p.accent); + let accent = Style::default().fg(pulse_accent(p, pose)); vec![ Line::from(Span::styled( " \u{2726} \u{2726} ".to_string(), @@ -421,23 +434,23 @@ mod tests { #[test] fn render_card_compact_is_glyph_only() { - let t = familiar_theme::resolve("kitty", &[]); + let t = familiar_theme::resolve("alpha", &[]); let lines = render_card(&t, CardSize::Compact, &CompanionPose::Static); - // Built-in archetypes return 5 rows (4 content + 1 blank); compact passes through. + // Sigil archetypes return 4 content rows; compact passes through. assert!(lines.len() >= 4); } #[test] fn render_card_standard_has_border() { - let t = familiar_theme::resolve("nova", &[]); + let t = familiar_theme::resolve("beta", &[]); let lines = render_card(&t, CardSize::Standard, &CompanionPose::Static); - // Top border + 5 glyph rows + access row + bottom border = at least 8. + // Top border + glyph rows + access row + bottom border = at least 7. assert!(lines.len() >= 7); } #[test] fn render_card_large_includes_rule_row() { - let t = familiar_theme::resolve("sage", &[]); + let t = familiar_theme::resolve("gamma", &[]); let lines = render_card(&t, CardSize::Large, &CompanionPose::Static); // Large has at least one more row than Standard (the rule + role region). let standard = render_card(&t, CardSize::Standard, &CompanionPose::Static).len(); diff --git a/src-rust/crates/tui/src/familiar_theme.rs b/src-rust/crates/tui/src/familiar_theme.rs index 523d253..a8b39a6 100644 --- a/src-rust/crates/tui/src/familiar_theme.rs +++ b/src-rust/crates/tui/src/familiar_theme.rs @@ -1,15 +1,13 @@ -//! Familiar theming — resolves any familiar id (built-in or user-defined from +//! Familiar theming — resolves any familiar id (user-defined in //! `~/.coven/familiars.toml`) to a stable [`FamiliarTheme`] used by //! [`crate::familiar_card`] when composing the static themed card shown in the //! welcome panel, F2 switcher, and `/agents` detail view. //! -//! Built-in familiars (`kitty`, `nova`, `cody`, `charm`, `sage`, `astra`, -//! `echo`) get hand-tuned palettes + their existing pixel-art archetypes. -//! -//! User-defined familiars get a procedurally derived palette + sigil -//! archetype hashed from the lowercased id so the same familiar always -//! looks the same across sessions and machines without persisting extra -//! state in `familiars.toml`. +//! There is no built-in roster: nothing ships with a named familiar, so a +//! fresh install never inherits one. Every familiar gets a procedurally +//! derived palette + sigil archetype hashed from the lowercased id, so the +//! same familiar always looks the same across sessions and machines without +//! persisting extra state in `familiars.toml`. //! //! The access tier flows through verbatim from `coven_shared` — it drives the //! coloured tier dot drawn on the card. @@ -52,19 +50,11 @@ impl FamiliarPalette { // ── Archetype ──────────────────────────────────────────────────────────────── -/// Which renderer in [`crate::mascot`] / [`crate::familiar_card`] draws the -/// glyph body. The first seven variants map to the hand-crafted built-ins; -/// `SigilCrystal`/`SigilHex`/`SigilRune`/`SigilSeal` are procedural frames -/// used for any user-defined familiar. +/// Which procedural frame in [`crate::familiar_card`] draws the glyph body. +/// One of these is picked for every familiar by hashing its id, so each +/// familiar gets a stable sigil without any named persona being baked in. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Archetype { - Cat, - SorceressCrown, - Robot, - Heart, - WizardBook, - Moon, - Ghost, SigilCrystal, SigilHex, SigilRune, @@ -102,64 +92,10 @@ impl FamiliarTheme { } } -// ── Built-in palettes ──────────────────────────────────────────────────────── - -/// Palettes for the seven hand-crafted built-ins. Each one breaks the old -/// uniform violet so familiars are visually distinct at a glance. -const BUILTIN_PALETTES: &[(&str, FamiliarPalette, Archetype, &str, &str)] = &[ - ( - "kitty", - FamiliarPalette::from_rgb((139, 92, 246), (167, 139, 250), (196, 181, 253)), - Archetype::Cat, - "Kitty", - "\u{1f431}", - ), - ( - "nova", - FamiliarPalette::from_rgb((245, 197, 24), (253, 230, 138), (254, 240, 138)), - Archetype::SorceressCrown, - "Nova", - "\u{1f451}", - ), - ( - "cody", - FamiliarPalette::from_rgb((34, 211, 238), (165, 243, 252), (165, 243, 252)), - Archetype::Robot, - "Cody", - "\u{1f4bb}", - ), - ( - "charm", - FamiliarPalette::from_rgb((236, 72, 153), (251, 207, 232), (251, 207, 232)), - Archetype::Heart, - "Charm", - "\u{2728}", - ), - ( - "sage", - FamiliarPalette::from_rgb((16, 185, 129), (167, 243, 208), (167, 243, 208)), - Archetype::WizardBook, - "Sage", - "\u{1f33f}", - ), - ( - "astra", - FamiliarPalette::from_rgb((99, 102, 241), (199, 210, 254), (199, 210, 254)), - Archetype::Moon, - "Astra", - "\u{1f319}", - ), - ( - "echo", - FamiliarPalette::from_rgb((20, 184, 166), (153, 246, 228), (153, 246, 228)), - Archetype::Ghost, - "Echo", - "\u{1f47b}", - ), -]; +// ── Procedural palettes ────────────────────────────────────────────────────── /// Eight-color palette table used to pick a deterministic accent for any -/// user-defined familiar by hashing its id. +/// familiar by hashing its id. const PROCEDURAL_PALETTES: &[FamiliarPalette] = &[ FamiliarPalette::from_rgb((139, 92, 246), (167, 139, 250), (196, 181, 253)), // violet FamiliarPalette::from_rgb((245, 197, 24), (253, 230, 138), (254, 240, 138)), // gold @@ -180,43 +116,25 @@ const PROCEDURAL_ARCHETYPES: &[Archetype] = &[ /// Resolve a familiar id to its theme. /// -/// Built-in ids win first. Anything else is matched against the supplied -/// `daemon_familiars` (callers pass [`coven_shared::load_familiars`] output). -/// Unknown ids fall back to the `kitty` theme so the welcome panel never -/// renders blank. +/// Ids present in the supplied `daemon_familiars` (callers pass +/// [`coven_shared::load_familiars`] output) use that definition's display +/// name, emoji, role, and access tier. Any other id is derived straight from +/// the id string so the welcome panel still renders — there is no named +/// fallback, so an unknown id never resolves to someone else's familiar. pub fn resolve(id: &str, daemon_familiars: &[CovenFamiliar]) -> FamiliarTheme { let lc = id.to_lowercase(); - if let Some(theme) = builtin(&lc) { - return theme; - } if let Some(def) = daemon_familiars.iter().find(|f| f.id.to_lowercase() == lc) { return procedural(def); } - builtin("kitty").expect("kitty is always present in BUILTIN_PALETTES") -} - -fn builtin(id: &str) -> Option { - BUILTIN_PALETTES - .iter() - .find(|(slug, _, _, _, _)| *slug == id) - .map(|(slug, palette, arch, name, emoji)| FamiliarTheme { - id: (*slug).to_string(), - display_name: (*name).to_string(), - emoji: (*emoji).to_string(), - role: None, - access: builtin_access(slug).to_string(), - palette: *palette, - archetype: *arch, - }) -} - -/// Built-in tier defaults match the recommendation table in `docs/familiars.md`: -/// `cody`, `nova`, `kitty` get `full`; the research-leaning rest stay read-only. -fn builtin_access(id: &str) -> &'static str { - match id { - "kitty" | "cody" | "nova" => "full", - _ => "read-only", - } + procedural(&CovenFamiliar { + id: id.to_string(), + display_name: None, + emoji: None, + role: None, + description: None, + pronouns: None, + access: None, + }) } fn procedural(def: &CovenFamiliar) -> FamiliarTheme { @@ -263,22 +181,46 @@ mod tests { } #[test] - fn builtin_resolution() { - let t = resolve("kitty", &[]); - assert_eq!(t.id, "kitty"); - assert!(matches!(t.archetype, Archetype::Cat)); + fn roster_definition_is_used() { + let mut f = fake_familiar("planner"); + f.display_name = Some("Planner".to_string()); + let t = resolve("planner", &[f]); + assert_eq!(t.id, "planner"); + assert_eq!(t.display_name, "Planner"); + // Every familiar resolves to a procedural sigil archetype. + assert!(matches!( + t.archetype, + Archetype::SigilCrystal + | Archetype::SigilHex + | Archetype::SigilRune + | Archetype::SigilSeal + )); } #[test] - fn case_insensitive_lookup() { - let t = resolve("KITTY", &[]); - assert_eq!(t.id, "kitty"); + fn unknown_id_renders_without_named_fallback() { + // An id that isn't in the roster derives a theme from itself rather + // than inheriting any built-in persona. + let t = resolve("does-not-exist", &[]); + assert_eq!(t.id, "does-not-exist"); + assert!(matches!( + t.archetype, + Archetype::SigilCrystal + | Archetype::SigilHex + | Archetype::SigilRune + | Archetype::SigilSeal + )); } #[test] - fn unknown_falls_back_to_kitty() { - let t = resolve("does-not-exist", &[]); - assert_eq!(t.id, "kitty"); + fn unknown_id_is_stable() { + let a = resolve("solo", &[]); + let b = resolve("solo", &[]); + assert_eq!(format!("{:?}", a.archetype), format!("{:?}", b.archetype)); + assert_eq!( + format!("{:?}", a.palette.primary), + format!("{:?}", b.palette.primary) + ); } #[test] @@ -296,12 +238,17 @@ mod tests { #[test] fn eye_socket_palette_varies_by_familiar() { - let kitty = resolve("kitty", &[]); - let cody = resolve("cody", &[]); - - assert_ne!( - kitty.palette.eye_socket, cody.palette.eye_socket, - "eye sockets should use each familiar palette instead of one hardcoded violet" + // The eye socket follows each familiar's palette rather than one + // shared hardcoded violet, so across a spread of ids more than one + // distinct eye socket shows up. + let ids = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"]; + let sockets: std::collections::HashSet = ids + .iter() + .map(|id| format!("{:?}", resolve(id, &[]).palette.eye_socket)) + .collect(); + assert!( + sockets.len() > 1, + "eye sockets should follow each familiar's palette, got {sockets:?}" ); } diff --git a/src-rust/crates/tui/src/handoff.rs b/src-rust/crates/tui/src/handoff.rs index 2ef2ba5..6c554c4 100644 --- a/src-rust/crates/tui/src/handoff.rs +++ b/src-rust/crates/tui/src/handoff.rs @@ -99,9 +99,9 @@ mod tests { #[test] fn handoff_context_handles_empty_messages() { - let ctx = build_handoff_context(&[], "sage"); - assert!(ctx.contains("**Familiar:** sage")); - assert!(ctx.contains("Continue this work as sage")); + let ctx = build_handoff_context(&[], "wisp"); + assert!(ctx.contains("**Familiar:** wisp")); + assert!(ctx.contains("Continue this work as wisp")); assert!(ctx.contains("(unknown topic)")); } @@ -111,7 +111,7 @@ mod tests { Message::user("Fix the login bug"), Message::assistant("I'll inspect auth"), ]; - let ctx = build_handoff_context(&msgs, "astra"); + let ctx = build_handoff_context(&msgs, "onyx"); assert!(ctx.contains("> **User**: Fix the login bug")); assert!(ctx.contains("> **Assistant**: I'll inspect auth")); assert!(ctx.contains("The user was working on: Fix the login bug")); @@ -119,7 +119,7 @@ mod tests { #[test] fn truncate_chars_does_not_split_unicode_boundaries() { - let text = "sage 🌿 keeps context"; - assert_eq!(truncate_chars(text, 6), "sage 🌿..."); + let text = "wisp 🌿 keeps context"; + assert_eq!(truncate_chars(text, 6), "wisp 🌿..."); } } diff --git a/src-rust/crates/tui/src/mascot.rs b/src-rust/crates/tui/src/mascot.rs index ea559ee..b527b31 100644 --- a/src-rust/crates/tui/src/mascot.rs +++ b/src-rust/crates/tui/src/mascot.rs @@ -1,582 +1,24 @@ -//! Companion mascot rendering for ratatui. +//! Companion pose state for the familiar card. //! -//! Each OpenCoven familiar has its own pixel-art glyph. The active familiar -//! is read from `config.familiar` (settings.json `"familiar"` key) and -//! determines which glyph renders in the welcome screen top-left, the F2 -//! switcher, and the `/agents` detail view. +//! The active familiar's glyph is composed by [`crate::familiar_card`] from the +//! procedural sigil for its archetype (resolved in [`crate::familiar_theme`]). +//! No named persona or bespoke pixel-art is baked into the binary, so a fresh +//! install never inherits a built-in familiar. //! -//! The glyph has subtle idle motion (blink/spark frames) plus a loading -//! spinner that rotates inside the eye row when the assistant is mid-turn -//! and stalled. Loading motion lives in [`loading_eye_spans`]. -//! -//! Public surface: -//! - [`CompanionPose`] — `Static`, `Idle { frame }`, or `Loading { frame }`. -//! - [`archetype_lines`] — palette-aware glyph dispatcher used by [`crate::familiar_card`]. -//! - [`mascot_lines_for`] — legacy entry point preserved for callers that still -//! pass a familiar slug; it routes through the theme/card path internally. -//! -//! # Built-in roster -//! -//! | ID | Archetype | -//! |----------|--------------------------------------------------------------------------| -//! | `kitty` | Cat head — pointy ears, whisker nose, square eyes (default). | -//! | `nova` | 4-point star + crown + orbit dots — sorceress. | -//! | `cody` | Robot face with antenna, bracket eyes, code body. | -//! | `charm` | Large pixel heart with sparkle dots. | -//! | `sage` | Wizard hat with star above an open spellbook. | -//! | `astra` | Crescent moon, compass star, dotted orbit. | -//! | `echo` | Round ghost with bracket eyes and floaty echo dots. | -//! -//! All glyphs are 11 chars wide × 4 content rows + 1 blank spacing row. - -use crate::familiar_theme::{Archetype, FamiliarPalette}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; - -// ── Pose ───────────────────────────────────────────────────────────────────── +//! This module only carries the pose/expression the card animates in: surfaces +//! that never animate pass [`CompanionPose::Static`]; the welcome panel passes +//! a live [`CompanionPose::Idle`] / [`CompanionPose::Loading`] whose frame +//! counter drives the card's subtle palette pulse. /// Pose / expression of the companion mascot. /// -/// Static is the resting frame, used by surfaces that never animate (F2 -/// switcher rows, `/agents` detail view). Idle and Loading carry a -/// monotonically-increasing frame counter: Idle drives the blink/sway idle -/// loop, Loading drives the eye-spinner animation. +/// `Static` is the resting frame, used by surfaces that never animate (F2 +/// switcher rows, `/agents` detail view). `Idle` and `Loading` carry a +/// monotonically-increasing frame counter: `Idle` drives the resting pulse, +/// `Loading` drives a faster pulse while the assistant is mid-turn and stalled. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CompanionPose { Static, Idle { frame: u64 }, Loading { frame: u64 }, } - -// ── Style helpers ──────────────────────────────────────────────────────────── - -fn body_style(palette: &FamiliarPalette) -> Style { - Style::default() - .fg(palette.primary) - .add_modifier(Modifier::BOLD) -} - -fn accent_style(palette: &FamiliarPalette) -> Style { - Style::default() - .fg(palette.accent) - .add_modifier(Modifier::BOLD) -} - -fn eye_bg_style(palette: &FamiliarPalette) -> Style { - Style::default() - .fg(palette.eye_socket) - .bg(palette.eye_bg) - .add_modifier(Modifier::BOLD) -} - -fn eyeball_style(palette: &FamiliarPalette) -> Style { - Style::default() - .fg(Color::White) - .bg(palette.eye_bg) - .add_modifier(Modifier::BOLD) -} - -// The event loop ticks at roughly 10 fps, so the 120-frame idle cycle is -// about 12 seconds: the glyph sways one column to the right for the second -// half of each cycle, and blinks (or sparks, for familiars without eyes) -// briefly inside that window. - -fn idle_blink(frame: u64) -> bool { - matches!(frame % 120, 90..=95) -} - -fn idle_sway(frame: u64) -> bool { - matches!(frame % 120, 60..=119) -} - -// ── Eye helpers ─────────────────────────────────────────────────────────────── - -fn eye_spans(palette: &FamiliarPalette, s: &'static str) -> Vec> { - let mut spans: Vec> = Vec::new(); - let mut buf = String::new(); - let mut buf_is_eyeball = false; - for ch in s.chars() { - let is_eyeball = matches!( - ch, - '\u{2598}' - | '\u{259d}' - | '\u{2580}' - | '\u{2584}' - | '\u{2596}' - | '\u{258c}' - | '\u{2590}' - ); - if is_eyeball != buf_is_eyeball && !buf.is_empty() { - spans.push(Span::styled( - buf.clone(), - if buf_is_eyeball { - eyeball_style(palette) - } else { - eye_bg_style(palette) - }, - )); - buf.clear(); - } - buf_is_eyeball = is_eyeball; - buf.push(ch); - } - if !buf.is_empty() { - spans.push(Span::styled( - buf, - if buf_is_eyeball { - eyeball_style(palette) - } else { - eye_bg_style(palette) - }, - )); - } - spans -} - -/// Five-cell eye row that rotates a quarter-block highlight clockwise. -/// Returned spans use the supplied palette for the dim trailing color so the -/// loader feels coherent with the rest of the glyph. -fn loading_eye_spans(palette: &FamiliarPalette, frame: u64) -> Vec> { - const QUARTERS: [char; 4] = ['\u{2598}', '\u{259d}', '\u{2597}', '\u{2596}']; - const CW: [usize; 4] = [0, 1, 2, 3]; - const CCW: [usize; 4] = [1, 0, 3, 2]; - let step = (frame / 5) as usize % 4; - let prev = (step + 3) % 4; - let trail = palette.primary; - let head = Color::White; - let bg = palette.eye_bg; - let bold = Modifier::BOLD; - vec![ - Span::styled( - QUARTERS[CW[prev]].to_string(), - Style::default().fg(trail).bg(bg).add_modifier(bold), - ), - Span::styled( - QUARTERS[CW[step]].to_string(), - Style::default().fg(head).bg(bg).add_modifier(bold), - ), - Span::styled("\u{2588}".to_string(), eye_bg_style(palette)), - Span::styled( - QUARTERS[CCW[step]].to_string(), - Style::default().fg(head).bg(bg).add_modifier(bold), - ), - Span::styled( - QUARTERS[CCW[prev]].to_string(), - Style::default().fg(trail).bg(bg).add_modifier(bold), - ), - ] -} - -// ── Per-archetype glyph builders ────────────────────────────────────────────── - -/// **Kitty** — cat head: pointy ears, square eyes, whisker nose. -fn kitty_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static>; 5] { - let row1 = Line::from(Span::styled( - " \u{2584}\u{2596} \u{2597}\u{2584}\u{2596} ".to_string(), - body_style(palette), - )); - let row2 = match pose { - CompanionPose::Idle { frame } if idle_blink(*frame) => Line::from(Span::styled( - " \u{2590}\u{2500} \u{2500}\u{2590}\u{258c} ".to_string(), - body_style(palette), - )), - CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( - " \u{2590}\u{25c8} \u{25c8}\u{2590}\u{258c} ".to_string(), - body_style(palette), - )), - CompanionPose::Loading { frame } => { - let mut spans = vec![Span::styled(" \u{2590}".to_string(), body_style(palette))]; - spans.extend(loading_eye_spans(palette, *frame)); - spans.push(Span::styled( - "\u{2590}\u{258c} ".to_string(), - body_style(palette), - )); - Line::from(spans) - } - }; - let row3 = Line::from(Span::styled( - " \u{2590}\u{258c} \u{1d25} \u{2590}\u{258c} ".to_string(), - body_style(palette), - )); - let row4 = Line::from(Span::styled( - " \u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580} ".to_string(), - body_style(palette), - )); - [row1, row2, row3, row4, Line::from("")] -} - -/// **Nova** — crown, hooded face, gem clasp, sparkle accents. -fn nova_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static>; 5] { - let row1 = Line::from(Span::styled( - " \u{00b7} \u{2726} \u{00b7} ".to_string(), - accent_style(palette), - )); - let row2 = match pose { - CompanionPose::Loading { frame } => { - let spin = ['\u{00b7}', '\u{2726}', '*', '\u{00b7}']; - let s = spin[(*frame / 5) as usize % 4]; - Line::from(Span::styled( - format!(" \u{2597}\u{2584}{}\u{2584}\u{2597}\u{2596} ", s), - body_style(palette), - )) - } - CompanionPose::Idle { frame } if idle_blink(*frame) => Line::from(Span::styled( - " \u{2597}\u{2584}\u{2726}\u{2584}\u{2597}\u{2596} ".to_string(), - body_style(palette), - )), - CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( - " \u{2597}\u{2584}\u{265b}\u{2584}\u{2597}\u{2596} ".to_string(), - body_style(palette), - )), - }; - let row3 = Line::from(Span::styled( - " \u{2590}\u{258c}\u{2588}\u{2588}\u{2588}\u{2590}\u{258c} ".to_string(), - body_style(palette), - )); - let row4 = Line::from(Span::styled( - " \u{25c6} \u{00b7} \u{25c6} ".to_string(), - accent_style(palette), - )); - [row1, row2, row3, row4, Line::from("")] -} - -/// **Cody** — robot programmer: antenna, bracket eyes, code body. -fn cody_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static>; 5] { - let row1 = Line::from(Span::styled( - " \u{2500}\u{253c}\u{2500} ".to_string(), - body_style(palette), - )); - let row2 = match pose { - CompanionPose::Loading { frame } => { - let anim = ['[', '(', '[', '<']; - let ch = anim[(*frame / 5) as usize % 4]; - Line::from(Span::styled( - format!(" \u{2584}\u{2584}[{ch} {ch}]\u{2584} "), - body_style(palette), - )) - } - CompanionPose::Idle { frame } if idle_blink(*frame) => Line::from(Span::styled( - " \u{2584}\u{2584}[\u{2500} \u{2500}]\u{2584} ".to_string(), - body_style(palette), - )), - CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( - " \u{2584}\u{2584}[\u{25c8} \u{25c8}]\u{2584} ".to_string(), - body_style(palette), - )), - }; - let row3 = Line::from(Span::styled( - " \u{258c} \u{2590} ".to_string(), - body_style(palette), - )); - let row4 = Line::from(Span::styled( - " \u{2584}\u{2588}\u{2588}\u{2588}\u{2588}\u{2584} ".to_string(), - body_style(palette), - )); - [row1, row2, row3, row4, Line::from("")] -} - -/// **Charm** — large pixel heart with sparkle dots. -fn charm_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static>; 5] { - let row1 = Line::from(Span::styled( - " \u{2584}\u{2588}\u{2588}\u{2584}\u{2584}\u{2588}\u{2588}\u{2584} ".to_string(), - body_style(palette), - )); - let row2 = match pose { - CompanionPose::Loading { frame } => { - let sparkle = ['\u{2726}', '\u{00b7}', '*', '\u{00b7}']; - let s = sparkle[(*frame / 5) as usize % 4]; - Line::from(Span::styled( - format!(" {s}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}{s} "), - body_style(palette), - )) - } - CompanionPose::Idle { frame } if idle_blink(*frame) => Line::from(Span::styled( - " \u{2726}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2726} " - .to_string(), - body_style(palette), - )), - CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( - " \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} " - .to_string(), - body_style(palette), - )), - }; - let row3 = Line::from(Span::styled( - " \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} ".to_string(), - body_style(palette), - )); - let row4 = Line::from(Span::styled( - " \u{2580}\u{2588}\u{2580} ".to_string(), - body_style(palette), - )); - [row1, row2, row3, row4, Line::from("")] -} - -/// **Sage** — wizard hat with star above an open spellbook. -fn sage_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static>; 5] { - let row1 = Line::from(Span::styled( - " \u{2597}\u{2584}\u{2596} ".to_string(), - body_style(palette), - )); - let row2 = Line::from(Span::styled( - " \u{2597}\u{2588}\u{2726}\u{2588}\u{2588}\u{2596} ".to_string(), - body_style(palette), - )); - let row3 = Line::from(Span::styled( - " \u{2584}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2584} ".to_string(), - body_style(palette), - )); - let row4 = match pose { - CompanionPose::Loading { frame } => { - let page = ['\u{2500}', '~', '\u{2500}', '~']; - let p = page[(*frame / 5) as usize % 4]; - Line::from(Span::styled( - format!(" \u{2590}{p}{p}\u{253c}{p}{p}\u{258c} "), - body_style(palette), - )) - } - CompanionPose::Idle { frame } if idle_blink(*frame) => Line::from(Span::styled( - " \u{2590}~~\u{253c}~~\u{258c} ".to_string(), - body_style(palette), - )), - CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( - " \u{2590}\u{2500}\u{2500}\u{253c}\u{2500}\u{2500}\u{258c} ".to_string(), - body_style(palette), - )), - }; - [row1, row2, row3, row4, Line::from("")] -} - -/// **Astra** — crescent moon with compass star and dotted orbit. -fn astra_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static>; 5] { - let row1 = Line::from(Span::styled( - " \u{2726} \u{00b7} ".to_string(), - accent_style(palette), - )); - let row2 = Line::from(Span::styled( - " \u{2597}\u{2588}\u{2588}\u{2588}\u{2588}\u{2596} ".to_string(), - body_style(palette), - )); - let row3 = match pose { - CompanionPose::Loading { frame } => { - let arcs = [ - " \u{2588} \u{2598} ", - " \u{2588} \u{00b7} ", - " \u{2588} \u{2598} ", - " \u{2588} \u{00b7} ", - ]; - Line::from(Span::styled( - arcs[(*frame / 5) as usize % 4].to_string(), - body_style(palette), - )) - } - CompanionPose::Idle { frame } if idle_blink(*frame) => Line::from(Span::styled( - " \u{2588} \u{00b7} ".to_string(), - body_style(palette), - )), - CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( - " \u{2588} \u{2726} ".to_string(), - body_style(palette), - )), - }; - let row4 = Line::from(Span::styled( - " \u{2580}\u{2584}\u{2584}\u{00b7} \u{00b7} ".to_string(), - accent_style(palette), - )); - [row1, row2, row3, row4, Line::from("")] -} - -/// **Echo** — round ghost with bracket eyes, blush smile, floaty dots. -fn echo_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static>; 5] { - let row1 = Line::from(Span::styled( - " \u{2584}\u{2588}\u{2588}\u{2588}\u{2588}\u{2584} ".to_string(), - body_style(palette), - )); - let row2 = match pose { - CompanionPose::Loading { frame } => { - let mut spans = vec![Span::styled(" \u{2588}[".to_string(), body_style(palette))]; - spans.extend(loading_eye_spans(palette, *frame)); - spans.push(Span::styled( - "]\u{2588} ".to_string(), - body_style(palette), - )); - Line::from(spans) - } - CompanionPose::Idle { frame } if idle_blink(*frame) => { - let mut spans = vec![Span::styled(" \u{2588}[".to_string(), body_style(palette))]; - spans.extend(eye_spans(palette, "\u{2500}\u{00b7}\u{2500}")); - spans.push(Span::styled( - "]\u{2588} ".to_string(), - body_style(palette), - )); - Line::from(spans) - } - CompanionPose::Static | CompanionPose::Idle { .. } => { - let mut spans = vec![Span::styled(" \u{2588}[".to_string(), body_style(palette))]; - spans.extend(eye_spans(palette, "\u{2580}\u{00b7}\u{2580}")); - spans.push(Span::styled( - "]\u{2588} ".to_string(), - body_style(palette), - )); - Line::from(spans) - } - }; - let row3 = Line::from(Span::styled( - " \u{2588} \u{203f} \u{2588} ".to_string(), - body_style(palette), - )); - let row4 = match pose { - CompanionPose::Loading { frame } => { - let dots = [ - " \u{2580}\u{2584}\u{2580}\u{2584}\u{2580} \u{00b7}\u{00b7}\u{00b7}", - " \u{2580}\u{2584}\u{2580}\u{2584}\u{2580} \u{00b7}\u{00b7} ", - " \u{2580}\u{2584}\u{2580}\u{2584}\u{2580} \u{00b7} ", - " \u{2580}\u{2584}\u{2580}\u{2584}\u{2580} ", - ]; - Line::from(Span::styled( - dots[(*frame / 8) as usize % 4].to_string(), - accent_style(palette), - )) - } - CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( - " \u{2580}\u{2584}\u{2580}\u{2584}\u{2580} \u{00b7}\u{00b7}\u{00b7}".to_string(), - accent_style(palette), - )), - }; - [row1, row2, row3, row4, Line::from("")] -} - -// ── Public API ──────────────────────────────────────────────────────────────── - -/// Render the 5-line glyph block for a built-in archetype. -/// -/// Sigil archetypes ([`Archetype::SigilCrystal`] etc.) are handled by -/// [`crate::familiar_card`] directly, so they will hit `kitty_lines` here as -/// a safe fallback if they ever route through this path by accident. -pub fn archetype_lines( - arch: Archetype, - palette: &FamiliarPalette, - pose: &CompanionPose, -) -> [Line<'static>; 5] { - let mut lines = match arch { - Archetype::Cat => kitty_lines(palette, pose), - Archetype::SorceressCrown => nova_lines(palette, pose), - Archetype::Robot => cody_lines(palette, pose), - Archetype::Heart => charm_lines(palette, pose), - Archetype::WizardBook => sage_lines(palette, pose), - Archetype::Moon => astra_lines(palette, pose), - Archetype::Ghost => echo_lines(palette, pose), - Archetype::SigilCrystal - | Archetype::SigilHex - | Archetype::SigilRune - | Archetype::SigilSeal => kitty_lines(palette, pose), - }; - // Idle sway: drift the whole glyph one column right for half of each - // idle cycle. Card layouts pad rows to a fixed width, so the shift only - // moves the glyph inside its slot. - if let CompanionPose::Idle { frame } = pose { - if idle_sway(*frame) { - for line in lines.iter_mut() { - line.spans.insert(0, Span::raw(" ")); - } - } - } - lines -} - -/// Resolve a familiar slug to its theme and render the glyph block. -/// -/// Newer UI surfaces usually go through [`crate::familiar_card::render_card`], -/// but this helper remains useful for tests and compact callers that already -/// know they only need the raw glyph lines. -pub fn mascot_lines_for(familiar: Option<&str>, pose: &CompanionPose) -> [Line<'static>; 5] { - let id = familiar.unwrap_or("kitty"); - let theme = crate::familiar_theme::resolve(id, &[]); - archetype_lines(theme.archetype, &theme.palette, pose) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::familiar_theme; - - fn line_text(line: &Line<'_>) -> String { - line.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::>() - .join("") - } - - #[test] - fn static_pose_renders_all_familiars() { - let familiars = ["kitty", "nova", "cody", "charm", "sage", "astra", "echo"]; - for fam in &familiars { - let lines = mascot_lines_for(Some(fam), &CompanionPose::Static); - assert_eq!(lines.len(), 5, "familiar {fam} should produce 5 rows"); - let row0 = line_text(&lines[0]); - assert!( - !row0.trim().is_empty(), - "familiar {fam} row 0 should not be blank: {row0:?}" - ); - } - } - - #[test] - fn loading_pose_drives_frame_dependent_output() { - // Different frame values should produce at least one frame where the - // visible text differs, proving the spinner is actually frame-driven. - let a = mascot_lines_for(Some("kitty"), &CompanionPose::Loading { frame: 0 }); - let b = mascot_lines_for(Some("kitty"), &CompanionPose::Loading { frame: 5 }); - let txt_a = line_text(&a[1]); - let txt_b = line_text(&b[1]); - assert_ne!(txt_a, txt_b, "loading row should differ between frames"); - } - - #[test] - fn idle_pose_blinks_between_frames() { - let awake = mascot_lines_for(Some("kitty"), &CompanionPose::Idle { frame: 0 }); - let blink = mascot_lines_for(Some("kitty"), &CompanionPose::Idle { frame: 90 }); - - assert_ne!( - line_text(&awake[1]), - line_text(&blink[1]), - "idle row should blink instead of staying completely static" - ); - } - - #[test] - fn idle_pose_sways_between_frames() { - // Frame 0 sits in the resting half of the idle cycle, frame 60 in the - // swayed half: every row gains a one-column shift. - let rest = mascot_lines_for(Some("sage"), &CompanionPose::Idle { frame: 0 }); - let sway = mascot_lines_for(Some("sage"), &CompanionPose::Idle { frame: 60 }); - assert_eq!(format!(" {}", line_text(&rest[0])), line_text(&sway[0])); - } - - #[test] - fn unknown_familiar_falls_back_to_kitty() { - let a = mascot_lines_for(Some("unknown_xxx"), &CompanionPose::Static); - let b = mascot_lines_for(Some("kitty"), &CompanionPose::Static); - assert_eq!(line_text(&a[0]), line_text(&b[0])); - } - - #[test] - fn none_familiar_falls_back_to_kitty() { - let a = mascot_lines_for(None, &CompanionPose::Static); - let b = mascot_lines_for(Some("kitty"), &CompanionPose::Static); - assert_eq!(line_text(&a[0]), line_text(&b[0])); - } - - #[test] - fn archetype_dispatcher_respects_palette() { - // Different palettes produce spans whose styles differ; the glyph - // shape itself stays the same. - let theme_a = familiar_theme::resolve("kitty", &[]); - let theme_b = familiar_theme::resolve("nova", &[]); - let a = archetype_lines(theme_a.archetype, &theme_a.palette, &CompanionPose::Static); - let b = archetype_lines(theme_b.archetype, &theme_b.palette, &CompanionPose::Static); - // Distinct archetypes → distinct row 0 content. - assert_ne!(line_text(&a[0]), line_text(&b[0])); - } -} diff --git a/src-rust/crates/tui/src/render.rs b/src-rust/crates/tui/src/render.rs index 39ec32e..722ce21 100644 --- a/src-rust/crates/tui/src/render.rs +++ b/src-rust/crates/tui/src/render.rs @@ -3367,7 +3367,7 @@ mod welcome_tests { std::fs::create_dir_all(&coven_home).expect("coven home dir"); let _guard = EnvGuard::set(&home, &coven_home); - let app = make_test_app_with_model_and_familiar(None, None, Some("sage"), None); + let app = make_test_app_with_model_and_familiar(None, None, Some("wisp"), None); assert_eq!(welcome_familiar_label(&app), "Familiar: none"); } @@ -3380,13 +3380,13 @@ mod welcome_tests { std::fs::create_dir_all(&coven_home).expect("coven home dir"); std::fs::write( coven_home.join("familiars.toml"), - "[[familiar]]\nid = \"sage\"\nemoji = \"🌿\"\n", + "[[familiar]]\nid = \"wisp\"\nemoji = \"🌿\"\n", ) .expect("familiar roster"); let _guard = EnvGuard::set(&home, &coven_home); - let app = make_test_app_with_model_and_familiar(None, None, Some("sage"), None); - assert_eq!(welcome_familiar_label(&app), "Familiar: sage"); + let app = make_test_app_with_model_and_familiar(None, None, Some("wisp"), None); + assert_eq!(welcome_familiar_label(&app), "Familiar: wisp"); } #[test]