diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index efd7d58d5..30d4bf364 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3802,6 +3802,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "shlex", "strum 0.27.2", "strum_macros 0.28.0", @@ -8981,7 +8982,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -9449,7 +9450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -14093,7 +14094,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 39fd0a442..dfc81852e 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -599,6 +599,16 @@ impl fmt::Display for NotificationCondition { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "kebab-case")] +pub enum TuiPetAnchor { + /// Anchor the pet to the bottom of the current TUI composer viewport. + #[default] + Composer, + /// Anchor the pet to the physical bottom of the terminal screen. + ScreenBottom, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct TuiNotificationSettings { @@ -695,6 +705,18 @@ pub struct Tui { #[serde(default)] pub theme: Option, + /// Pet id to preselect in the terminal pet picker. + /// + /// Custom pet ids resolve against CODEX_HOME/pets//pet.json. + #[serde(default)] + pub pet: Option, + + /// Where the terminal pet should anchor vertically. + /// + /// Defaults to `composer`, which follows the current TUI composer viewport. + #[serde(default)] + pub pet_anchor: TuiPetAnchor, + /// Preferred layout for resume/fork session picker results. #[serde(default)] pub session_picker_view: Option, diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index ea3fe042c..04ebaf8e7 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -22,6 +22,7 @@ pub use codex_config::types::SessionPickerViewMode; pub use codex_config::types::ToolSuggestConfig; pub use codex_config::types::TuiKeymap; pub use codex_config::types::TuiNotificationSettings; +pub use codex_config::types::TuiPetAnchor; pub use codex_config::types::UriBasedFileOpener; pub use codex_core::CodexThread; pub use codex_core::ForkSnapshot; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1f17bd028..5af6b7435 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2692,6 +2692,20 @@ "default": true, "description": "Enable desktop notifications from the TUI. Defaults to `true`." }, + "pet": { + "default": null, + "description": "Pet id to preselect in the terminal pet picker.\n\nCustom pet ids resolve against CODEX_HOME/pets//pet.json.", + "type": "string" + }, + "pet_anchor": { + "allOf": [ + { + "$ref": "#/definitions/TuiPetAnchor" + } + ], + "default": "composer", + "description": "Where the terminal pet should anchor vertically.\n\nDefaults to `composer`, which follows the current TUI composer viewport." + }, "raw_output_mode": { "default": false, "description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.", @@ -3436,6 +3450,24 @@ }, "type": "object" }, + "TuiPetAnchor": { + "oneOf": [ + { + "description": "Anchor the pet to the bottom of the current TUI composer viewport.", + "enum": [ + "composer" + ], + "type": "string" + }, + { + "description": "Anchor the pet to the physical bottom of the terminal screen.", + "enum": [ + "screen-bottom" + ], + "type": "string" + } + ] + }, "TuiVimNormalKeymap": { "additionalProperties": false, "description": "Vim normal-mode keybindings for modal editing inside text areas.\n\nActions that use uppercase letters (like `A` for append-line-end) should be specified as `shift-a` in config; the runtime matcher handles cross-terminal shift-reporting differences automatically.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 8ff8df4e8..59ea489e3 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -55,6 +55,7 @@ use codex_config::types::ToolSuggestDiscoverableType; use codex_config::types::Tui; use codex_config::types::TuiKeymap; use codex_config::types::TuiNotificationSettings; +use codex_config::types::TuiPetAnchor; use codex_config::types::WindowsSandboxModeToml; use codex_config::types::WindowsToml; use codex_core_plugins::PluginsManager; @@ -566,6 +567,8 @@ fn config_toml_deserializes_model_availability_nux() { status_line_use_colors: true, terminal_title: None, theme: None, + pet: None, + pet_anchor: TuiPetAnchor::Composer, session_picker_view: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig { @@ -2530,6 +2533,19 @@ session_picker_view = "dense" ); } +#[test] +fn tui_pet_deserializes_from_toml() { + let cfg = r#" +[tui] +pet = "chefito" +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().and_then(|t| t.pet.as_deref()), + Some("chefito"), + ); +} + #[test] fn tui_session_picker_view_defaults_to_none() { let cfg = r#" @@ -2542,6 +2558,56 @@ fn tui_session_picker_view_defaults_to_none() { ); } +#[test] +fn tui_pet_defaults_to_none() { + let cfg = r#" +[tui] +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!(parsed.tui.as_ref().and_then(|t| t.pet.as_deref()), None); +} + +#[test] +fn tui_pet_anchor_deserializes_from_toml() { + let cfg = r#" +[tui] +pet_anchor = "screen-bottom" +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().map(|t| t.pet_anchor), + Some(TuiPetAnchor::ScreenBottom), + ); +} + +#[test] +fn tui_pet_anchor_defaults_to_composer() { + let cfg = r#" +[tui] +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().map(|t| t.pet_anchor), + Some(TuiPetAnchor::Composer), + ); +} + +#[test] +fn tui_pet_anchor_rejects_unknown_value() { + let cfg = r#" +[tui] +pet_anchor = "bottom" +"#; + let err = toml::from_str::(cfg).expect_err("reject unknown pet anchor"); + let err = err.to_string(); + assert!( + err.contains("unknown variant `bottom`") + && err.contains("composer") + && err.contains("screen-bottom"), + "unexpected error: {err}" + ); +} + #[test] fn tui_config_missing_notifications_field_defaults_to_enabled() { let cfg = r#" @@ -2565,6 +2631,8 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { status_line_use_colors: true, terminal_title: None, theme: None, + pet: None, + pet_anchor: TuiPetAnchor::Composer, session_picker_view: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), @@ -7322,6 +7390,8 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }, @@ -7766,6 +7836,8 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; @@ -7924,6 +7996,8 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; @@ -8067,6 +8141,8 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 959eaaf15..4dde2c0cb 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -92,6 +92,14 @@ pub fn syntax_theme_edit(name: &str) -> ConfigEdit { } } +/// Produces a config edit that sets [tui].pet = "". +pub fn tui_pet_edit(name: &str) -> ConfigEdit { + ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "pet".to_string()], + value: value(name.to_string()), + } +} + /// Produces a config edit that sets `[tui].session_picker_view = ""`. pub fn session_picker_view_edit(mode: SessionPickerViewMode) -> ConfigEdit { ConfigEdit::SetPath { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c65b713a9..f5dda3fc3 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -51,6 +51,7 @@ use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::TuiKeymap; use codex_config::types::TuiNotificationSettings; +use codex_config::types::TuiPetAnchor; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; use codex_core_plugins::PluginsConfigInput; @@ -549,6 +550,12 @@ pub struct Config { /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, + /// Pet id preselected by the terminal pet picker. + pub tui_pet: Option, + + /// Vertical anchor used by terminal pet rendering. + pub tui_pet_anchor: TuiPetAnchor, + /// Preferred layout for resume/fork session picker results. pub tui_session_picker_view: SessionPickerViewMode, @@ -3205,6 +3212,12 @@ impl Config { .unwrap_or(true), tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()), tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), + tui_pet: cfg.tui.as_ref().and_then(|t| t.pet.clone()), + tui_pet_anchor: cfg + .tui + .as_ref() + .map(|t| t.pet_anchor) + .unwrap_or_default(), tui_session_picker_view: config_profile .tui .as_ref() diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 191c294c9..335dd4ac5 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -48,6 +48,7 @@ use codex_core_api::ThreadStoreConfig; use codex_core_api::ToolSuggestConfig; use codex_core_api::TuiKeymap; use codex_core_api::TuiNotificationSettings; +use codex_core_api::TuiPetAnchor; use codex_core_api::UriBasedFileOpener; use codex_core_api::UserInput; use codex_core_api::WebSearchMode; @@ -205,6 +206,8 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R tui_terminal_title: None, tui_theme: None, tui_raw_output_mode: false, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, terminal_resize_reflow: TerminalResizeReflowConfig::default(), tui_keymap: TuiKeymap::default(), tui_session_picker_view: SessionPickerViewMode::Dense, diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index d3d28461b..92062f88a 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -86,10 +86,11 @@ ratatui = { workspace = true, features = [ ] } ratatui-macros = { workspace = true } regex-lite = { workspace = true } -reqwest = { workspace = true, features = ["json"] } +reqwest = { workspace = true, features = ["blocking", "json"] } rmcp = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } +sha2 = { workspace = true } shlex = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8aefbd0f4..81515603d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -155,6 +155,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use ratatui::backend::Backend; +use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; @@ -192,6 +193,7 @@ mod history_ui; mod input; mod loaded_threads; mod pending_interactive_replay; +mod pets; mod platform_actions; mod replay_filter; mod resize_reflow; @@ -1080,13 +1082,18 @@ See the Codex keymap documentation for supported actions and examples." if let Err(err) = app_server.shutdown().await { tracing::warn!(error = %err, "failed to shut down embedded app server"); } + let clear_pet_result = tui.clear_ambient_pet_image(); let clear_result = tui.terminal.clear(); let exit_reason = match exit_reason_result { Ok(exit_reason) => { + clear_pet_result?; clear_result?; exit_reason } Err(err) => { + if let Err(clear_pet_err) = clear_pet_result { + tracing::warn!(error = %clear_pet_err, "failed to clear ambient pet image"); + } if let Err(clear_err) = clear_result { tracing::warn!(error = %clear_err, "failed to clear terminal UI"); } @@ -1154,9 +1161,11 @@ See the Codex keymap documentation for supported actions and examples." self.chat_widget.pre_draw_tick(); let desired_height = self.chat_widget.desired_height(tui.terminal.size()?.width); + let mut rendered_area = Rect::default(); if terminal_resize_reflow_enabled { tui.draw_with_resize_reflow(desired_height, |frame| { let area = frame.area(); + rendered_area = area; self.chat_widget.render(area, frame.buffer); if let Some((x, y)) = self.chat_widget.cursor_pos(area) { frame.set_cursor_style(self.chat_widget.cursor_style(area)); @@ -1166,6 +1175,7 @@ See the Codex keymap documentation for supported actions and examples." } else { tui.draw(desired_height, |frame| { let area = frame.area(); + rendered_area = area; self.chat_widget.render(area, frame.buffer); if let Some((x, y)) = self.chat_widget.cursor_pos(area) { frame.set_cursor_style(self.chat_widget.cursor_style(area)); @@ -1173,6 +1183,30 @@ See the Codex keymap documentation for supported actions and examples." } })?; } + if self.chat_widget.ambient_pet_image_enabled() { + let terminal_size = tui.terminal.size()?; + let ambient_pet_area = Rect::new( + /*x*/ 0, + /*y*/ 0, + terminal_size.width, + terminal_size.height, + ); + if let Err(err) = tui.draw_ambient_pet_image( + self.chat_widget + .ambient_pet_draw(ambient_pet_area, rendered_area.bottom()), + ) { + self.handle_ambient_pet_image_render_error(tui, err)?; + } + } + if let Some(request) = self.chat_widget.pet_picker_preview_draw() { + if let Err(err) = tui.draw_pet_picker_preview_image(Some(request)) { + self.handle_pet_picker_preview_image_render_error(tui, err)?; + } + } else if self.chat_widget.should_clear_pet_picker_preview_image() + && let Err(err) = tui.draw_pet_picker_preview_image(/*request*/ None) + { + self.handle_pet_picker_preview_image_render_error(tui, err)?; + } if self.chat_widget.external_editor_state() == ExternalEditorState::Requested { self.chat_widget .set_external_editor_state(ExternalEditorState::Active); diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index e50c2782c..d20ffb821 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -515,6 +515,18 @@ impl App { self.chat_widget.set_tui_theme(Some(name)); } + #[cfg(test)] + pub(super) fn sync_tui_pet_selection(&mut self, pet: String) { + self.config.tui_pet = Some(pet.clone()); + self.chat_widget.set_tui_pet(Some(pet)); + } + + pub(super) fn sync_tui_pet_disabled(&mut self) { + let pet = crate::pets::DISABLED_PET_ID.to_string(); + self.config.tui_pet = Some(pet.clone()); + self.chat_widget.set_tui_pet(Some(pet)); + } + pub(super) fn restore_runtime_theme_from_config(&self) { if let Some(name) = self.config.tui_theme.as_deref() && let Some(theme) = @@ -732,4 +744,33 @@ terminal_resize_reflow_max_rows = 9000 Some("dracula") ); } + + #[tokio::test] + async fn sync_tui_pet_selection_updates_chat_widget_config_copy() { + let mut app = make_test_app().await; + + app.sync_tui_pet_selection("chefito".to_string()); + + assert_eq!(app.config.tui_pet.as_deref(), Some("chefito")); + assert_eq!( + app.chat_widget.config_ref().tui_pet.as_deref(), + Some("chefito") + ); + } + + #[tokio::test] + async fn sync_tui_pet_disabled_updates_chat_widget_config_copy() { + let mut app = make_test_app().await; + + app.sync_tui_pet_disabled(); + + assert_eq!( + app.config.tui_pet.as_deref(), + Some(crate::pets::DISABLED_PET_ID) + ); + assert_eq!( + app.chat_widget.config_ref().tui_pet.as_deref(), + Some(crate::pets::DISABLED_PET_ID) + ); + } } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 886aa9175..e1ae3099a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -204,13 +204,15 @@ impl App { self.insert_history_cell_lines_with_initial_replay_buffer( tui, cell.as_ref(), - tui.terminal.last_known_screen_size.width, + self.chat_widget + .history_wrap_width(tui.terminal.last_known_screen_size.width), ); } else { self.insert_history_cell_lines( tui, cell.as_ref(), - tui.terminal.last_known_screen_size.width, + self.chat_widget + .history_wrap_width(tui.terminal.last_known_screen_size.width), ); } } @@ -262,7 +264,8 @@ impl App { self.insert_history_cell_lines( tui, consolidated.as_ref(), - tui.terminal.last_known_screen_size.width, + self.chat_widget + .history_wrap_width(tui.terminal.last_known_screen_size.width), ); self.maybe_finish_stream_reflow(tui)?; @@ -389,6 +392,30 @@ impl App { AppEvent::OpenUrlInBrowser { url } => { self.open_url_in_browser(url); } + AppEvent::PetSelected { pet_id } => { + self.handle_pet_selected(tui, pet_id); + } + AppEvent::PetDisabled => { + self.handle_pet_disabled(tui).await; + } + AppEvent::PetPreviewRequested { pet_id } => { + self.chat_widget.start_pet_picker_preview(pet_id); + } + AppEvent::PetPreviewLoaded { request_id, result } => { + self.handle_pet_preview_loaded(tui, request_id, result); + } + AppEvent::PetSelectionLoaded { + request_id, + pet_id, + result, + } => { + return self + .handle_pet_selection_loaded(tui, request_id, pet_id, result) + .await; + } + AppEvent::ConfiguredPetLoaded { pet_id, result } => { + self.handle_configured_pet_loaded(tui, pet_id, result); + } AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index f6fec98f1..e923db177 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -41,7 +41,9 @@ impl App { } pub(super) fn queue_clear_ui_header(&mut self, tui: &mut tui::Tui) { - let width = tui.terminal.last_known_screen_size.width; + let width = self + .chat_widget + .history_wrap_width(tui.terminal.last_known_screen_size.width); let header_lines = self.clear_ui_header_lines(width); if !header_lines.is_empty() { tui.insert_history_lines(header_lines); diff --git a/codex-rs/tui/src/app/pets.rs b/codex-rs/tui/src/app/pets.rs new file mode 100644 index 000000000..fe6e90673 --- /dev/null +++ b/codex-rs/tui/src/app/pets.rs @@ -0,0 +1,181 @@ +//! App-level handlers for ambient terminal pet events. + +use super::*; + +impl App { + pub(super) fn handle_ambient_pet_image_render_error( + &mut self, + tui: &mut tui::Tui, + err: crate::pets::PetImageRenderError, + ) -> Result<()> { + match err { + crate::pets::PetImageRenderError::Terminal(err) => Err(err.into()), + crate::pets::PetImageRenderError::Asset(err) => { + tracing::warn!( + error = %err, + "failed to render ambient pet image; disabling pet for session" + ); + self.chat_widget.disable_ambient_pet_for_session(); + if let Err(clear_err) = tui.clear_ambient_pet_image() { + match clear_err { + crate::pets::PetImageRenderError::Terminal(err) => return Err(err.into()), + crate::pets::PetImageRenderError::Asset(err) => { + tracing::warn!( + error = %err, + "failed to clear ambient pet image after render failure" + ); + } + } + } + Ok(()) + } + } + } + + pub(super) fn handle_pet_picker_preview_image_render_error( + &mut self, + tui: &mut tui::Tui, + err: crate::pets::PetImageRenderError, + ) -> Result<()> { + match err { + crate::pets::PetImageRenderError::Terminal(err) => Err(err.into()), + crate::pets::PetImageRenderError::Asset(err) => { + tracing::warn!(error = %err, "failed to render pet picker preview image"); + self.chat_widget + .fail_pet_picker_preview_render(err.to_string()); + if let Err(clear_err) = tui.draw_pet_picker_preview_image(/*request*/ None) { + match clear_err { + crate::pets::PetImageRenderError::Terminal(err) => return Err(err.into()), + crate::pets::PetImageRenderError::Asset(err) => { + tracing::warn!( + error = %err, + "failed to clear pet picker preview image after render failure" + ); + } + } + } + Ok(()) + } + } + } + + pub(super) fn handle_pet_selected(&mut self, tui: &mut tui::Tui, pet_id: String) { + let request_id = self.chat_widget.show_pet_selection_loading_popup(); + tui.frame_requester().schedule_frame(); + let codex_home = self.config.codex_home.clone(); + let frame_requester = tui.frame_requester(); + let animations_enabled = self.config.animations; + let tx = self.app_event_tx.clone(); + std::mem::drop(tokio::task::spawn_blocking(move || { + let result = crate::pets::ensure_builtin_pack_for_pet(&pet_id, &codex_home) + .and_then(|()| { + crate::pets::AmbientPet::load( + Some(&pet_id), + &codex_home, + frame_requester, + animations_enabled, + ) + }) + .map(Some) + .map_err(|err| err.to_string()); + tx.send(AppEvent::PetSelectionLoaded { + request_id, + pet_id, + result, + }); + })); + } + + pub(super) async fn handle_pet_disabled(&mut self, tui: &mut tui::Tui) { + let edit = crate::legacy_core::config::edit::tui_pet_edit(crate::pets::DISABLED_PET_ID); + let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await; + match apply_result { + Ok(()) => { + self.sync_tui_pet_disabled(); + tui.frame_requester().schedule_frame(); + } + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to disable pets: {err}")); + } + } + } + + pub(super) fn handle_pet_preview_loaded( + &mut self, + tui: &mut tui::Tui, + request_id: u64, + result: Result, + ) { + self.chat_widget + .finish_pet_picker_preview_load(request_id, result); + tui.frame_requester().schedule_frame(); + } + + pub(super) async fn handle_pet_selection_loaded( + &mut self, + tui: &mut tui::Tui, + request_id: u64, + pet_id: String, + result: Result, String>, + ) -> Result { + if !self + .chat_widget + .finish_pet_selection_loading_popup(request_id) + { + return Ok(AppRunControl::Continue); + } + match result { + Ok(ambient_pet) => { + let edit = crate::legacy_core::config::edit::tui_pet_edit(&pet_id); + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await + { + Ok(()) => { + self.config.tui_pet = Some(pet_id.clone()); + self.chat_widget + .set_tui_pet_loaded(Some(pet_id), ambient_pet); + } + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to save pet selection: {err}")); + } + } + } + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to load pet: {err}")); + } + } + tui.frame_requester().schedule_frame(); + Ok(AppRunControl::Continue) + } + + pub(super) fn handle_configured_pet_loaded( + &mut self, + tui: &mut tui::Tui, + pet_id: String, + result: Result, String>, + ) { + if self.config.tui_pet.as_deref() != Some(pet_id.as_str()) { + return; + } + + match result { + Ok(ambient_pet) => { + self.chat_widget + .set_tui_pet_loaded(Some(pet_id), ambient_pet); + tui.frame_requester().schedule_frame(); + } + Err(err) => { + self.chat_widget + .add_warning_message(format!("Failed to load configured pet: {err}")); + } + } + } +} diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 7775aed71..6ef172126 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -419,12 +419,13 @@ impl App { } pub(super) fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result { - let width = tui.terminal.size()?.width; + let terminal_width = tui.terminal.size()?.width; + let width = self.chat_widget.history_wrap_width(terminal_width); if self.transcript_cells.is_empty() { // Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells. tui.clear_pending_history_lines(); self.reset_history_emission_state(); - return Ok(width); + return Ok(terminal_width); } let reflow_result = self.render_transcript_lines_for_reflow(width); @@ -442,7 +443,7 @@ impl App { ); } - Ok(width) + Ok(terminal_width) } /// Render transcript cells for the current resize rebuild. diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index d2a942bb4..6d1e4d31c 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -13,6 +13,7 @@ use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::chatwidget::tests::set_chatgpt_auth; use crate::chatwidget::tests::set_fast_mode_test_catalog; use crate::file_search::FileSearchManager; +use crate::history_cell::AgentMarkdownCell; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; @@ -85,6 +86,7 @@ use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::Line; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -4159,6 +4161,32 @@ async fn uncapped_resize_reflow_renders_all_cells_when_row_cap_absent() { assert_eq!(rendered_line_text(&rendered.lines[38]), "cell 19"); } +#[tokio::test] +async fn resize_reflow_wraps_transcript_early_when_pet_is_enabled() { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled; + app.transcript_cells = vec![Arc::new(AgentMarkdownCell::new( + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda".to_string(), + Path::new("/tmp"), + ))]; + + let without_pet = app.render_transcript_lines_for_reflow(/*width*/ 40); + app.chat_widget + .set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported( + crate::pets::ImageProtocol::Kitty, + )); + app.chat_widget + .install_test_ambient_pet_for_tests(/*animations_enabled*/ false); + let width = app.chat_widget.history_wrap_width(/*width*/ 40); + assert!(width < 40); + let with_pet = app.render_transcript_lines_for_reflow(width); + + assert!( + with_pet.lines.len() > without_pet.lines.len(), + "expected pet-enabled transcript reflow to wrap earlier" + ); +} + #[tokio::test] async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 307fb8559..625f8d084 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -259,7 +259,9 @@ impl App { /// Useful when switching sessions to ensure prior history remains visible. pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) { if !self.transcript_cells.is_empty() { - let width = tui.terminal.last_known_screen_size.width; + let width = self + .chat_widget + .history_wrap_width(tui.terminal.last_known_screen_size.width); for cell in &self.transcript_cells { tui.insert_history_lines_with_wrap_policy( cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 03434b130..530e8876a 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -318,6 +318,38 @@ pub(crate) enum AppEvent { url: String, }, + /// Persist a pet selection and reload the ambient pet. + PetSelected { + pet_id: String, + }, + + /// Persist terminal pets as disabled and remove the ambient pet. + PetDisabled, + + /// Start loading the side preview for the pet picker. + PetPreviewRequested { + pet_id: String, + }, + + /// Result of loading the side preview for the pet picker. + PetPreviewLoaded { + request_id: u64, + result: Result, + }, + + /// Result of loading the selected ambient pet before config persistence. + PetSelectionLoaded { + request_id: u64, + pet_id: String, + result: Result, String>, + }, + + /// Result of restoring the configured ambient pet during startup. + ConfiguredPetLoaded { + pet_id: String, + result: Result, String>, + }, + /// Refresh app connector state and mention bindings. RefreshConnectors { force_refetch: bool, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 131236533..5f93febfa 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -800,6 +800,14 @@ impl ChatComposer { self.windows_degraded_sandbox_active = enabled; } fn layout_areas(&self, area: Rect) -> [Rect; 4] { + self.layout_areas_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0) + } + + fn layout_areas_with_textarea_right_reserve( + &self, + area: Rect, + textarea_right_reserve: u16, + ) -> [Rect; 4] { let footer_props = self.footer_props(); let footer_hint_height = self .custom_footer_height() @@ -825,7 +833,7 @@ impl ChatComposer { /*top*/ 1, LIVE_PREFIX_COLS, /*bottom*/ 1, - /*right*/ 1, + /*right*/ 1u16.saturating_add(textarea_right_reserve), )); let remote_images_height = self .remote_images_lines(textarea_rect.width) @@ -855,7 +863,15 @@ impl ChatComposer { } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - if !self.input_enabled { + self.cursor_pos_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0) + } + + pub(crate) fn cursor_pos_with_textarea_right_reserve( + &self, + area: Rect, + textarea_right_reserve: u16, + ) -> Option<(u16, u16)> { + if !self.input_enabled || self.selected_remote_image_index.is_some() { return None; } @@ -863,7 +879,8 @@ impl ChatComposer { return Some(pos); } - let [_, _, textarea_rect, _] = self.layout_areas(area); + let [_, _, textarea_rect, _] = + self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve); let state = *self.textarea_state.borrow(); self.textarea.cursor_pos_with_state(textarea_rect, state) } @@ -4459,17 +4476,7 @@ fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option impl Renderable for ChatComposer { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - if !self.input_enabled || self.selected_remote_image_index.is_some() { - return None; - } - - if let Some(pos) = self.history_search_cursor_pos(area) { - return Some(pos); - } - - let [_, _, textarea_rect, _] = self.layout_areas(area); - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) + self.cursor_pos_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0) } fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle { @@ -4481,6 +4488,20 @@ impl Renderable for ChatComposer { } fn desired_height(&self, width: u16) -> u16 { + self.desired_height_with_textarea_right_reserve(width, /*textarea_right_reserve*/ 0) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.render_with_mask(area, buf, /*mask_char*/ None); + } +} + +impl ChatComposer { + pub(crate) fn desired_height_with_textarea_right_reserve( + &self, + width: u16, + textarea_right_reserve: u16, + ) -> u16 { let footer_props = self.footer_props(); let footer_hint_height = self .custom_footer_height() @@ -4488,7 +4509,8 @@ impl Renderable for ChatComposer { let footer_spacing = Self::footer_spacing(footer_hint_height); let footer_total_height = footer_hint_height + footer_spacing; const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; - let inner_width = width.saturating_sub(COLS_WITH_MARGIN); + let inner_width = + width.saturating_sub(COLS_WITH_MARGIN.saturating_add(textarea_right_reserve)); let remote_images_height: u16 = self .remote_images_lines(inner_width) .len() @@ -4507,16 +4529,24 @@ impl Renderable for ChatComposer { ActivePopup::MentionV2(c) => c.calculate_required_height(width), } } - - fn render(&self, area: Rect, buf: &mut Buffer) { - self.render_with_mask(area, buf, /*mask_char*/ None); - } } impl ChatComposer { pub(crate) fn render_with_mask(&self, area: Rect, buf: &mut Buffer, mask_char: Option) { + self.render_with_mask_and_textarea_right_reserve( + area, buf, mask_char, /*textarea_right_reserve*/ 0, + ); + } + + pub(crate) fn render_with_mask_and_textarea_right_reserve( + &self, + area: Rect, + buf: &mut Buffer, + mask_char: Option, + textarea_right_reserve: u16, + ) { let [composer_rect, remote_images_rect, textarea_rect, popup_rect] = - self.layout_areas(area); + self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve); match &self.active_popup { ActivePopup::Command(popup) => { popup.render_ref(popup_rect, buf); @@ -7873,6 +7903,60 @@ mod tests { } } + #[test] + fn slash_popup_pets_for_pet_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'p', 'e', 't']); + + let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_pet", terminal.backend()); + } + + #[test] + fn slash_popup_pets_for_pet_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + type_chars_humanlike(&mut composer, &['/', 'p', 'e', 't']); + + match &composer.active_popup { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "pets") + } + Some(CommandItem::ServiceTier(command)) => { + panic!("expected pets command, got service tier {command:?}") + } + None => panic!("no selected command for '/pet'"), + }, + _ => panic!("slash popup not active after typing '/pet'"), + } + } + #[test] fn service_tier_slash_command_dispatches_from_catalog_name() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 975062e4c..5a0f967ee 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -106,6 +106,7 @@ pub(crate) use footer::GoalStatusIndicator; pub(crate) use footer::goal_status_indicator_line; pub(crate) use list_selection_view::ColumnWidthMode; pub(crate) use list_selection_view::ListSelectionView; +pub(crate) use list_selection_view::OnSelectionChangedCallback; pub(crate) use list_selection_view::SelectionRowDisplay; pub(crate) use list_selection_view::SelectionToggle; pub(crate) use list_selection_view::SelectionViewParams; @@ -1129,6 +1130,20 @@ impl BottomPane { .and_then(|view| view.active_tab_id()) } + pub(crate) fn dismiss_active_view_if_id(&mut self, view_id: &'static str) -> bool { + let is_match = self + .view_stack + .last() + .is_some_and(|view| view.view_id() == Some(view_id)); + if !is_match { + return false; + } + + self.view_stack.pop(); + self.request_redraw(); + true + } + /// Update the pending-input preview shown above the composer. pub(crate) fn set_pending_input_preview( &mut self, @@ -1529,6 +1544,13 @@ impl BottomPane { } fn as_renderable(&'_ self) -> RenderableItem<'_> { + self.as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0) + } + + fn as_renderable_with_composer_right_reserve( + &'_ self, + composer_right_reserve: u16, + ) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) } else { @@ -1570,11 +1592,56 @@ impl BottomPane { } let mut flex2 = FlexRenderable::new(); flex2.push(/*flex*/ 1, RenderableItem::Owned(flex.into())); - flex2.push(/*flex*/ 0, RenderableItem::Borrowed(&self.composer)); + let composer: RenderableItem<'_> = if composer_right_reserve == 0 { + RenderableItem::Borrowed(&self.composer) + } else { + RenderableItem::Owned(Box::new(ChatComposerRightReserveRenderable { + composer: &self.composer, + right_reserve: composer_right_reserve, + })) + }; + flex2.push(/*flex*/ 0, composer); RenderableItem::Owned(Box::new(flex2)) } } + pub(crate) fn render_with_composer_right_reserve( + &self, + area: Rect, + buf: &mut Buffer, + composer_right_reserve: u16, + ) { + self.as_renderable_with_composer_right_reserve(composer_right_reserve) + .render(area, buf); + } + + pub(crate) fn desired_height_with_composer_right_reserve( + &self, + width: u16, + composer_right_reserve: u16, + ) -> u16 { + self.as_renderable_with_composer_right_reserve(composer_right_reserve) + .desired_height(width) + } + + pub(crate) fn cursor_pos_with_composer_right_reserve( + &self, + area: Rect, + composer_right_reserve: u16, + ) -> Option<(u16, u16)> { + self.as_renderable_with_composer_right_reserve(composer_right_reserve) + .cursor_pos(area) + } + + pub(crate) fn cursor_style_with_composer_right_reserve( + &self, + area: Rect, + composer_right_reserve: u16, + ) -> crossterm::cursor::SetCursorStyle { + self.as_renderable_with_composer_right_reserve(composer_right_reserve) + .cursor_style(area) + } + pub(crate) fn set_status_line(&mut self, status_line: Option>) { if self.composer.set_status_line(status_line) { self.request_redraw(); @@ -1610,6 +1677,36 @@ impl BottomPane { } } +struct ChatComposerRightReserveRenderable<'a> { + composer: &'a chat_composer::ChatComposer, + right_reserve: u16, +} + +impl Renderable for ChatComposerRightReserveRenderable<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.composer.render_with_mask_and_textarea_right_reserve( + area, + buf, + /*mask_char*/ None, + self.right_reserve, + ); + } + + fn desired_height(&self, width: u16) -> u16 { + self.composer + .desired_height_with_textarea_right_reserve(width, self.right_reserve) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.composer + .cursor_pos_with_textarea_right_reserve(area, self.right_reserve) + } + + fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { + self.composer.cursor_style(area) + } +} + #[cfg(not(target_os = "linux"))] impl BottomPane { pub(crate) fn insert_recording_meter_placeholder(&mut self, text: &str) -> String { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap new file mode 100644 index 000000000..3ac116bb4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /pet " +" " +" " +" /pets choose or hide the terminal pet " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 131a3bf3e..9efc99531 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -182,7 +182,10 @@ use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::text::Text; +use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; use ratatui::widgets::Wrap; use tokio::sync::mpsc::UnboundedSender; use tracing::debug; @@ -203,6 +206,8 @@ const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change"; const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override"; const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override"; const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; +const PET_SELECTION_LOADING_VIEW_ID: &str = "pet-selection-loading"; +const AMBIENT_PET_WRAP_GAP_COLUMNS: u16 = 2; const TUI_STUB_MESSAGE: &str = "Not available in TUI yet."; /// Choose the keybinding used to edit the most-recently queued message. @@ -334,6 +339,7 @@ use self::interrupts::InterruptManager; mod keymap_picker; mod mcp_startup; use self::mcp_startup::McpStartupStatus; +mod pets; mod session_header; use self::session_header::SessionHeader; mod hooks; @@ -743,6 +749,15 @@ pub(crate) struct ChatWidget { review: ReviewState, // Active hook runs render in a dedicated live cell so they can run alongside tools. active_hook_cell: Option, + // Ambient companion rendered over the transcript area, never inside the footer rows. + ambient_pet: Option, + pet_picker_preview_state: crate::pets::PetPickerPreviewState, + pet_picker_preview_pet: Option, + pet_picker_preview_request_id: u64, + pet_picker_preview_image_visible: std::cell::Cell, + pet_selection_load_request_id: u64, + #[cfg(test)] + pet_image_support_override: Option, thread_id: Option, /// Nudge dismissals that should survive draft edits within the current thread scope. /// @@ -2270,6 +2285,10 @@ impl ChatWidget { self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Running, + /*body*/ None, + ); self.request_redraw(); } @@ -2363,6 +2382,10 @@ impl ChatWidget { self.suppressed_exec_calls.clear(); self.last_unified_wait = None; self.unified_exec_wait_streak = None; + if !from_replay { + let body = Notification::agent_turn_preview(¬ification_response); + self.set_ambient_pet_notification(crate::pets::PetNotificationKind::Review, body); + } self.request_redraw(); let had_pending_steers = !self.input_queue.pending_steers.is_empty(); @@ -2847,6 +2870,10 @@ impl ChatWidget { self.input_queue.submit_pending_steers_after_interrupt = false; self.finalize_turn(); self.add_to_history(history_cell::new_error_event(message)); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Failed, + /*body*/ None, + ); self.request_redraw(); // After an error ends the turn, try sending the next queued input. @@ -4051,6 +4078,9 @@ impl ChatWidget { self.update_due_hook_visibility(); self.schedule_hook_timer_if_needed(); self.bottom_pane.pre_draw_tick(); + if let Some(pet) = self.ambient_pet.as_ref() { + pet.schedule_next_frame(); + } self.refresh_plan_mode_nudge(); self.refresh_goal_status_indicator_for_time_tick(); if self.terminal_title_shows_action_required() != self.last_terminal_title_requires_action { @@ -4395,6 +4425,10 @@ impl ChatWidget { }; self.bottom_pane .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); } @@ -4411,6 +4445,10 @@ impl ChatWidget { }; self.bottom_pane .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); self.notify(Notification::EditApprovalRequested { cwd: self.config.cwd.to_path_buf(), @@ -4469,12 +4507,20 @@ impl ChatWidget { } } } + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); } pub(crate) fn push_approval_request(&mut self, request: ApprovalRequest) { self.bottom_pane .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); } @@ -4484,6 +4530,10 @@ impl ChatWidget { ) { self.bottom_pane .push_mcp_server_elicitation_request(request); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); } @@ -4498,6 +4548,10 @@ impl ChatWidget { }; self.notify(Notification::PlanModePrompt { title }); self.bottom_pane.push_user_input_request(ev); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); } @@ -4512,6 +4566,10 @@ impl ChatWidget { }; self.bottom_pane .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); } @@ -4772,6 +4830,12 @@ impl ChatWidget { &chat_keymap.edit_queued_message, current_terminal_info, ); + pets::start_configured_pet_load_if_needed( + &config, + /*ambient_pet_missing*/ true, + frame_requester.clone(), + app_event_tx.clone(), + ); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -4846,6 +4910,14 @@ impl ChatWidget { status_state: StatusState::default(), review: ReviewState::default(), active_hook_cell: None, + ambient_pet: None, + pet_picker_preview_state: crate::pets::PetPickerPreviewState::default(), + pet_picker_preview_pet: None, + pet_picker_preview_request_id: 0, + pet_picker_preview_image_visible: std::cell::Cell::new(/*value*/ false), + pet_selection_load_request_id: 0, + #[cfg(test)] + pet_image_support_override: None, thread_id: None, dismissed_plan_mode_nudge_scopes: HashSet::new(), thread_name: None, @@ -9369,6 +9441,11 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn add_warning_message(&mut self, message: String) { + self.add_to_history(history_cell::new_warning_event(message)); + self.request_redraw(); + } + pub(crate) fn add_error_message(&mut self, message: String) { self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); @@ -9775,6 +9852,8 @@ impl ChatWidget { if width == 0 { None } else { + let width = u16::try_from(width).unwrap_or(u16::MAX); + let width = usize::from(self.history_wrap_width(width)); Some(crate::width::usable_content_width(width, reserved_cols).unwrap_or(1)) } }) @@ -10405,17 +10484,22 @@ impl ChatWidget { } fn as_renderable(&self) -> RenderableItem<'_> { + let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols(); let active_cell_renderable = match &self.transcript.active_cell { - Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr( - /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, - )), + Some(cell) => RenderableItem::Owned(Box::new(TranscriptAreaRenderable { + child: cell.as_ref(), + top: 1, + right: active_cell_right_reserve, + })), None => RenderableItem::Owned(Box::new(())), }; let active_hook_cell_renderable = match &self.active_hook_cell { Some(cell) if cell.should_render() => { - RenderableItem::Borrowed(cell).inset(Insets::tlbr( - /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, - )) + RenderableItem::Owned(Box::new(TranscriptAreaRenderable { + child: cell, + top: 1, + right: active_cell_right_reserve, + })) } _ => RenderableItem::Owned(Box::new(())), }; @@ -10424,7 +10508,11 @@ impl ChatWidget { flex.push(/*flex*/ 0, active_hook_cell_renderable); flex.push( /*flex*/ 0, - RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr( + RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable { + bottom_pane: &self.bottom_pane, + right_reserve: active_cell_right_reserve, + })) + .inset(Insets::tlbr( /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, )), ); @@ -10432,6 +10520,75 @@ impl ChatWidget { } } +struct BottomPaneComposerReserveRenderable<'a> { + bottom_pane: &'a BottomPane, + right_reserve: u16, +} + +impl Renderable for BottomPaneComposerReserveRenderable<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.bottom_pane + .render_with_composer_right_reserve(area, buf, self.right_reserve); + } + + fn desired_height(&self, width: u16) -> u16 { + self.bottom_pane + .desired_height_with_composer_right_reserve(width, self.right_reserve) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.bottom_pane + .cursor_pos_with_composer_right_reserve(area, self.right_reserve) + } + + fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { + self.bottom_pane + .cursor_style_with_composer_right_reserve(area, self.right_reserve) + } +} + +struct TranscriptAreaRenderable<'a> { + child: &'a dyn HistoryCell, + top: u16, + right: u16, +} + +impl Renderable for TranscriptAreaRenderable<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + let area = self.child_area(area); + let lines = self.child.display_lines(area.width); + let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); + let y = if area.height == 0 { + 0 + } else { + let overflow = paragraph + .line_count(area.width) + .saturating_sub(usize::from(area.height)); + u16::try_from(overflow).unwrap_or(u16::MAX) + }; + Clear.render(area, buf); + paragraph.scroll((y, 0)).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + let child_width = width.saturating_sub(self.right).max(1); + HistoryCell::desired_height(self.child, child_width) + self.top + } +} + +impl TranscriptAreaRenderable<'_> { + fn child_area(&self, area: Rect) -> Rect { + let y = area.y.saturating_add(self.top); + let height = area.height.saturating_sub(self.top); + Rect::new( + area.x, + y, + area.width.saturating_sub(self.right).max(1), + height, + ) + } +} + #[cfg(not(target_os = "linux"))] impl ChatWidget { pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool { diff --git a/codex-rs/tui/src/chatwidget/pets.rs b/codex-rs/tui/src/chatwidget/pets.rs new file mode 100644 index 000000000..71583414f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/pets.rs @@ -0,0 +1,335 @@ +//! Chat widget helpers for ambient terminal pets and the pets picker. + +use super::*; +use codex_config::types::TuiPetAnchor; + +pub(super) fn load_ambient_pet( + config: &Config, + frame_requester: FrameRequester, +) -> Option { + let selected_pet = config.tui_pet.as_deref()?; + if selected_pet == crate::pets::DISABLED_PET_ID { + return None; + } + + crate::pets::AmbientPet::load( + Some(selected_pet), + &config.codex_home, + frame_requester, + config.animations, + ) + .ok() +} + +pub(super) fn start_configured_pet_load_if_needed( + config: &Config, + ambient_pet_missing: bool, + frame_requester: FrameRequester, + app_event_tx: AppEventSender, +) { + let Some(pet_id) = config.tui_pet.clone() else { + return; + }; + if pet_id == crate::pets::DISABLED_PET_ID || !ambient_pet_missing { + return; + } + + let codex_home = config.codex_home.clone(); + let animations_enabled = config.animations; + spawn_pet_load(move || { + let result = crate::pets::ensure_builtin_pack_for_pet(&pet_id, &codex_home) + .and_then(|()| { + crate::pets::AmbientPet::load( + Some(&pet_id), + &codex_home, + frame_requester, + animations_enabled, + ) + }) + .map(Some) + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::ConfiguredPetLoaded { pet_id, result }); + }); +} + +impl ChatWidget { + pub(super) fn set_ambient_pet_notification( + &mut self, + kind: crate::pets::PetNotificationKind, + body: Option, + ) { + if let Some(pet) = self.ambient_pet.as_mut() { + pet.set_notification(kind, body); + } + } + + pub(crate) fn ambient_pet_image_enabled(&self) -> bool { + self.ambient_pet + .as_ref() + .is_some_and(crate::pets::AmbientPet::image_enabled) + } + + pub(crate) fn disable_ambient_pet_for_session(&mut self) { + self.ambient_pet = None; + self.request_redraw(); + } + + pub(crate) fn ambient_pet_draw( + &self, + area: Rect, + composer_bottom_y: u16, + ) -> Option { + if !self.bottom_pane.no_modal_or_popup_active() { + return None; + } + + let anchor_bottom_y = match self.config.tui_pet_anchor { + TuiPetAnchor::Composer => composer_bottom_y, + TuiPetAnchor::ScreenBottom => area.bottom(), + }; + self.ambient_pet + .as_ref()? + .draw_request(area, anchor_bottom_y) + } + + pub(super) fn ambient_pet_wrap_reserved_cols(&self) -> u16 { + self.ambient_pet + .as_ref() + .filter(|pet| pet.image_enabled()) + .map(|pet| { + pet.image_columns() + .saturating_add(AMBIENT_PET_WRAP_GAP_COLUMNS) + }) + .unwrap_or(0) + } + + pub(crate) fn history_wrap_width(&self, width: u16) -> u16 { + width + .saturating_sub(self.ambient_pet_wrap_reserved_cols()) + .max(1) + } + + pub(crate) fn pet_picker_preview_draw(&self) -> Option { + self.bottom_pane + .selected_index_for_active_view(crate::pets::PET_PICKER_VIEW_ID)?; + let area = self.pet_picker_preview_state.area()?; + let request = self + .pet_picker_preview_pet + .as_ref()? + .preview_draw_request(area)?; + self.pet_picker_preview_image_visible.set(true); + Some(request) + } + + pub(crate) fn should_clear_pet_picker_preview_image(&self) -> bool { + self.pet_picker_preview_image_visible.replace(false) + } + + pub(crate) fn fail_pet_picker_preview_render(&mut self, message: String) { + self.pet_picker_preview_state.set_error(message); + self.pet_picker_preview_pet = None; + self.request_redraw(); + } + + pub(crate) fn open_pets_picker(&mut self) { + if self.warn_if_pets_unsupported() { + return; + } + + self.pet_picker_preview_state.clear(); + self.pet_picker_preview_pet = None; + let params = crate::pets::build_pet_picker_params( + self.config.tui_pet.as_deref(), + &self.config.codex_home, + self.pet_picker_preview_state.clone(), + ); + self.bottom_pane.show_selection_view(params); + let initial_pet_id = self + .config + .tui_pet + .as_deref() + .unwrap_or(crate::pets::DEFAULT_PET_ID) + .to_string(); + self.start_pet_picker_preview(initial_pet_id); + } + + pub(crate) fn select_pet_by_id(&mut self, pet_id: String) { + if self.warn_if_pets_unsupported() { + return; + } + + self.app_event_tx.send(AppEvent::PetSelected { pet_id }); + } + + fn warn_if_pets_unsupported(&mut self) -> bool { + let support = self.pet_image_support(); + let Some(message) = support.unsupported_message() else { + return false; + }; + + self.add_warning_message(message.to_string()); + true + } + + fn pet_image_support(&self) -> crate::pets::PetImageSupport { + #[cfg(test)] + if let Some(support) = self.pet_image_support_override { + return support; + } + + #[cfg(test)] + return crate::pets::PetImageSupport::Unsupported( + crate::pets::PetImageUnsupportedReason::Terminal, + ); + + #[cfg(not(test))] + crate::pets::detect_pet_image_support() + } + + /// Set the pet preselected by the TUI picker in the widget's config copy. + pub(crate) fn set_tui_pet(&mut self, pet: Option) { + self.config.tui_pet = pet; + self.ambient_pet = load_ambient_pet(&self.config, self.frame_requester.clone()); + self.apply_ambient_pet_image_support_override_for_tests(); + self.request_redraw(); + } + + pub(crate) fn set_tui_pet_loaded( + &mut self, + pet: Option, + ambient_pet: Option, + ) { + self.config.tui_pet = pet; + self.ambient_pet = ambient_pet; + self.apply_ambient_pet_image_support_override_for_tests(); + self.request_redraw(); + } + + #[cfg(test)] + fn apply_ambient_pet_image_support_override_for_tests(&mut self) { + if let Some(support) = self.pet_image_support_override + && let Some(pet) = self.ambient_pet.as_mut() + { + pet.set_image_support_for_tests(support); + } + } + + #[cfg(not(test))] + fn apply_ambient_pet_image_support_override_for_tests(&mut self) {} + + pub(crate) fn start_pet_picker_preview(&mut self, pet_id: String) { + self.pet_picker_preview_request_id = + self.pet_picker_preview_request_id.wrapping_add(/*rhs*/ 1); + let request_id = self.pet_picker_preview_request_id; + self.pet_picker_preview_pet = None; + if pet_id == crate::pets::DISABLED_PET_ID { + self.pet_picker_preview_state.set_disabled(); + self.request_redraw(); + return; + } + + self.pet_picker_preview_state.set_loading(); + self.request_redraw(); + + let codex_home = self.config.codex_home.clone(); + let frame_requester = self.frame_requester.clone(); + let tx = self.app_event_tx.clone(); + spawn_pet_load(move || { + let result = crate::pets::ensure_builtin_pack_for_pet(&pet_id, &codex_home) + .and_then(|()| { + crate::pets::AmbientPet::load( + Some(&pet_id), + &codex_home, + frame_requester, + /*animations_enabled*/ false, + ) + }) + .map_err(|err| err.to_string()); + tx.send(AppEvent::PetPreviewLoaded { request_id, result }); + }); + } + + pub(crate) fn finish_pet_picker_preview_load( + &mut self, + request_id: u64, + result: Result, + ) { + if request_id != self.pet_picker_preview_request_id { + return; + } + + match result { + Ok(pet) => { + self.pet_picker_preview_state.set_ready(); + self.pet_picker_preview_pet = Some(pet); + #[cfg(test)] + if let Some(support) = self.pet_image_support_override + && let Some(pet) = self.pet_picker_preview_pet.as_mut() + { + pet.set_image_support_for_tests(support); + } + } + Err(message) => { + self.pet_picker_preview_state.set_error(message); + self.pet_picker_preview_pet = None; + } + } + self.request_redraw(); + } + + pub(crate) fn show_pet_selection_loading_popup(&mut self) -> u64 { + self.pet_selection_load_request_id = + self.pet_selection_load_request_id.wrapping_add(/*rhs*/ 1); + self.pet_picker_preview_state.clear(); + self.pet_picker_preview_pet = None; + self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(PET_SELECTION_LOADING_VIEW_ID), + title: Some("Loading Pet".to_string()), + subtitle: Some("Preparing the terminal pet.".to_string()), + items: vec![SelectionItem { + name: "Loading selected pet...".to_string(), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + }); + self.pet_selection_load_request_id + } + + pub(crate) fn finish_pet_selection_loading_popup(&mut self, request_id: u64) -> bool { + if request_id != self.pet_selection_load_request_id { + return false; + } + self.bottom_pane + .dismiss_active_view_if_id(PET_SELECTION_LOADING_VIEW_ID); + true + } + + #[cfg(test)] + pub(crate) fn set_pet_image_support_for_tests( + &mut self, + support: crate::pets::PetImageSupport, + ) { + self.pet_image_support_override = Some(support); + self.apply_ambient_pet_image_support_override_for_tests(); + } + + #[cfg(test)] + pub(crate) fn install_test_ambient_pet_for_tests(&mut self, animations_enabled: bool) { + self.set_tui_pet_loaded( + Some("test".to_string()), + Some(crate::pets::test_ambient_pet( + self.frame_requester.clone(), + animations_enabled, + )), + ); + } +} + +fn spawn_pet_load(f: impl FnOnce() + Send + 'static) { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + std::mem::drop(handle.spawn_blocking(f)); + } else { + let _ = std::thread::spawn(f); + } +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 5f047b9af..14a2dffb1 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -405,6 +405,9 @@ impl ChatWidget { SlashCommand::Theme => { self.open_theme_picker(); } + SlashCommand::Pets => { + self.open_pets_picker(); + } SlashCommand::Ps => { self.add_ps_output(); } @@ -781,6 +784,17 @@ impl ChatWidget { self.app_event_tx .send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args }); } + SlashCommand::Pets + if matches!( + args.trim().to_ascii_lowercase().as_str(), + "disable" | "disabled" | "hide" | "hidden" | "off" | "none" + ) => + { + self.app_event_tx.send(AppEvent::PetDisabled); + } + SlashCommand::Pets if !trimmed.is_empty() => { + self.select_pet_by_id(args); + } _ => self.dispatch_command(cmd), } if source == SlashCommandDispatchSource::Live && cmd != SlashCommand::Goal { @@ -970,7 +984,8 @@ impl ChatWidget { | SlashCommand::Hooks | SlashCommand::Title | SlashCommand::Statusline - | SlashCommand::Theme => QueueDrain::Stop, + | SlashCommand::Theme + | SlashCommand::Pets => QueueDrain::Stop, } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index 15511611a..f26da349a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: terminal.backend().vt100().screen().contents() --- diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap index 3c256fe92..00cc144eb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: contents --- diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index 04b278a12..7395f8d57 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: terminal.backend().vt100().screen().contents() --- diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap index f0956946c..52ac57614 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/permissions.rs expression: popup --- Update Model Permissions diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap index 4487d0652..19e8ac600 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index 52779fd84..dfcfae199 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) --- diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap index ffc3e7f15..2ba27d409 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Upload logs? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap index 01f1175f3..b027333ad 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Upload logs? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap index 71dac5f59..d98ce2b77 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/permissions.rs expression: popup --- Enable full access? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap index f9b0daefc..a229db53b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Enable memories? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index 27c7d061e..baef861f4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Select Reasoning Level for gpt-5.4 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap index 0d57fceac..58478fa91 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Enable subagents? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap index d9a6e0a23..8613d6681 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Select Personality diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap index 25897974f..c64088b38 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/plan_mode.rs expression: popup --- Implement this plan? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap index e8083a0e0..a24ce666d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/plan_mode.rs expression: popup --- Implement this plan? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_error_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_error_popup.snap index 5305a9fc7..7350ccc0b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_error_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_error_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Plugins diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap index e55edcae8..c7edae171 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: popup +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: strip_osc8_for_snapshot(&popup) --- Plugins Figma · Can be installed · ChatGPT Marketplace diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap index 272ebb7c2..4d91b17f3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: popup +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: strip_osc8_for_snapshot(&popup) --- Plugins Figma · Installed · ChatGPT Marketplace diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap index bb217615d..83213897e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/status_and_layout.rs expression: popup --- Approaching rate limits diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap index 8c60f961f..05c5acac4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Settings diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap index 8c60f961f..05c5acac4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Settings diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap index 418fb5c9e..76ee7ec7e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Select Microphone diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__renamed_thread_footer_title.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__renamed_thread_footer_title.snap index e024f534a..2616a5981 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__renamed_thread_footer_title.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__renamed_thread_footer_title.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap new file mode 100644 index 000000000..88210edc5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: popup +--- + Select Pet + Choose a pet to wake in the terminal. + + Type to filter pets... + Disable terminal pets + BSOD A tiny blue-screen + gremlin +› Codex The original Codex + companion + Dewey A tidy duck for calm Loading preview... + workspace days + Fireball Hot path energy for + fast iteration + Null Signal Quiet signal from the + void + Rocky A steady rock when + the diff gets large + Seedy Small green shoots + for new ideas + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_fast_mode_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_fast_mode_footer.snap index ad644699c..c558ac472 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_fast_mode_footer.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_fast_mode_footer.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_plan_mode_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_plan_mode_footer.snap index 0eef02e0a..fc8251888 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_plan_mode_footer.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_plan_mode_footer.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index 5e6e33dec..532b44c6c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " " " diff --git a/codex-rs/tui/src/chatwidget/tests/guardian.rs b/codex-rs/tui/src/chatwidget/tests/guardian.rs index 87ec94ef1..27a226db0 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -149,7 +149,7 @@ async fn guardian_approved_exec_renders_approved_request() { let width: u16 = 120; let ui_height: u16 = chat.desired_height(width); - let vt_height: u16 = 12; + let vt_height: u16 = ui_height.saturating_add(1).max(12); let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); let backend = VT100Backend::new(width, vt_height); @@ -227,7 +227,7 @@ async fn guardian_approved_request_permissions_renders_request_summary() { let width: u16 = 110; let ui_height: u16 = chat.desired_height(width); - let vt_height: u16 = 12; + let vt_height: u16 = ui_height.saturating_add(1).max(12); let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); let backend = VT100Backend::new(width, vt_height); @@ -412,7 +412,7 @@ async fn app_server_guardian_review_denied_renders_denied_request_snapshot() { let width: u16 = 140; let ui_height: u16 = chat.desired_height(width); - let vt_height: u16 = 16; + let vt_height: u16 = ui_height.saturating_add(1).max(16); let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); let backend = VT100Backend::new(width, vt_height); @@ -493,7 +493,7 @@ async fn app_server_guardian_review_timed_out_renders_timed_out_request_snapshot let width: u16 = 140; let ui_height: u16 = chat.desired_height(width); - let vt_height: u16 = 16; + let vt_height: u16 = ui_height.saturating_add(1).max(16); let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); let backend = VT100Backend::new(width, vt_height); diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 977bea288..da4777bfb 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -102,7 +102,7 @@ async fn app_server_mcp_startup_failure_renders_warning_history() { let width: u16 = 120; let ui_height: u16 = chat.desired_height(width); - let vt_height: u16 = 10; + let vt_height: u16 = ui_height.saturating_add(1).max(10); let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); let backend = VT100Backend::new(width, vt_height); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index a1bf28c80..e4865e64f 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1,6 +1,19 @@ use super::*; use crate::bottom_pane::slash_commands::ServiceTierCommand; use pretty_assertions::assert_eq; +use serial_test::serial; + +fn force_pet_image_support(chat: &mut ChatWidget) { + chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported( + crate::pets::ImageProtocol::Kitty, + )); +} + +fn force_tmux_pet_image_unsupported(chat: &mut ChatWidget) { + chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Unsupported( + crate::pets::PetImageUnsupportedReason::Tmux, + )); +} fn fast_tier_command() -> ServiceTierCommand { ServiceTierCommand { @@ -1819,6 +1832,108 @@ async fn slash_resume_with_arg_requests_named_session() { assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); } +#[tokio::test] +#[serial] +async fn slash_pets_opens_picker() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_pet_image_support(&mut chat); + + chat.dispatch_command(SlashCommand::Pets); + + assert!(chat.bottom_pane.has_active_view()); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("slash_pets_picker", popup); +} + +#[tokio::test] +#[serial] +async fn slash_pets_with_arg_selects_named_pet() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_pet_image_support(&mut chat); + + chat.bottom_pane + .set_composer_text("/pets chefito".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::PetSelected { pet_id }) if pet_id == "chefito" + ); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[tokio::test] +#[serial] +async fn slash_pets_disable_disables_pets_even_on_unsupported_terminal() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_tmux_pet_image_unsupported(&mut chat); + + chat.bottom_pane + .set_composer_text("/pets disable".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::PetDisabled)); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[tokio::test] +#[serial] +async fn slash_pet_hide_disables_pets_even_on_unsupported_terminal() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_tmux_pet_image_unsupported(&mut chat); + + chat.bottom_pane + .set_composer_text("/pet hide".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::PetDisabled)); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[tokio::test] +#[serial] +async fn slash_pets_on_unsupported_terminal_warns_without_picker() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_tmux_pet_image_unsupported(&mut chat); + + chat.dispatch_command(SlashCommand::Pets); + + assert!(!chat.bottom_pane.has_active_view()); + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!(rendered.contains("Pets are disabled in tmux.")); + assert!(rendered.contains("outside tmux")); +} + +#[tokio::test] +#[serial] +async fn slash_pets_with_arg_on_unsupported_terminal_warns_without_selection() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_tmux_pet_image_unsupported(&mut chat); + + chat.bottom_pane + .set_composer_text("/pets chefito".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!(rendered.contains("Pets are disabled in tmux.")); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + #[tokio::test] async fn slash_fork_requests_current_fork() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap index 055a6292f..7e766d67e 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/approval_requests.rs expression: "format!(\"{buf:?}\")" --- Buffer { diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 2f4aa30b3..6cc9abc70 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1,6 +1,15 @@ use super::*; use crate::bottom_pane::goal_status_indicator_line; use pretty_assertions::assert_eq; +use ratatui::backend::TestBackend; +use serial_test::serial; + +fn enable_test_ambient_pet(chat: &mut ChatWidget) { + chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported( + crate::pets::ImageProtocol::Kitty, + )); + chat.install_test_ambient_pet_for_tests(/*animations_enabled*/ false); +} /// Receiving a token usage update without usage clears the context indicator. #[tokio::test] @@ -392,10 +401,12 @@ async fn completed_plan_table_tail_skips_provisional_history_insert() { } #[tokio::test] -async fn helpers_are_available_and_do_not_panic() { - let (tx_raw, _rx) = unbounded_channel::(); +async fn configured_pet_load_is_deferred_until_after_construction() { + let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let cfg = test_config().await; + let mut cfg = test_config().await; + cfg.tui_pet = Some(crate::pets::DEFAULT_PET_ID.to_string()); + crate::pets::write_test_pack(&cfg.codex_home); let resolved_model = crate::legacy_core::test_support::get_model_offline(cfg.model.as_deref()); let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { @@ -419,9 +430,21 @@ async fn helpers_are_available_and_do_not_panic() { terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), session_telemetry, }; - let mut w = ChatWidget::new_with_app_event(init); - // Basic construction sanity. - let _ = &mut w; + + let chat = ChatWidget::new_with_app_event(init); + + assert!(!chat.ambient_pet_image_enabled()); + let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .unwrap() + .unwrap(); + assert_matches!( + event, + AppEvent::ConfiguredPetLoaded { pet_id, result } => { + assert_eq!(pet_id, crate::pets::DEFAULT_PET_ID); + assert!(result.unwrap().is_some()); + } + ); } #[tokio::test] @@ -1352,6 +1375,223 @@ async fn ui_snapshots_small_heights_task_running() { } } +#[tokio::test] +#[serial] +async fn ambient_pet_stays_hidden_until_a_pet_is_selected() { + use ratatui::layout::Rect; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported( + crate::pets::ImageProtocol::Kitty, + )); + assert!(chat.ambient_pet.is_none()); + + crate::pets::write_test_pack(&chat.config.codex_home); + chat.set_tui_pet(Some("codex".to_string())); + + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 20, + ); + let draw = chat + .ambient_pet_draw(area, area.bottom()) + .expect("ambient pet draw request"); + assert_eq!(draw.x, 51); + assert_eq!(draw.y, 14); + assert_eq!(draw.columns, 9); + assert_eq!(draw.rows, 5); + assert_eq!( + draw.y.saturating_add(draw.rows), + area.bottom().saturating_sub(/*rhs*/ 1) + ); + + handle_turn_started(&mut chat, "turn-1"); + handle_agent_reasoning_delta(&mut chat, "**Thinking**"); + let draw_with_status = chat + .ambient_pet_draw(area, area.bottom()) + .expect("ambient pet draw request with status"); + assert_eq!(draw_with_status.y, draw.y); + assert_eq!( + draw_with_status.y.saturating_add(draw_with_status.rows), + area.bottom().saturating_sub(/*rhs*/ 1) + ); +} + +#[tokio::test] +#[serial] +async fn ambient_pet_screen_bottom_anchor_uses_terminal_bottom() { + use codex_config::types::TuiPetAnchor; + use ratatui::layout::Rect; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_test_ambient_pet(&mut chat); + + let terminal_area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24, + ); + let composer_bottom_y = 20; + let default_draw = chat + .ambient_pet_draw(terminal_area, composer_bottom_y) + .expect("composer-anchored pet draw request"); + assert_eq!(default_draw.y, 14); + + chat.config.tui_pet_anchor = TuiPetAnchor::ScreenBottom; + let screen_bottom_draw = chat + .ambient_pet_draw(terminal_area, composer_bottom_y) + .expect("screen-bottom anchored pet draw request"); + assert_eq!(screen_bottom_draw.y, 18); +} + +#[tokio::test] +#[serial] +async fn ambient_pet_can_be_disabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.set_tui_pet(Some(crate::pets::DISABLED_PET_ID.to_string())); + + assert!(chat.ambient_pet.is_none()); +} + +#[tokio::test] +#[serial] +async fn ambient_pet_reserves_history_wrap_width() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_test_ambient_pet(&mut chat); + + assert_eq!(chat.history_wrap_width(/*width*/ 80), 69); + + chat.set_tui_pet(Some(crate::pets::DISABLED_PET_ID.to_string())); + + assert_eq!(chat.history_wrap_width(/*width*/ 80), 80); +} + +#[tokio::test] +#[serial] +async fn ambient_pet_reduces_stream_width_and_composer_text_width() { + use ratatui::Terminal; + + let (mut with_pet, _with_pet_rx, _with_pet_op_rx) = + make_chatwidget_manual(/*model_override*/ None).await; + enable_test_ambient_pet(&mut with_pet); + with_pet.last_rendered_width.set(Some(80)); + let stream_width_with_pet = with_pet.current_stream_width(/*reserved_cols*/ 2); + + let (mut disabled, _disabled_rx, _disabled_op_rx) = + make_chatwidget_manual(/*model_override*/ None).await; + disabled.set_tui_pet(Some(crate::pets::DISABLED_PET_ID.to_string())); + disabled.last_rendered_width.set(Some(80)); + let stream_width_without_pet = disabled.current_stream_width(/*reserved_cols*/ 2); + + assert_eq!( + stream_width_with_pet, + crate::width::usable_content_width(/*total_width*/ 69, /*reserved_cols*/ 2) + ); + assert_eq!( + stream_width_without_pet, + crate::width::usable_content_width(/*total_width*/ 80, /*reserved_cols*/ 2) + ); + assert!(stream_width_with_pet < stream_width_without_pet); + + let draft = + "Minim commodo esse elit Lorem exercitation elit ipsum proident labore. Esse culpa aliqua" + .to_string(); + with_pet + .bottom_pane + .set_composer_text(draft.clone(), Vec::new(), Vec::new()); + disabled + .bottom_pane + .set_composer_text(draft, Vec::new(), Vec::new()); + + let mut with_pet_terminal = + Terminal::new(TestBackend::new(/*width*/ 80, /*height*/ 6)).expect("create terminal"); + with_pet_terminal + .draw(|f| with_pet.render(f.area(), f.buffer_mut())) + .expect("draw pet-enabled chat"); + let mut disabled_terminal = + Terminal::new(TestBackend::new(/*width*/ 80, /*height*/ 6)).expect("create terminal"); + disabled_terminal + .draw(|f| disabled.render(f.area(), f.buffer_mut())) + .expect("draw disabled-pet chat"); + + let pet_row = buffer_row_containing(with_pet_terminal.backend().buffer(), "Minim") + .expect("pet-enabled composer row should render draft"); + let disabled_row = buffer_row_containing(disabled_terminal.backend().buffer(), "Minim") + .expect("disabled-pet composer row should render draft"); + + assert!(row_tail_is_blank(&pet_row, /*start_col*/ 69)); + assert!(!row_tail_is_blank(&disabled_row, /*start_col*/ 69)); +} + +fn buffer_row_containing(buffer: &ratatui::buffer::Buffer, text: &str) -> Option { + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer.cell((x, y)).expect("cell should exist").symbol()) + .collect::() + }) + .find(|row| row.contains(text)) +} + +fn row_tail_is_blank(row: &str, start_col: usize) -> bool { + row.chars().skip(start_col).all(char::is_whitespace) +} + +#[tokio::test] +#[serial] +async fn ambient_pet_draw_uses_terminal_screen_area_not_short_inline_viewport() { + use ratatui::layout::Rect; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_test_ambient_pet(&mut chat); + + assert!( + chat.ambient_pet_draw( + Rect::new( + /*x*/ 0, /*y*/ 21, /*width*/ 80, /*height*/ 3, + ), + /*composer_bottom_y*/ 24 + ) + .is_none(), + "a normal short inline viewport cannot fit the ambient pet" + ); + + let draw = chat + .ambient_pet_draw( + Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24, + ), + /*composer_bottom_y*/ 24, + ) + .expect("full terminal screen has room for the ambient pet"); + assert_eq!(draw.x, 71); + assert_eq!(draw.y, 18); +} + +#[tokio::test] +#[serial] +async fn ambient_pet_hides_notification_text_overlay() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_test_ambient_pet(&mut chat); + for (kind, label) in [ + (crate::pets::PetNotificationKind::Running, "Running"), + (crate::pets::PetNotificationKind::Waiting, "Needs input"), + (crate::pets::PetNotificationKind::Review, "Ready"), + (crate::pets::PetNotificationKind::Failed, "Blocked"), + ] { + chat.set_ambient_pet_notification(kind, /*body*/ None); + let mut terminal = Terminal::new(TestBackend::new(60, 20)).expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw ambient pet notification"); + assert!( + !normalized_backend_snapshot(terminal.backend()).contains(label), + "did not expect {label} notification text to render" + ); + } +} + // Snapshot test: status widget + approval modal active together // The modal takes precedence visually; this captures the layout with a running // task (status indicator active) while an approval request is shown. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e3e36ab48..0246b5380 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -111,6 +111,7 @@ mod clipboard_paste; mod collaboration_modes; mod color; pub(crate) mod custom_terminal; +mod pets; pub use custom_terminal::Terminal; mod auto_review_denials; mod cwd_prompt; diff --git a/codex-rs/tui/src/pets/ambient.rs b/codex-rs/tui/src/pets/ambient.rs new file mode 100644 index 000000000..3427f0a4c --- /dev/null +++ b/codex-rs/tui/src/pets/ambient.rs @@ -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) -> 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, + sixel_dir: PathBuf, + frame_requester: FrameRequester, + notification: Option, + 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 { + 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) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +} + +fn current_animation_frame(animation: &Animation, elapsed: Duration) -> Option { + 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::(); + let loop_nanos = animation.frames[loop_start..] + .iter() + .map(|frame| frame.duration.as_nanos()) + .sum::(); + 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 { + 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); + } +} diff --git a/codex-rs/tui/src/pets/asset_pack.rs b/codex-rs/tui/src/pets/asset_pack.rs new file mode 100644 index 000000000..7035d1dea --- /dev/null +++ b/codex-rs/tui/src/pets/asset_pack.rs @@ -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 { + 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> { + 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(); + } + } +} diff --git a/codex-rs/tui/src/pets/catalog.rs b/codex-rs/tui/src/pets/catalog.rs new file mode 100644 index 000000000..e1d5c0c78 --- /dev/null +++ b/codex-rs/tui/src/pets/catalog.rs @@ -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 { + 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(); +} diff --git a/codex-rs/tui/src/pets/frames.rs b/codex-rs/tui/src/pets/frames.rs new file mode 100644 index 000000000..4c65c900a --- /dev/null +++ b/codex-rs/tui/src/pets/frames.rs @@ -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> { + fs::create_dir_all(frame_dir).with_context(|| format!("create {}", frame_dir.display()))?; + + let expected: Vec = (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> { + 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, Vec> = 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()); + } +} diff --git a/codex-rs/tui/src/pets/image_protocol.rs b/codex-rs/tui/src/pets/image_protocol.rs new file mode 100644 index 000000000..25a20c352 --- /dev/null +++ b/codex-rs/tui/src/pets/image_protocol.rs @@ -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 { + 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 don’t 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 don’t stay reliably pane-local in Zellij. Run Codex outside Zellij to use pets." + } + Self::Terminal => { + "Pets aren’t available in this terminal. Terminal pets need image support, and this terminal environment doesn’t 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 { + 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, +) -> Result { + 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::>(); + + 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, +) -> Result { + 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) -> 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 { + 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, + } + + 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::().unwrap(), + ProtocolSelection::Auto + ); + assert_eq!( + "kitty".parse::().unwrap(), + ProtocolSelection::Kitty + ); + assert_eq!( + "sixel".parse::().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, + 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\\") + ); + } +} diff --git a/codex-rs/tui/src/pets/mod.rs b/codex-rs/tui/src/pets/mod.rs new file mode 100644 index 000000000..74d8d3738 --- /dev/null +++ b/codex-rs/tui/src/pets/mod.rs @@ -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.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 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, +) -> 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, +) -> 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, + last_protocol: Option, +} + +fn render_pet_image( + writer: &mut impl Write, + state: &mut PetImageRenderState, + image_id: u32, + request: Option, +) -> 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), +} + +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 { + 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()); + } +} diff --git a/codex-rs/tui/src/pets/model.rs b/codex-rs/tui/src/pets/model.rs new file mode 100644 index 000000000..aa02731ab --- /dev/null +++ b/codex-rs/tui/src/pets/model.rs @@ -0,0 +1,1036 @@ +//! Pet manifest loading and normalization. +//! +//! This module converts several user-facing selectors into one normalized +//! in-memory `Pet`: built-in catalog ids, custom pet ids under CODEX_HOME, +//! legacy avatar directories, and explicit filesystem paths used by tests or +//! local iteration. +//! +//! The key invariant is that every returned `Pet` points at a local +//! spritesheet path that already exists and has app-compatible dimensions. +//! Asset acquisition is intentionally out of scope here; callers must ensure a +//! built-in pet has been downloaded before asking the model layer to load it. + +use std::collections::HashMap; +use std::fs; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use serde::Deserialize; +use sha2::Digest as _; +use sha2::Sha256; + +use super::catalog; + +const MAX_PET_FRAMES: usize = 256; +const MAX_ANIMATION_FPS: f64 = 60.0; + +#[derive(Debug, Clone)] +pub struct AnimationFrame { + pub sprite_index: usize, + pub duration: Duration, +} + +#[derive(Debug, Clone)] +pub struct Animation { + pub frames: Vec, + pub loop_start: Option, + pub fallback: String, +} + +impl Animation { + pub(super) fn total_duration(&self) -> Duration { + self.frames + .iter() + .map(|frame| frame.duration) + .sum::() + } +} + +/// One named animation track for a pet spritesheet. +/// +/// Tracks use sprite indices into the already-decoded frame grid plus a +/// fallback animation name for one-shot sequences. Callers should not assume +/// an animation loops just because it has multiple frames; `loop_start == None` +/// means the final frame eventually hands off to `fallback`. +#[derive(Debug, Clone)] +pub struct Pet { + pub id: String, + pub display_name: String, + pub description: String, + pub spritesheet_path: PathBuf, + pub frame_width: u32, + pub frame_height: u32, + pub columns: u32, + pub rows: u32, + pub frame_count: usize, + pub animations: HashMap, +} + +impl Pet { + /// Load a pet selector into a concrete local pet definition. + /// + /// Selectors may name a built-in catalog pet, a custom pet id, a legacy + /// avatar id, or an explicit path. This method assumes any built-in asset + /// has already been materialized into CODEX_HOME; if callers skip the + /// asset-fetch step, they will get a missing-spritesheet error here on + /// first use. + pub(super) fn load_with_codex_home(value: &str, codex_home: Option<&Path>) -> Result { + if path_like(value) { + return load_pet_path(value); + } + + if let Some(custom_id) = value.strip_prefix(CUSTOM_PET_PREFIX) { + return load_custom_pet(custom_id, codex_home); + } + + if let Some(builtin) = catalog::builtin_pet(value) { + return load_builtin_pet(builtin, codex_home); + } + + load_custom_pet(value, codex_home) + } + + pub fn frame_count(&self) -> usize { + self.frame_count + } + + pub(super) fn frame_cache_key(&self) -> Result { + let bytes = fs::read(&self.spritesheet_path) + .with_context(|| format!("read {}", self.spritesheet_path.display()))?; + let digest = Sha256::digest(&bytes); + Ok(format!( + "sha256-{digest:x}-{}x{}-{}x{}", + self.frame_width, self.frame_height, self.columns, self.rows + )) + } +} + +pub(super) const CUSTOM_PET_PREFIX: &str = "custom:"; + +#[derive(Debug, Deserialize)] +struct PetFile { + #[serde(default)] + id: Option, + #[serde(default, rename = "displayName")] + display_name: Option, + #[serde(default)] + description: Option, + #[serde(default, rename = "spritesheetPath")] + spritesheet_path: Option, + frame: Option, + #[serde(default)] + animations: HashMap, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +struct FrameSpec { + width: u32, + height: u32, + columns: u32, + rows: u32, +} + +impl Default for FrameSpec { + fn default() -> Self { + Self { + width: catalog::DEFAULT_FRAME_WIDTH, + height: catalog::DEFAULT_FRAME_HEIGHT, + columns: catalog::DEFAULT_FRAME_COLUMNS, + rows: catalog::DEFAULT_FRAME_ROWS, + } + } +} + +pub(super) fn custom_pet_selector(id: &str) -> String { + format!("{CUSTOM_PET_PREFIX}{id}") +} + +#[derive(Debug, Deserialize)] +struct AnimationSpec { + #[serde(default)] + frames: Vec, + fps: Option, + #[serde(rename = "loop")] + loop_animation: Option, + #[serde(default)] + fallback: String, +} + +fn load_builtin_pet(pet: catalog::BuiltinPet, codex_home: Option<&Path>) -> Result { + let codex_home = codex_home.context("CODEX_HOME is not available")?; + let spritesheet_path = super::builtin_spritesheet_path(codex_home, pet.spritesheet_file); + if !spritesheet_path.exists() { + bail!("missing spritesheet {}", spritesheet_path.display()); + } + + Ok(Pet { + id: pet.id.to_string(), + display_name: pet.display_name.to_string(), + description: pet.description.to_string(), + spritesheet_path, + frame_width: catalog::DEFAULT_FRAME_WIDTH, + frame_height: catalog::DEFAULT_FRAME_HEIGHT, + columns: catalog::DEFAULT_FRAME_COLUMNS, + rows: catalog::DEFAULT_FRAME_ROWS, + frame_count: default_frame_count(), + animations: default_animations(), + }) +} + +fn load_custom_pet(value: &str, codex_home: Option<&Path>) -> Result { + let codex_home = codex_home.context("CODEX_HOME is not available")?; + let pet_dir = codex_home.join("pets").join(value); + if pet_dir.join("pet.json").is_file() { + return load_pet_manifest(&pet_dir, "pet.json", value, &custom_pet_cache_id(value)); + } + + let avatar_dir = codex_home.join("avatars").join(value); + if avatar_dir.join("avatar.json").is_file() { + return load_pet_manifest( + &avatar_dir, + "avatar.json", + value, + &custom_pet_cache_id(value), + ); + } + + bail!("unknown pet {value}") +} + +fn load_pet_path(value: &str) -> Result { + let path = expand_path(value)?; + let metadata = fs::metadata(&path).with_context(|| format!("pet path {}", path.display()))?; + let dir = if metadata.is_dir() { + path + } else { + path.parent() + .context("pet json path has no containing directory")? + .to_path_buf() + }; + let pet_dir = dir + .canonicalize() + .with_context(|| format!("resolve {}", dir.display()))?; + let manifest_file = if pet_dir.join("pet.json").is_file() { + "pet.json" + } else if pet_dir.join("avatar.json").is_file() { + "avatar.json" + } else { + bail!("missing pet.json or avatar.json in {}", pet_dir.display()); + }; + let fallback_id = pet_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("pet"); + load_pet_manifest(&pet_dir, manifest_file, fallback_id, fallback_id) +} + +fn load_pet_manifest( + pet_dir: &Path, + manifest_file: &str, + fallback_id: &str, + cache_id: &str, +) -> Result { + let config_path = pet_dir.join(manifest_file); + let raw = fs::read_to_string(&config_path) + .with_context(|| format!("read {}", config_path.display()))?; + let file: PetFile = + serde_json::from_str(&raw).with_context(|| format!("parse {}", config_path.display()))?; + + let manifest_id = file + .id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()); + let display_name = file + .display_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .or(manifest_id) + .unwrap_or(fallback_id) + .to_string(); + let pet_id = if cache_id == fallback_id { + manifest_id.unwrap_or(fallback_id).to_string() + } else { + cache_id.to_string() + }; + let description = file + .description + .map(|description| description.trim().to_string()) + .unwrap_or_default(); + let spritesheet_path = resolve_spritesheet_path( + pet_dir, + file.spritesheet_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .unwrap_or("spritesheet.webp"), + )?; + if !spritesheet_path.exists() { + bail!("missing spritesheet {}", spritesheet_path.display()); + } + let (spritesheet_width, spritesheet_height) = + validate_app_spritesheet_dimensions(&spritesheet_path)?; + + let frame = file.frame.unwrap_or_default(); + let frame_count = validate_frame_spec(&frame, spritesheet_width, spritesheet_height)?; + Ok(Pet { + id: pet_id, + display_name, + description, + spritesheet_path, + frame_width: frame.width, + frame_height: frame.height, + columns: frame.columns, + rows: frame.rows, + frame_count, + animations: load_animations(file.animations, frame_count)?, + }) +} + +/// Resolve a manifest-relative spritesheet path while keeping it inside the pet directory. +/// +/// The manifest format intentionally supports only relative child paths. +/// Allowing absolute or parent-traversing paths here would let one custom pet +/// manifest reach outside its own directory and make cache/debug behavior +/// depend on unrelated local files. +fn resolve_spritesheet_path(pet_dir: &Path, spritesheet_path: &str) -> Result { + let path = Path::new(spritesheet_path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_))) + { + bail!("spritesheet path must stay inside {}", pet_dir.display()); + } + Ok(pet_dir.join(path)) +} + +fn validate_app_spritesheet_dimensions(path: &Path) -> Result<(u32, u32)> { + let (width, height) = + image::image_dimensions(path).with_context(|| format!("read {}", path.display()))?; + if width != catalog::SPRITESHEET_WIDTH || height != catalog::SPRITESHEET_HEIGHT { + bail!( + "spritesheet must be {}x{} pixels", + catalog::SPRITESHEET_WIDTH, + catalog::SPRITESHEET_HEIGHT + ); + } + Ok((width, height)) +} + +fn validate_frame_spec( + frame: &FrameSpec, + spritesheet_width: u32, + spritesheet_height: u32, +) -> Result { + if frame.width == 0 || frame.height == 0 || frame.columns == 0 || frame.rows == 0 { + bail!("pet frame dimensions and grid counts must be non-zero"); + } + + let total_width = frame + .width + .checked_mul(frame.columns) + .context("pet frame grid width overflow")?; + let total_height = frame + .height + .checked_mul(frame.rows) + .context("pet frame grid height overflow")?; + if total_width != spritesheet_width || total_height != spritesheet_height { + bail!( + "pet frame grid must cover spritesheet exactly: expected {spritesheet_width}x{spritesheet_height}, got {total_width}x{total_height}" + ); + } + + let frame_count = frame + .columns + .checked_mul(frame.rows) + .context("pet frame count overflow")?; + let frame_count = usize::try_from(frame_count).context("pet frame count does not fit usize")?; + if frame_count > MAX_PET_FRAMES { + bail!("pet frame count {frame_count} exceeds maximum {MAX_PET_FRAMES}"); + } + Ok(frame_count) +} + +fn custom_pet_cache_id(id: &str) -> String { + format!("custom-{id}") +} + +fn path_like(value: &str) -> bool { + value == "." + || value == ".." + || value.starts_with("~/") + || value.starts_with("../") + || value.starts_with("./") + || Path::new(value).is_absolute() + || value.contains('/') + || value.contains('\\') +} + +fn expand_path(value: &str) -> Result { + if value == "~" || value.starts_with("~/") { + let home = std::env::var_os("HOME").context("HOME is not set")?; + if value == "~" { + return Ok(PathBuf::from(home)); + } + return Ok(PathBuf::from(home).join(&value[2..])); + } + + Ok(PathBuf::from(value)) +} + +fn load_animations( + specs: HashMap, + frame_count: usize, +) -> Result> { + let mut animations = default_animations(); + if specs.is_empty() { + validate_animation_indices(&animations, frame_count)?; + return Ok(animations); + } + + for (name, spec) in specs { + if spec.frames.is_empty() { + bail!("animation {name} must include at least one frame"); + } + for sprite_index in &spec.frames { + if *sprite_index >= frame_count { + bail!( + "animation {name} references sprite index {sprite_index}, but pet has {frame_count} frames" + ); + } + } + + let fps = match spec.fps { + Some(fps) if fps.is_finite() && fps > 0.0 && fps <= MAX_ANIMATION_FPS => fps, + Some(fps) => { + bail!( + "animation {name} fps must be finite and between 0 and {MAX_ANIMATION_FPS}, got {fps}" + ); + } + None => 8.0, + }; + let duration = Duration::from_secs_f64(1.0 / fps); + let fallback = if spec.fallback.is_empty() { + "idle".to_string() + } else { + spec.fallback + }; + let loop_start = spec + .loop_animation + .unwrap_or(/*default*/ true) + .then_some(/*loop_start*/ 0); + + animations.insert( + name, + Animation { + frames: spec + .frames + .into_iter() + .map(|sprite_index| AnimationFrame { + sprite_index, + duration, + }) + .collect(), + loop_start, + fallback, + }, + ); + } + + animations + .entry("idle".to_string()) + .or_insert_with(idle_animation); + validate_animation_indices(&animations, frame_count)?; + Ok(animations) +} + +fn validate_animation_indices( + animations: &HashMap, + frame_count: usize, +) -> Result<()> { + for (name, animation) in animations { + if animation.frames.is_empty() { + bail!("animation {name} must include at least one frame"); + } + for frame in &animation.frames { + if frame.sprite_index >= frame_count { + bail!( + "animation {name} references sprite index {}, but pet has {frame_count} frames", + frame.sprite_index + ); + } + } + if !animations.contains_key(&animation.fallback) { + bail!( + "animation {name} fallback {} does not exist", + animation.fallback + ); + } + } + Ok(()) +} + +fn default_frame_count() -> usize { + (catalog::DEFAULT_FRAME_COLUMNS * catalog::DEFAULT_FRAME_ROWS) as usize +} + +fn default_animations() -> HashMap { + [ + ("idle", idle_animation()), + ( + "running-right", + app_state_animation( + /*row_index*/ 1, /*frame_count*/ 8, /*frame_duration_ms*/ 120, + /*final_frame_duration_ms*/ 220, + ), + ), + ( + "running-left", + app_state_animation( + /*row_index*/ 2, /*frame_count*/ 8, /*frame_duration_ms*/ 120, + /*final_frame_duration_ms*/ 220, + ), + ), + ( + "waving", + app_state_animation( + /*row_index*/ 3, /*frame_count*/ 4, /*frame_duration_ms*/ 140, + /*final_frame_duration_ms*/ 280, + ), + ), + ( + "jumping", + app_state_animation( + /*row_index*/ 4, /*frame_count*/ 5, /*frame_duration_ms*/ 140, + /*final_frame_duration_ms*/ 280, + ), + ), + ( + "failed", + app_state_animation( + /*row_index*/ 5, /*frame_count*/ 8, /*frame_duration_ms*/ 140, + /*final_frame_duration_ms*/ 240, + ), + ), + ( + "waiting", + app_state_animation( + /*row_index*/ 6, /*frame_count*/ 6, /*frame_duration_ms*/ 150, + /*final_frame_duration_ms*/ 260, + ), + ), + ( + "running", + app_state_animation( + /*row_index*/ 7, /*frame_count*/ 6, /*frame_duration_ms*/ 120, + /*final_frame_duration_ms*/ 220, + ), + ), + ( + "review", + app_state_animation( + /*row_index*/ 8, /*frame_count*/ 6, /*frame_duration_ms*/ 150, + /*final_frame_duration_ms*/ 280, + ), + ), + ( + "move_right", + app_state_animation( + /*row_index*/ 1, /*frame_count*/ 8, /*frame_duration_ms*/ 120, + /*final_frame_duration_ms*/ 220, + ), + ), + ( + "move_left", + app_state_animation( + /*row_index*/ 2, /*frame_count*/ 8, /*frame_duration_ms*/ 120, + /*final_frame_duration_ms*/ 220, + ), + ), + ( + "wave", + app_state_animation( + /*row_index*/ 3, /*frame_count*/ 4, /*frame_duration_ms*/ 140, + /*final_frame_duration_ms*/ 280, + ), + ), + ( + "bounce", + app_state_animation( + /*row_index*/ 4, /*frame_count*/ 5, /*frame_duration_ms*/ 140, + /*final_frame_duration_ms*/ 280, + ), + ), + ( + "sad", + app_state_animation( + /*row_index*/ 5, /*frame_count*/ 8, /*frame_duration_ms*/ 140, + /*final_frame_duration_ms*/ 240, + ), + ), + ] + .into_iter() + .map(|(name, animation)| (name.to_string(), animation)) + .collect() +} + +fn idle_animation() -> Animation { + Animation { + frames: [(0, 1680), (1, 660), (2, 660), (3, 840), (4, 840), (5, 1920)] + .into_iter() + .map(|(sprite_index, duration_ms)| AnimationFrame { + sprite_index, + duration: Duration::from_millis(duration_ms), + }) + .collect(), + loop_start: Some(/*loop_start*/ 0), + fallback: "idle".to_string(), + } +} + +fn app_state_animation( + row_index: usize, + frame_count: usize, + frame_duration_ms: u64, + final_frame_duration_ms: u64, +) -> Animation { + let primary_frames = (0..frame_count) + .map(|column_index| AnimationFrame { + sprite_index: row_index * catalog::DEFAULT_FRAME_COLUMNS as usize + column_index, + duration: Duration::from_millis(if column_index == frame_count - 1 { + final_frame_duration_ms + } else { + frame_duration_ms + }), + }) + .collect::>(); + let primary_frame_count = primary_frames.len() * 3; + let frames = primary_frames + .iter() + .chain(primary_frames.iter()) + .chain(primary_frames.iter()) + .cloned() + .chain(idle_animation().frames) + .collect(); + Animation { + frames, + loop_start: Some(primary_frame_count), + fallback: "idle".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_minimal_pet() -> tempfile::TempDir { + write_pet_manifest( + r#"{ + "id": "chefito", + "displayName": "Chefito", + "description": "A tiny recipe-loving chef", + "spritesheetPath": "spritesheet.webp" + }"#, + ) + } + + fn write_pet_manifest(manifest: &str) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("pet.json"), manifest).unwrap(); + catalog::write_test_spritesheet(&dir.path().join("spritesheet.webp")); + dir + } + + fn load_pet_from_dir(dir: &tempfile::TempDir) -> Pet { + Pet::load_with_codex_home(dir.path().to_str().unwrap(), /*codex_home*/ None).unwrap() + } + + fn load_pet_error_from_dir(dir: &tempfile::TempDir) -> anyhow::Error { + Pet::load_with_codex_home(dir.path().to_str().unwrap(), /*codex_home*/ None).unwrap_err() + } + + #[test] + fn load_builtin_pet_uses_app_catalog_storage() { + let codex_home = tempfile::tempdir().unwrap(); + super::super::asset_pack::write_test_pack(codex_home.path()); + + let pet = + Pet::load_with_codex_home("dewey", /*codex_home*/ Some(codex_home.path())).unwrap(); + + assert_eq!(pet.id, "dewey"); + assert_eq!(pet.display_name, "Dewey"); + assert_eq!(pet.description, "A tidy duck for calm workspace days"); + assert_eq!( + pet.spritesheet_path, + super::super::builtin_spritesheet_path(codex_home.path(), "dewey-spritesheet-v4.webp") + ); + assert_eq!(pet.frame_width, 192); + assert_eq!(pet.frame_height, 208); + assert_eq!(pet.columns, 8); + assert_eq!(pet.rows, 9); + } + + #[test] + fn app_idle_animation_uses_calm_loop() { + let animations = default_animations(); + let idle = &animations["idle"]; + + assert_eq!(sprite_indices(idle), vec![0, 1, 2, 3, 4, 5]); + assert_eq!(durations_ms(idle), vec![1680, 660, 660, 840, 840, 1920]); + assert_eq!(idle.loop_start, Some(/*loop_start*/ 0)); + } + + #[test] + fn app_running_animation_repeats_then_settles_into_idle() { + let animations = default_animations(); + let running = &animations["running"]; + let primary = vec![56, 57, 58, 59, 60, 61]; + + assert_eq!(sprite_indices(running)[0..6], primary); + assert_eq!(sprite_indices(running)[6..12], primary); + assert_eq!(sprite_indices(running)[12..18], primary); + assert_eq!( + sprite_indices(running)[18..], + sprite_indices(&animations["idle"]) + ); + assert_eq!( + durations_ms(running)[0..6], + vec![120, 120, 120, 120, 120, 220] + ); + assert_eq!(running.loop_start, Some(/*loop_start*/ 18)); + } + + #[test] + fn app_notification_states_use_expected_rows() { + let animations = default_animations(); + + assert_eq!( + sprite_indices(&animations["waiting"])[0..6], + vec![48, 49, 50, 51, 52, 53] + ); + assert_eq!( + sprite_indices(&animations["review"])[0..6], + vec![64, 65, 66, 67, 68, 69] + ); + assert_eq!( + sprite_indices(&animations["failed"])[0..8], + vec![40, 41, 42, 43, 44, 45, 46, 47] + ); + } + + #[test] + fn custom_animation_specs_keep_manifest_fps_and_loop_shape() { + let animations = load_animations( + HashMap::from([( + "custom".to_string(), + AnimationSpec { + frames: vec![1, 2], + fps: Some(/*fps*/ 2.0), + loop_animation: Some(/*loop_animation*/ false), + fallback: "idle".to_string(), + }, + )]), + default_frame_count(), + ) + .unwrap(); + let custom = &animations["custom"]; + + assert_eq!(sprite_indices(custom), vec![1, 2]); + assert_eq!(durations_ms(custom), vec![500, 500]); + assert_eq!(custom.loop_start, None); + assert_eq!(custom.fallback, "idle"); + } + + #[test] + fn load_pet_directory_uses_app_pet_manifest_defaults() { + let dir = write_minimal_pet(); + + let pet = load_pet_from_dir(&dir); + + assert_eq!(pet.id, "chefito"); + assert_eq!(pet.display_name, "Chefito"); + assert_eq!(pet.frame_width, 192); + assert_eq!(pet.frame_height, 208); + assert_eq!(pet.columns, 8); + assert_eq!(pet.rows, 9); + assert_eq!(pet.frame_count(), 72); + assert!(!pet.animations["idle"].frames.is_empty()); + } + + #[test] + fn frame_cache_key_changes_with_spritesheet_contents() { + let dir = write_minimal_pet(); + let spritesheet_path = dir.path().join("spritesheet.webp"); + let pet = load_pet_from_dir(&dir); + let first_key = pet.frame_cache_key().unwrap(); + + let image = image::RgbaImage::from_pixel( + catalog::SPRITESHEET_WIDTH, + catalog::SPRITESHEET_HEIGHT, + image::Rgba([1, 2, 3, 255]), + ); + image.save(&spritesheet_path).unwrap(); + let pet = load_pet_from_dir(&dir); + + assert_ne!(pet.frame_cache_key().unwrap(), first_key); + } + + #[test] + fn frame_cache_key_changes_with_frame_spec() { + let default_dir = write_minimal_pet(); + let default_pet = load_pet_from_dir(&default_dir); + let custom_dir = write_pet_manifest( + r#"{ + "displayName": "Tall", + "spritesheetPath": "spritesheet.webp", + "frame": { "width": 384, "height": 104, "columns": 4, "rows": 18 } + }"#, + ); + let custom_pet = load_pet_from_dir(&custom_dir); + + assert_ne!( + custom_pet.frame_cache_key().unwrap(), + default_pet.frame_cache_key().unwrap() + ); + } + + #[test] + fn load_pet_json_path_uses_containing_directory() { + let dir = write_minimal_pet(); + + let pet = Pet::load_with_codex_home( + dir.path().join("pet.json").to_str().unwrap(), + /*codex_home*/ None, + ) + .unwrap(); + let expected = dir.path().join("spritesheet.webp").canonicalize().unwrap(); + + assert_eq!(pet.spritesheet_path, expected); + } + + #[test] + fn custom_pet_selector_loads_codex_home_pet_manifest() { + let dir = write_minimal_pet(); + let codex_home = tempfile::tempdir().unwrap(); + let pet_dir = codex_home.path().join("pets").join("chefito"); + fs::create_dir_all(&pet_dir).unwrap(); + fs::copy(dir.path().join("pet.json"), pet_dir.join("pet.json")).unwrap(); + fs::copy( + dir.path().join("spritesheet.webp"), + pet_dir.join("spritesheet.webp"), + ) + .unwrap(); + + let pet = Pet::load_with_codex_home( + &custom_pet_selector("chefito"), + /*codex_home*/ Some(codex_home.path()), + ) + .unwrap(); + + assert_eq!(pet.id, "custom-chefito"); + assert_eq!(pet.spritesheet_path, pet_dir.join("spritesheet.webp"),); + } + + #[test] + fn custom_pet_selector_falls_back_to_legacy_avatar_manifest() { + let dir = write_minimal_pet(); + let codex_home = tempfile::tempdir().unwrap(); + let avatar_dir = codex_home.path().join("avatars").join("legacy"); + fs::create_dir_all(&avatar_dir).unwrap(); + fs::copy(dir.path().join("pet.json"), avatar_dir.join("avatar.json")).unwrap(); + fs::copy( + dir.path().join("spritesheet.webp"), + avatar_dir.join("spritesheet.webp"), + ) + .unwrap(); + + let pet = Pet::load_with_codex_home( + &custom_pet_selector("legacy"), + /*codex_home*/ Some(codex_home.path()), + ) + .unwrap(); + + assert_eq!(pet.id, "custom-legacy"); + assert_eq!(pet.display_name, "Chefito"); + } + + #[test] + fn custom_pet_rejects_spritesheet_path_escape() { + let codex_home = tempfile::tempdir().unwrap(); + let pet_dir = codex_home.path().join("pets").join("escape"); + fs::create_dir_all(&pet_dir).unwrap(); + fs::write( + pet_dir.join("pet.json"), + r#"{ + "displayName": "Escape", + "spritesheetPath": "../spritesheet.webp" + }"#, + ) + .unwrap(); + + let err = Pet::load_with_codex_home( + &custom_pet_selector("escape"), + /*codex_home*/ Some(codex_home.path()), + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("spritesheet path must stay inside") + ); + } + + #[test] + fn custom_pet_rejects_zero_frame_dimensions() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Zero", + "spritesheetPath": "spritesheet.webp", + "frame": { "width": 0, "height": 208, "columns": 8, "rows": 9 } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!( + err.to_string() + .contains("pet frame dimensions and grid counts must be non-zero") + ); + } + + #[test] + fn custom_pet_rejects_frame_grid_that_does_not_cover_spritesheet() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Short", + "spritesheetPath": "spritesheet.webp", + "frame": { "width": 192, "height": 208, "columns": 7, "rows": 9 } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!( + err.to_string() + .contains("pet frame grid must cover spritesheet exactly") + ); + } + + #[test] + fn custom_pet_rejects_excessive_frame_count() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Dense", + "spritesheetPath": "spritesheet.webp", + "frame": { "width": 8, "height": 8, "columns": 192, "rows": 234 } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!(err.to_string().contains("exceeds maximum")); + } + + #[test] + fn custom_pet_rejects_empty_animation_frames() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Empty", + "spritesheetPath": "spritesheet.webp", + "animations": { + "idle": { "frames": [] } + } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!( + err.to_string() + .contains("animation idle must include at least one frame") + ); + } + + #[test] + fn custom_pet_rejects_animation_frame_outside_grid() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Outside", + "spritesheetPath": "spritesheet.webp", + "animations": { + "idle": { "frames": [72] } + } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!( + err.to_string() + .contains("animation idle references sprite index 72") + ); + } + + #[test] + fn custom_pet_rejects_invalid_animation_fps() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Fast", + "spritesheetPath": "spritesheet.webp", + "animations": { + "idle": { "frames": [0], "fps": 120.0 } + } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!( + err.to_string() + .contains("animation idle fps must be finite and between") + ); + } + + #[test] + fn custom_pet_rejects_animation_fallback_to_missing_animation() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Fallback", + "spritesheetPath": "spritesheet.webp", + "animations": { + "wave": { "frames": [1], "loop": false, "fallback": "missing" } + } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!( + err.to_string() + .contains("animation wave fallback missing does not exist") + ); + } + + fn sprite_indices(animation: &Animation) -> Vec { + animation + .frames + .iter() + .map(|frame| frame.sprite_index) + .collect() + } + + fn durations_ms(animation: &Animation) -> Vec { + animation + .frames + .iter() + .map(|frame| frame.duration.as_millis()) + .collect() + } +} diff --git a/codex-rs/tui/src/pets/picker.rs b/codex-rs/tui/src/pets/picker.rs new file mode 100644 index 000000000..935d230b9 --- /dev/null +++ b/codex-rs/tui/src/pets/picker.rs @@ -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, + display_name: String, + description: Option, +} + +/// 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::>(); + 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 = 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 { + let mut entries = catalog::BUILTIN_PETS + .iter() + .map(|pet| PetPickerEntry { + selector: pet.id.to_string(), + legacy_selector: None, + display_name: pet.display_name.to_string(), + description: Some(pet.description.to_string()), + }) + .collect::>(); + entries.push(PetPickerEntry { + selector: DISABLED_PET_ID.to_string(), + legacy_selector: None, + display_name: "Disable terminal pets".to_string(), + description: None, + }); + entries.extend(custom_pet_entries(codex_home)); + entries +} + +fn custom_pet_entries(codex_home: &Path) -> Vec { + let mut entries_by_selector = HashMap::new(); + for (directory_name, manifest_file) in [("avatars", "avatar.json"), ("pets", "pet.json")] { + let Ok(children) = fs::read_dir(codex_home.join(directory_name)) else { + continue; + }; + for child in children.flatten() { + let path = child.path(); + if !path.join(manifest_file).is_file() { + continue; + } + let Some(id) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if id == DISABLED_PET_ID || id.starts_with(CUSTOM_PET_PREFIX) { + continue; + } + let selector = custom_pet_selector(id); + let Ok(pet) = + Pet::load_with_codex_home(&selector, /*codex_home*/ Some(codex_home)) + else { + continue; + }; + entries_by_selector.insert( + selector.clone(), + PetPickerEntry { + selector, + legacy_selector: Some(id.to_string()), + display_name: pet.display_name, + description: (!pet.description.is_empty()).then_some(pet.description), + }, + ); + } + } + + 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![ + "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")); + } +} diff --git a/codex-rs/tui/src/pets/preview.rs b/codex-rs/tui/src/pets/preview.rs new file mode 100644 index 000000000..260362ac4 --- /dev/null +++ b/codex-rs/tui/src/pets/preview.rs @@ -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>, +} + +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 { + 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, +} + +#[derive(Debug, Default)] +enum PetPickerPreviewStatus { + #[default] + Hidden, + Loading, + Disabled, + Ready, + Error { + message: String, + }, +} + +pub(crate) struct PetPickerPreviewRenderable { + inner: Arc>, +} + +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 + ) + ); + } +} diff --git a/codex-rs/tui/src/pets/sixel.rs b/codex-rs/tui/src/pets/sixel.rs new file mode 100644 index 000000000..624fd7bc1 --- /dev/null +++ b/codex-rs/tui/src/pets/sixel.rs @@ -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> { + 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, + 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> { + 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 { + 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> { + 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, run_len: &mut usize, output: &mut Vec, 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, run_len: &mut usize, output: &mut Vec) { + 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 { + 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 { + 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 + '_ { + (0..=u8::MAX).filter(|index| self.used[usize::from(*index)]) + } + + fn write_definitions(&self, output: &mut Vec) { + 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"); + } +} diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index be89655d7..5d5e88839 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -5738,6 +5738,7 @@ session_picker_view = "dense" name: None, turns: vec![codex_app_server_protocol::Turn { id: String::from("turn-1"), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ ThreadItem::UserMessage { id: String::from("user-1"), @@ -5757,7 +5758,6 @@ session_picker_view = "dense" text: String::from("1. Do the thing"), }, ], - items_view: codex_app_server_protocol::TurnItemsView::Full, status: codex_app_server_protocol::TurnStatus::Completed, error: None, started_at: None, @@ -5805,12 +5805,12 @@ session_picker_view = "dense" name: None, turns: vec![codex_app_server_protocol::Turn { id: String::from("turn-1"), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::Reasoning { id: String::from("reasoning-1"), summary: Vec::new(), content: vec![String::from("private raw chain of thought")], }], - items_view: codex_app_server_protocol::TurnItemsView::Full, status: codex_app_server_protocol::TurnStatus::Completed, error: None, started_at: None, @@ -5862,12 +5862,12 @@ session_picker_view = "dense" name: None, turns: vec![codex_app_server_protocol::Turn { id: String::from("turn-1"), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::Reasoning { id: String::from("reasoning-1"), summary: vec![String::from("public summary")], content: vec![String::from("raw reasoning content")], }], - items_view: codex_app_server_protocol::TurnItemsView::Full, status: codex_app_server_protocol::TurnStatus::Completed, error: None, started_at: None, diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index 4b06a69b2..b1f533a05 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -169,6 +169,33 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { }); LOGGER.write_json_line(value); } + AppEvent::PetPreviewLoaded { request_id, result } => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "app_event", + "variant": "PetPreviewLoaded", + "request_id": request_id, + "ok": result.is_ok(), + }); + LOGGER.write_json_line(value); + } + AppEvent::PetSelectionLoaded { + request_id, + pet_id, + result, + } => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "app_event", + "variant": "PetSelectionLoaded", + "request_id": request_id, + "pet_id": pet_id, + "ok": result.is_ok(), + }); + LOGGER.write_json_line(value); + } // Noise or control flow – record variant only other => { let value = json!({ diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index cce0bc38d..beaaa7f31 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -48,6 +48,8 @@ pub enum SlashCommand { Title, Statusline, Theme, + #[strum(to_string = "pets", serialize = "pet")] + Pets, Mcp, Apps, Plugins, @@ -98,6 +100,7 @@ impl SlashCommand { SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", SlashCommand::Theme => "choose a syntax highlighting theme", + SlashCommand::Pets => "choose or hide the terminal pet", SlashCommand::Ps => "list background terminals", SlashCommand::Stop => "stop all background terminals", SlashCommand::MemoryDrop => "DO NOT USE", @@ -151,6 +154,7 @@ impl SlashCommand { | SlashCommand::Keymap | SlashCommand::Mcp | SlashCommand::Raw + | SlashCommand::Pets | SlashCommand::Side | SlashCommand::Resume | SlashCommand::SandboxReadRoot @@ -222,7 +226,7 @@ impl SlashCommand { SlashCommand::Settings => true, SlashCommand::Collab => true, SlashCommand::Agent | SlashCommand::MultiAgents => true, - SlashCommand::Theme => false, + SlashCommand::Theme | SlashCommand::Pets => false, } } @@ -261,6 +265,12 @@ mod tests { assert_eq!(SlashCommand::from_str("clean"), Ok(SlashCommand::Stop)); } + #[test] + fn pet_alias_parses_to_pets_command() { + assert_eq!(SlashCommand::Pets.command(), "pets"); + assert_eq!(SlashCommand::from_str("pet"), Ok(SlashCommand::Pets)); + } + #[test] fn certain_commands_are_available_during_task() { assert!(SlashCommand::Goal.available_during_task()); diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 06417e31e..9255d188c 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -75,6 +75,14 @@ fn should_emit_notification(condition: NotificationCondition, terminal_focused: } } +impl Drop for Tui { + fn drop(&mut self) { + if let Err(err) = self.clear_ambient_pet_image() { + tracing::debug!(error = %err, "failed to clear ambient pet image on TUI drop"); + } + } +} + #[cfg(test)] mod tests { use std::io::Write as _; @@ -420,6 +428,8 @@ pub struct Tui { event_broker: Arc, pub(crate) terminal: Terminal, pending_history_lines: Vec, + ambient_pet_image_state: crate::pets::PetImageRenderState, + pet_picker_preview_image_state: crate::pets::PetImageRenderState, alt_saved_viewport: Option, #[cfg(unix)] suspend_context: SuspendContext, @@ -475,6 +485,8 @@ impl Tui { event_broker: Arc::new(EventBroker::new()), terminal, pending_history_lines: vec![], + ambient_pet_image_state: crate::pets::PetImageRenderState::default(), + pet_picker_preview_image_state: crate::pets::PetImageRenderState::default(), alt_saved_viewport: None, #[cfg(unix)] suspend_context: SuspendContext::new(), @@ -863,6 +875,50 @@ impl Tui { })? } + pub fn draw_ambient_pet_image( + &mut self, + request: Option, + ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + let terminal = &mut self.terminal; + let state = &mut self.ambient_pet_image_state; + stdout().sync_update(|_| { + match crate::pets::render_ambient_pet_image(terminal.backend_mut(), state, request) { + Ok(()) => Ok(Ok(())), + Err(crate::pets::PetImageRenderError::Terminal(err)) => Err(err), + Err(err @ crate::pets::PetImageRenderError::Asset(_)) => Ok(Err(err)), + } + })?? + } + + pub fn draw_pet_picker_preview_image( + &mut self, + request: Option, + ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + let terminal = &mut self.terminal; + let state = &mut self.pet_picker_preview_image_state; + stdout().sync_update(|_| { + match crate::pets::render_pet_picker_preview_image( + terminal.backend_mut(), + state, + request, + ) { + Ok(()) => Ok(Ok(())), + Err(crate::pets::PetImageRenderError::Terminal(err)) => Err(err), + Err(err @ crate::pets::PetImageRenderError::Asset(_)) => Ok(Err(err)), + } + })?? + } + + pub fn clear_ambient_pet_image( + &mut self, + ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + crate::pets::render_ambient_pet_image( + self.terminal.backend_mut(), + &mut self.ambient_pet_image_state, + /*request*/ None, + ) + } + /// Draw a frame using the resize-reflow viewport and history insertion rules. /// /// This is the feature-gated counterpart to `draw`. It intentionally skips