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

## Why

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

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

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

## What Changed

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

## Current Scope

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

## How to Test

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

Targeted tests:
- `cargo test -p codex-tui ambient_pet_`
- `cargo test -p codex-tui
resize_reflow_wraps_transcript_early_when_pet_is_enabled`
- `cargo insta pending-snapshots`
This commit is contained in:
Felipe Coury
2026-05-12 10:43:17 -03:00
committed by GitHub
parent cb55b769d1
commit 95b332c820
71 changed files with 5576 additions and 91 deletions
+335
View File
@@ -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<crate::pets::AmbientPet> {
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<String>,
) {
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<crate::pets::AmbientPetDraw> {
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<crate::pets::AmbientPetDraw> {
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<String>) {
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<String>,
ambient_pet: Option<crate::pets::AmbientPet>,
) {
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<crate::pets::AmbientPet, String>,
) {
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);
}
}
+16 -1
View File
@@ -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,
}
}
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/exec_flow.rs
expression: terminal.backend().vt100().screen().contents()
---
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/exec_flow.rs
expression: contents
---
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/exec_flow.rs
expression: terminal.backend().vt100().screen().contents()
---
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/permissions.rs
expression: popup
---
Update Model Permissions
@@ -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())
---
" "
" "
@@ -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())
---
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Upload logs?
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Upload logs?
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/permissions.rs
expression: popup
---
Enable full access?
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Enable memories?
@@ -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
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Enable subagents?
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Select Personality
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/plan_mode.rs
expression: popup
---
Implement this plan?
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/plan_mode.rs
expression: popup
---
Implement this plan?
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Plugins
@@ -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
@@ -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
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: popup
---
Approaching rate limits
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Settings
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Settings
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Select Microphone
@@ -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())
---
" "
" "
@@ -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
@@ -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())
---
" "
" "
@@ -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())
---
" "
" "
@@ -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())
---
" "
" "
@@ -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);
@@ -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);
@@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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;
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/approval_requests.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
@@ -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::<AppEvent>();
async fn configured_pet_load_is_deferred_until_after_construction() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
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<String> {
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer.cell((x, y)).expect("cell should exist").symbol())
.collect::<String>()
})
.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.