Files
codex/codex-rs/tui/src/slash_command.rs
T
Felipe Coury 95b332c820 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`
2026-05-12 10:43:17 -03:00

294 lines
11 KiB
Rust

use strum::IntoEnumIterator;
use strum_macros::AsRefStr;
use strum_macros::EnumIter;
use strum_macros::EnumString;
use strum_macros::IntoStaticStr;
/// Commands that can be invoked by starting a message with a leading slash.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr,
)]
#[strum(serialize_all = "kebab-case")]
pub enum SlashCommand {
// DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so
// more frequently used commands should be listed first.
Model,
Ide,
Permissions,
Keymap,
Vim,
#[strum(serialize = "setup-default-sandbox")]
ElevateSandbox,
#[strum(serialize = "sandbox-add-read-dir")]
SandboxReadRoot,
Experimental,
#[strum(to_string = "approve")]
AutoReview,
Memories,
Skills,
Hooks,
Review,
Rename,
New,
Resume,
Fork,
Init,
Compact,
Plan,
Goal,
Collab,
Agent,
Side,
Copy,
Raw,
Diff,
Mention,
Status,
DebugConfig,
Title,
Statusline,
Theme,
#[strum(to_string = "pets", serialize = "pet")]
Pets,
Mcp,
Apps,
Plugins,
Logout,
Quit,
Exit,
Feedback,
Rollout,
Ps,
#[strum(to_string = "stop", serialize = "clean")]
Stop,
Clear,
Personality,
Realtime,
Settings,
TestApproval,
#[strum(serialize = "subagents")]
MultiAgents,
// Debugging commands.
#[strum(serialize = "debug-m-drop")]
MemoryDrop,
#[strum(serialize = "debug-m-update")]
MemoryUpdate,
}
impl SlashCommand {
/// User-visible description shown in the popup.
pub fn description(self) -> &'static str {
match self {
SlashCommand::Feedback => "send logs to maintainers",
SlashCommand::New => "start a new chat during a conversation",
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my current changes and find issues",
SlashCommand::Rename => "rename the current thread",
SlashCommand::Resume => "resume a saved chat",
SlashCommand::Clear => "clear the terminal and start a new chat",
SlashCommand::Fork => "fork the current chat",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Copy => "copy last response as markdown",
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Mention => "mention a file",
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
SlashCommand::Hooks => "view and manage lifecycle hooks",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
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",
SlashCommand::MemoryUpdate => "DO NOT USE",
SlashCommand::Model => "choose what model and reasoning effort to use",
SlashCommand::Ide => {
"include current selection, open files, and other context from your IDE"
}
SlashCommand::Personality => "choose a communication style for Codex",
SlashCommand::Realtime => "toggle realtime voice mode (experimental)",
SlashCommand::Settings => "configure realtime microphone/speaker",
SlashCommand::Plan => "switch to Plan mode",
SlashCommand::Goal => "set or view the goal for a long-running task",
SlashCommand::Collab => "change collaboration mode (experimental)",
SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread",
SlashCommand::Side => "start a side conversation in an ephemeral fork",
SlashCommand::Permissions => "choose what Codex is allowed to do",
SlashCommand::Keymap => "remap TUI shortcuts",
SlashCommand::Vim => "toggle Vim mode for the composer",
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
SlashCommand::SandboxReadRoot => {
"let sandbox read a directory: /sandbox-add-read-dir <absolute_path>"
}
SlashCommand::Experimental => "toggle experimental features",
SlashCommand::AutoReview => "approve one retry of a recent auto-review denial",
SlashCommand::Memories => "configure memory use and generation",
SlashCommand::Mcp => "list configured MCP tools; use /mcp verbose for details",
SlashCommand::Apps => "manage apps",
SlashCommand::Plugins => "browse plugins",
SlashCommand::Logout => "log out of Codex",
SlashCommand::Rollout => "print the rollout file path",
SlashCommand::TestApproval => "test approval request",
}
}
/// Command string without the leading '/'. Provided for compatibility with
/// existing code that expects a method named `command()`.
pub fn command(self) -> &'static str {
self.into()
}
/// Whether this command supports inline args (for example `/review ...`).
pub fn supports_inline_args(self) -> bool {
matches!(
self,
SlashCommand::Review
| SlashCommand::Rename
| SlashCommand::Plan
| SlashCommand::Goal
| SlashCommand::Ide
| SlashCommand::Keymap
| SlashCommand::Mcp
| SlashCommand::Raw
| SlashCommand::Pets
| SlashCommand::Side
| SlashCommand::Resume
| SlashCommand::SandboxReadRoot
)
}
/// Whether this command remains available inside an active side conversation.
pub fn available_in_side_conversation(self) -> bool {
matches!(
self,
SlashCommand::Copy
| SlashCommand::Raw
| SlashCommand::Diff
| SlashCommand::Mention
| SlashCommand::Status
| SlashCommand::Ide
)
}
/// Whether this command can be run while a task is in progress.
pub fn available_during_task(self) -> bool {
match self {
SlashCommand::New
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Model
| SlashCommand::Personality
| SlashCommand::Permissions
| SlashCommand::Keymap
| SlashCommand::Vim
| SlashCommand::ElevateSandbox
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental
| SlashCommand::Memories
| SlashCommand::Review
| SlashCommand::Plan
| SlashCommand::Clear
| SlashCommand::Logout
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate => false,
SlashCommand::Diff
| SlashCommand::Copy
| SlashCommand::Raw
| SlashCommand::Rename
| SlashCommand::Mention
| SlashCommand::Skills
| SlashCommand::Hooks
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Stop
| SlashCommand::Goal
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Title
| SlashCommand::Statusline
| SlashCommand::AutoReview
| SlashCommand::Feedback
| SlashCommand::Ide
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Side => true,
SlashCommand::Rollout => true,
SlashCommand::TestApproval => true,
SlashCommand::Realtime => true,
SlashCommand::Settings => true,
SlashCommand::Collab => true,
SlashCommand::Agent | SlashCommand::MultiAgents => true,
SlashCommand::Theme | SlashCommand::Pets => false,
}
}
fn is_visible(self) -> bool {
match self {
SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"),
SlashCommand::Copy => !cfg!(target_os = "android"),
SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions),
_ => true,
}
}
}
/// Return all built-in commands in a Vec paired with their command string.
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
SlashCommand::iter()
.filter(|command| command.is_visible())
.map(|c| (c.command(), c))
.collect()
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use std::str::FromStr;
use super::SlashCommand;
#[test]
fn stop_command_is_canonical_name() {
assert_eq!(SlashCommand::Stop.command(), "stop");
}
#[test]
fn clean_alias_parses_to_stop_command() {
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());
assert!(SlashCommand::Ide.available_during_task());
assert!(SlashCommand::Title.available_during_task());
assert!(SlashCommand::Statusline.available_during_task());
assert!(SlashCommand::Raw.available_during_task());
assert!(SlashCommand::Raw.available_in_side_conversation());
assert!(SlashCommand::Raw.supports_inline_args());
}
#[test]
fn auto_review_command_is_approve() {
assert_eq!(SlashCommand::AutoReview.command(), "approve");
assert_eq!(
SlashCommand::from_str("approve"),
Ok(SlashCommand::AutoReview)
);
}
}