diff --git a/codex-rs/tui/src/pets/image_protocol.rs b/codex-rs/tui/src/pets/image_protocol.rs index 018ef9989b..bd3c42d8c0 100644 --- a/codex-rs/tui/src/pets/image_protocol.rs +++ b/codex-rs/tui/src/pets/image_protocol.rs @@ -25,6 +25,7 @@ const KITTY_CHUNK_SIZE: usize = 4096; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ImageProtocol { Kitty, + KittyLocalFile, Sixel, } @@ -137,6 +138,10 @@ fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport { None => {} } + if supports_iterm2_kitty_graphics(info) { + return PetImageSupport::Supported(ImageProtocol::KittyLocalFile); + } + if supports_kitty_graphics(info) { return PetImageSupport::Supported(ImageProtocol::Kitty); } @@ -148,16 +153,20 @@ fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport { PetImageSupport::Unsupported(PetImageUnsupportedReason::Terminal) } +fn supports_iterm2_kitty_graphics(info: &TerminalInfo) -> bool { + matches!(info.name, TerminalName::Iterm2) + || terminal_field_contains(info.term_program.as_deref(), "iterm") +} + fn supports_kitty_graphics(info: &TerminalInfo) -> bool { matches!( info.name, - TerminalName::Ghostty | TerminalName::Iterm2 | TerminalName::Kitty | TerminalName::WezTerm + TerminalName::Ghostty | TerminalName::Kitty | TerminalName::WezTerm ) || terminal_field_contains(info.term.as_deref(), "kitty") || terminal_field_contains(info.term.as_deref(), "ghostty") || terminal_field_contains(info.term.as_deref(), "wezterm") || terminal_field_contains(info.term_program.as_deref(), "kitty") || terminal_field_contains(info.term_program.as_deref(), "ghostty") - || terminal_field_contains(info.term_program.as_deref(), "iterm") || terminal_field_contains(info.term_program.as_deref(), "wezterm") } @@ -213,6 +222,24 @@ pub fn kitty_transmit_png_with_id( Ok(wrap_for_tmux_if_needed(&command)) } +pub fn kitty_transmit_png_file_with_id( + path: &Path, + columns: u16, + rows: u16, + image_id: Option, +) -> Result { + let path = path + .canonicalize() + .with_context(|| format!("canonicalize {}", path.display()))?; + let payload = general_purpose::STANDARD.encode(path.to_string_lossy().as_bytes()); + let image_id = image_id + .map(|image_id| format!(",i={image_id}")) + .unwrap_or_default(); + let command = format!("{ESC}_Ga=T,t=f,f=100,c={columns},r={rows},q=2{image_id};{payload}{ST}"); + + Ok(wrap_for_tmux_if_needed(&command)) +} + fn wrap_for_tmux_if_needed(command: &str) -> String { if env::var_os("TMUX").is_none() { return command.to_string(); @@ -377,6 +404,29 @@ mod tests { ); } + #[test] + fn pet_image_support_detects_iterm2_kitty_file_graphics() { + for info in [ + terminal_info_for_test( + TerminalName::Iterm2, + /*multiplexer*/ None, + Some("iTerm.app"), + /*term*/ None, + ), + terminal_info_for_test( + TerminalName::Unknown, + /*multiplexer*/ None, + Some("iTerm.app"), + Some("xterm-256color"), + ), + ] { + assert_eq!( + pet_image_support_for_terminal(&info), + PetImageSupport::Supported(ImageProtocol::KittyLocalFile) + ); + } + } + #[test] fn pet_image_support_detects_kitty_graphics_terminals() { for info in [ @@ -386,12 +436,6 @@ mod tests { Some("Ghostty"), /*term*/ None, ), - terminal_info_for_test( - TerminalName::Iterm2, - /*multiplexer*/ None, - Some("iTerm.app"), - /*term*/ None, - ), terminal_info_for_test( TerminalName::Kitty, /*multiplexer*/ None, @@ -416,12 +460,6 @@ mod tests { /*term_program*/ None, Some("wezterm"), ), - terminal_info_for_test( - TerminalName::Unknown, - /*multiplexer*/ None, - Some("iTerm.app"), - Some("xterm-256color"), - ), terminal_info_for_test( TerminalName::Unknown, /*multiplexer*/ None, @@ -525,4 +563,26 @@ mod tests { assert!(sixel.starts_with("\x1bP")); assert!(sixel.ends_with("\x1b\\")); } + + #[test] + fn kitty_file_png_transmission_encodes_local_file_reference() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("frame.png"); + fs::write(&path, b"png").unwrap(); + + let command = kitty_transmit_png_file_with_id( + &path, + /*columns*/ 4, + /*rows*/ 3, + /*image_id*/ Some(7), + ) + .unwrap(); + let path = path.canonicalize().unwrap(); + let payload = general_purpose::STANDARD.encode(path.to_string_lossy().as_bytes()); + + assert_eq!( + command, + format!("\x1b_Ga=T,t=f,f=100,c=4,r=3,q=2,i=7;{payload}\x1b\\") + ); + } } diff --git a/codex-rs/tui/src/pets/mod.rs b/codex-rs/tui/src/pets/mod.rs index c2495e692d..596d13eaaf 100644 --- a/codex-rs/tui/src/pets/mod.rs +++ b/codex-rs/tui/src/pets/mod.rs @@ -59,12 +59,19 @@ fn render_pet_image( use crossterm::queue; use image_protocol::ImageProtocol; - write!(writer, "{}", image_protocol::kitty_delete_image(image_id))?; let Some(request) = request else { + write!(writer, "{}", image_protocol::kitty_delete_image(image_id))?; writer.flush()?; return Ok(()); }; + if matches!( + request.protocol, + ImageProtocol::Kitty | ImageProtocol::KittyLocalFile + ) { + write!(writer, "{}", image_protocol::kitty_delete_image(image_id))?; + } + let payload = match request.protocol { ImageProtocol::Kitty => { AmbientPetPayload::Text(image_protocol::kitty_transmit_png_with_id( @@ -74,6 +81,14 @@ fn render_pet_image( Some(image_id), )?) } + ImageProtocol::KittyLocalFile => { + AmbientPetPayload::Text(image_protocol::kitty_transmit_png_file_with_id( + &request.frame, + request.columns, + request.rows, + Some(image_id), + )?) + } ImageProtocol::Sixel => { let path = image_protocol::sixel_frame(&request.frame, &request.sixel_dir, request.height_px)?; @@ -145,4 +160,31 @@ mod tests { assert!(!output.contains("\x1b[")); assert!(!output.contains("\x1b8")); } + + #[test] + fn kitty_local_file_pet_image_uses_file_reference_without_inline_payload() { + let dir = tempfile::tempdir().unwrap(); + let frame = dir.path().join("frame.png"); + std::fs::write(&frame, b"png").unwrap(); + let request = AmbientPetDraw { + frame, + protocol: ImageProtocol::KittyLocalFile, + x: 2, + y: 3, + columns: 4, + rows: 2, + height_px: 75, + sixel_dir: PathBuf::new(), + }; + let mut output = Vec::new(); + + render_ambient_pet_image(&mut output, Some(request)).unwrap(); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("a=d,d=I,i=49374,q=2;")); + assert!(output.contains("\x1b[4;3H")); + assert!(output.contains("a=T,t=f,f=100,c=4,r=2,q=2,i=49374;")); + assert!(!output.contains("cG5n")); + assert!(output.contains("\x1b8")); + } }