diff --git a/codex-rs/tui/pets/assets/bsod-spritesheet-v3.webp b/codex-rs/tui/pets/assets/bsod-spritesheet-v3.webp new file mode 100644 index 0000000000..9d5d945ab1 Binary files /dev/null and b/codex-rs/tui/pets/assets/bsod-spritesheet-v3.webp differ diff --git a/codex-rs/tui/pets/assets/codex-spritesheet-v3.webp b/codex-rs/tui/pets/assets/codex-spritesheet-v3.webp new file mode 100644 index 0000000000..58aa88bdad Binary files /dev/null and b/codex-rs/tui/pets/assets/codex-spritesheet-v3.webp differ diff --git a/codex-rs/tui/pets/assets/dewey-spritesheet-v3.webp b/codex-rs/tui/pets/assets/dewey-spritesheet-v3.webp new file mode 100644 index 0000000000..fb67365394 Binary files /dev/null and b/codex-rs/tui/pets/assets/dewey-spritesheet-v3.webp differ diff --git a/codex-rs/tui/pets/assets/fireball-spritesheet-v3.webp b/codex-rs/tui/pets/assets/fireball-spritesheet-v3.webp new file mode 100644 index 0000000000..39c0ac853f Binary files /dev/null and b/codex-rs/tui/pets/assets/fireball-spritesheet-v3.webp differ diff --git a/codex-rs/tui/pets/assets/null-signal-spritesheet-v3.webp b/codex-rs/tui/pets/assets/null-signal-spritesheet-v3.webp new file mode 100644 index 0000000000..abf5cc5bf8 Binary files /dev/null and b/codex-rs/tui/pets/assets/null-signal-spritesheet-v3.webp differ diff --git a/codex-rs/tui/pets/assets/rocky-spritesheet-v3.webp b/codex-rs/tui/pets/assets/rocky-spritesheet-v3.webp new file mode 100644 index 0000000000..5ac773ef8c Binary files /dev/null and b/codex-rs/tui/pets/assets/rocky-spritesheet-v3.webp differ diff --git a/codex-rs/tui/pets/assets/seedy-spritesheet-v3.webp b/codex-rs/tui/pets/assets/seedy-spritesheet-v3.webp new file mode 100644 index 0000000000..b1478379c8 Binary files /dev/null and b/codex-rs/tui/pets/assets/seedy-spritesheet-v3.webp differ diff --git a/codex-rs/tui/pets/assets/stacky-spritesheet-v3.webp b/codex-rs/tui/pets/assets/stacky-spritesheet-v3.webp new file mode 100644 index 0000000000..06a44453e7 Binary files /dev/null and b/codex-rs/tui/pets/assets/stacky-spritesheet-v3.webp differ diff --git a/codex-rs/tui/pets/boba/pet.json b/codex-rs/tui/pets/boba/pet.json deleted file mode 100644 index f9944dc72c..0000000000 --- a/codex-rs/tui/pets/boba/pet.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "id": "boba", - "displayName": "Boba", - "description": "A tiny otter sipping bubble tea while keeping you company in Codex.", - "spritesheetPath": "spritesheet.webp", - "frame": { - "width": 192, - "height": 208, - "columns": 8, - "rows": 9 - }, - "animations": { - "idle": { - "frames": [0, 1, 2, 3, 4, 5], - "fps": 5 - }, - "move_left": { - "frames": [8, 9, 10, 11, 12, 13, 14, 15], - "fps": 10 - }, - "move_right": { - "frames": [16, 17, 18, 19, 20, 21, 22, 23], - "fps": 10 - }, - "wave": { - "frames": [24, 25, 26, 27], - "fps": 7, - "loop": false, - "fallback": "idle" - }, - "sit": { - "frames": [32, 33, 34, 35, 36], - "fps": 6 - }, - "sad": { - "frames": [40, 41, 42, 43, 44, 45, 46], - "fps": 6 - }, - "sleep": { - "frames": [43, 44, 47], - "fps": 3 - }, - "sip": { - "frames": [48, 49, 50, 51, 52, 53], - "fps": 8, - "loop": false, - "fallback": "idle" - }, - "bounce": { - "frames": [56, 57, 58, 59, 60, 61], - "fps": 9, - "loop": false, - "fallback": "idle" - }, - "grumpy": { - "frames": [64, 65, 66, 67, 68, 69], - "fps": 6, - "loop": false, - "fallback": "idle" - } - } -} diff --git a/codex-rs/tui/pets/boba/spritesheet.webp b/codex-rs/tui/pets/boba/spritesheet.webp deleted file mode 100644 index 83ebad4ab1..0000000000 Binary files a/codex-rs/tui/pets/boba/spritesheet.webp and /dev/null differ diff --git a/codex-rs/tui/pets/codex/pet.json b/codex-rs/tui/pets/codex/pet.json deleted file mode 100644 index 7669cbebce..0000000000 --- a/codex-rs/tui/pets/codex/pet.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "id": "codex", - "displayName": "Codex", - "description": "The original Codex companion.", - "spritesheetPath": "spritesheet.webp", - "frame": { - "width": 192, - "height": 208, - "columns": 8, - "rows": 9 - }, - "animations": { - "idle": { "frames": [0, 1, 2, 3, 4, 5], "fps": 1.5 }, - "move_right": { "frames": [8, 9, 10, 11, 12, 13, 14, 15], "fps": 8 }, - "move_left": { "frames": [16, 17, 18, 19, 20, 21, 22, 23], "fps": 8 }, - "wave": { "frames": [24, 25, 26, 27], "fps": 7, "loop": false, "fallback": "idle" }, - "bounce": { "frames": [32, 33, 34, 35, 36], "fps": 7, "loop": false, "fallback": "idle" }, - "failed": { "frames": [40, 41, 42, 43, 44, 45, 46, 47], "fps": 7 }, - "waiting": { "frames": [48, 49, 50, 51, 52, 53], "fps": 6 }, - "running": { "frames": [56, 57, 58, 59, 60, 61], "fps": 8 }, - "review": { "frames": [64, 65, 66, 67, 68, 69], "fps": 6 } - } -} diff --git a/codex-rs/tui/pets/codex/spritesheet.webp b/codex-rs/tui/pets/codex/spritesheet.webp deleted file mode 100644 index 8979a9eab6..0000000000 Binary files a/codex-rs/tui/pets/codex/spritesheet.webp and /dev/null differ diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap index 0ac0e91bcc..43d3f20d4c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap @@ -6,9 +6,13 @@ expression: popup Choose a pet to wake in the terminal. Type to filter pets... - Boba A tiny otter sipping bubble tea while keeping you company - in Codex. + BSOD A tiny blue-screen gremlin. › Codex (current) The original Codex companion. + Dewey A tidy duck for calm workspace days. + Fireball Hot path energy for fast iteration. None Disable terminal pets. + Null Signal Quiet signal from the void. + Rocky A steady rock when the diff gets large. + Seedy Small green shoots for new ideas. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/pets/ambient.rs b/codex-rs/tui/src/pets/ambient.rs index 290a18cbcd..efa7333b8f 100644 --- a/codex-rs/tui/src/pets/ambient.rs +++ b/codex-rs/tui/src/pets/ambient.rs @@ -125,9 +125,11 @@ impl AmbientPet { codex_home: &std::path::Path, frame_requester: FrameRequester, ) -> Result { - let pet = - Pet::load_with_codex_home(selected_pet.unwrap_or(DEFAULT_PET_ID), Some(codex_home)) - .with_context(|| "load ambient pet")?; + let pet = Pet::load_with_codex_home( + selected_pet.unwrap_or(DEFAULT_PET_ID), + /*codex_home*/ Some(codex_home), + ) + .with_context(|| "load ambient pet")?; let cache_dir = frames::cache_dir().join("tui-pets").join(&pet.id); let frame_dir = cache_dir.join("frames"); let sixel_dir = cache_dir.join("sixel"); diff --git a/codex-rs/tui/src/pets/catalog.rs b/codex-rs/tui/src/pets/catalog.rs new file mode 100644 index 0000000000..a4febdde46 --- /dev/null +++ b/codex-rs/tui/src/pets/catalog.rs @@ -0,0 +1,81 @@ +//! Built-in pet catalog ported from the Codex App avatar catalog. + +use std::path::Path; +use std::path::PathBuf; + +pub(super) const DEFAULT_FRAME_WIDTH: u32 = 192; +pub(super) const DEFAULT_FRAME_HEIGHT: u32 = 208; +pub(super) const DEFAULT_FRAME_COLUMNS: u32 = 8; +pub(super) const DEFAULT_FRAME_ROWS: u32 = 9; +pub(super) const SPRITESHEET_WIDTH: u32 = DEFAULT_FRAME_WIDTH * DEFAULT_FRAME_COLUMNS; +pub(super) const SPRITESHEET_HEIGHT: u32 = DEFAULT_FRAME_HEIGHT * DEFAULT_FRAME_ROWS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct BuiltinPet { + pub(super) id: &'static str, + pub(super) display_name: &'static str, + pub(super) description: &'static str, + pub(super) spritesheet_file: &'static str, +} + +pub(super) const BUILTIN_PETS: &[BuiltinPet] = &[ + BuiltinPet { + id: "codex", + display_name: "Codex", + description: "The original Codex companion.", + spritesheet_file: "codex-spritesheet-v3.webp", + }, + BuiltinPet { + id: "dewey", + display_name: "Dewey", + description: "A tidy duck for calm workspace days.", + spritesheet_file: "dewey-spritesheet-v3.webp", + }, + BuiltinPet { + id: "fireball", + display_name: "Fireball", + description: "Hot path energy for fast iteration.", + spritesheet_file: "fireball-spritesheet-v3.webp", + }, + BuiltinPet { + id: "rocky", + display_name: "Rocky", + description: "A steady rock when the diff gets large.", + spritesheet_file: "rocky-spritesheet-v3.webp", + }, + BuiltinPet { + id: "seedy", + display_name: "Seedy", + description: "Small green shoots for new ideas.", + spritesheet_file: "seedy-spritesheet-v3.webp", + }, + BuiltinPet { + id: "stacky", + display_name: "Stacky", + description: "A balanced stack for deep work.", + spritesheet_file: "stacky-spritesheet-v3.webp", + }, + BuiltinPet { + id: "bsod", + display_name: "BSOD", + description: "A tiny blue-screen gremlin.", + spritesheet_file: "bsod-spritesheet-v3.webp", + }, + BuiltinPet { + id: "null-signal", + display_name: "Null Signal", + description: "Quiet signal from the void.", + spritesheet_file: "null-signal-spritesheet-v3.webp", + }, +]; + +pub(super) fn builtin_pet(id: &str) -> Option { + BUILTIN_PETS.iter().copied().find(|pet| pet.id == id) +} + +pub(super) fn builtin_spritesheet_path(file: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("pets") + .join("assets") + .join(file) +} diff --git a/codex-rs/tui/src/pets/mod.rs b/codex-rs/tui/src/pets/mod.rs index e0df3a944d..478d382e47 100644 --- a/codex-rs/tui/src/pets/mod.rs +++ b/codex-rs/tui/src/pets/mod.rs @@ -1,18 +1,21 @@ //! Ambient terminal pets configured from the /pets slash command. //! -//! The Codex app stores custom pets under $CODEX_HOME/pets//pet.json. +//! The Codex app stores default pets as bundled spritesheet assets and custom pets under +//! $CODEX_HOME/pets//pet.json. //! This module keeps that package shape intact while rendering the selected pet inline in the TUI. use std::io::Write; -use anyhow::Context; -use anyhow::Result; mod ambient; +mod catalog; mod frames; mod image_protocol; mod model; mod picker; +use anyhow::Context; +use anyhow::Result; + pub(crate) use ambient::AmbientPet; pub(crate) use ambient::AmbientPetDraw; pub(crate) use ambient::PetNotificationKind; diff --git a/codex-rs/tui/src/pets/model.rs b/codex-rs/tui/src/pets/model.rs index d0cd0d67b5..7d851ac448 100644 --- a/codex-rs/tui/src/pets/model.rs +++ b/codex-rs/tui/src/pets/model.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::fs; +use std::path::Component; use std::path::Path; use std::path::PathBuf; @@ -8,6 +9,8 @@ use anyhow::Result; use anyhow::bail; use serde::Deserialize; +use super::catalog; + #[derive(Debug, Clone)] pub struct Animation { pub frames: Vec, @@ -30,58 +33,20 @@ pub struct Pet { } impl Pet { - pub(super) fn load(value: &str) -> Result { - Self::load_with_codex_home( - value, - crate::legacy_core::config::find_codex_home() - .ok() - .as_deref(), - ) - } - pub(super) fn load_with_codex_home(value: &str, codex_home: Option<&Path>) -> Result { - let pet_dir = resolve_pet_dir(value, codex_home)?; - let config_path = pet_dir.join("pet.json"); - let raw = fs::read_to_string(&config_path) - .with_context(|| format!("read {}", config_path.display()))?; - let mut file: PetFile = serde_json::from_str(&raw) - .with_context(|| format!("parse {}", config_path.display()))?; - - if file.id.is_empty() { - file.id = pet_dir - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("pet") - .to_string(); - } - if file.display_name.is_empty() { - file.display_name.clone_from(&file.id); - } - if file.spritesheet_path.is_empty() { - file.spritesheet_path = "spritesheet.webp".to_string(); + if path_like(value) { + return load_pet_path(value); } - let frame = file.frame.unwrap_or_default(); - let spritesheet_path = if Path::new(&file.spritesheet_path).is_absolute() { - PathBuf::from(&file.spritesheet_path) - } else { - pet_dir.join(&file.spritesheet_path) - }; - if !spritesheet_path.exists() { - bail!("missing spritesheet {}", spritesheet_path.display()); + if let Some(custom_id) = value.strip_prefix(CUSTOM_PET_PREFIX) { + return load_custom_pet(custom_id, codex_home); } - Ok(Self { - id: file.id, - display_name: file.display_name, - description: file.description, - spritesheet_path, - frame_width: frame.width, - frame_height: frame.height, - columns: frame.columns, - rows: frame.rows, - animations: load_animations(file.animations), - }) + if let Some(builtin) = catalog::builtin_pet(value) { + return load_builtin_pet(builtin); + } + + load_custom_pet(value, codex_home) } pub fn frame_count(&self) -> usize { @@ -89,16 +54,18 @@ impl Pet { } } +pub(super) const CUSTOM_PET_PREFIX: &str = "custom:"; + #[derive(Debug, Deserialize)] struct PetFile { #[serde(default)] - id: String, + id: Option, #[serde(default, rename = "displayName")] - display_name: String, + display_name: Option, #[serde(default)] - description: String, + description: Option, #[serde(default, rename = "spritesheetPath")] - spritesheet_path: String, + spritesheet_path: Option, frame: Option, #[serde(default)] animations: HashMap, @@ -115,14 +82,18 @@ struct FrameSpec { impl Default for FrameSpec { fn default() -> Self { Self { - width: 192, - height: 208, - columns: 8, - rows: 9, + width: catalog::DEFAULT_FRAME_WIDTH, + height: catalog::DEFAULT_FRAME_HEIGHT, + columns: catalog::DEFAULT_FRAME_COLUMNS, + rows: catalog::DEFAULT_FRAME_ROWS, } } } +pub(super) fn custom_pet_selector(id: &str) -> String { + format!("{CUSTOM_PET_PREFIX}{id}") +} + #[derive(Debug, Deserialize)] struct AnimationSpec { #[serde(default)] @@ -134,37 +105,160 @@ struct AnimationSpec { fallback: String, } -fn resolve_pet_dir(value: &str, codex_home: Option<&Path>) -> Result { - if path_like(value) { - let path = expand_path(value)?; - let metadata = - fs::metadata(&path).with_context(|| format!("pet path {}", path.display()))?; - let dir = if metadata.is_dir() { - path - } else { - path.parent() - .context("pet json path has no containing directory")? - .to_path_buf() - }; - return dir - .canonicalize() - .with_context(|| format!("resolve {}", dir.display())); +fn load_builtin_pet(pet: catalog::BuiltinPet) -> Result { + let spritesheet_path = catalog::builtin_spritesheet_path(pet.spritesheet_file); + if !spritesheet_path.exists() { + bail!("missing spritesheet {}", spritesheet_path.display()); } - Ok(resolve_named_pet_dir(value, codex_home)) + Ok(Pet { + id: pet.id.to_string(), + display_name: pet.display_name.to_string(), + description: pet.description.to_string(), + spritesheet_path, + frame_width: catalog::DEFAULT_FRAME_WIDTH, + frame_height: catalog::DEFAULT_FRAME_HEIGHT, + columns: catalog::DEFAULT_FRAME_COLUMNS, + rows: catalog::DEFAULT_FRAME_ROWS, + animations: default_animations(), + }) } -fn resolve_named_pet_dir(value: &str, codex_home: Option<&Path>) -> PathBuf { - if let Some(codex_home) = codex_home { - let installed_pet = codex_home.join("pets").join(value); - if installed_pet.join("pet.json").is_file() { - return installed_pet; - } +fn load_custom_pet(value: &str, codex_home: Option<&Path>) -> Result { + let codex_home = codex_home.context("CODEX_HOME is not available")?; + let pet_dir = codex_home.join("pets").join(value); + if pet_dir.join("pet.json").is_file() { + return load_pet_manifest(&pet_dir, "pet.json", value, &custom_pet_cache_id(value)); } - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("pets") - .join(value) + let avatar_dir = codex_home.join("avatars").join(value); + if avatar_dir.join("avatar.json").is_file() { + return load_pet_manifest( + &avatar_dir, + "avatar.json", + value, + &custom_pet_cache_id(value), + ); + } + + bail!("unknown pet {value}") +} + +fn load_pet_path(value: &str) -> Result { + let path = expand_path(value)?; + let metadata = fs::metadata(&path).with_context(|| format!("pet path {}", path.display()))?; + let dir = if metadata.is_dir() { + path + } else { + path.parent() + .context("pet json path has no containing directory")? + .to_path_buf() + }; + let pet_dir = dir + .canonicalize() + .with_context(|| format!("resolve {}", dir.display()))?; + let manifest_file = if pet_dir.join("pet.json").is_file() { + "pet.json" + } else if pet_dir.join("avatar.json").is_file() { + "avatar.json" + } else { + bail!("missing pet.json or avatar.json in {}", pet_dir.display()); + }; + let fallback_id = pet_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("pet"); + load_pet_manifest(&pet_dir, manifest_file, fallback_id, fallback_id) +} + +fn load_pet_manifest( + pet_dir: &Path, + manifest_file: &str, + fallback_id: &str, + cache_id: &str, +) -> Result { + let config_path = pet_dir.join(manifest_file); + let raw = fs::read_to_string(&config_path) + .with_context(|| format!("read {}", config_path.display()))?; + let file: PetFile = + serde_json::from_str(&raw).with_context(|| format!("parse {}", config_path.display()))?; + + let manifest_id = file + .id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()); + let display_name = file + .display_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .or(manifest_id) + .unwrap_or(fallback_id) + .to_string(); + let pet_id = if cache_id == fallback_id { + manifest_id.unwrap_or(fallback_id).to_string() + } else { + cache_id.to_string() + }; + let description = file + .description + .map(|description| description.trim().to_string()) + .unwrap_or_default(); + let spritesheet_path = resolve_spritesheet_path( + pet_dir, + file.spritesheet_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .unwrap_or("spritesheet.webp"), + )?; + if !spritesheet_path.exists() { + bail!("missing spritesheet {}", spritesheet_path.display()); + } + validate_app_spritesheet_dimensions(&spritesheet_path)?; + + let frame = file.frame.unwrap_or_default(); + Ok(Pet { + id: pet_id, + display_name, + description, + spritesheet_path, + frame_width: frame.width, + frame_height: frame.height, + columns: frame.columns, + rows: frame.rows, + animations: load_animations(file.animations), + }) +} + +fn resolve_spritesheet_path(pet_dir: &Path, spritesheet_path: &str) -> Result { + let path = Path::new(spritesheet_path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_))) + { + bail!("spritesheet path must stay inside {}", pet_dir.display()); + } + Ok(pet_dir.join(path)) +} + +fn validate_app_spritesheet_dimensions(path: &Path) -> Result<()> { + let (width, height) = + image::image_dimensions(path).with_context(|| format!("read {}", path.display()))?; + if width != catalog::SPRITESHEET_WIDTH || height != catalog::SPRITESHEET_HEIGHT { + bail!( + "spritesheet must be {}x{} pixels", + catalog::SPRITESHEET_WIDTH, + catalog::SPRITESHEET_HEIGHT + ); + } + Ok(()) +} + +fn custom_pet_cache_id(id: &str) -> String { + format!("custom-{id}") } fn path_like(value: &str) -> bool { @@ -277,8 +371,6 @@ fn idle_animation() -> Animation { #[cfg(test)] mod tests { - use std::io::Write; - use super::*; fn write_minimal_pet() -> tempfile::TempDir { @@ -293,18 +385,40 @@ mod tests { }"#, ) .unwrap(); - fs::File::create(dir.path().join("spritesheet.webp")) - .unwrap() - .write_all(b"not-used-by-loader") - .unwrap(); + fs::copy( + catalog::builtin_spritesheet_path("codex-spritesheet-v3.webp"), + dir.path().join("spritesheet.webp"), + ) + .unwrap(); dir } #[test] - fn load_pet_directory_uses_installed_pet_defaults() { + fn load_builtin_pet_uses_app_catalog_storage() { + let codex_home = tempfile::tempdir().unwrap(); + + let pet = + Pet::load_with_codex_home("dewey", /*codex_home*/ Some(codex_home.path())).unwrap(); + + assert_eq!(pet.id, "dewey"); + assert_eq!(pet.display_name, "Dewey"); + assert_eq!(pet.description, "A tidy duck for calm workspace days."); + assert_eq!( + pet.spritesheet_path, + catalog::builtin_spritesheet_path("dewey-spritesheet-v3.webp") + ); + assert_eq!(pet.frame_width, 192); + assert_eq!(pet.frame_height, 208); + assert_eq!(pet.columns, 8); + assert_eq!(pet.rows, 9); + } + + #[test] + fn load_pet_directory_uses_app_pet_manifest_defaults() { let dir = write_minimal_pet(); - let pet = Pet::load(dir.path().to_str().unwrap()).unwrap(); + let pet = + Pet::load_with_codex_home(dir.path().to_str().unwrap(), /*codex_home*/ None).unwrap(); assert_eq!(pet.id, "chefito"); assert_eq!(pet.display_name, "Chefito"); @@ -319,14 +433,18 @@ mod tests { fn load_pet_json_path_uses_containing_directory() { let dir = write_minimal_pet(); - let pet = Pet::load(dir.path().join("pet.json").to_str().unwrap()).unwrap(); + let pet = Pet::load_with_codex_home( + dir.path().join("pet.json").to_str().unwrap(), + /*codex_home*/ None, + ) + .unwrap(); let expected = dir.path().join("spritesheet.webp").canonicalize().unwrap(); assert_eq!(pet.spritesheet_path, expected); } #[test] - fn named_pet_prefers_codex_home_installation() { + fn custom_pet_selector_loads_codex_home_pet_manifest() { let dir = write_minimal_pet(); let codex_home = tempfile::tempdir().unwrap(); let pet_dir = codex_home.path().join("pets").join("chefito"); @@ -338,9 +456,62 @@ mod tests { ) .unwrap(); - let pet = Pet::load_with_codex_home("chefito", Some(codex_home.path())).unwrap(); + let pet = Pet::load_with_codex_home( + &custom_pet_selector("chefito"), + /*codex_home*/ Some(codex_home.path()), + ) + .unwrap(); - assert_eq!(pet.id, "chefito"); + assert_eq!(pet.id, "custom-chefito"); assert_eq!(pet.spritesheet_path, pet_dir.join("spritesheet.webp"),); } + + #[test] + fn custom_pet_selector_falls_back_to_legacy_avatar_manifest() { + let dir = write_minimal_pet(); + let codex_home = tempfile::tempdir().unwrap(); + let avatar_dir = codex_home.path().join("avatars").join("legacy"); + fs::create_dir_all(&avatar_dir).unwrap(); + fs::copy(dir.path().join("pet.json"), avatar_dir.join("avatar.json")).unwrap(); + fs::copy( + dir.path().join("spritesheet.webp"), + avatar_dir.join("spritesheet.webp"), + ) + .unwrap(); + + let pet = Pet::load_with_codex_home( + &custom_pet_selector("legacy"), + /*codex_home*/ Some(codex_home.path()), + ) + .unwrap(); + + assert_eq!(pet.id, "custom-legacy"); + assert_eq!(pet.display_name, "Chefito"); + } + + #[test] + fn custom_pet_rejects_spritesheet_path_escape() { + let codex_home = tempfile::tempdir().unwrap(); + let pet_dir = codex_home.path().join("pets").join("escape"); + fs::create_dir_all(&pet_dir).unwrap(); + fs::write( + pet_dir.join("pet.json"), + r#"{ + "displayName": "Escape", + "spritesheetPath": "../spritesheet.webp" + }"#, + ) + .unwrap(); + + let err = Pet::load_with_codex_home( + &custom_pet_selector("escape"), + /*codex_home*/ Some(codex_home.path()), + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("spritesheet path must stay inside") + ); + } } diff --git a/codex-rs/tui/src/pets/picker.rs b/codex-rs/tui/src/pets/picker.rs index 9e59d8e441..101b418066 100644 --- a/codex-rs/tui/src/pets/picker.rs +++ b/codex-rs/tui/src/pets/picker.rs @@ -1,5 +1,6 @@ //! Builds the /pets picker dialog for the TUI. +use std::collections::HashMap; use std::fs; use std::path::Path; @@ -11,11 +12,15 @@ use crate::bottom_pane::popup_consts::standard_popup_hint_line; use super::DEFAULT_PET_ID; use super::DISABLED_PET_ID; +use super::catalog; +use super::model::CUSTOM_PET_PREFIX; use super::model::Pet; +use super::model::custom_pet_selector; #[derive(Debug, Clone, PartialEq, Eq)] struct PetPickerEntry { selector: String, + legacy_selector: Option, display_name: String, description: Option, } @@ -33,7 +38,8 @@ pub(crate) fn build_pet_picker_params( .into_iter() .enumerate() .map(|(idx, entry)| { - let is_current = current_pet == entry.selector; + let is_current = current_pet == entry.selector + || entry.legacy_selector.as_deref() == Some(current_pet); if is_current { initial_selected_idx = Some(idx); } @@ -74,70 +80,65 @@ pub(crate) fn build_pet_picker_params( } fn available_pet_entries(codex_home: &Path) -> Vec { - let mut entries = vec![ - pet_picker_entry(DEFAULT_PET_ID), - pet_picker_entry("boba"), - PetPickerEntry { - selector: DISABLED_PET_ID.to_string(), - display_name: "None".to_string(), - description: Some("Disable terminal pets.".to_string()), - }, - ]; - let pets_dir = codex_home.join("pets"); - let Ok(children) = fs::read_dir(pets_dir) else { - return entries; - }; - - for child in children.flatten() { - let path = child.path(); - if !path.join("pet.json").is_file() { - continue; - } - let Some(selector) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if selector == DISABLED_PET_ID { - continue; - } - entries.push(pet_picker_entry_from_path(selector, &path)); - } + let mut entries = catalog::BUILTIN_PETS + .iter() + .map(|pet| PetPickerEntry { + selector: pet.id.to_string(), + legacy_selector: None, + display_name: pet.display_name.to_string(), + description: Some(pet.description.to_string()), + }) + .collect::>(); + entries.push(PetPickerEntry { + selector: DISABLED_PET_ID.to_string(), + legacy_selector: None, + display_name: "None".to_string(), + description: Some("Disable terminal pets.".to_string()), + }); + entries.extend(custom_pet_entries(codex_home)); entries } -fn pet_picker_entry(selector: &str) -> PetPickerEntry { - match Pet::load(selector) { - Ok(pet) => PetPickerEntry { - selector: selector.to_string(), - display_name: pet.display_name, - description: (!pet.description.is_empty()).then_some(pet.description), - }, - Err(_) => PetPickerEntry { - selector: selector.to_string(), - display_name: selector.to_string(), - description: None, - }, +fn custom_pet_entries(codex_home: &Path) -> Vec { + let mut entries_by_selector = HashMap::new(); + for (directory_name, manifest_file) in [("avatars", "avatar.json"), ("pets", "pet.json")] { + let Ok(children) = fs::read_dir(codex_home.join(directory_name)) else { + continue; + }; + for child in children.flatten() { + let path = child.path(); + if !path.join(manifest_file).is_file() { + continue; + } + let Some(id) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if id == DISABLED_PET_ID || id.starts_with(CUSTOM_PET_PREFIX) { + continue; + } + let selector = custom_pet_selector(id); + let Ok(pet) = + Pet::load_with_codex_home(&selector, /*codex_home*/ Some(codex_home)) + else { + continue; + }; + entries_by_selector.insert( + selector.clone(), + PetPickerEntry { + selector, + legacy_selector: Some(id.to_string()), + display_name: pet.display_name, + description: (!pet.description.is_empty()).then_some(pet.description), + }, + ); + } } -} -fn pet_picker_entry_from_path(selector: &str, path: &Path) -> PetPickerEntry { - match Pet::load(path.to_string_lossy().as_ref()) { - Ok(pet) => PetPickerEntry { - selector: selector.to_string(), - display_name: pet.display_name, - description: (!pet.description.is_empty()).then_some(pet.description), - }, - Err(_) => PetPickerEntry { - selector: selector.to_string(), - display_name: selector.to_string(), - description: None, - }, - } + entries_by_selector.into_values().collect() } #[cfg(test)] mod tests { - use std::io::Write; - use super::*; fn write_pet(dir: &Path, folder_name: &str, display_name: &str) { @@ -155,14 +156,36 @@ mod tests { ), ) .unwrap(); - fs::File::create(pet_dir.join("spritesheet.webp")) - .unwrap() - .write_all(b"not-used-by-loader") - .unwrap(); + fs::copy( + catalog::builtin_spritesheet_path("codex-spritesheet-v3.webp"), + pet_dir.join("spritesheet.webp"), + ) + .unwrap(); + } + + fn write_legacy_avatar(dir: &Path, folder_name: &str, display_name: &str) { + let avatar_dir = dir.join("avatars").join(folder_name); + fs::create_dir_all(&avatar_dir).unwrap(); + fs::write( + avatar_dir.join("avatar.json"), + format!( + r#"{{ + "displayName": "{display_name}", + "description": "legacy custom pet", + "spritesheetPath": "spritesheet.webp" + }}"# + ), + ) + .unwrap(); + fs::copy( + catalog::builtin_spritesheet_path("codex-spritesheet-v3.webp"), + avatar_dir.join("spritesheet.webp"), + ) + .unwrap(); } #[test] - fn picker_lists_bundled_and_installed_pets() { + fn picker_lists_app_bundled_and_custom_pets() { let codex_home = tempfile::tempdir().unwrap(); write_pet(codex_home.path(), "chefito", "Chefito"); @@ -174,9 +197,24 @@ mod tests { .iter() .map(|item| item.name.as_str()) .collect::>(), - vec!["Boba", "Chefito", "Codex", "None"], + vec![ + "BSOD", + "Chefito", + "Codex", + "Dewey", + "Fireball", + "None", + "Null Signal", + "Rocky", + "Seedy", + "Stacky", + ], ); assert_eq!(params.initial_selected_idx, Some(1)); + assert_eq!( + params.items[1].search_value.as_deref(), + Some("custom:chefito") + ); } #[test] @@ -194,8 +232,24 @@ mod tests { let codex_home = tempfile::tempdir().unwrap(); let params = build_pet_picker_params(Some(DISABLED_PET_ID), codex_home.path()); - assert_eq!(params.initial_selected_idx, Some(2)); - assert_eq!(params.items[2].name, "None"); - assert!(params.items[2].is_current); + assert_eq!(params.initial_selected_idx, Some(4)); + assert_eq!(params.items[4].name, "None"); + assert!(params.items[4].is_current); + } + + #[test] + fn picker_imports_legacy_avatar_manifests() { + let codex_home = tempfile::tempdir().unwrap(); + write_legacy_avatar(codex_home.path(), "legacy", "Legacy"); + + let params = build_pet_picker_params(Some("custom:legacy"), codex_home.path()); + let legacy = params + .items + .iter() + .find(|item| item.name == "Legacy") + .unwrap(); + + assert!(legacy.is_current); + assert_eq!(legacy.search_value.as_deref(), Some("custom:legacy")); } }