Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions codex-rs/tui/src/chatwidget/tests/slash_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ fn force_tmux_pet_image_unsupported(chat: &mut ChatWidget) {
));
}

fn force_terminal_pet_image_unsupported(chat: &mut ChatWidget) {
chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Unsupported(
crate::pets::PetImageUnsupportedReason::Terminal,
));
}

fn force_old_iterm2_pet_image_unsupported(chat: &mut ChatWidget) {
chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Unsupported(
crate::pets::PetImageUnsupportedReason::Iterm2TooOld,
));
}

fn fast_tier_command() -> ServiceTierCommand {
ServiceTierCommand {
id: ServiceTier::Fast.request_value().to_string(),
Expand Down Expand Up @@ -1934,6 +1946,44 @@ async fn slash_pets_with_arg_on_unsupported_terminal_warns_without_selection() {
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}

#[tokio::test]
#[serial]
async fn slash_pets_on_unsupported_terminal_shows_terminal_warning() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
force_terminal_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 aren’t available in this terminal."));
assert!(rendered.contains("Kitty graphics or Sixel support"));
}

#[tokio::test]
#[serial]
async fn slash_pets_on_old_iterm2_shows_upgrade_warning() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
force_old_iterm2_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 require iTerm2 3.6 or newer."));
assert!(rendered.contains("Upgrade iTerm2 to use terminal pets."));
}

#[tokio::test]
async fn slash_fork_requests_current_fork() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
Expand Down
112 changes: 109 additions & 3 deletions codex-rs/tui/src/pets/image_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const ESC: &str = "\x1b";
const ST: &str = "\x1b\\";
const KITTY_CHUNK_SIZE: usize = 4096;
const SIXEL_CACHE_VERSION: &str = "v2";
const ITERM2_KITTY_MIN_VERSION: (u64, u64, u64) = (3, 6, 0);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageProtocol {
Expand Down Expand Up @@ -55,6 +56,7 @@ impl PetImageSupport {
pub(crate) enum PetImageUnsupportedReason {
Tmux,
Zellij,
Iterm2TooOld,
Terminal,
}

Expand All @@ -67,6 +69,9 @@ impl PetImageUnsupportedReason {
Self::Zellij => {
"Pets are disabled in Zellij. Terminal images don’t stay reliably pane-local in Zellij. Run Codex outside Zellij to use pets."
}
Self::Iterm2TooOld => {
"Pets require iTerm2 3.6 or newer. Upgrade iTerm2 to use terminal pets."
}
Self::Terminal => {
"Pets aren’t available in this terminal. Terminal pets need image support, and this terminal environment doesn’t expose a supported image protocol. Try a terminal with Kitty graphics or Sixel support, or run Codex outside tmux."
}
Expand Down Expand Up @@ -142,6 +147,10 @@ fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport {
return PetImageSupport::Supported(ImageProtocol::KittyLocalFile);
}

if is_iterm2_terminal(info) {
return PetImageSupport::Unsupported(PetImageUnsupportedReason::Iterm2TooOld);
}

if supports_kitty_graphics(info) {
return PetImageSupport::Supported(ImageProtocol::Kitty);
}
Expand All @@ -154,6 +163,14 @@ fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport {
}

fn supports_iterm2_kitty_graphics(info: &TerminalInfo) -> bool {
is_iterm2_terminal(info)
&& version_is_at_least(
info.version.as_deref(),
/*minimum*/ ITERM2_KITTY_MIN_VERSION,
)
}

fn is_iterm2_terminal(info: &TerminalInfo) -> bool {
matches!(info.name, TerminalName::Iterm2)
|| terminal_field_contains(info.term_program.as_deref(), "iterm")
}
Expand Down Expand Up @@ -181,6 +198,24 @@ fn terminal_field_contains(value: Option<&str>, needle: &str) -> bool {
value.is_some_and(|value| value.to_ascii_lowercase().contains(needle))
}

fn version_is_at_least(version: Option<&str>, minimum: (u64, u64, u64)) -> bool {
parse_dotted_version(version).is_some_and(|version| version >= minimum)
}

fn parse_dotted_version(version: Option<&str>) -> Option<(u64, u64, u64)> {
let version = version?;
let mut parts = version.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next().unwrap_or("0").parse().ok()?;
let patch = parts.next().unwrap_or("0").parse().ok()?;

if parts.next().is_some() {
return None;
}

Some((major, minor, patch))
}

pub fn kitty_delete_image(image_id: u32) -> String {
wrap_for_tmux_if_needed(&format!("{ESC}_Ga=d,d=I,i={image_id},q=2;{ST}"))
}
Expand Down Expand Up @@ -400,16 +435,18 @@ mod tests {
#[test]
fn pet_image_support_detects_iterm2_kitty_file_graphics() {
for info in [
terminal_info_for_test(
terminal_info_with_version_for_test(
TerminalName::Iterm2,
/*multiplexer*/ None,
Some("iTerm.app"),
Some("3.6.10"),
/*term*/ None,
),
terminal_info_for_test(
terminal_info_with_version_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
Some("iTerm.app"),
Some("3.6.10"),
Some("xterm-256color"),
),
] {
Expand All @@ -420,6 +457,49 @@ mod tests {
}
}

#[test]
fn pet_image_support_rejects_old_iterm2_versions() {
for info in [
terminal_info_with_version_for_test(
TerminalName::Iterm2,
/*multiplexer*/ None,
Some("iTerm.app"),
Some("3.5.14"),
/*term*/ None,
),
terminal_info_with_version_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
Some("iTerm.app"),
/*version*/ None,
Some("xterm-256color"),
),
terminal_info_with_version_for_test(
TerminalName::Iterm2,
/*multiplexer*/ None,
Some("iTerm.app"),
Some("3.5"),
/*term*/ None,
),
] {
assert_eq!(
pet_image_support_for_terminal(&info),
PetImageSupport::Unsupported(PetImageUnsupportedReason::Iterm2TooOld)
);
}
}

#[test]
fn pet_image_support_old_iterm2_message_mentions_upgrade() {
let message = PetImageSupport::Unsupported(PetImageUnsupportedReason::Iterm2TooOld)
.unsupported_message();

assert_eq!(
message,
Some("Pets require iTerm2 3.6 or newer. Upgrade iTerm2 to use terminal pets.")
);
}

#[test]
fn pet_image_support_detects_kitty_graphics_terminals() {
for info in [
Expand Down Expand Up @@ -538,16 +618,42 @@ mod tests {
multiplexer: Option<Multiplexer>,
term_program: Option<&str>,
term: Option<&str>,
) -> TerminalInfo {
terminal_info_with_version_for_test(
name,
multiplexer,
term_program,
/*version*/ None,
term,
)
}

fn terminal_info_with_version_for_test(
name: TerminalName,
multiplexer: Option<Multiplexer>,
term_program: Option<&str>,
version: Option<&str>,
term: Option<&str>,
) -> TerminalInfo {
TerminalInfo {
name,
term_program: term_program.map(str::to_string),
version: /*version*/ None,
version: version.map(str::to_string),
term: term.map(str::to_string),
multiplexer,
}
}

#[test]
fn parse_dotted_version_requires_simple_numeric_components() {
assert_eq!(parse_dotted_version(Some("3.6.10")), Some((3, 6, 10)));
assert_eq!(parse_dotted_version(Some("3.6")), Some((3, 6, 0)));
assert_eq!(parse_dotted_version(Some("3")), Some((3, 0, 0)));
assert_eq!(parse_dotted_version(Some("3.6.10.1")), None);
assert_eq!(parse_dotted_version(Some("3.6beta")), None);
assert_eq!(parse_dotted_version(/*version*/ None), None);
}

#[test]
fn sixel_frame_encodes_without_external_crate() {
let dir = tempfile::tempdir().unwrap();
Expand Down
Loading