use opi_ai::message::{ImageSource, MediaType, OutputContent};
use opi_tui::{
ImageData, ImagePayload, MediaType as TuiMediaType, Message as TuiMessage, Role as TuiRole,
TerminalGraphicsProtocol,
};
fn convert_media_type(mt: &MediaType) -> TuiMediaType {
match mt {
MediaType::Png => TuiMediaType::Png,
MediaType::Jpeg => TuiMediaType::Jpeg,
MediaType::Gif => TuiMediaType::Gif,
MediaType::WebP => TuiMediaType::WebP,
_ => TuiMediaType::Png,
}
}
fn source_to_bytes(source: &ImageSource) -> Option<Vec<u8>> {
match source {
ImageSource::Base64 { data } => {
use base64::Engine;
Some(
base64::engine::general_purpose::STANDARD
.decode(data)
.unwrap_or_default(),
)
}
ImageSource::Bytes { data } => Some(data.clone()),
ImageSource::Url { .. } => None,
_ => None,
}
}
fn image_contents_to_tui_messages(
contents: &[OutputContent],
protocol: TerminalGraphicsProtocol,
role: TuiRole,
) -> Vec<TuiMessage> {
contents
.iter()
.filter_map(|c| match c {
OutputContent::Image { source, media_type } => {
let bytes = source_to_bytes(source)?;
if bytes.is_empty() {
return None;
}
let data = ImageData {
bytes,
media_type: convert_media_type(media_type),
width: None,
height: None,
};
Some(TuiMessage::image(
role.clone(),
ImagePayload { data, protocol },
))
}
_ => None,
})
.collect()
}
#[test]
fn convert_base64_image_to_tui_message() {
let contents = vec![
OutputContent::Text {
text: "Screenshot captured".into(),
},
OutputContent::Image {
source: ImageSource::Base64 {
data: "iVBORw0KGgo=".into(),
},
media_type: MediaType::Png,
},
];
let messages = image_contents_to_tui_messages(
&contents,
TerminalGraphicsProtocol::Fallback,
TuiRole::Tool,
);
assert_eq!(messages.len(), 1);
let msg = &messages[0];
assert_eq!(msg.role, TuiRole::Tool);
assert!(msg.image.is_some());
assert!(msg.content.contains("[Image:"));
assert!(msg.content.contains("PNG"));
}
#[test]
fn convert_bytes_image_to_tui_message() {
let contents = vec![OutputContent::Image {
source: ImageSource::Bytes {
data: vec![0x89, 0x50, 0x4E, 0x47],
},
media_type: MediaType::Png,
}];
let messages = image_contents_to_tui_messages(
&contents,
TerminalGraphicsProtocol::Fallback,
TuiRole::Tool,
);
assert_eq!(messages.len(), 1);
let payload = messages[0].image.as_ref().unwrap();
assert_eq!(payload.data.bytes, vec![0x89, 0x50, 0x4E, 0x47]);
assert_eq!(payload.data.media_type, TuiMediaType::Png);
}
#[test]
fn convert_url_image_skipped() {
let contents = vec![OutputContent::Image {
source: ImageSource::Url {
url: "https://example.com/img.png".into(),
},
media_type: MediaType::Png,
}];
let messages = image_contents_to_tui_messages(
&contents,
TerminalGraphicsProtocol::Fallback,
TuiRole::Tool,
);
assert!(
messages.is_empty(),
"URL images should be skipped in TUI (no async fetch available)"
);
}
#[test]
fn convert_text_only_no_image_messages() {
let contents = vec![
OutputContent::Text {
text: "Just text".into(),
},
OutputContent::Text {
text: "More text".into(),
},
];
let messages = image_contents_to_tui_messages(
&contents,
TerminalGraphicsProtocol::Fallback,
TuiRole::Tool,
);
assert!(messages.is_empty());
}
#[test]
fn convert_all_media_types() {
for (ai_mt, tui_name) in [
(MediaType::Png, TuiMediaType::Png),
(MediaType::Jpeg, TuiMediaType::Jpeg),
(MediaType::Gif, TuiMediaType::Gif),
(MediaType::WebP, TuiMediaType::WebP),
] {
let contents = vec![OutputContent::Image {
source: ImageSource::Bytes {
data: vec![0x00, 0x01],
},
media_type: ai_mt,
}];
let messages = image_contents_to_tui_messages(
&contents,
TerminalGraphicsProtocol::Fallback,
TuiRole::Tool,
);
assert_eq!(
messages.len(),
1,
"Expected one image message for {tui_name:?}"
);
assert_eq!(
messages[0].image.as_ref().unwrap().data.media_type,
tui_name
);
}
}
#[test]
fn protocol_carried_through_to_tui() {
let contents = vec![OutputContent::Image {
source: ImageSource::Bytes { data: vec![0x00] },
media_type: MediaType::Png,
}];
for protocol in [
TerminalGraphicsProtocol::Kitty,
TerminalGraphicsProtocol::Iterm2,
TerminalGraphicsProtocol::Sixel,
TerminalGraphicsProtocol::Fallback,
] {
let messages = image_contents_to_tui_messages(&contents, protocol, TuiRole::Tool);
assert_eq!(messages[0].image.as_ref().unwrap().protocol, protocol);
}
}
#[test]
fn binary_preservation_through_conversion() {
let original: Vec<u8> = (0u8..=255).collect();
let contents = vec![OutputContent::Image {
source: ImageSource::Bytes {
data: original.clone(),
},
media_type: MediaType::Png,
}];
let messages = image_contents_to_tui_messages(
&contents,
TerminalGraphicsProtocol::Fallback,
TuiRole::Tool,
);
assert_eq!(messages[0].image.as_ref().unwrap().data.bytes, original);
}
#[test]
fn base64_decode_preserves_bytes() {
use base64::Engine;
let original = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let encoded = base64::engine::general_purpose::STANDARD.encode(&original);
let contents = vec![OutputContent::Image {
source: ImageSource::Base64 { data: encoded },
media_type: MediaType::Png,
}];
let messages = image_contents_to_tui_messages(
&contents,
TerminalGraphicsProtocol::Fallback,
TuiRole::Tool,
);
assert_eq!(messages[0].image.as_ref().unwrap().data.bytes, original);
}
#[test]
fn mixed_text_and_image_contents() {
let contents = vec![
OutputContent::Text {
text: "Tool ran successfully".into(),
},
OutputContent::Image {
source: ImageSource::Bytes {
data: vec![0xFF, 0xD8, 0xFF],
},
media_type: MediaType::Jpeg,
},
OutputContent::Text {
text: "See image above".into(),
},
];
let messages = image_contents_to_tui_messages(
&contents,
TerminalGraphicsProtocol::Fallback,
TuiRole::Tool,
);
assert_eq!(
messages.len(),
1,
"Only image content produces TUI messages"
);
assert!(messages[0].content.contains("JPEG"));
}