feat(tui): add ambient terminal pets (#21206)

## Why

The Codex App has animated pets, but the TUI had no equivalent ambient
companion surface. This brings that experience into terminal Codex while
keeping the main chat flow usable: the pet should feel present, but it
cannot cover transcript text, composer input, approvals, or picker
content.

The feature also needs to be terminal-aware. Different terminals support
different image protocols, tmux can interfere with image rendering, and
some users will want pets disabled entirely or anchored differently
depending on their layout.

<table>
<tr><td>
<img width="4110" height="2584" alt="CleanShot 2026-05-05 at 12 41
45@2x"
src="https://github.com/user-attachments/assets/68a1fcbc-2104-48d6-b834-69c6aaa95cdf"
/>
<p align="center">macOS - Ghostty, iTerm2 and WezTerm with Custom
Pet</p>
</td></tr>
<tr><td>
![Uploading CleanShot 2026-05-10 at 20.28.30.png…]()
<p align="center">Windows Terminal</p>
</td></tr>
<tr><td>
<img width="3902" height="2752" alt="CleanShot 2026-05-05 at 12 39
02@2x"
src="https://github.com/user-attachments/assets/300e2931-6b00-467e-91cb-ab8e28470500"
/>
<p align="center">Linux - WezTerm and Ghostty</p>
</td></tr>
</table>

## What Changed

- Add a TUI ambient pet renderer in `codex-rs/tui/src/pets/`.
- Port the app-style pet animation states so the sprite changes with
task status, waiting-for-input states, review/ready states, and
failures.
- Add `/pets` selection UI with a preview pane, loading state, built-in
pet choices, and a first-row `Disable terminal pets` option.
- Download built-in pet spritesheets on demand from the same public CDN
path already used by Android, under
`https://persistent.oaistatic.com/codex/pets/v1/...`, and cache them
locally under `~/.codex/cache/tui-pets/`.
- Keep custom pets local.
- Add config support for pet selection, disabling pets, and choosing
whether the pet follows the composer bottom or anchors to the terminal
bottom.
- Reserve layout space around the pet so transcript wrapping, live
responses, and composer input do not render underneath the sprite.
- Gate image rendering by terminal capability, disable image pets under
tmux, and support both Kitty Graphics and SIXEL terminals.
- Add redraw cleanup for terminal image artifacts, including sixel cell
clearing.

## Current Scope

- This is an initial TUI version of ambient pets, not full App parity.
- It focuses on ambient sprite rendering, `/pets` selection, custom
pets, terminal capability gating, and on-demand CDN-backed built-in
assets.
- The ambient text overlay is currently disabled, so the TUI renders the
pet sprite without extra status text beside it.

## How to Test

1. Start Codex TUI in a terminal with image support.
2. Run `/pets`.
3. Confirm the picker shows built-in pets plus custom pets, and the
first item is `Disable terminal pets`.
4. On a fresh `~/.codex/cache/tui-pets/`, move onto a built-in pet and
confirm the first preview downloads the spritesheet from the shared
Codex pets CDN and renders successfully.
5. Move through the pet list and confirm subsequent built-in previews
use the local cache.
6. Select a pet, then send and receive messages. Confirm transcript and
composer text wrap before the pet instead of rendering underneath the
sprite.
7. Change the pet anchor setting and confirm the pet can either follow
the composer bottom or sit at the terminal bottom.
8. Return to `/pets`, choose `Disable terminal pets`, and confirm the
sprite disappears cleanly.

Targeted tests:
- `cargo test -p codex-tui ambient_pet_`
- `cargo test -p codex-tui
resize_reflow_wraps_transcript_early_when_pet_is_enabled`
- `cargo insta pending-snapshots`
This commit is contained in:
Felipe Coury
2026-05-12 10:43:17 -03:00
committed by GitHub
parent cb55b769d1
commit 95b332c820
71 changed files with 5576 additions and 91 deletions
+528
View File
@@ -0,0 +1,528 @@
//! Ambient terminal rendering for the Codex companion.
//!
//! Ambient pets reuse the same extracted image frames as the full-screen viewer
//! but are rendered through a different ownership split: ratatui still owns the
//! transcript/composer layout, while the sprite itself is emitted through the
//! terminal image protocol after the frame draw completes.
//!
//! This module therefore owns two separate contracts:
//! choosing which animation frame should be visible for the current semantic
//! pet state, and translating that frame into a precise on-screen image request
//! that does not overlap reserved bottom-pane space. It does not persist pet
//! selection or decide when modal/popover UI should suppress the sprite.
#[cfg(test)]
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use ratatui::layout::Rect;
use crate::tui::FrameRequester;
use super::DEFAULT_PET_ID;
use super::frames;
use super::image_protocol::ImageProtocol;
use super::image_protocol::PetImageSupport;
#[cfg(not(test))]
use super::image_protocol::ProtocolSelection;
use super::model::Animation;
#[cfg(test)]
use super::model::AnimationFrame;
use super::model::Pet;
const PET_TARGET_HEIGHT_PX: u16 = 75;
const PET_COMPOSER_GAP_PX: u16 = 10;
const TERMINAL_ROW_HEIGHT_PX: u16 = 15;
const RUNNING_LIFETIME: Duration = Duration::from_secs(3 * 60);
const FAILED_LIFETIME: Duration = Duration::from_secs(60 * 60);
const WAITING_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
const REVIEW_LIFETIME: Duration = Duration::from_secs(7 * 24 * 60 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PetNotificationKind {
Running,
Waiting,
Review,
Failed,
}
impl PetNotificationKind {
fn animation_name(self) -> &'static str {
match self {
Self::Running => "running",
Self::Waiting => "waiting",
Self::Review => "review",
Self::Failed => "failed",
}
}
fn label(self) -> &'static str {
match self {
Self::Running => "Running",
Self::Waiting => "Needs input",
Self::Review => "Ready",
Self::Failed => "Blocked",
}
}
fn fallback_body(self) -> &'static str {
match self {
Self::Running => "Thinking",
Self::Waiting => "Needs input",
Self::Review => "Ready",
Self::Failed => "Blocked",
}
}
fn lifetime(self) -> Duration {
match self {
Self::Running => RUNNING_LIFETIME,
Self::Waiting => WAITING_LIFETIME,
Self::Review => REVIEW_LIFETIME,
Self::Failed => FAILED_LIFETIME,
}
}
}
#[derive(Debug, Clone)]
struct PetNotification {
kind: PetNotificationKind,
body: String,
updated_at: Instant,
}
impl PetNotification {
fn new(kind: PetNotificationKind, body: Option<String>) -> Self {
Self {
kind,
body: body.unwrap_or_else(|| kind.fallback_body().to_string()),
updated_at: Instant::now(),
}
}
fn is_expired(&self, now: Instant) -> bool {
now.saturating_duration_since(self.updated_at) >= self.kind.lifetime()
}
}
#[derive(Debug, Clone)]
pub(crate) struct AmbientPetDraw {
pub(crate) frame: PathBuf,
pub(crate) protocol: ImageProtocol,
pub(crate) x: u16,
pub(crate) y: u16,
pub(crate) clear_top_y: u16,
pub(crate) columns: u16,
pub(crate) rows: u16,
pub(crate) height_px: u16,
pub(crate) sixel_dir: PathBuf,
}
#[derive(Debug)]
pub(crate) struct AmbientPet {
pet: Pet,
support: PetImageSupport,
frames: Vec<PathBuf>,
sixel_dir: PathBuf,
frame_requester: FrameRequester,
notification: Option<PetNotification>,
animation_started_at: Instant,
animations_enabled: bool,
}
impl AmbientPet {
/// Load the active ambient pet and prepare its frame cache.
///
/// This resolves the selected pet id, extracts per-frame PNGs into the
/// CODEX_HOME cache, and records the terminal protocol support snapshot used
/// for later draw requests. A caller that repeatedly recreates `AmbientPet`
/// instead of mutating one instance would lose animation timing continuity
/// and pay the frame-cache preparation cost more often than necessary.
pub(crate) fn load(
selected_pet: Option<&str>,
codex_home: &std::path::Path,
frame_requester: FrameRequester,
animations_enabled: bool,
) -> Result<Self> {
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 = codex_home
.join("cache")
.join("tui-pets")
.join("frame-cache")
.join(&pet.id)
.join(pet.frame_cache_key()?);
let frame_dir = cache_dir.join("frames");
let sixel_dir = cache_dir.join("sixel");
let frames = frames::prepare_png_frames(&pet, &frame_dir)?;
Ok(Self {
pet,
support: default_image_support(),
frames,
sixel_dir,
frame_requester,
notification: None,
animation_started_at: Instant::now(),
animations_enabled,
})
}
pub(crate) fn set_notification(&mut self, kind: PetNotificationKind, body: Option<String>) {
self.notification = Some(PetNotification::new(kind, body));
self.animation_started_at = Instant::now();
}
pub(crate) fn image_enabled(&self) -> bool {
self.support.protocol().is_some()
}
pub(crate) fn image_columns(&self) -> u16 {
self.image_size().columns
}
#[cfg(test)]
pub(crate) fn set_image_support_for_tests(&mut self, support: PetImageSupport) {
self.support = support;
}
pub(crate) fn schedule_next_frame(&self) {
if let Some(delay) = self.next_frame_delay() {
self.frame_requester.schedule_frame_in(delay);
}
}
fn next_frame_delay(&self) -> Option<Duration> {
if self.support.protocol().is_none() || !self.animations_enabled {
return None;
}
current_animation_frame(
self.current_animation()?,
self.animation_started_at.elapsed(),
)?
.delay
}
/// Build an image draw request for the ambient pet anchored above the composer.
///
/// Returning `None` means "do not render the sprite this frame", typically
/// because the terminal protocol is unavailable or the current layout cannot
/// fit the image without overlapping reserved UI. Callers should not try to
/// partially clip the image themselves; that would desynchronize the image
/// protocol output from the TUI's notion of cleared rows.
pub(crate) fn draw_request(
&self,
area: Rect,
composer_bottom_y: u16,
) -> Option<AmbientPetDraw> {
let protocol = self.support.protocol()?;
let size = self.image_size();
let notification = self.visible_notification(Instant::now());
let notification_height = notification.map_or(0, notification_height);
let required_height = size.rows.saturating_add(notification_height);
let sprite_bottom_y = composer_bottom_y.saturating_sub(composer_gap_rows());
if sprite_bottom_y < area.y.saturating_add(required_height) || area.width < size.columns {
return None;
}
let x = area.x + area.width.saturating_sub(size.columns);
let y = sprite_bottom_y.saturating_sub(size.rows);
Some(AmbientPetDraw {
frame: self.current_frame_path()?,
protocol,
x,
y,
clear_top_y: area.y,
columns: size.columns,
rows: size.rows,
height_px: size.height_px,
sixel_dir: self.sixel_dir.clone(),
})
}
/// Build a centered preview draw request for the `/pets` picker side pane.
///
/// The picker preview intentionally uses the first idle frame rather than
/// the live animation state so selection browsing stays stable and does not
/// require the full ambient animation lifecycle.
pub(crate) fn preview_draw_request(&self, area: Rect) -> Option<AmbientPetDraw> {
let protocol = self.support.protocol()?;
let size = self.image_size();
if area.width < size.columns || area.height < size.rows {
return None;
}
let y = area.y + area.height.saturating_sub(size.rows) / 2;
Some(AmbientPetDraw {
frame: self.first_idle_frame_path()?,
protocol,
x: area.x + area.width.saturating_sub(size.columns) / 2,
y,
clear_top_y: y,
columns: size.columns,
rows: size.rows,
height_px: size.height_px,
sixel_dir: self.sixel_dir.clone(),
})
}
fn visible_notification(&self, now: Instant) -> Option<&PetNotification> {
self.notification
.as_ref()
.filter(|notification| !notification.is_expired(now))
}
fn current_animation(&self) -> Option<&Animation> {
let animation_name = self
.visible_notification(Instant::now())
.map_or("idle", |notification| notification.kind.animation_name());
let animation = self
.pet
.animations
.get(animation_name)
.or_else(|| self.pet.animations.get("idle"))?;
if animation.loop_start.is_none() {
let elapsed = self.animation_started_at.elapsed();
if elapsed >= animation.total_duration()
&& let Some(fallback) = self.pet.animations.get(&animation.fallback)
{
return Some(fallback);
}
}
Some(animation)
}
fn current_frame_path(&self) -> Option<PathBuf> {
let sprite_index = self
.current_animation()
.and_then(|animation| {
if self.animations_enabled {
current_animation_frame(animation, self.animation_started_at.elapsed())
.map(|frame| frame.sprite_index)
} else {
animation.frames.first().map(|frame| frame.sprite_index)
}
})
.unwrap_or(0);
self.frame_path_for_sprite_index(sprite_index)
}
fn first_idle_frame_path(&self) -> Option<PathBuf> {
let sprite_index = self
.pet
.animations
.get("idle")
.and_then(|animation| animation.frames.first())
.map_or(0, |frame| frame.sprite_index);
self.frame_path_for_sprite_index(sprite_index)
}
fn frame_path_for_sprite_index(&self, sprite_index: usize) -> Option<PathBuf> {
self.frames
.get(sprite_index.min(self.frames.len().saturating_sub(1)))
.cloned()
}
fn image_size(&self) -> ImageSize {
let rows = (f64::from(PET_TARGET_HEIGHT_PX) / f64::from(TERMINAL_ROW_HEIGHT_PX))
.round()
.max(/*other*/ 1.0) as u16;
let aspect = f64::from(self.pet.frame_height) / f64::from(self.pet.frame_width) * 0.52;
let columns = (f64::from(rows) / aspect).round() as u16;
ImageSize {
columns: columns.max(1),
rows,
height_px: PET_TARGET_HEIGHT_PX,
}
}
}
fn composer_gap_rows() -> u16 {
((f64::from(PET_COMPOSER_GAP_PX) / f64::from(TERMINAL_ROW_HEIGHT_PX)).round() as u16)
.max(/*other*/ 1)
}
#[cfg(not(test))]
fn default_image_support() -> PetImageSupport {
ProtocolSelection::Auto.resolve()
}
#[cfg(test)]
fn default_image_support() -> PetImageSupport {
PetImageSupport::Unsupported(super::image_protocol::PetImageUnsupportedReason::Terminal)
}
#[derive(Debug, Clone, Copy)]
struct ImageSize {
columns: u16,
rows: u16,
height_px: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AnimationFrameTick {
sprite_index: usize,
delay: Option<Duration>,
}
fn current_animation_frame(animation: &Animation, elapsed: Duration) -> Option<AnimationFrameTick> {
if animation.frames.len() <= 1 {
return Some(AnimationFrameTick {
sprite_index: animation.frames.first()?.sprite_index,
delay: None,
});
}
let elapsed_nanos = elapsed.as_nanos();
if let Some(loop_start) = animation
.loop_start
.filter(|idx| *idx < animation.frames.len())
{
let total_nanos = animation.total_duration().as_nanos();
let prefix_nanos = animation.frames[..loop_start]
.iter()
.map(|frame| frame.duration.as_nanos())
.sum::<u128>();
let loop_nanos = animation.frames[loop_start..]
.iter()
.map(|frame| frame.duration.as_nanos())
.sum::<u128>();
let effective_elapsed = if elapsed_nanos >= total_nanos && loop_nanos > 0 {
prefix_nanos + elapsed_nanos.saturating_sub(prefix_nanos) % loop_nanos
} else {
elapsed_nanos
};
frame_at_elapsed(animation, effective_elapsed)
} else if elapsed_nanos >= animation.total_duration().as_nanos() {
Some(AnimationFrameTick {
sprite_index: animation.frames.last()?.sprite_index,
delay: None,
})
} else {
frame_at_elapsed(animation, elapsed_nanos)
}
}
fn frame_at_elapsed(animation: &Animation, elapsed_nanos: u128) -> Option<AnimationFrameTick> {
let mut remaining_elapsed = elapsed_nanos;
for frame in &animation.frames {
let frame_nanos = frame.duration.as_nanos().max(/*other*/ 1);
if remaining_elapsed < frame_nanos {
return Some(AnimationFrameTick {
sprite_index: frame.sprite_index,
delay: Some(nanos_to_duration(frame_nanos - remaining_elapsed)),
});
}
remaining_elapsed = remaining_elapsed.saturating_sub(frame_nanos);
}
Some(AnimationFrameTick {
sprite_index: animation.frames.last()?.sprite_index,
delay: None,
})
}
fn nanos_to_duration(nanos: u128) -> Duration {
Duration::from_nanos(nanos.min(u128::from(u64::MAX)) as u64)
}
fn notification_height(notification: &PetNotification) -> u16 {
if notification.body == notification.kind.label() {
1
} else {
2
}
}
#[cfg(test)]
pub(crate) fn test_ambient_pet(
frame_requester: FrameRequester,
animations_enabled: bool,
) -> AmbientPet {
AmbientPet {
pet: Pet {
id: "test".to_string(),
display_name: "Test".to_string(),
description: String::new(),
spritesheet_path: PathBuf::from("spritesheet.webp"),
frame_width: 192,
frame_height: 208,
columns: 8,
rows: 9,
frame_count: 72,
animations: HashMap::from([("idle".to_string(), test_animation())]),
},
support: PetImageSupport::Supported(ImageProtocol::Kitty),
frames: vec![PathBuf::from("frame-0.png"), PathBuf::from("frame-1.png")],
sixel_dir: PathBuf::new(),
frame_requester,
notification: None,
animation_started_at: Instant::now()
.checked_sub(Duration::from_millis(/*millis*/ 15))
.unwrap(),
animations_enabled,
}
}
#[cfg(test)]
fn test_animation() -> Animation {
Animation {
frames: vec![
AnimationFrame {
sprite_index: 0,
duration: Duration::from_millis(/*millis*/ 10),
},
AnimationFrame {
sprite_index: 1,
duration: Duration::from_millis(/*millis*/ 10),
},
],
loop_start: Some(/*loop_start*/ 0),
fallback: "idle".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notification_labels_match_codex_app_vocabulary() {
assert_eq!(PetNotificationKind::Running.label(), "Running");
assert_eq!(PetNotificationKind::Waiting.label(), "Needs input");
assert_eq!(PetNotificationKind::Review.label(), "Ready");
assert_eq!(PetNotificationKind::Failed.label(), "Blocked");
}
#[test]
fn animation_frame_uses_per_frame_duration() {
let animation = test_animation();
assert_eq!(
current_animation_frame(&animation, Duration::from_millis(/*millis*/ 15)),
Some(AnimationFrameTick {
sprite_index: 1,
delay: Some(Duration::from_millis(/*millis*/ 5)),
})
);
}
#[test]
fn reduced_motion_uses_stable_first_frame_and_schedules_no_follow_up() {
let pet = test_ambient_pet(
FrameRequester::test_dummy(),
/*animations_enabled*/ false,
);
assert_eq!(pet.current_frame_path(), Some(PathBuf::from("frame-0.png")));
assert_eq!(pet.next_frame_delay(), None);
}
}
+190
View File
@@ -0,0 +1,190 @@
//! Built-in pet asset acquisition and cache ownership.
//!
//! Unlike custom pets, built-in pets are not checked into the TUI package as
//! local spritesheets. The TUI resolves them from the public Codex pets CDN on
//! first use, verifies that the downloaded file has the expected spritesheet
//! geometry, and installs it into a versioned cache under CODEX_HOME.
//!
//! This module deliberately stops at "a validated spritesheet exists at this
//! path". Higher layers remain responsible for deciding when downloads are
//! allowed, when previews should block on them, and when a successfully loaded
//! built-in pet is safe to persist to config.
use std::fs;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use url::Url;
use uuid::Uuid;
use super::catalog;
const PET_PACK_VERSION: &str = "v1";
const PET_PACK_DIR: &str = "cache/tui-pets";
const PET_CDN_BASE_URL: &str = "https://persistent.oaistatic.com/codex/pets/v1";
const PET_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60);
const PET_MAX_DOWNLOAD_BYTES: u64 = 4 * 1024 * 1024;
pub(crate) fn builtin_spritesheet_path(codex_home: &Path, file: &str) -> PathBuf {
pack_dir(codex_home).join("assets").join(file)
}
/// Ensure that a built-in pet's spritesheet is present and structurally valid.
///
/// The cache key is the CDN-facing filename, so updating a built-in pet means
/// publishing a new versioned filename rather than mutating an existing one in
/// place. If a cached file is missing or invalid, this downloads a fresh copy,
/// validates the decoded image dimensions, and installs it atomically. Callers
/// should treat any error here as "the asset is unavailable", not as a partial
/// install they can safely ignore.
pub(crate) fn ensure_builtin_pet(codex_home: &Path, pet: catalog::BuiltinPet) -> Result<()> {
let destination = builtin_spritesheet_path(codex_home, pet.spritesheet_file);
if validate_cached_spritesheet(&destination).is_ok() {
return Ok(());
}
let url = builtin_pet_url(pet)?;
let bytes = download_bytes_with_limit(&url, PET_MAX_DOWNLOAD_BYTES)?;
let parent = destination
.parent()
.context("pet spritesheet path should include an assets directory")?;
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
let staging = destination.with_file_name(format!(
".{}.download-{}.webp",
pet.spritesheet_file,
Uuid::new_v4()
));
fs::write(&staging, &bytes).with_context(|| format!("write {}", staging.display()))?;
if let Err(err) = validate_cached_spritesheet(&staging) {
let _ = fs::remove_file(&staging);
return Err(err);
}
if install_downloaded_spritesheet(&staging, &destination).is_ok() {
return Ok(());
}
if validate_cached_spritesheet(&destination).is_ok() {
let _ = fs::remove_file(&staging);
return Ok(());
}
if destination.exists() {
fs::remove_file(&destination)
.with_context(|| format!("remove {}", destination.display()))?;
}
install_downloaded_spritesheet(&staging, &destination)
}
fn builtin_pet_url(pet: catalog::BuiltinPet) -> Result<String> {
let url = format!("{PET_CDN_BASE_URL}/{}", pet.spritesheet_file);
validate_download_url(&url)?;
Ok(url)
}
fn pack_dir(codex_home: &Path) -> PathBuf {
codex_home.join(PET_PACK_DIR).join(PET_PACK_VERSION)
}
fn download_bytes_with_limit(url: &str, max_bytes: u64) -> Result<Vec<u8>> {
validate_download_url(url)?;
let response = reqwest::blocking::Client::builder()
.timeout(PET_DOWNLOAD_TIMEOUT)
.build()
.context("build pet asset download client")?
.get(url)
.send()
.with_context(|| format!("download pet asset from {url}"))?
.error_for_status()
.with_context(|| format!("download pet asset from {url}"))?;
validate_download_url(response.url().as_str())?;
if response.content_length().is_some_and(|len| len > max_bytes) {
bail!("pet asset download from {url} exceeded {max_bytes} bytes");
}
let mut bytes = Vec::new();
response
.take(max_bytes.saturating_add(/*rhs*/ 1))
.read_to_end(&mut bytes)
.with_context(|| format!("read pet asset download from {url}"))?;
if bytes.len() as u64 > max_bytes {
bail!("pet asset download from {url} exceeded {max_bytes} bytes");
}
Ok(bytes)
}
fn install_downloaded_spritesheet(staging: &Path, destination: &Path) -> Result<()> {
fs::rename(staging, destination).with_context(|| format!("install {}", destination.display()))
}
fn validate_download_url(value: &str) -> Result<()> {
let url = Url::parse(value).with_context(|| format!("parse pet asset download URL {value}"))?;
if url.scheme() != "https" {
bail!("unsupported pet asset download URL scheme {}", url.scheme());
}
Ok(())
}
fn validate_cached_spritesheet(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!(
"invalid pet spritesheet dimensions for {}: expected {}x{}, got {}x{}",
path.display(),
catalog::SPRITESHEET_WIDTH,
catalog::SPRITESHEET_HEIGHT,
width,
height
);
}
Ok(())
}
#[cfg(test)]
pub(crate) fn write_test_pack(codex_home: &Path) {
let assets_dir = pack_dir(codex_home).join("assets");
fs::create_dir_all(&assets_dir).unwrap();
for pet in catalog::BUILTIN_PETS {
let path = assets_dir.join(pet.spritesheet_file);
catalog::write_test_spritesheet(&path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn builtin_pet_url_uses_public_cdn_path() {
let pet = catalog::builtin_pet("dewey").unwrap();
let url = builtin_pet_url(pet).unwrap();
assert_eq!(
url,
"https://persistent.oaistatic.com/codex/pets/v1/dewey-spritesheet-v4.webp"
);
}
#[test]
fn write_test_pack_installs_all_builtins() {
let dir = tempfile::tempdir().unwrap();
write_test_pack(dir.path());
for pet in catalog::BUILTIN_PETS {
let path = builtin_spritesheet_path(dir.path(), pet.spritesheet_file);
assert!(path.is_file());
validate_cached_spritesheet(&path).unwrap();
}
}
}
+77
View File
@@ -0,0 +1,77 @@
//! Built-in pet catalog ported from the Codex App avatar catalog.
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-v4.webp",
},
BuiltinPet {
id: "dewey",
display_name: "Dewey",
description: "A tidy duck for calm workspace days",
spritesheet_file: "dewey-spritesheet-v4.webp",
},
BuiltinPet {
id: "fireball",
display_name: "Fireball",
description: "Hot path energy for fast iteration",
spritesheet_file: "fireball-spritesheet-v4.webp",
},
BuiltinPet {
id: "rocky",
display_name: "Rocky",
description: "A steady rock when the diff gets large",
spritesheet_file: "rocky-spritesheet-v4.webp",
},
BuiltinPet {
id: "seedy",
display_name: "Seedy",
description: "Small green shoots for new ideas",
spritesheet_file: "seedy-spritesheet-v4.webp",
},
BuiltinPet {
id: "stacky",
display_name: "Stacky",
description: "A balanced stack for deep work",
spritesheet_file: "stacky-spritesheet-v4.webp",
},
BuiltinPet {
id: "bsod",
display_name: "BSOD",
description: "A tiny blue-screen gremlin",
spritesheet_file: "bsod-spritesheet-v4.webp",
},
BuiltinPet {
id: "null-signal",
display_name: "Null Signal",
description: "Quiet signal from the void",
spritesheet_file: "null-signal-spritesheet-v4.webp",
},
];
pub(super) fn builtin_pet(id: &str) -> Option<BuiltinPet> {
BUILTIN_PETS.iter().copied().find(|pet| pet.id == id)
}
#[cfg(test)]
pub(super) fn write_test_spritesheet(path: &std::path::Path) {
let image = image::RgbaImage::new(SPRITESHEET_WIDTH, SPRITESHEET_HEIGHT);
image.save(path).unwrap();
}
+116
View File
@@ -0,0 +1,116 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
use image::GenericImageView;
use super::model::Pet;
pub(super) fn prepare_png_frames(pet: &Pet, frame_dir: &Path) -> Result<Vec<PathBuf>> {
fs::create_dir_all(frame_dir).with_context(|| format!("create {}", frame_dir.display()))?;
let expected: Vec<PathBuf> = (0..pet.frame_count())
.map(|index| frame_dir.join(format!("frame_{index:03}.png")))
.collect();
let complete = expected.iter().all(|path| path.exists());
if !complete {
for stale in glob_frame_files(frame_dir)? {
let _ = fs::remove_file(stale);
}
let spritesheet = image::open(&pet.spritesheet_path)
.with_context(|| format!("read {}", pet.spritesheet_path.display()))?;
for row in 0..pet.rows {
for column in 0..pet.columns {
let index = row
.checked_mul(pet.columns)
.and_then(|row_offset| row_offset.checked_add(column))
.context("pet frame index overflow")?;
let index = usize::try_from(index).context("pet frame index does not fit usize")?;
let path = expected
.get(index)
.context("pet frame index exceeds expected frame count")?;
let x = column
.checked_mul(pet.frame_width)
.context("pet frame x offset overflow")?;
let y = row
.checked_mul(pet.frame_height)
.context("pet frame y offset overflow")?;
let frame = spritesheet.try_view(x, y, pet.frame_width, pet.frame_height)?;
frame
.to_image()
.save_with_format(path, image::ImageFormat::Png)
.with_context(|| format!("write {}", path.display()))?;
}
}
}
Ok(expected)
}
fn glob_frame_files(frame_dir: &Path) -> Result<Vec<PathBuf>> {
if !frame_dir.exists() {
return Ok(Vec::new());
}
let mut paths = Vec::new();
for entry in fs::read_dir(frame_dir).with_context(|| format!("read {}", frame_dir.display()))? {
let path = entry?.path();
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("frame_") && name.ends_with(".png"))
{
paths.push(path);
}
}
Ok(paths)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use image::ImageBuffer;
use image::Rgba;
use super::*;
#[test]
fn prepare_png_frames_slices_spritesheet_without_external_command() {
let dir = tempfile::tempdir().unwrap();
let spritesheet_path = dir.path().join("spritesheet.png");
let spritesheet: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::from_fn(2, 1, |x, _| {
if x == 0 {
Rgba([255, 0, 0, 255])
} else {
Rgba([0, 255, 0, 255])
}
});
spritesheet.save(&spritesheet_path).unwrap();
let frames = prepare_png_frames(
&Pet {
id: "tiny".to_string(),
display_name: "Tiny".to_string(),
description: String::new(),
spritesheet_path,
frame_width: 1,
frame_height: 1,
columns: 2,
rows: 1,
frame_count: 2,
animations: HashMap::new(),
},
&dir.path().join("frames"),
)
.unwrap();
assert_eq!(frames.len(), 2);
assert!(frames[0].exists());
assert!(frames[1].exists());
}
}
+591
View File
@@ -0,0 +1,591 @@
use std::env;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use base64::Engine as _;
use base64::engine::general_purpose;
use codex_terminal_detection::Multiplexer;
use codex_terminal_detection::TerminalInfo;
use codex_terminal_detection::TerminalName;
use codex_terminal_detection::terminal_info;
use image::imageops::FilterType;
use super::sixel;
const ESC: &str = "\x1b";
const ST: &str = "\x1b\\";
const KITTY_CHUNK_SIZE: usize = 4096;
const SIXEL_CACHE_VERSION: &str = "v2";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageProtocol {
Kitty,
KittyLocalFile,
Sixel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PetImageSupport {
Supported(ImageProtocol),
Unsupported(PetImageUnsupportedReason),
}
impl PetImageSupport {
pub(crate) fn protocol(self) -> Option<ImageProtocol> {
match self {
Self::Supported(protocol) => Some(protocol),
Self::Unsupported(_) => None,
}
}
pub(crate) fn unsupported_message(self) -> Option<&'static str> {
match self {
Self::Supported(_) => None,
Self::Unsupported(reason) => Some(reason.message()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PetImageUnsupportedReason {
Tmux,
Zellij,
Terminal,
}
impl PetImageUnsupportedReason {
fn message(self) -> &'static str {
match self {
Self::Tmux => {
"Pets are disabled in tmux. Terminal images dont stay pane-local in tmux and can corrupt scrollback or move between panes. Run Codex outside tmux to use pets."
}
Self::Zellij => {
"Pets are disabled in Zellij. Terminal images dont stay reliably pane-local in Zellij. Run Codex outside Zellij to use pets."
}
Self::Terminal => {
"Pets arent available in this terminal. Terminal pets need image support, and this terminal environment doesnt expose a supported image protocol. Try a terminal with Kitty graphics or Sixel support, or run Codex outside tmux."
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolSelection {
Auto,
Kitty,
Sixel,
}
impl ProtocolSelection {
pub(crate) fn resolve(self) -> PetImageSupport {
match self {
Self::Kitty => PetImageSupport::Supported(ImageProtocol::Kitty),
Self::Sixel => PetImageSupport::Supported(ImageProtocol::Sixel),
Self::Auto => detect_pet_image_support(),
}
}
}
impl FromStr for ProtocolSelection {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self> {
match value {
"auto" => Ok(Self::Auto),
"kitty" => Ok(Self::Kitty),
"sixel" => Ok(Self::Sixel),
other => bail!("unknown protocol {other}; expected auto, kitty, or sixel"),
}
}
}
pub(crate) fn detect_pet_image_support() -> PetImageSupport {
if env::var_os("TMUX").is_some() || env::var_os("TMUX_PANE").is_some() {
return PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux);
}
if env::var_os("ZELLIJ").is_some()
|| env::var_os("ZELLIJ_SESSION_NAME").is_some()
|| env::var_os("ZELLIJ_VERSION").is_some()
{
return PetImageSupport::Unsupported(PetImageUnsupportedReason::Zellij);
}
if env::var_os("KITTY_WINDOW_ID").is_some() {
return PetImageSupport::Supported(ImageProtocol::Kitty);
}
if env::var_os("WEZTERM_EXECUTABLE").is_some() || env::var_os("WEZTERM_VERSION").is_some() {
return PetImageSupport::Supported(ImageProtocol::Kitty);
}
pet_image_support_for_terminal(&terminal_info())
}
fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport {
match info.multiplexer {
Some(Multiplexer::Tmux { .. }) => {
return PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux);
}
Some(Multiplexer::Zellij {}) => {
return PetImageSupport::Unsupported(PetImageUnsupportedReason::Zellij);
}
None => {}
}
if supports_iterm2_kitty_graphics(info) {
return PetImageSupport::Supported(ImageProtocol::KittyLocalFile);
}
if supports_kitty_graphics(info) {
return PetImageSupport::Supported(ImageProtocol::Kitty);
}
if supports_sixel(info) {
return PetImageSupport::Supported(ImageProtocol::Sixel);
}
PetImageSupport::Unsupported(PetImageUnsupportedReason::Terminal)
}
fn supports_iterm2_kitty_graphics(info: &TerminalInfo) -> bool {
matches!(info.name, TerminalName::Iterm2)
|| terminal_field_contains(info.term_program.as_deref(), "iterm")
}
fn supports_kitty_graphics(info: &TerminalInfo) -> bool {
matches!(
info.name,
TerminalName::Ghostty | TerminalName::Kitty | TerminalName::WezTerm
) || terminal_field_contains(info.term.as_deref(), "kitty")
|| terminal_field_contains(info.term.as_deref(), "ghostty")
|| terminal_field_contains(info.term.as_deref(), "wezterm")
|| terminal_field_contains(info.term_program.as_deref(), "kitty")
|| terminal_field_contains(info.term_program.as_deref(), "ghostty")
|| terminal_field_contains(info.term_program.as_deref(), "wezterm")
}
fn supports_sixel(info: &TerminalInfo) -> bool {
matches!(info.name, TerminalName::WindowsTerminal)
|| terminal_field_contains(info.term.as_deref(), "sixel")
|| terminal_field_contains(info.term.as_deref(), "mlterm")
|| terminal_field_contains(info.term.as_deref(), "foot")
}
fn terminal_field_contains(value: Option<&str>, needle: &str) -> bool {
value.is_some_and(|value| value.to_ascii_lowercase().contains(needle))
}
pub fn kitty_delete_image(image_id: u32) -> String {
wrap_for_tmux_if_needed(&format!("{ESC}_Ga=d,d=I,i={image_id},q=2;{ST}"))
}
pub fn kitty_transmit_png_with_id(
path: &Path,
columns: u16,
rows: u16,
image_id: Option<u32>,
) -> Result<String> {
let png = fs::read(path).with_context(|| format!("read {}", path.display()))?;
let payload = general_purpose::STANDARD.encode(png);
let chunks = payload
.as_bytes()
.chunks(KITTY_CHUNK_SIZE)
.collect::<Vec<_>>();
let mut command = String::new();
for (index, chunk) in chunks.iter().enumerate() {
let chunk = std::str::from_utf8(chunk).context("base64 payload is not valid UTF-8")?;
let has_more = index + 1 < chunks.len();
let more_flag = u8::from(has_more);
if index == 0 {
let image_id = kitty_image_id_arg(image_id);
command.push_str(&format!(
"{ESC}_Ga=T,t=d,f=100,c={columns},r={rows},q=2{image_id},m={more_flag};{chunk}{ST}",
));
} else {
command.push_str(&format!("{ESC}_Gm={more_flag};{chunk}{ST}"));
}
}
Ok(wrap_for_tmux_if_needed(&command))
}
pub fn kitty_transmit_png_file_with_id(
path: &Path,
columns: u16,
rows: u16,
image_id: Option<u32>,
) -> Result<String> {
let path = path
.canonicalize()
.with_context(|| format!("canonicalize {}", path.display()))?;
let payload = general_purpose::STANDARD.encode(path.to_string_lossy().as_bytes());
let image_id = kitty_image_id_arg(image_id);
let command = format!("{ESC}_Ga=T,t=f,f=100,c={columns},r={rows},q=2{image_id};{payload}{ST}");
Ok(wrap_for_tmux_if_needed(&command))
}
fn kitty_image_id_arg(image_id: Option<u32>) -> String {
image_id
.map(|image_id| format!(",i={image_id}"))
.unwrap_or_default()
}
fn wrap_for_tmux_if_needed(command: &str) -> String {
if env::var_os("TMUX").is_none() {
return command.to_string();
}
let escaped = command.replace(ESC, "\x1b\x1b");
format!("{ESC}Ptmux;{escaped}{ST}")
}
pub fn sixel_frame(frame_path: &Path, cache_dir: &Path, height_px: u16) -> Result<PathBuf> {
fs::create_dir_all(cache_dir).with_context(|| format!("create {}", cache_dir.display()))?;
let stem = frame_path
.file_stem()
.and_then(|stem| stem.to_str())
.context("frame path has no valid file stem")?;
let path = cache_dir.join(format!("{stem}_h{height_px}_{SIXEL_CACHE_VERSION}.six"));
if path.exists() {
return Ok(path);
}
let frame =
image::open(frame_path).with_context(|| format!("read {}", frame_path.display()))?;
let height = u32::from(height_px).max(1);
let width = ((u64::from(frame.width()) * u64::from(height)) / u64::from(frame.height()))
.try_into()
.unwrap_or(u32::MAX)
.max(1);
let rgba = frame.resize(width, height, FilterType::Lanczos3).to_rgba8();
let (width, height) = rgba.dimensions();
let sixel = sixel::encode_rgba(&rgba.into_raw(), width, height)?;
fs::write(&path, sixel).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
#[cfg(test)]
mod tests {
use serial_test::serial;
use super::*;
struct EnvVarGuard {
name: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn new(name: &'static str, value: Option<&str>) -> Self {
let previous = env::var_os(name);
match value {
Some(value) => unsafe { env::set_var(name, value) },
None => unsafe { env::remove_var(name) },
}
Self { name, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.previous.take() {
Some(value) => unsafe { env::set_var(self.name, value) },
None => unsafe { env::remove_var(self.name) },
}
}
}
#[test]
#[serial]
fn kitty_png_transmission_encodes_inline_data() {
let _guard = EnvVarGuard::new("TMUX", /*value*/ None);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("frame.png");
fs::write(&path, b"png").unwrap();
let command = kitty_transmit_png_with_id(
&path, /*columns*/ 4, /*rows*/ 3, /*image_id*/ None,
)
.unwrap();
assert!(command.starts_with("\x1b_Ga=T,t=d,f=100,c=4,r=3,q=2,m=0;"));
assert!(command.contains("cG5n"));
assert!(command.ends_with("\x1b\\"));
}
#[test]
#[serial]
fn tmux_passthrough_wraps_and_escapes_control_sequence() {
let _guard = EnvVarGuard::new("TMUX", Some("session"));
assert_eq!(
wrap_for_tmux_if_needed("\x1b_Gx;\x1b\\"),
"\x1bPtmux;\x1b\x1b_Gx;\x1b\x1b\\\x1b\\"
);
}
#[test]
fn parses_protocol_selection() {
assert_eq!(
"auto".parse::<ProtocolSelection>().unwrap(),
ProtocolSelection::Auto
);
assert_eq!(
"kitty".parse::<ProtocolSelection>().unwrap(),
ProtocolSelection::Kitty
);
assert_eq!(
"sixel".parse::<ProtocolSelection>().unwrap(),
ProtocolSelection::Sixel
);
}
#[test]
#[serial]
fn auto_protocol_is_disabled_inside_tmux() {
let _guard = EnvVarGuard::new("TMUX", Some("session"));
assert_eq!(
ProtocolSelection::Auto.resolve(),
PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux)
);
}
#[test]
#[serial]
fn explicit_protocol_still_resolves_inside_tmux() {
let _guard = EnvVarGuard::new("TMUX", Some("session"));
assert_eq!(
ProtocolSelection::Kitty.resolve(),
PetImageSupport::Supported(ImageProtocol::Kitty)
);
assert_eq!(
ProtocolSelection::Sixel.resolve(),
PetImageSupport::Supported(ImageProtocol::Sixel)
);
}
#[test]
fn pet_image_support_prefers_multiplexer_safety() {
assert_eq!(
pet_image_support_for_terminal(&terminal_info_for_test(
TerminalName::Ghostty,
Some(Multiplexer::Tmux { version: None }),
Some("Ghostty"),
/*term*/ None,
)),
PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux)
);
assert_eq!(
pet_image_support_for_terminal(&terminal_info_for_test(
TerminalName::Kitty,
Some(Multiplexer::Zellij {}),
Some("kitty"),
/*term*/ None,
)),
PetImageSupport::Unsupported(PetImageUnsupportedReason::Zellij)
);
}
#[test]
fn pet_image_support_detects_iterm2_kitty_file_graphics() {
for info in [
terminal_info_for_test(
TerminalName::Iterm2,
/*multiplexer*/ None,
Some("iTerm.app"),
/*term*/ None,
),
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
Some("iTerm.app"),
Some("xterm-256color"),
),
] {
assert_eq!(
pet_image_support_for_terminal(&info),
PetImageSupport::Supported(ImageProtocol::KittyLocalFile)
);
}
}
#[test]
fn pet_image_support_detects_kitty_graphics_terminals() {
for info in [
terminal_info_for_test(
TerminalName::Ghostty,
/*multiplexer*/ None,
Some("Ghostty"),
/*term*/ None,
),
terminal_info_for_test(
TerminalName::Kitty,
/*multiplexer*/ None,
Some("kitty"),
/*term*/ None,
),
terminal_info_for_test(
TerminalName::WezTerm,
/*multiplexer*/ None,
Some("WezTerm"),
/*term*/ None,
),
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
/*term_program*/ None,
Some("xterm-kitty"),
),
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
/*term_program*/ None,
Some("wezterm"),
),
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
Some("WezTerm"),
Some("xterm-256color"),
),
] {
assert_eq!(
pet_image_support_for_terminal(&info),
PetImageSupport::Supported(ImageProtocol::Kitty)
);
}
}
#[test]
fn pet_image_support_detects_sixel_terminals() {
for info in [
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
/*term_program*/ None,
Some("xterm-sixel"),
),
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
/*term_program*/ None,
Some("foot"),
),
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
/*term_program*/ None,
Some("mlterm"),
),
terminal_info_for_test(
TerminalName::WindowsTerminal,
/*multiplexer*/ None,
Some("WindowsTerminal"),
Some("xterm-256color"),
),
] {
assert_eq!(
pet_image_support_for_terminal(&info),
PetImageSupport::Supported(ImageProtocol::Sixel)
);
}
}
#[test]
#[serial]
fn wezterm_env_uses_kitty_graphics_for_ambient_pets() {
let _tmux = EnvVarGuard::new("TMUX", /*value*/ None);
let _tmux_pane = EnvVarGuard::new("TMUX_PANE", /*value*/ None);
let _zellij = EnvVarGuard::new("ZELLIJ", /*value*/ None);
let _zellij_session = EnvVarGuard::new("ZELLIJ_SESSION_NAME", /*value*/ None);
let _zellij_version = EnvVarGuard::new("ZELLIJ_VERSION", /*value*/ None);
let _kitty = EnvVarGuard::new("KITTY_WINDOW_ID", /*value*/ None);
let _wezterm = EnvVarGuard::new("WEZTERM_VERSION", Some("20240203"));
let _wezterm_executable = EnvVarGuard::new("WEZTERM_EXECUTABLE", /*value*/ None);
assert_eq!(
detect_pet_image_support(),
PetImageSupport::Supported(ImageProtocol::Kitty)
);
}
#[test]
fn pet_image_support_rejects_unknown_terminals() {
assert_eq!(
pet_image_support_for_terminal(&terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
/*term_program*/ None,
Some("xterm-256color"),
)),
PetImageSupport::Unsupported(PetImageUnsupportedReason::Terminal)
);
}
fn terminal_info_for_test(
name: TerminalName,
multiplexer: Option<Multiplexer>,
term_program: Option<&str>,
term: Option<&str>,
) -> TerminalInfo {
TerminalInfo {
name,
term_program: term_program.map(str::to_string),
version: /*version*/ None,
term: term.map(str::to_string),
multiplexer,
}
}
#[test]
fn sixel_frame_encodes_without_external_crate() {
let dir = tempfile::tempdir().unwrap();
let frame_path = dir.path().join("frame.png");
let rgba = image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255]));
rgba.save(&frame_path).unwrap();
let sixel_path =
sixel_frame(&frame_path, &dir.path().join("sixel"), /*height_px*/ 1).unwrap();
let sixel = fs::read_to_string(sixel_path).unwrap();
assert!(sixel.starts_with("\x1bP9;1;0q\"1;1;1;1"));
assert!(sixel.contains("#224;2;100;0;0"));
assert!(sixel.contains("#224@"));
assert!(sixel.ends_with("\x1b\\"));
}
#[test]
#[serial]
fn kitty_file_png_transmission_encodes_local_file_reference() {
let _guard = EnvVarGuard::new("TMUX", /*value*/ None);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("frame.png");
fs::write(&path, b"png").unwrap();
let command = kitty_transmit_png_file_with_id(
&path,
/*columns*/ 4,
/*rows*/ 3,
/*image_id*/ Some(7),
)
.unwrap();
let path = path.canonicalize().unwrap();
let payload = general_purpose::STANDARD.encode(path.to_string_lossy().as_bytes());
assert_eq!(
command,
format!("\x1b_Ga=T,t=f,f=100,c=4,r=3,q=2,i=7;{payload}\x1b\\")
);
}
}
+468
View File
@@ -0,0 +1,468 @@
//! Ambient terminal pets configured from the /pets slash command.
//!
//! The TUI treats built-in and custom pets differently on purpose:
//! built-in pets are versioned application assets fetched on demand into a
//! managed CODEX_HOME cache, while custom pets remain entirely user-owned data
//! under `$CODEX_HOME/pets/<pet-id>/pet.json` or legacy avatar directories.
//!
//! This module owns the TUI-facing contracts around that split:
//! resolving a selected pet id, preparing frames for terminal image protocols,
//! rendering the ambient sprite and picker preview, and preserving enough
//! metadata for `/pets` to behave like a first-class configuration surface.
//! It does not own config persistence or popup orchestration; callers must
//! ensure a built-in asset exists before loading it and must persist the final
//! selection only after the load succeeds.
use std::io::Write;
mod ambient;
mod asset_pack;
mod catalog;
mod frames;
mod image_protocol;
mod model;
mod picker;
mod preview;
mod sixel;
use anyhow::Context;
use anyhow::Result;
pub(crate) use ambient::AmbientPet;
pub(crate) use ambient::AmbientPetDraw;
pub(crate) use ambient::PetNotificationKind;
#[cfg(test)]
pub(crate) use ambient::test_ambient_pet;
pub(crate) use asset_pack::builtin_spritesheet_path;
#[cfg(test)]
pub(crate) use asset_pack::write_test_pack;
#[cfg(test)]
pub(crate) use image_protocol::ImageProtocol;
pub(crate) use image_protocol::PetImageSupport;
#[cfg(test)]
pub(crate) use image_protocol::PetImageUnsupportedReason;
#[cfg(not(test))]
pub(crate) use image_protocol::detect_pet_image_support;
pub(crate) use picker::PET_PICKER_VIEW_ID;
pub(crate) use picker::build_pet_picker_params;
pub(crate) use preview::PetPickerPreviewState;
pub(crate) const DEFAULT_PET_ID: &str = "codex";
pub(crate) const DISABLED_PET_ID: &str = "disabled";
/// Ensure that a selected built-in pet has a locally cached spritesheet.
///
/// Custom pets are intentionally a no-op here because their source of truth is
/// already local. Callers should invoke this before loading a built-in pet for
/// preview or selection; skipping it would make first-use preview and
/// persistence failures depend on deeper image-loading errors instead of the
/// asset-fetch boundary.
pub(crate) fn ensure_builtin_pack_for_pet(
pet_id: &str,
codex_home: &std::path::Path,
) -> Result<()> {
if let Some(pet) = catalog::builtin_pet(pet_id) {
asset_pack::ensure_builtin_pet(codex_home, pet)?;
}
Ok(())
}
#[derive(Debug)]
pub(crate) enum PetImageRenderError {
Terminal(std::io::Error),
Asset(anyhow::Error),
}
impl std::fmt::Display for PetImageRenderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Terminal(err) => write!(f, "terminal image write failed: {err}"),
Self::Asset(err) => write!(f, "pet image asset unavailable: {err}"),
}
}
}
impl std::error::Error for PetImageRenderError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Terminal(err) => Some(err),
Self::Asset(err) => Some(err.as_ref()),
}
}
}
impl From<std::io::Error> for PetImageRenderError {
fn from(err: std::io::Error) -> Self {
Self::Terminal(err)
}
}
pub(crate) fn render_ambient_pet_image(
writer: &mut impl Write,
state: &mut PetImageRenderState,
request: Option<AmbientPetDraw>,
) -> std::result::Result<(), PetImageRenderError> {
render_pet_image(writer, state, /*image_id*/ 0xC0DE, request)
}
pub(crate) fn render_pet_picker_preview_image(
writer: &mut impl Write,
state: &mut PetImageRenderState,
request: Option<AmbientPetDraw>,
) -> std::result::Result<(), PetImageRenderError> {
render_pet_image(writer, state, /*image_id*/ 0xC0DF, request)
}
#[derive(Debug, Default)]
pub(crate) struct PetImageRenderState {
last_sixel_clear_area: Option<SixelClearArea>,
last_protocol: Option<image_protocol::ImageProtocol>,
}
fn render_pet_image(
writer: &mut impl Write,
state: &mut PetImageRenderState,
image_id: u32,
request: Option<AmbientPetDraw>,
) -> std::result::Result<(), PetImageRenderError> {
use crossterm::cursor::MoveTo;
use crossterm::cursor::RestorePosition;
use crossterm::cursor::SavePosition;
use crossterm::queue;
use image_protocol::ImageProtocol;
let Some(request) = request else {
if state.last_protocol.take().is_some_and(is_kitty_protocol) {
write!(writer, "{}", image_protocol::kitty_delete_image(image_id))?;
}
if let Some(area) = state.last_sixel_clear_area.take() {
queue!(writer, SavePosition)?;
clear_sixel_area(writer, area)?;
queue!(writer, RestorePosition)?;
}
writer.flush()?;
return Ok(());
};
if state.last_protocol.take().is_some_and(is_kitty_protocol)
|| is_kitty_protocol(request.protocol)
{
write!(writer, "{}", image_protocol::kitty_delete_image(image_id))?;
}
state.last_protocol = Some(request.protocol);
let payload = match request.protocol {
ImageProtocol::Kitty => AmbientPetPayload::Text(
image_protocol::kitty_transmit_png_with_id(
&request.frame,
request.columns,
request.rows,
Some(image_id),
)
.map_err(PetImageRenderError::Asset)?,
),
ImageProtocol::KittyLocalFile => AmbientPetPayload::Text(
image_protocol::kitty_transmit_png_file_with_id(
&request.frame,
request.columns,
request.rows,
Some(image_id),
)
.map_err(PetImageRenderError::Asset)?,
),
ImageProtocol::Sixel => {
let path =
image_protocol::sixel_frame(&request.frame, &request.sixel_dir, request.height_px)
.map_err(PetImageRenderError::Asset)?;
let sixel = std::fs::read(&path)
.with_context(|| format!("read {}", path.display()))
.map_err(PetImageRenderError::Asset)?;
AmbientPetPayload::Bytes(sixel)
}
};
queue!(writer, SavePosition)?;
let current_sixel_clear_area = if matches!(request.protocol, ImageProtocol::Sixel) {
Some(SixelClearArea::from(&request))
} else {
None
};
if let Some(previous_area) = state.last_sixel_clear_area.take()
&& Some(previous_area) != current_sixel_clear_area
{
clear_sixel_area(writer, previous_area)?;
}
if let Some(area) = current_sixel_clear_area {
clear_sixel_area(writer, area)?;
state.last_sixel_clear_area = Some(area);
}
queue!(writer, MoveTo(request.x, request.y))?;
match payload {
AmbientPetPayload::Text(payload) => write!(writer, "{payload}")?,
AmbientPetPayload::Bytes(payload) => writer.write_all(&payload)?,
}
queue!(writer, RestorePosition)?;
writer.flush()?;
Ok(())
}
enum AmbientPetPayload {
Text(String),
Bytes(Vec<u8>),
}
fn is_kitty_protocol(protocol: image_protocol::ImageProtocol) -> bool {
matches!(
protocol,
image_protocol::ImageProtocol::Kitty | image_protocol::ImageProtocol::KittyLocalFile
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SixelClearArea {
x: u16,
clear_top_y: u16,
clear_bottom_y: u16,
columns: u16,
}
impl From<&AmbientPetDraw> for SixelClearArea {
fn from(request: &AmbientPetDraw) -> Self {
Self {
x: request.x,
clear_top_y: request.clear_top_y,
clear_bottom_y: request.y.saturating_add(request.rows),
columns: request.columns,
}
}
}
fn clear_sixel_area(writer: &mut impl Write, area: SixelClearArea) -> std::io::Result<()> {
use crossterm::cursor::MoveTo;
use crossterm::queue;
let blank = " ".repeat(area.columns.into());
for row in area.clear_top_y..area.clear_bottom_y {
queue!(writer, MoveTo(area.x, row))?;
write!(writer, "{blank}")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::error::Error as _;
use std::io;
use std::path::PathBuf;
use super::image_protocol::ImageProtocol;
use super::*;
#[test]
fn ambient_pet_image_restores_cursor_after_drawing() {
let dir = tempfile::tempdir().unwrap();
let frame = dir.path().join("frame.png");
std::fs::write(&frame, b"png").unwrap();
let request = AmbientPetDraw {
frame,
protocol: ImageProtocol::Kitty,
x: 2,
y: 3,
clear_top_y: 3,
columns: 4,
rows: 5,
height_px: 75,
sixel_dir: PathBuf::new(),
};
let mut output = Vec::new();
let mut state = PetImageRenderState::default();
render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap();
let output = String::from_utf8(output).unwrap();
let save = output.find("\x1b7").expect("saves cursor position");
let move_to = output.find("\x1b[4;3H").expect("moves to pet position");
let image = output.find("cG5n").expect("writes image payload");
let restore = output.find("\x1b8").expect("restores cursor position");
assert!(save < move_to);
assert!(move_to < image);
assert!(image < restore);
}
#[test]
fn kitty_pet_image_clear_deletes_without_moving_cursor() {
let dir = tempfile::tempdir().unwrap();
let frame = dir.path().join("frame.png");
std::fs::write(&frame, b"png").unwrap();
let request = AmbientPetDraw {
frame,
protocol: ImageProtocol::Kitty,
x: 2,
y: 3,
clear_top_y: 3,
columns: 4,
rows: 5,
height_px: 75,
sixel_dir: PathBuf::new(),
};
let mut output = Vec::new();
let mut state = PetImageRenderState::default();
render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap();
output.clear();
render_ambient_pet_image(&mut output, &mut state, /*request*/ None).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("Ga=d,d=I,i=49374,q=2;"));
assert!(!output.contains("\x1b7"));
assert!(!output.contains("\x1b["));
assert!(!output.contains("\x1b8"));
}
#[test]
fn kitty_local_file_pet_image_uses_file_reference_without_inline_payload() {
let dir = tempfile::tempdir().unwrap();
let frame = dir.path().join("frame.png");
std::fs::write(&frame, b"png").unwrap();
let request = AmbientPetDraw {
frame,
protocol: ImageProtocol::KittyLocalFile,
x: 2,
y: 3,
clear_top_y: 3,
columns: 4,
rows: 2,
height_px: 75,
sixel_dir: PathBuf::new(),
};
let mut output = Vec::new();
let mut state = PetImageRenderState::default();
render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("a=d,d=I,i=49374,q=2;"));
assert!(output.contains("\x1b[4;3H"));
assert!(output.contains("a=T,t=f,f=100,c=4,r=2,q=2,i=49374;"));
assert!(!output.contains("cG5n"));
assert!(output.contains("\x1b8"));
}
#[test]
fn sixel_pet_image_clears_cell_area_before_redrawing() {
let dir = tempfile::tempdir().unwrap();
let frame = dir.path().join("frame.png");
std::fs::write(&frame, b"png").unwrap();
let sixel_dir = dir.path().join("sixel");
std::fs::create_dir(&sixel_dir).unwrap();
let sixel_frame = sixel_dir.join("frame_h75_v2.six");
std::fs::write(&sixel_frame, b"fake-sixel").unwrap();
let request = AmbientPetDraw {
frame,
protocol: ImageProtocol::Sixel,
x: 2,
y: 3,
clear_top_y: 1,
columns: 4,
rows: 2,
height_px: 75,
sixel_dir,
};
let mut output = Vec::new();
let mut state = PetImageRenderState::default();
render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("\x1b[2;3H \x1b[3;3H \x1b[4;3H \x1b[5;3H \x1b[4;3H"));
assert!(output.contains("fake-sixel"));
assert!(output.contains("\x1b8"));
}
#[test]
fn sixel_pet_image_clear_erases_last_drawn_area() {
let dir = tempfile::tempdir().unwrap();
let frame = dir.path().join("frame.png");
std::fs::write(&frame, b"png").unwrap();
let sixel_dir = dir.path().join("sixel");
std::fs::create_dir(&sixel_dir).unwrap();
let sixel_frame = sixel_dir.join("frame_h75_v2.six");
std::fs::write(&sixel_frame, b"fake-sixel").unwrap();
let request = AmbientPetDraw {
frame,
protocol: ImageProtocol::Sixel,
x: 2,
y: 3,
clear_top_y: 1,
columns: 4,
rows: 2,
height_px: 75,
sixel_dir,
};
let mut output = Vec::new();
let mut state = PetImageRenderState::default();
render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap();
output.clear();
render_ambient_pet_image(&mut output, &mut state, /*request*/ None).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(!output.contains("Ga=d,d=I,i=49374,q=2;"));
assert!(output.contains("\x1b7"));
assert!(output.contains("\x1b[2;3H \x1b[3;3H \x1b[4;3H \x1b[5;3H "));
assert!(output.contains("\x1b8"));
assert!(!output.contains("fake-sixel"));
}
#[test]
fn missing_frame_is_an_asset_error() {
let dir = tempfile::tempdir().unwrap();
let request = AmbientPetDraw {
frame: dir.path().join("missing.png"),
protocol: ImageProtocol::Kitty,
x: 2,
y: 3,
clear_top_y: 3,
columns: 4,
rows: 5,
height_px: 75,
sixel_dir: PathBuf::new(),
};
let mut output = Vec::new();
let mut state = PetImageRenderState::default();
let err = render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap_err();
assert!(matches!(err, PetImageRenderError::Asset(_)));
assert!(err.source().is_some());
}
#[test]
fn writer_failure_is_a_terminal_error() {
struct FailingWriter;
impl io::Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"test writer failed",
))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let mut writer = FailingWriter;
let mut state = PetImageRenderState {
last_protocol: Some(ImageProtocol::Kitty),
..Default::default()
};
let err = render_ambient_pet_image(&mut writer, &mut state, /*request*/ None).unwrap_err();
assert!(matches!(err, PetImageRenderError::Terminal(_)));
assert!(err.source().is_some());
}
}
File diff suppressed because it is too large Load Diff
+324
View File
@@ -0,0 +1,324 @@
//! Builds the `/pets` picker dialog for the TUI.
//!
//! The picker deliberately merges three sources into one list:
//! built-in catalog pets, a synthetic "disable" entry, and user-managed custom
//! pets. It does not load preview images itself; instead it emits selection
//! change events so the surrounding chat widget can coordinate async asset
//! downloads, preview loading, and final config persistence.
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::app_event::AppEvent;
use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::SideContentWidth;
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;
use super::preview::PetPickerPreviewState;
pub(crate) const PET_PICKER_VIEW_ID: &str = "pet-picker";
const PET_PICKER_PREVIEW_WIDTH: u16 = 30;
#[derive(Debug, Clone, PartialEq, Eq)]
struct PetPickerEntry {
selector: String,
legacy_selector: Option<String>,
display_name: String,
description: Option<String>,
}
/// Build the selection popup parameters for `/pets`.
///
/// The picker preselects `DEFAULT_PET_ID` when no pet is configured so the UI
/// has a sensible starting point without implying that Codex is already the
/// active ambient pet. Callers should treat the returned actions as the only
/// supported mutation path; bypassing them would skip preview-loading and
/// selection-specific event wiring.
pub(crate) fn build_pet_picker_params(
current_pet: Option<&str>,
codex_home: &Path,
preview_state: PetPickerPreviewState,
) -> SelectionViewParams {
let preferred_pet = current_pet.unwrap_or(DEFAULT_PET_ID);
let mut entries = available_pet_entries(codex_home);
entries.sort_by(|left, right| left.display_name.cmp(&right.display_name));
if let Some(disabled_idx) = entries
.iter()
.position(|entry| entry.selector == DISABLED_PET_ID)
{
let disabled_entry = entries.remove(disabled_idx);
entries.insert(0, disabled_entry);
}
let mut initial_selected_idx = None;
let preview_pet_ids = entries
.iter()
.map(|entry| entry.selector.clone())
.collect::<Vec<_>>();
let on_selection_changed: crate::bottom_pane::OnSelectionChangedCallback = Some(Box::new(
move |idx: usize, tx: &crate::app_event_sender::AppEventSender| {
if let Some(pet_id) = preview_pet_ids.get(idx) {
tx.send(AppEvent::PetPreviewRequested {
pet_id: pet_id.clone(),
});
}
},
));
let items = entries
.into_iter()
.enumerate()
.map(|(idx, entry)| {
let is_current = current_pet.is_some_and(|current_pet| {
current_pet == entry.selector
|| entry.legacy_selector.as_deref() == Some(current_pet)
});
if preferred_pet == entry.selector
|| entry.legacy_selector.as_deref() == Some(preferred_pet)
{
initial_selected_idx = Some(idx);
}
let pet_id = entry.selector.clone();
let search_value = if pet_id == DISABLED_PET_ID {
"disable disabled hide hidden off none".to_string()
} else {
entry.selector
};
let actions: Vec<SelectionAction> = if pet_id == DISABLED_PET_ID {
vec![Box::new(|tx| {
tx.send(AppEvent::PetDisabled);
})]
} else {
vec![Box::new(move |tx| {
tx.send(AppEvent::PetSelected {
pet_id: pet_id.clone(),
});
})]
};
SelectionItem {
name: entry.display_name,
description: entry.description,
is_current,
dismiss_on_select: true,
search_value: Some(search_value),
actions,
..Default::default()
}
})
.collect();
SelectionViewParams {
view_id: Some(PET_PICKER_VIEW_ID),
title: Some("Select Pet".to_string()),
subtitle: Some("Choose a pet to wake in the terminal.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to filter pets...".to_string()),
initial_selected_idx,
side_content: Box::new(preview_state.renderable()),
side_content_width: SideContentWidth::Fixed(PET_PICKER_PREVIEW_WIDTH),
side_content_min_width: 28,
stacked_side_content: Some(Box::new(())),
preserve_side_content_bg: true,
on_selection_changed,
..Default::default()
}
}
fn available_pet_entries(codex_home: &Path) -> Vec<PetPickerEntry> {
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::<Vec<_>>();
entries.push(PetPickerEntry {
selector: DISABLED_PET_ID.to_string(),
legacy_selector: None,
display_name: "Disable terminal pets".to_string(),
description: None,
});
entries.extend(custom_pet_entries(codex_home));
entries
}
fn custom_pet_entries(codex_home: &Path) -> Vec<PetPickerEntry> {
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),
},
);
}
}
entries_by_selector.into_values().collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn write_pet(dir: &Path, folder_name: &str, display_name: &str) {
let pet_dir = dir.join("pets").join(folder_name);
fs::create_dir_all(&pet_dir).unwrap();
fs::write(
pet_dir.join("pet.json"),
format!(
r#"{{
"id": "{folder_name}",
"displayName": "{display_name}",
"description": "custom pet",
"spritesheetPath": "spritesheet.webp"
}}"#
),
)
.unwrap();
catalog::write_test_spritesheet(&pet_dir.join("spritesheet.webp"));
}
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();
catalog::write_test_spritesheet(&avatar_dir.join("spritesheet.webp"));
}
#[test]
fn picker_lists_app_bundled_and_custom_pets() {
let codex_home = tempfile::tempdir().unwrap();
write_pet(codex_home.path(), "chefito", "Chefito");
let params = build_pet_picker_params(
Some("chefito"),
codex_home.path(),
PetPickerPreviewState::default(),
);
assert_eq!(
params
.items
.iter()
.map(|item| item.name.as_str())
.collect::<Vec<_>>(),
vec![
"Disable terminal pets",
"BSOD",
"Chefito",
"Codex",
"Dewey",
"Fireball",
"Null Signal",
"Rocky",
"Seedy",
"Stacky",
],
);
assert_eq!(params.initial_selected_idx, Some(2));
assert_eq!(
params.items[2].search_value.as_deref(),
Some("custom:chefito")
);
}
#[test]
fn picker_preselects_codex_without_marking_it_current_when_no_pet_is_configured() {
let codex_home = tempfile::tempdir().unwrap();
let params = build_pet_picker_params(
/*current_pet*/ None,
codex_home.path(),
PetPickerPreviewState::default(),
);
assert_eq!(params.initial_selected_idx, Some(2));
assert_eq!(params.items[2].name, "Codex");
assert!(!params.items[2].is_current);
}
#[test]
fn picker_marks_disabled_pet_as_current() {
let codex_home = tempfile::tempdir().unwrap();
let params = build_pet_picker_params(
Some(DISABLED_PET_ID),
codex_home.path(),
PetPickerPreviewState::default(),
);
assert_eq!(params.initial_selected_idx, Some(0));
assert_eq!(params.items[0].name, "Disable terminal pets");
assert_eq!(params.items[0].description, None);
assert!(params.items[0].is_current);
assert_eq!(
params.items[0].search_value.as_deref(),
Some("disable disabled hide hidden off none")
);
}
#[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(),
PetPickerPreviewState::default(),
);
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"));
}
}
+164
View File
@@ -0,0 +1,164 @@
//! Shared preview-state model for the `/pets` side pane.
//!
//! The preview pane is intentionally small and stateful: the selection popup
//! renders it synchronously, while async preview loading updates this state
//! from outside the widget tree. Keeping the state in a mutex-backed object lets
//! the picker remember the last preview area for out-of-band image rendering
//! without requiring the rest of the popup machinery to know about pet images.
use std::sync::Arc;
use std::sync::Mutex;
use ratatui::buffer::Buffer;
use ratatui::layout::Alignment;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use crate::render::renderable::Renderable;
#[derive(Debug, Clone, Default)]
pub(crate) struct PetPickerPreviewState {
inner: Arc<Mutex<PetPickerPreviewInner>>,
}
impl PetPickerPreviewState {
/// Return a renderable wrapper for the picker side pane.
///
/// The wrapper is cheap to clone and intentionally shares interior state
/// with the controller so selection-change callbacks can update the visible
/// loading/error/ready state without rebuilding the popup.
pub(crate) fn renderable(&self) -> PetPickerPreviewRenderable {
PetPickerPreviewRenderable {
inner: Arc::clone(&self.inner),
}
}
pub(crate) fn set_loading(&self) {
self.update(|inner| {
inner.status = PetPickerPreviewStatus::Loading;
});
}
pub(crate) fn set_disabled(&self) {
self.update(|inner| {
inner.status = PetPickerPreviewStatus::Disabled;
});
}
pub(crate) fn set_ready(&self) {
self.update(|inner| {
inner.status = PetPickerPreviewStatus::Ready;
});
}
pub(crate) fn set_error(&self, message: String) {
self.update(|inner| {
inner.status = PetPickerPreviewStatus::Error { message };
});
}
pub(crate) fn clear(&self) {
self.update(|inner| {
inner.status = PetPickerPreviewStatus::Hidden;
inner.last_area = None;
});
}
pub(crate) fn area(&self) -> Option<Rect> {
self.inner.lock().ok().and_then(|inner| inner.last_area)
}
fn update(&self, f: impl FnOnce(&mut PetPickerPreviewInner)) {
if let Ok(mut inner) = self.inner.lock() {
f(&mut inner);
}
}
}
#[derive(Debug, Default)]
struct PetPickerPreviewInner {
status: PetPickerPreviewStatus,
last_area: Option<Rect>,
}
#[derive(Debug, Default)]
enum PetPickerPreviewStatus {
#[default]
Hidden,
Loading,
Disabled,
Ready,
Error {
message: String,
},
}
pub(crate) struct PetPickerPreviewRenderable {
inner: Arc<Mutex<PetPickerPreviewInner>>,
}
impl Renderable for PetPickerPreviewRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
let (title, body) = {
let Ok(mut inner) = self.inner.lock() else {
return;
};
inner.last_area = Some(area);
match &inner.status {
PetPickerPreviewStatus::Hidden => return,
PetPickerPreviewStatus::Loading => ("Loading preview...", None),
PetPickerPreviewStatus::Disabled => (
"Terminal pets disabled",
Some("No pet will be shown.".to_string()),
),
PetPickerPreviewStatus::Ready => return,
PetPickerPreviewStatus::Error { message } => {
("Preview unavailable", Some(message.clone()))
}
}
};
let text_height = if body.is_some() { 2 } else { 1 };
let text_area = centered_text_area(area, text_height);
let mut lines = vec![Line::from(title.bold())];
if let Some(body) = body {
lines.push(Line::from(body.dim()));
}
Paragraph::new(lines)
.alignment(Alignment::Center)
.render(text_area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
4
}
}
fn centered_text_area(area: Rect, height: u16) -> Rect {
let height = height.min(area.height);
let y = area.y + area.height.saturating_sub(height) / 2;
Rect::new(area.x, y, area.width, height)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn centered_text_area_centers_vertically() {
assert_eq!(
centered_text_area(
Rect::new(
/*x*/ 5, /*y*/ 10, /*width*/ 20, /*height*/ 8
),
/*height*/ 2
),
Rect::new(
/*x*/ 5, /*y*/ 13, /*width*/ 20, /*height*/ 2
)
);
}
}
+315
View File
@@ -0,0 +1,315 @@
//! Minimal Sixel encoder for pet sprites.
//!
//! This is intentionally not a general-purpose Sixel implementation. Pet frames
//! are already small RGBA images by the time they reach this module, so the
//! encoder uses deterministic RGB332 color reduction and transparent pixels are
//! simply omitted from the emitted color planes.
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
const ST: &[u8] = b"\x1b\\";
const SIXEL_BAND_HEIGHT: u32 = 6;
const PALETTE_COLOR_COUNT: usize = 256;
const TRANSPARENT_ALPHA_THRESHOLD: u8 = 128;
const TRANSPARENT_BACKGROUND_DCS: &[u8] = b"\x1bP9;1;0q";
pub(crate) fn encode_rgba(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>> {
if width == 0 || height == 0 {
bail!("sixel image dimensions must be non-zero");
}
let expected_len = pixel_count(width, height)?
.checked_mul(4)
.context("sixel RGBA buffer length overflow")?;
if rgba.len() != expected_len {
bail!(
"sixel RGBA buffer has {} bytes, expected {expected_len}",
rgba.len()
);
}
let palette = Palette::from_rgba(rgba);
let mut output = Vec::new();
output.extend_from_slice(TRANSPARENT_BACKGROUND_DCS);
output.extend_from_slice(format!("\"1;1;{width};{height}").as_bytes());
palette.write_definitions(&mut output);
write_pixels(&mut output, rgba, width, height, &palette)?;
output.extend_from_slice(ST);
Ok(output)
}
fn write_pixels(
output: &mut Vec<u8>,
rgba: &[u8],
width: u32,
height: u32,
palette: &Palette,
) -> Result<()> {
let band_count = height.div_ceil(SIXEL_BAND_HEIGHT);
for band_index in 0..band_count {
let band_top = band_index * SIXEL_BAND_HEIGHT;
let colors = active_colors_for_band(rgba, width, height, band_top, palette)?;
for (position, color_index) in colors.iter().enumerate() {
output.extend_from_slice(format!("#{color_index}").as_bytes());
let mut run_char = None;
let mut run_len = 0usize;
for x in 0..width {
let data = sixel_data_for_column(rgba, width, height, band_top, x, *color_index)?;
push_run(&mut run_char, &mut run_len, output, data);
}
flush_run(&mut run_char, &mut run_len, output);
if position + 1 < colors.len() {
output.push(b'$');
}
}
if band_index + 1 < band_count {
if colors.is_empty() {
output.push(b'-');
} else {
output.extend_from_slice(b"$-");
}
}
}
Ok(())
}
fn active_colors_for_band(
rgba: &[u8],
width: u32,
height: u32,
band_top: u32,
palette: &Palette,
) -> Result<Vec<u8>> {
let mut active = [false; PALETTE_COLOR_COUNT];
for y in band_top..height.min(band_top + SIXEL_BAND_HEIGHT) {
for x in 0..width {
if let Some(color_index) = color_index_at(rgba, width, x, y)? {
active[usize::from(color_index)] = true;
}
}
}
Ok(palette
.indices()
.filter(|color_index| active[usize::from(*color_index)])
.collect())
}
fn sixel_data_for_column(
rgba: &[u8],
width: u32,
height: u32,
band_top: u32,
x: u32,
color_index: u8,
) -> Result<u8> {
let mut mask = 0u8;
for bit in 0..SIXEL_BAND_HEIGHT {
let y = band_top + bit;
if y >= height {
continue;
}
if color_index_at(rgba, width, x, y)? == Some(color_index) {
mask |= 1 << bit;
}
}
Ok(b'?' + mask)
}
fn color_index_at(rgba: &[u8], width: u32, x: u32, y: u32) -> Result<Option<u8>> {
let pixel_index = pixel_offset(width, x, y)?;
let alpha = rgba[pixel_index + 3];
if alpha < TRANSPARENT_ALPHA_THRESHOLD {
return Ok(None);
}
Ok(Some(rgb332_index(
rgba[pixel_index],
rgba[pixel_index + 1],
rgba[pixel_index + 2],
)))
}
fn push_run(run_char: &mut Option<u8>, run_len: &mut usize, output: &mut Vec<u8>, byte: u8) {
match *run_char {
Some(current) if current == byte => {
*run_len += 1;
}
_ => {
flush_run(run_char, run_len, output);
*run_char = Some(byte);
*run_len = 1;
}
}
}
fn flush_run(run_char: &mut Option<u8>, run_len: &mut usize, output: &mut Vec<u8>) {
let Some(byte) = run_char.take() else {
return;
};
if *run_len > 3 {
output.extend_from_slice(format!("!{}", *run_len).as_bytes());
output.push(byte);
} else {
output.extend(std::iter::repeat_n(byte, *run_len));
}
*run_len = 0;
}
fn pixel_offset(width: u32, x: u32, y: u32) -> Result<usize> {
let pixel_index = u64::from(y)
.checked_mul(u64::from(width))
.and_then(|row| row.checked_add(u64::from(x)))
.context("sixel pixel index overflow")?;
let byte_index = pixel_index
.checked_mul(4)
.context("sixel byte index overflow")?;
usize::try_from(byte_index).context("sixel byte index does not fit usize")
}
fn pixel_count(width: u32, height: u32) -> Result<usize> {
let count = u64::from(width)
.checked_mul(u64::from(height))
.context("sixel pixel count overflow")?;
usize::try_from(count).context("sixel pixel count does not fit usize")
}
fn rgb332_index(red: u8, green: u8, blue: u8) -> u8 {
let red = red >> 5;
let green = green >> 5;
let blue = blue >> 6;
(red << 5) | (green << 2) | blue
}
fn rgb332_color(index: u8) -> (u8, u8, u8) {
let red = index >> 5;
let green = (index >> 2) & 0b111;
let blue = index & 0b11;
(
scale_bucket_to_byte(red, /*max*/ 7),
scale_bucket_to_byte(green, /*max*/ 7),
scale_bucket_to_byte(blue, /*max*/ 3),
)
}
fn scale_bucket_to_byte(bucket: u8, max: u8) -> u8 {
let value = (u16::from(bucket) * 255) / u16::from(max);
u8::try_from(value).unwrap_or(u8::MAX)
}
fn byte_to_sixel_percent(value: u8) -> u8 {
let value = (u16::from(value) * 100) / 255;
u8::try_from(value).unwrap_or(100)
}
struct Palette {
used: [bool; PALETTE_COLOR_COUNT],
}
impl Palette {
fn from_rgba(rgba: &[u8]) -> Self {
let mut used = [false; PALETTE_COLOR_COUNT];
for pixel in rgba.chunks_exact(4) {
if pixel[3] < TRANSPARENT_ALPHA_THRESHOLD {
continue;
}
used[usize::from(rgb332_index(pixel[0], pixel[1], pixel[2]))] = true;
}
Self { used }
}
fn indices(&self) -> impl Iterator<Item = u8> + '_ {
(0..=u8::MAX).filter(|index| self.used[usize::from(*index)])
}
fn write_definitions(&self, output: &mut Vec<u8>) {
for color_index in self.indices() {
let (red, green, blue) = rgb332_color(color_index);
output.extend_from_slice(
format!(
"#{color_index};2;{};{};{}",
byte_to_sixel_percent(red),
byte_to_sixel_percent(green),
byte_to_sixel_percent(blue)
)
.as_bytes(),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const EXPECTED_TRANSPARENT_BACKGROUND_DCS: &str = "\x1bP9;1;0q";
#[test]
fn encodes_red_pixel_with_palette_and_pixel_data() {
let sixel = encode_rgba(&[255, 0, 0, 255], /*width*/ 1, /*height*/ 1).unwrap();
let sixel = String::from_utf8(sixel).unwrap();
assert_eq!(
sixel,
format!("{EXPECTED_TRANSPARENT_BACKGROUND_DCS}\"1;1;1;1#224;2;100;0;0#224@\x1b\\")
);
}
#[test]
fn transparent_pixels_do_not_emit_palette_or_pixel_data() {
let sixel = encode_rgba(&[255, 0, 0, 0], /*width*/ 1, /*height*/ 1).unwrap();
let sixel = String::from_utf8(sixel).unwrap();
assert_eq!(
sixel,
format!("{EXPECTED_TRANSPARENT_BACKGROUND_DCS}\"1;1;1;1\x1b\\")
);
}
#[test]
fn multi_band_images_advance_to_next_sixel_band() {
let mut rgba = Vec::new();
for _ in 0..7 {
rgba.extend_from_slice(&[255, 0, 0, 255]);
}
let sixel = encode_rgba(&rgba, /*width*/ 1, /*height*/ 7).unwrap();
let sixel = String::from_utf8(sixel).unwrap();
assert_eq!(
sixel,
format!(
"{EXPECTED_TRANSPARENT_BACKGROUND_DCS}\"1;1;1;7#224;2;100;0;0#224~$-#224@\x1b\\"
)
);
}
#[test]
fn repeated_cells_use_sixel_run_length_encoding() {
let mut rgba = Vec::new();
for _ in 0..4 {
rgba.extend_from_slice(&[255, 0, 0, 255]);
}
let sixel = encode_rgba(&rgba, /*width*/ 4, /*height*/ 1).unwrap();
let sixel = String::from_utf8(sixel).unwrap();
assert!(sixel.contains("#224!4@"));
}
#[test]
fn rejects_mismatched_rgba_buffer_length() {
let err = encode_rgba(&[255, 0, 0], /*width*/ 1, /*height*/ 1).unwrap_err();
assert_eq!(err.to_string(), "sixel RGBA buffer has 3 bytes, expected 4");
}
}