Skip to content

Commit 896920b

Browse files
authored
feat(tui): render user images inline (#27)
1 parent 5378147 commit 896920b

11 files changed

Lines changed: 1299 additions & 120 deletions

File tree

Cargo.lock

Lines changed: 350 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,15 @@ h2 = "=0.4.17"
4141
httpdate = "=1.0.3"
4242
hyper = "=1.11.0"
4343
hyper-util = { version = "=0.1.20", features = ["server", "http1", "http2", "tokio"] }
44+
image = { version = "=0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] }
4445
jsonwebtoken = { version = "=11.0.0", default-features = false, features = ["aws_lc_rs"] }
4546
keyring = { version = "=4.1.6", default-features = false, features = ["v1"] }
4647
jsonschema = { version = "=0.50.1", default-features = false }
4748
opentelemetry = { version = "=0.32.0", default-features = false, features = ["trace"] }
4849
opentelemetry-otlp = { version = "=0.32.0", default-features = false, features = ["grpc-tonic", "tls-webpki-roots", "trace"] }
4950
opentelemetry_sdk = { version = "=0.32.1", default-features = false, features = ["trace"] }
5051
ratatui = { version = "=0.30.2", default-features = false, features = ["crossterm", "layout-cache"] }
52+
ratatui-image = { version = "=11.0.6", default-features = false, features = ["crossterm"] }
5153
reqwest = { version = "=0.13.4", default-features = false, features = ["blocking", "form", "rustls", "stream"] }
5254
rmcp = { version = "=3.1.4", default-features = false, features = ["auth", "client"] }
5355
runlet = "=0.4.0"

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -631,8 +631,11 @@ only when every pasted token is a supported file; otherwise it preserves the
631631
whole paste as text. A prompt can contain at most 8 attachments, 10 MiB each
632632
and 20 MiB total. Kit sends the bytes through OpenRouter or OpenAI subscription
633633
while retaining canonical local `file://` links in model-facing text. Model
634-
modality support varies. Video, terminal image rendering, and audio playback are
635-
not supported, and Kit never displays base64 or `data:` URLs. See
634+
modality support varies. In terminals detected as supporting Kitty, Sixel, or
635+
iTerm2 graphics, user-attached images render inline as a bounded static first
636+
frame; other terminals and decode failures keep the safe clickable attachment
637+
label. Video and audio playback are not supported, and Kit never displays
638+
base64 or `data:` URLs. See
636639
[the TUI guide](docs/user/tui-and-sessions.md#attach-local-images-and-audio).
637640

638641
## Deliberate limits

docs/user/tui-and-sessions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ An accepted file appears in the editor as `[Image #N]` or `[Audio #N]`. Add surr
5858

5959
The model-facing prompt retains canonical `file://` Markdown links, while Kit also reads and sends the file bytes because remote providers cannot access local files. Image and audio acceptance remains model-dependent. Kit supports these request shapes through OpenRouter and OpenAI subscription; an individual model can still reject a modality it does not support. Video is not supported.
6060

61-
Assistant- and tool-produced media appears as portable Markdown placeholders or links. Kit does not render images in the terminal or play audio. Only bounded `file://`, `http://`, and `https://` links are displayed; base64 and `data:` URLs are never copied into terminal text or Markdown links.
61+
User-attached images render inline as a bounded static first frame when Kit detects Kitty, Sixel, or iTerm2 graphics support. No setting is required. Unsupported terminals, malformed or oversized images, and decode failures retain the safe clickable attachment label. Animated GIF and WebP files currently show only their first frame. Assistant- and tool-produced media remains portable Markdown placeholders or links, and audio is not played. Only bounded `file://`, `http://`, and `https://` links are displayed; base64 and `data:` URLs are never copied into terminal text or Markdown links.
6262

6363
### Interrupt a running turn or quit
6464

src/protocols/acp.rs

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -160,24 +160,15 @@ pub(super) fn tool_output_raw(output: &ToolOutput) -> Option<serde_json::Value>
160160
}
161161

162162
fn media_replay_content(media: &MediaPart) -> ContentBlock {
163-
match media.modality {
164-
Modality::Image
165-
if matches!(media.data, DataRef::InlineText(_) | DataRef::InlineBytes(_)) =>
166-
{
167-
ContentBlock::Image(ImageContent::new(
168-
data_ref_base64_payload(&media.data),
169-
media.mime_type.clone(),
170-
))
163+
let payload = data_ref_base64_payload(&media.data);
164+
match (media.modality, payload) {
165+
(Modality::Image, Some(payload)) => {
166+
ContentBlock::Image(ImageContent::new(payload, media.mime_type.clone()))
171167
}
172-
Modality::Audio
173-
if matches!(media.data, DataRef::InlineText(_) | DataRef::InlineBytes(_)) =>
174-
{
175-
ContentBlock::Audio(AudioContent::new(
176-
data_ref_base64_payload(&media.data),
177-
media.mime_type.clone(),
178-
))
168+
(Modality::Audio, Some(payload)) => {
169+
ContentBlock::Audio(AudioContent::new(payload, media.mime_type.clone()))
179170
}
180-
Modality::Image | Modality::Audio | Modality::Video | Modality::Binary => {
171+
(Modality::Image | Modality::Audio | Modality::Video | Modality::Binary, _) => {
181172
data_ref_replay_content(None, Some(&media.mime_type), &media.data)
182173
}
183174
}
@@ -218,7 +209,7 @@ fn data_ref_replay_content(
218209
}
219210
_ => {
220211
let mut resource = BlobResourceContents::new(
221-
data_ref_base64_payload(data),
212+
data_ref_base64_payload(data).unwrap_or_default(),
222213
format!("agentkit://session-replay/{}", name.unwrap_or("content")),
223214
);
224215
if let Some(mime_type) = mime_type {
@@ -231,13 +222,14 @@ fn data_ref_replay_content(
231222
}
232223
}
233224

234-
fn data_ref_base64_payload(data: &DataRef) -> String {
225+
fn data_ref_base64_payload(data: &DataRef) -> Option<String> {
235226
match data {
236227
DataRef::InlineText(text) => {
237-
data_url_base64_payload(text).unwrap_or_else(|| BASE64.encode(text.as_bytes()))
228+
Some(data_url_base64_payload(text).unwrap_or_else(|| BASE64.encode(text.as_bytes())))
238229
}
239-
DataRef::InlineBytes(bytes) => BASE64.encode(bytes),
240-
DataRef::Uri(_) | DataRef::Handle(_) => String::new(),
230+
DataRef::InlineBytes(bytes) => Some(BASE64.encode(bytes)),
231+
DataRef::Uri(uri) => data_url_base64_payload(uri),
232+
DataRef::Handle(_) => None,
241233
}
242234
}
243235

@@ -1754,6 +1746,21 @@ mod tests {
17541746
);
17551747
}
17561748

1749+
#[test]
1750+
fn replay_restores_data_url_images_as_image_content() {
1751+
let part = Part::media(
1752+
Modality::Image,
1753+
"image/png",
1754+
DataRef::uri("data:image/png;base64,AQID"),
1755+
);
1756+
let chunk = user_replay_content(&part).expect("image replay content");
1757+
1758+
assert!(matches!(
1759+
chunk.content,
1760+
ContentBlock::Image(image) if image.data == "AQID"
1761+
));
1762+
}
1763+
17571764
#[test]
17581765
fn transcript_replay_preserves_order_and_skips_unrepresentable_history() {
17591766
let transcript = vec![

src/protocols/acp/v2.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1724,7 +1724,8 @@ pub(crate) fn component(
17241724
mod tests {
17251725
use serde_json::json;
17261726

1727-
use agentkit_core::{MetadataMap, TurnCancellation};
1727+
use agent_client_protocol::schema::MaybeUndefined;
1728+
use agentkit_core::{DataRef, MetadataMap, Modality, TurnCancellation};
17281729
use agentkit_loop::{
17291730
Agent, ModelAdapter, ModelTurn, ModelTurnEvent, ModelTurnResult, SessionConfig,
17301731
TurnRequest, TurnResult,
@@ -2731,6 +2732,31 @@ mod tests {
27312732
));
27322733
}
27332734

2735+
#[test]
2736+
fn replay_preserves_data_url_user_images() {
2737+
let replay = transcript_replay(
2738+
&wire::SessionId::new("saved"),
2739+
&[Item::new(
2740+
ItemKind::User,
2741+
vec![Part::media(
2742+
Modality::Image,
2743+
"image/png",
2744+
DataRef::uri("data:image/png;base64,AQID"),
2745+
)],
2746+
)],
2747+
);
2748+
let wire::SessionUpdate::UserMessage(message) = &replay[0].update else {
2749+
panic!("expected user message");
2750+
};
2751+
let MaybeUndefined::Value(content) = &message.content else {
2752+
panic!("expected user content");
2753+
};
2754+
assert!(matches!(
2755+
content.as_slice(),
2756+
[wire::ContentBlock::Image(image)] if image.data == "AQID"
2757+
));
2758+
}
2759+
27342760
#[test]
27352761
fn v2_config_mapping_uses_v2_ids_categories_and_values() {
27362762
let current =

0 commit comments

Comments
 (0)