Codex Handbook
tui/src/pets/ambient.rs 528 lines
//! 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);    }}