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

## Why

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

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

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

## What Changed

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

## Current Scope

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

## How to Test

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

Targeted tests:
- `cargo test -p codex-tui ambient_pet_`
- `cargo test -p codex-tui
resize_reflow_wraps_transcript_early_when_pet_is_enabled`
- `cargo insta pending-snapshots`
This commit is contained in:
Felipe Coury
2026-05-12 10:43:17 -03:00
committed by GitHub
Unverified
parent cb55b769d1
commit 95b332c820
71 changed files with 5576 additions and 91 deletions
@@ -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)
);
}
}
+30 -3
View File
@@ -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);
}
+3 -1
View File
@@ -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);
+181
View File
@@ -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<crate::pets::AmbientPet, String>,
) {
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<Option<crate::pets::AmbientPet>, String>,
) -> Result<AppRunControl> {
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<Option<crate::pets::AmbientPet>, 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}"));
}
}
}
}
+4 -3
View File
@@ -419,12 +419,13 @@ impl App {
}
pub(super) fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result<u16> {
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.
+28
View File
@@ -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;