mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
-1
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/exec_flow.rs
|
||||
expression: contents
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/permissions.rs
|
||||
expression: popup
|
||||
---
|
||||
Update Model Permissions
|
||||
|
||||
+2
-2
@@ -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())
|
||||
---
|
||||
" "
|
||||
" "
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Upload logs?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Upload logs?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/permissions.rs
|
||||
expression: popup
|
||||
---
|
||||
Enable full access?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Enable memories?
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Enable subagents?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Select Personality
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/plan_mode.rs
|
||||
expression: popup
|
||||
---
|
||||
Implement this plan?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/plan_mode.rs
|
||||
expression: popup
|
||||
---
|
||||
Implement this plan?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Settings
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Settings
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Select Microphone
|
||||
|
||||
+2
-2
@@ -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())
|
||||
---
|
||||
" "
|
||||
" "
|
||||
|
||||
+25
@@ -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
|
||||
+2
-2
@@ -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())
|
||||
---
|
||||
" "
|
||||
" "
|
||||
|
||||
+2
-2
@@ -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())
|
||||
---
|
||||
" "
|
||||
" "
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user