fix(tui): use local-file kitty graphics for iterm pets

This commit is contained in:
Felipe Coury
2026-05-05 11:33:21 -03:00
parent 342ddd435c
commit 5cfdec994b
2 changed files with 117 additions and 15 deletions

View File

@@ -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<u32>,
) -> Result<String> {
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\\")
);
}
}

View File

@@ -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"));
}
}