diff --git a/Cargo.lock b/Cargo.lock index 93490d4..9e3b3e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -777,6 +777,16 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -804,6 +814,18 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake3" version = "1.8.7" @@ -890,12 +912,38 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -1028,6 +1076,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1494,6 +1548,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "filetime" version = "0.2.29" @@ -1575,6 +1638,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.34" @@ -1746,6 +1815,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "h2" version = "0.4.17" @@ -1969,7 +2048,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -2064,6 +2143,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icy_sixel" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bfb5a63225620b59df34a235d1fb56ff7b766909c3212a8ff927511a22b181d" +dependencies = [ + "quantette", + "thiserror 2.0.18", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -2091,6 +2180,34 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2437,6 +2554,7 @@ dependencies = [ "httpdate", "hyper", "hyper-util", + "image", "jsonschema", "jsonwebtoken", "keyring", @@ -2445,6 +2563,7 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry_sdk", "ratatui", + "ratatui-image", "reqwest", "rmcp", "runlet", @@ -2606,6 +2725,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nix" version = "0.31.3" @@ -2706,6 +2835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2826,6 +2956,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2849,6 +2988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" dependencies = [ "approx", + "bytemuck", "libm", "palette_derive", "palette_math", @@ -2963,6 +3103,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3045,7 +3198,7 @@ dependencies = [ "nix", "tokio", "tracing", - "windows", + "windows 0.62.2", ] [[package]] @@ -3080,6 +3233,36 @@ dependencies = [ "prost", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quantette" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5d37e94c17b8870a5b936001d2845a6782a22ebdb1706c84294eca777f6729" +dependencies = [ + "bitvec", + "bytemuck", + "libm", + "num-traits", + "ordered-float", + "palette", + "rand 0.10.2", + "rand_xoshiro", + "ref-cast", + "wide", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.11" @@ -3158,6 +3341,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.8" @@ -3243,6 +3432,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xoshiro" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662effc7698e08ea324d3acccf8d9d7f7bf79b9785e270a174ea36e56900c91d" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "ratatui" version = "0.30.2" @@ -3290,6 +3488,23 @@ dependencies = [ "ratatui-core", ] +[[package]] +name = "ratatui-image" +version = "11.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000b7a22eae639460bc6ec8bb1cc689ecae5b0ed21935cd7d7dd52d38270c86" +dependencies = [ + "base64-simd", + "icy_sixel", + "image", + "rand 0.8.8", + "ratatui", + "rustix 0.38.44", + "self_cell", + "thiserror 1.0.69", + "windows 0.58.0", +] + [[package]] name = "ratatui-widgets" version = "0.3.2" @@ -3615,6 +3830,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3729,6 +3953,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -4141,6 +4371,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.46" @@ -4887,6 +5123,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wide" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2aaf408e58689c2096682331b1f42bb2d9f2ed6b11560407d023cd0a6c634e" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4918,6 +5170,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets", +] + [[package]] name = "windows" version = "0.62.2" @@ -4925,7 +5187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.62.2", "windows-future", "windows-numerics", ] @@ -4936,7 +5198,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets", ] [[package]] @@ -4945,11 +5220,11 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -4958,11 +5233,22 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4974,6 +5260,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -5010,10 +5307,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -5023,6 +5329,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -5153,6 +5469,15 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5376,6 +5701,21 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.15.0" diff --git a/Cargo.toml b/Cargo.toml index 0bf4a49..ce52dcf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ h2 = "=0.4.17" httpdate = "=1.0.3" hyper = "=1.11.0" hyper-util = { version = "=0.1.20", features = ["server", "http1", "http2", "tokio"] } +image = { version = "=0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] } jsonwebtoken = { version = "=11.0.0", default-features = false, features = ["aws_lc_rs"] } keyring = { version = "=4.1.6", default-features = false, features = ["v1"] } jsonschema = { version = "=0.50.1", default-features = false } @@ -48,6 +49,7 @@ opentelemetry = { version = "=0.32.0", default-features = false, features = ["tr opentelemetry-otlp = { version = "=0.32.0", default-features = false, features = ["grpc-tonic", "tls-webpki-roots", "trace"] } opentelemetry_sdk = { version = "=0.32.1", default-features = false, features = ["trace"] } ratatui = { version = "=0.30.2", default-features = false, features = ["crossterm", "layout-cache"] } +ratatui-image = { version = "=11.0.6", default-features = false, features = ["crossterm"] } reqwest = { version = "=0.13.4", default-features = false, features = ["blocking", "form", "rustls", "stream"] } rmcp = { version = "=3.1.4", default-features = false, features = ["auth", "client"] } runlet = "=0.4.0" diff --git a/README.md b/README.md index 90fdb14..42e0184 100644 --- a/README.md +++ b/README.md @@ -631,8 +631,11 @@ only when every pasted token is a supported file; otherwise it preserves the whole paste as text. A prompt can contain at most 8 attachments, 10 MiB each and 20 MiB total. Kit sends the bytes through OpenRouter or OpenAI subscription while retaining canonical local `file://` links in model-facing text. Model -modality support varies. Video, terminal image rendering, and audio playback are -not supported, and Kit never displays base64 or `data:` URLs. See +modality support varies. In terminals detected as supporting Kitty, Sixel, or +iTerm2 graphics, user-attached images render inline as a bounded static first +frame; other terminals and decode failures keep the safe clickable attachment +label. Video and audio playback are not supported, and Kit never displays +base64 or `data:` URLs. See [the TUI guide](docs/user/tui-and-sessions.md#attach-local-images-and-audio). ## Deliberate limits diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index e78eff5..a3619d5 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -58,7 +58,7 @@ An accepted file appears in the editor as `[Image #N]` or `[Audio #N]`. Add surr 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. -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. +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. ### Interrupt a running turn or quit diff --git a/src/protocols/acp.rs b/src/protocols/acp.rs index 7019762..9db5d58 100644 --- a/src/protocols/acp.rs +++ b/src/protocols/acp.rs @@ -160,24 +160,15 @@ pub(super) fn tool_output_raw(output: &ToolOutput) -> Option } fn media_replay_content(media: &MediaPart) -> ContentBlock { - match media.modality { - Modality::Image - if matches!(media.data, DataRef::InlineText(_) | DataRef::InlineBytes(_)) => - { - ContentBlock::Image(ImageContent::new( - data_ref_base64_payload(&media.data), - media.mime_type.clone(), - )) + let payload = data_ref_base64_payload(&media.data); + match (media.modality, payload) { + (Modality::Image, Some(payload)) => { + ContentBlock::Image(ImageContent::new(payload, media.mime_type.clone())) } - Modality::Audio - if matches!(media.data, DataRef::InlineText(_) | DataRef::InlineBytes(_)) => - { - ContentBlock::Audio(AudioContent::new( - data_ref_base64_payload(&media.data), - media.mime_type.clone(), - )) + (Modality::Audio, Some(payload)) => { + ContentBlock::Audio(AudioContent::new(payload, media.mime_type.clone())) } - Modality::Image | Modality::Audio | Modality::Video | Modality::Binary => { + (Modality::Image | Modality::Audio | Modality::Video | Modality::Binary, _) => { data_ref_replay_content(None, Some(&media.mime_type), &media.data) } } @@ -218,7 +209,7 @@ fn data_ref_replay_content( } _ => { let mut resource = BlobResourceContents::new( - data_ref_base64_payload(data), + data_ref_base64_payload(data).unwrap_or_default(), format!("agentkit://session-replay/{}", name.unwrap_or("content")), ); if let Some(mime_type) = mime_type { @@ -231,13 +222,14 @@ fn data_ref_replay_content( } } -fn data_ref_base64_payload(data: &DataRef) -> String { +fn data_ref_base64_payload(data: &DataRef) -> Option { match data { DataRef::InlineText(text) => { - data_url_base64_payload(text).unwrap_or_else(|| BASE64.encode(text.as_bytes())) + Some(data_url_base64_payload(text).unwrap_or_else(|| BASE64.encode(text.as_bytes()))) } - DataRef::InlineBytes(bytes) => BASE64.encode(bytes), - DataRef::Uri(_) | DataRef::Handle(_) => String::new(), + DataRef::InlineBytes(bytes) => Some(BASE64.encode(bytes)), + DataRef::Uri(uri) => data_url_base64_payload(uri), + DataRef::Handle(_) => None, } } @@ -1754,6 +1746,21 @@ mod tests { ); } + #[test] + fn replay_restores_data_url_images_as_image_content() { + let part = Part::media( + Modality::Image, + "image/png", + DataRef::uri("data:image/png;base64,AQID"), + ); + let chunk = user_replay_content(&part).expect("image replay content"); + + assert!(matches!( + chunk.content, + ContentBlock::Image(image) if image.data == "AQID" + )); + } + #[test] fn transcript_replay_preserves_order_and_skips_unrepresentable_history() { let transcript = vec![ diff --git a/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs index 2624ef8..2d60038 100644 --- a/src/protocols/acp/v2.rs +++ b/src/protocols/acp/v2.rs @@ -1724,7 +1724,8 @@ pub(crate) fn component( mod tests { use serde_json::json; - use agentkit_core::{MetadataMap, TurnCancellation}; + use agent_client_protocol::schema::MaybeUndefined; + use agentkit_core::{DataRef, MetadataMap, Modality, TurnCancellation}; use agentkit_loop::{ Agent, ModelAdapter, ModelTurn, ModelTurnEvent, ModelTurnResult, SessionConfig, TurnRequest, TurnResult, @@ -2731,6 +2732,31 @@ mod tests { )); } + #[test] + fn replay_preserves_data_url_user_images() { + let replay = transcript_replay( + &wire::SessionId::new("saved"), + &[Item::new( + ItemKind::User, + vec![Part::media( + Modality::Image, + "image/png", + DataRef::uri("data:image/png;base64,AQID"), + )], + )], + ); + let wire::SessionUpdate::UserMessage(message) = &replay[0].update else { + panic!("expected user message"); + }; + let MaybeUndefined::Value(content) = &message.content else { + panic!("expected user content"); + }; + assert!(matches!( + content.as_slice(), + [wire::ContentBlock::Image(image)] if image.data == "AQID" + )); + } + #[test] fn v2_config_mapping_uses_v2_ids_categories_and_values() { let current = diff --git a/src/tui/app.rs b/src/tui/app.rs index 9302bbc..76b5934 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -26,6 +26,9 @@ use crate::compaction::is_compaction_summary; use crate::events::RuntimeEvent; const MAX_TOOL_OUTPUT_LINES: usize = 5_000; +const MAX_IMAGE_BASE64_BYTES: usize = 14 * 1024 * 1024; +const MAX_IMAGE_SOURCE_BYTES: usize = 10 * 1024 * 1024; +const MAX_RETAINED_IMAGE_SOURCE_BYTES: usize = 32 * 1024 * 1024; use super::{ command::{Parsed, known_token, parse}, @@ -45,6 +48,7 @@ pub enum Update { UserMessage { id: String, text: String, + images: Vec, append: bool, }, /// Agent prose, either appended as a chunk or replaced by an upsert. @@ -345,8 +349,67 @@ impl ToolCall { } /// One entry in the transcript. +#[derive(Clone, Debug)] +pub struct UserImage { + pub(super) key: [u8; 32], + pub(super) data: String, + pub(super) mime_type: String, + /// Source line after which the fixed image viewport is reserved. + pub(super) line: usize, +} + +impl UserImage { + pub(super) fn new(data: String, mime_type: String, line: usize) -> Option { + // Check the encoded and maximum decoded lengths before hashing or retaining + // attacker-controlled ACP payloads. The exact decode stays lazy. + if data.len() > MAX_IMAGE_BASE64_BYTES { + return None; + } + let padding = data + .as_bytes() + .iter() + .rev() + .take_while(|&&byte| byte == b'=') + .take(2) + .count(); + let decoded_upper_bound = data + .len() + .div_ceil(4) + .saturating_mul(3) + .saturating_sub(padding); + if decoded_upper_bound > MAX_IMAGE_SOURCE_BYTES { + return None; + } + let mut hasher = blake3::Hasher::new(); + hasher.update(mime_type.as_bytes()); + hasher.update(&[0]); + hasher.update(data.as_bytes()); + Some(Self { + key: *hasher.finalize().as_bytes(), + data, + mime_type, + line, + }) + } +} + +#[derive(Clone, Debug)] +pub struct UserMessage { + pub(super) text: String, + pub(super) images: Vec, +} + +impl From for UserMessage { + fn from(text: String) -> Self { + Self { + text, + images: Vec::new(), + } + } +} + pub enum Block { - User(String), + User(UserMessage), Agent(String), Thought { text: String, @@ -359,9 +422,15 @@ pub enum Block { Error(String), } +pub(super) struct CachedTranscriptImage { + pub source: usize, + pub row: usize, +} + pub(super) struct CachedTranscriptBlock { pub revision: u64, pub rows: Vec, + pub images: Vec, } /// What the client is doing right now. @@ -402,6 +471,7 @@ pub struct App { pub(super) transcript_thoughts: BTreeSet, pub(super) transcript_prefixes: Vec, pub(super) transcript_cache_width: usize, + retained_image_source_bytes: usize, next_transcript_revision: u64, transcript_focus_index: Option, pub editor: Editor, @@ -664,6 +734,7 @@ impl App { transcript_thoughts: BTreeSet::new(), transcript_prefixes: vec![0], transcript_cache_width: 0, + retained_image_source_bytes: 0, next_transcript_revision: 0, transcript_focus_index: None, editor: Editor::default(), @@ -893,7 +964,10 @@ impl App { if !text.is_empty() { if item.kind == ItemKind::User { self.latest_agent_source.clear(); - self.push_block(Block::User(text)); + self.push_block(Block::User(UserMessage { + text, + images: Vec::new(), + })); } else { self.push_block(Block::Notice(text)); } @@ -1031,15 +1105,67 @@ impl App { self.toast = Some((text.into(), Instant::now())); } - fn apply_message(&mut self, id: String, text: String, append: bool, role: MessageRole) { - if let Some(&index) = self.message_blocks.get(&id) { + fn apply_message( + &mut self, + id: String, + text: String, + images: Vec, + append: bool, + role: MessageRole, + ) { + let mut images = images; + let existing_index = self.message_blocks.get(&id).copied(); + if !append + && matches!(role, MessageRole::User) + && let Some(index) = existing_index + && let Block::User(existing) = &self.blocks[index] + { + let replaced = existing + .images + .iter() + .map(|image| image.data.len()) + .sum::(); + self.retained_image_source_bytes = + self.retained_image_source_bytes.saturating_sub(replaced); + } + images.retain(|image| { + let retained = self + .retained_image_source_bytes + .saturating_add(image.data.len()); + if retained > MAX_RETAINED_IMAGE_SOURCE_BYTES { + false + } else { + self.retained_image_source_bytes = retained; + true + } + }); + if let Some(index) = existing_index { let mut changed = false; match (&mut self.blocks[index], role) { (Block::User(existing), MessageRole::User) => { if append { - existing.push_str(&text); + let last_line = existing.text.bytes().filter(|&byte| byte == b'\n').count(); + let follows_image = + existing.images.iter().any(|image| image.line == last_line); + let starts_image = !images.is_empty(); + if !existing.text.is_empty() + && !existing.text.ends_with('\n') + && !text.starts_with('\n') + && !text.is_empty() + && (follows_image || starts_image) + { + existing.text.push('\n'); + } + let line_offset = + existing.text.bytes().filter(|&byte| byte == b'\n').count(); + existing.text.push_str(&text); + for image in &mut images { + image.line += line_offset; + } + existing.images.extend(std::mem::take(&mut images)); } else { - *existing = text.clone(); + existing.text = text.clone(); + existing.images = std::mem::take(&mut images); } changed = true; } @@ -1079,7 +1205,7 @@ impl App { MessageRole::User => { self.close_thought(); self.agent_stream_sealed = true; - self.push_block(Block::User(text)); + self.push_block(Block::User(UserMessage { text, images })); } MessageRole::Agent => { self.close_thought(); @@ -1194,15 +1320,20 @@ impl App { self.pending_steers.push_back(PendingSteer { id, text }); } } - Update::UserMessage { id, text, append } => { + Update::UserMessage { + id, + text, + images, + append, + } => { self.pending_steers.retain(|pending| pending.id != id); - self.apply_message(id, text, append, MessageRole::User); + self.apply_message(id, text, images, append, MessageRole::User); } Update::AgentMessage { id, text, append } => { - self.apply_message(id, text, append, MessageRole::Agent); + self.apply_message(id, text, Vec::new(), append, MessageRole::Agent); } Update::AgentThought { id, text, append } => { - self.apply_message(id, text, append, MessageRole::Thought); + self.apply_message(id, text, Vec::new(), append, MessageRole::Thought); } Update::ToolStarted { id, @@ -1475,6 +1606,7 @@ impl App { self.transcript_prefixes.clear(); self.transcript_prefixes.push(0); self.transcript_cache_width = 0; + self.retained_image_source_bytes = 0; self.transcript_focus_index = None; self.clear_attachments(); self.latest_agent_source.clear(); @@ -1503,6 +1635,7 @@ impl App { self.apply(Update::UserMessage { id, text: prompt, + images: Vec::new(), append: false, }); self.apply(Update::State { @@ -2115,7 +2248,10 @@ mod tests { use agentkit_core::{DataRef, Item, ItemKind, MediaPart, MetadataMap, Modality, Part}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; - use super::{Action, App, AttachmentKind, Block, Phase, Update}; + use super::{ + Action, App, AttachmentKind, Block, MAX_IMAGE_BASE64_BYTES, MAX_IMAGE_SOURCE_BYTES, + MAX_RETAINED_IMAGE_SOURCE_BYTES, Phase, Update, UserImage, + }; use crate::{events::RuntimeEvent, tui::wrap::LinkHit}; fn press(code: KeyCode) -> KeyEvent { @@ -2140,6 +2276,38 @@ mod tests { ) } + #[test] + fn oversized_user_image_payload_is_rejected_before_retention() { + let encoded_too_large = "A".repeat(MAX_IMAGE_BASE64_BYTES + 1); + assert!(UserImage::new(encoded_too_large, "image/png".into(), 0).is_none()); + + let decoded_too_large = "A".repeat((MAX_IMAGE_SOURCE_BYTES + 1).div_ceil(3) * 4); + assert!(UserImage::new(decoded_too_large, "image/png".into(), 0).is_none()); + } + + #[test] + fn retained_user_image_sources_have_an_aggregate_bound() { + let source_bytes = 9 * 1024 * 1024; + let mut app = app(); + for index in 0..4 { + let image = UserImage::new("A".repeat(source_bytes), "image/png".into(), 0) + .expect("source is within the per-image limit"); + app.apply(Update::UserMessage { + id: format!("image-{index}"), + text: format!("[Image #{index}]"), + images: vec![image], + append: false, + }); + } + + assert_eq!(app.retained_image_source_bytes, source_bytes * 3); + assert!(app.retained_image_source_bytes <= MAX_RETAINED_IMAGE_SOURCE_BYTES); + assert!(matches!( + app.blocks.last(), + Some(Block::User(message)) if message.images.is_empty() + )); + } + fn compose(app: &mut App, script: &str) { app.apply(Update::ToolStarted { id: "call-1".into(), @@ -2257,7 +2425,7 @@ mod tests { app.restore_transcript("session".into(), &transcript); assert_eq!(app.blocks.len(), 2); - assert!(matches!(&app.blocks[0], Block::User(text) if text == "run the build")); + assert!(matches!(&app.blocks[0], Block::User(message) if message.text == "run the build")); assert!(matches!(&app.blocks[1], Block::Agent(text) if text == "the build passed")); } @@ -2298,9 +2466,9 @@ mod tests { assert!(matches!( &app.blocks[0], - Block::User(text) - if text == "inspect these\n[Image #1](file:///tmp/image.png)\n[Image #2]" - && !text.contains("data:") + Block::User(message) + if message.text == "inspect these\n[Image #1](file:///tmp/image.png)\n[Image #2]" + && !message.text.contains("data:") )); assert!(matches!(&app.blocks[1], Block::Agent(text) if text == "done")); assert!(matches!( @@ -2814,10 +2982,13 @@ mod tests { app.apply(Update::UserMessage { id: "injected-1".into(), text: "change direction".into(), + images: Vec::new(), append: false, }); assert!(app.pending_steers.is_empty()); - assert!(matches!(app.blocks.last(), Some(Block::User(text)) if text == "change direction")); + assert!( + matches!(app.blocks.last(), Some(Block::User(message)) if message.text == "change direction") + ); assert!(app.working()); } @@ -2854,7 +3025,8 @@ mod tests { #[test] fn switching_sessions_clears_only_transcript_derived_state() { let mut app = app(); - app.blocks.push(Block::User("old transcript".into())); + app.blocks + .push(Block::User("old transcript".to_string().into())); app.logs.push("diagnostic".into()); app.usage = Some(super::ContextUsage { used: 1, size: 2 }); app.start_session("fresh".into()); diff --git a/src/tui/image.rs b/src/tui/image.rs new file mode 100644 index 0000000..867e2d1 --- /dev/null +++ b/src/tui/image.rs @@ -0,0 +1,258 @@ +use std::{collections::HashMap, io::Cursor, time::Duration}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use image::{ImageReader, Limits}; +use ratatui::{ + Frame, + layout::{Rect, Size}, +}; +use ratatui_image::{ + Resize, + picker::{Picker, ProtocolType, cap_parser::QueryStdioOptions}, + sliced::{SignedPosition, SlicedImage, SlicedProtocol}, +}; + +use super::app::UserImage; + +const TERMINAL_QUERY_TIMEOUT: Duration = Duration::from_millis(150); +const MAX_DECODED_ALLOCATION: u64 = 64 * 1024 * 1024; +const MAX_DECODED_BACKING_BYTES: u64 = 128 * 1024 * 1024; +const MAX_CACHE_ENTRIES: usize = 16; +const MAX_DIMENSION: u32 = 8_192; +pub(super) const RESERVED_ROWS: u16 = 12; + +#[derive(Clone, Copy)] +pub(super) struct PreparedImage { + pub key: [u8; 32], +} + +struct CacheEntry { + decoded: Option, + decoded_backing_bytes: u64, + protocol: Option<(u16, SlicedProtocol)>, + last_used: u64, +} + +pub(super) struct ImageRuntime { + picker: Option, + cache: HashMap<[u8; 32], CacheEntry>, + decoded_backing_bytes: u64, + clock: u64, +} + +impl ImageRuntime { + pub fn detect() -> Self { + let picker = Picker::from_query_stdio_with_options(QueryStdioOptions { + timeout: TERMINAL_QUERY_TIMEOUT, + ..QueryStdioOptions::default() + }) + .ok() + .filter(|picker| picker.protocol_type() != ProtocolType::Halfblocks); + Self { + picker, + cache: HashMap::new(), + decoded_backing_bytes: 0, + clock: 0, + } + } + + #[cfg(test)] + pub fn disabled() -> Self { + Self { + picker: None, + cache: HashMap::new(), + decoded_backing_bytes: 0, + clock: 0, + } + } + + #[cfg(test)] + pub fn with_picker(picker: Picker) -> Self { + Self { + picker: Some(picker), + cache: HashMap::new(), + decoded_backing_bytes: 0, + clock: 0, + } + } + + pub fn enabled(&self) -> bool { + self.picker.is_some() + } + + pub fn clear(&mut self) { + self.cache.clear(); + self.decoded_backing_bytes = 0; + } + + #[cfg(test)] + pub fn cached_entries(&self) -> usize { + self.cache.len() + } + + pub fn prepare(&mut self, image: &UserImage, width: u16) -> Option { + let picker = self.picker.clone()?; + if width == 0 { + return None; + } + self.clock = self.clock.wrapping_add(1); + if !self.cache.contains_key(&image.key) { + let decoded = decode(image); + // This only accounts for the decoded image backing buffer. Protocol + // encoders can allocate additional implementation-defined memory. + let decoded_backing_bytes = decoded + .as_ref() + .map_or(0, |image| image.as_bytes().len() as u64); + if decoded_backing_bytes > MAX_DECODED_BACKING_BYTES { + return None; + } + self.evict_for(decoded_backing_bytes); + self.decoded_backing_bytes += decoded_backing_bytes; + self.cache.insert( + image.key, + CacheEntry { + decoded, + decoded_backing_bytes, + protocol: None, + last_used: self.clock, + }, + ); + } + let entry = self.cache.get_mut(&image.key)?; + entry.last_used = self.clock; + if entry + .protocol + .as_ref() + .is_none_or(|(cached_width, _)| *cached_width != width) + { + let target = Size::new(width, RESERVED_ROWS); + let protocol = SlicedProtocol::new_with_resize( + &picker, + entry.decoded.as_ref()?.clone(), + target, + Resize::Fit(None), + ) + .ok()?; + entry.protocol = Some((width, protocol)); + } + entry.protocol.as_ref()?; + Some(PreparedImage { key: image.key }) + } + + pub fn render(&mut self, frame: &mut Frame<'_>, image: PreparedImage, area: Rect, y: i16) { + self.clock = self.clock.wrapping_add(1); + let Some(entry) = self.cache.get_mut(&image.key) else { + return; + }; + entry.last_used = self.clock; + let Some((_, protocol)) = entry.protocol.as_ref() else { + return; + }; + frame.render_widget( + SlicedImage::new(protocol, SignedPosition::from((0, y))), + area, + ); + } + + fn evict_for(&mut self, incoming: u64) { + while !self.cache.is_empty() + && (self.cache.len() >= MAX_CACHE_ENTRIES + || self.decoded_backing_bytes.saturating_add(incoming) > MAX_DECODED_BACKING_BYTES) + { + let Some(key) = self + .cache + .iter() + .min_by_key(|(_, entry)| entry.last_used) + .map(|(key, _)| *key) + else { + break; + }; + if let Some(entry) = self.cache.remove(&key) { + self.decoded_backing_bytes = self + .decoded_backing_bytes + .saturating_sub(entry.decoded_backing_bytes); + } + } + } +} + +fn decode(source: &UserImage) -> Option { + let bytes = STANDARD.decode(source.data.as_bytes()).ok()?; + if bytes.len() as u64 > MAX_DECODED_ALLOCATION { + return None; + } + let mut reader = ImageReader::new(Cursor::new(bytes)); + if let Some(format) = image::ImageFormat::from_mime_type(&source.mime_type) { + reader.set_format(format); + } else { + reader = reader.with_guessed_format().ok()?; + } + let mut limits = Limits::default(); + limits.max_image_width = Some(MAX_DIMENSION); + limits.max_image_height = Some(MAX_DIMENSION); + limits.max_alloc = Some(MAX_DECODED_ALLOCATION); + reader.limits(limits); + reader.decode().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn image(data: &str) -> UserImage { + UserImage::new(data.into(), "image/png".into(), 0).unwrap() + } + + #[test] + fn disabled_runtime_uses_text_fallback() { + assert!( + ImageRuntime::disabled() + .prepare(&image("invalid"), 40) + .is_none() + ); + } + + #[test] + fn decoded_image_is_reused_when_width_changes() { + let mut png = Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(200, 100) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let source = image(&STANDARD.encode(png.into_inner())); + let mut runtime = ImageRuntime::with_picker(Picker::halfblocks()); + + assert!(runtime.prepare(&source, 20).is_some()); + let decoded_backing_bytes = runtime.decoded_backing_bytes; + assert_eq!(decoded_backing_bytes, 200 * 100 * 3); + assert!(runtime.prepare(&source, 40).is_some()); + assert_eq!(runtime.cache.len(), 1); + assert_eq!(runtime.decoded_backing_bytes, decoded_backing_bytes); + assert_eq!(runtime.cache[&source.key].protocol.as_ref().unwrap().0, 40); + } + + #[test] + fn failed_decodes_are_cached() { + let mut runtime = ImageRuntime::with_picker(Picker::halfblocks()); + let source = image("aW52YWxpZA=="); + assert!(runtime.prepare(&source, 40).is_none()); + assert!(runtime.prepare(&source, 20).is_none()); + assert_eq!(runtime.cache.len(), 1); + assert!(runtime.cache[&source.key].decoded.is_none()); + } + + #[test] + fn clear_drops_all_decoded_backing_bytes() { + let mut png = Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(20, 10) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let source = image(&STANDARD.encode(png.into_inner())); + let mut runtime = ImageRuntime::with_picker(Picker::halfblocks()); + assert!(runtime.prepare(&source, 20).is_some()); + + runtime.clear(); + + assert!(runtime.cache.is_empty()); + assert_eq!(runtime.decoded_backing_bytes, 0); + } +} diff --git a/src/tui/markdown.rs b/src/tui/markdown.rs index a859da4..d99892c 100644 --- a/src/tui/markdown.rs +++ b/src/tui/markdown.rs @@ -240,7 +240,7 @@ fn next_markdown_link(source: &str) -> Option> { } let url_end = url_end?; let url = &source[url_start..url_end]; - if url.starts_with("https://") || url.starts_with("http://") { + if super::safe_media_uri(url) { return Some(Link { start, end: url_end + 1, @@ -294,8 +294,28 @@ fn next_link(source: &str) -> Option> { } } +pub(super) fn line_with_link(source: &str, url: &str) -> Option { + source.split('\n').position(|line| { + inline(line, Style::default()) + .iter() + .any(|span| span.url.as_deref() == Some(url)) + }) +} + +pub(super) fn inline_spans(source: &str, base: Style) -> Vec { + inline_with_link_destinations(source, base, false) +} + /// Splits inline links, emphasis, and code spans out of one line of Markdown. fn inline(source: &str, base: Style) -> Vec { + inline_with_link_destinations(source, base, true) +} + +fn inline_with_link_destinations( + source: &str, + base: Style, + show_link_destinations: bool, +) -> Vec { let mut spans = Vec::new(); let mut plain = String::new(); let mut rest = source; @@ -310,12 +330,16 @@ fn inline(source: &str, base: Style) -> Vec { if !plain.is_empty() { spans.push(plain_span(std::mem::take(&mut plain), base)); } - let link_style = theme::accent().add_modifier(Modifier::UNDERLINED); + let link_style = base + .patch(theme::accent()) + .add_modifier(Modifier::UNDERLINED); if let Some(label) = link.label { spans.push(link_span(label.to_string(), link_style, link.url)); - spans.push(plain_span(" (", base)); - spans.push(link_span(link.url.to_string(), link_style, link.url)); - spans.push(plain_span(")", base)); + if show_link_destinations { + spans.push(plain_span(" (", base)); + spans.push(link_span(link.url.to_string(), link_style, link.url)); + spans.push(plain_span(")", base)); + } } else { spans.push(link_span(link.url.to_string(), link_style, link.url)); } @@ -375,7 +399,15 @@ fn inline(source: &str, base: Style) -> Vec { if !plain.is_empty() { spans.push(plain_span(std::mem::take(&mut plain), base)); } - spans.push(plain_span(body[..close].to_string(), style)); + if delimiter == "`" { + spans.push(plain_span(body[..close].to_string(), style)); + } else { + spans.extend(inline_with_link_destinations( + &body[..close], + style, + show_link_destinations, + )); + } rest = &body[close + delimiter.len()..]; } if !plain.is_empty() { @@ -532,6 +564,52 @@ mod tests { ); } + #[test] + fn parses_links_inside_bold_and_italic() { + let url = "https://example.com/docs"; + for (source, emphasis) in [ + (format!("**[label]({url})**"), Modifier::BOLD), + (format!("*[label]({url})*"), Modifier::ITALIC), + (format!("_[label]({url})_"), Modifier::ITALIC), + ] { + let rendered = render_linked(&source); + let line = &rendered[0]; + let joined: String = line + .spans + .iter() + .map(|span| span.span.content.as_ref()) + .collect(); + assert_eq!(joined, format!("label ({url})")); + + let linked: Vec<_> = line + .spans + .iter() + .filter(|span| span.url.is_some()) + .collect(); + assert_eq!(linked.len(), 2); + assert!(linked.iter().all(|span| span.url.as_deref() == Some(url))); + assert!(linked.iter().all(|span| { + span.span.style.add_modifier.contains(emphasis) + && span.span.style.add_modifier.contains(Modifier::UNDERLINED) + })); + } + } + + #[test] + fn leaves_markdown_links_inside_code_spans_literal_and_unlinked() { + let source = "`[label](https://example.com/docs)`"; + let rendered = render_linked(source); + let line = &rendered[0]; + let joined: String = line + .spans + .iter() + .map(|span| span.span.content.as_ref()) + .collect(); + + assert_eq!(joined, "[label](https://example.com/docs)"); + assert!(linked_urls(source).is_empty()); + } + #[test] fn parses_balanced_parentheses_in_link_destinations() { assert_eq!( diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f8833d8..60614be 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -9,6 +9,7 @@ mod app; mod command; mod editor; +mod image; mod markdown; mod plan; mod theme; @@ -61,6 +62,7 @@ use crate::{ use app::{ Action, App, Attachment, AttachmentKind, EffortChoice, ModelChoice, SubmittedPrompt, Update, + UserImage, }; /// Animation and elapsed-time refresh interval. @@ -472,7 +474,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( // an installation failure cannot leave the caller's terminal altered. let mut stop = Stop::new().map_err(agent_client_protocol::Error::into_internal_error)?; - let mut terminal = + let (mut terminal, mut images) = enter().map_err(agent_client_protocol::Error::into_internal_error)?; let mut app = App::new( root.clone(), @@ -492,7 +494,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( let result: Result<(), agent_client_protocol::Error> = async { loop { terminal - .draw(|frame| ui::draw(frame, &mut app)) + .draw(|frame| ui::draw(frame, &mut app, &mut images)) .map_err(agent_client_protocol::Error::into_internal_error)?; tokio::select! { terminal_event = events.next() => { @@ -581,6 +583,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( if let Ok(mut active) = transition_session.lock() { *active = persisted_id.clone(); } + images.clear(); app.start_session(persisted_id); refresh_config_state(&mut app, Some(&session.config_options)); if let Some(prompt) = first_prompt { @@ -627,6 +630,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( if let Ok(mut active) = transition_session.lock() { *active = requested_id.clone(); } + images.clear(); app.start_session(requested_id.clone()); match request_resume(&connection, session_id.clone(), root.clone()).await { Ok(response) => refresh_config_state( @@ -638,6 +642,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( if let Ok(mut active) = transition_session.lock() { *active = previous_persisted_id.clone(); } + images.clear(); app.start_session(previous_persisted_id); let restored = request_resume( &connection, @@ -1104,11 +1109,12 @@ fn prompt_blocks(prompt: &SubmittedPrompt) -> Result, String> Ok(blocks) } -fn enter() -> std::io::Result { - // Asked before the alternate screen is entered: the query needs the - // terminal's own input stream, which the event loop owns from here on. +fn enter() -> std::io::Result<(DefaultTerminal, image::ImageRuntime)> { theme::detect(); let terminal = ratatui::try_init()?; + // Query after entering the alternate screen but before the event stream owns + // terminal input, as required by ratatui-image. The query has a short bound. + let images = image::ImageRuntime::detect(); let mut stdout = std::io::stdout(); // Bracketed paste is what keeps pasted text out of the key stream: without // it every newline in a paste arrives as a return press, which submits the @@ -1131,7 +1137,7 @@ fn enter() -> std::io::Result { restore_modes(); previous(info); })); - Ok(terminal) + Ok((terminal, images)) } /// Whether the keyboard enhancement flags were pushed and still need popping. @@ -1239,14 +1245,15 @@ fn durable_session_id(session_id: &wire::SessionId) -> Result { fn translate(notification: UpdateSessionNotification) -> (String, Vec) { let session_id = notification.session_id.to_string(); let updates = match notification.update { - SessionUpdate::UserMessageChunk(chunk) => message_of(chunk.content) - .map(|text| Update::UserMessage { + SessionUpdate::UserMessageChunk(chunk) => { + let (text, images) = user_message_of(vec![chunk.content]); + vec![Update::UserMessage { id: chunk.message_id.to_string(), text, + images, append: true, - }) - .into_iter() - .collect(), + }] + } SessionUpdate::AgentMessageChunk(chunk) => message_of(chunk.content) .map(|text| Update::AgentMessage { id: chunk.message_id.to_string(), @@ -1255,14 +1262,14 @@ fn translate(notification: UpdateSessionNotification) -> (String, Vec) { }) .into_iter() .collect(), - SessionUpdate::AgentThoughtChunk(chunk) => text_of(chunk.content) - .map(|text| Update::AgentThought { + SessionUpdate::AgentThoughtChunk(chunk) => match chunk.content { + ContentBlock::Text(text) => vec![Update::AgentThought { id: chunk.message_id.to_string(), - text, + text: text.text, append: true, - }) - .into_iter() - .collect(), + }], + _ => Vec::new(), + }, SessionUpdate::UserMessage(message) => message_patch( message.message_id.to_string(), message.content, @@ -1404,17 +1411,22 @@ fn message_patch( MaybeUndefined::Null => Vec::new(), MaybeUndefined::Value(blocks) => blocks, }; + if matches!(kind, MessageKind::User) { + let (text, images) = user_message_of(blocks); + return vec![Update::UserMessage { + id, + text, + images, + append: false, + }]; + } let text = blocks .into_iter() .filter_map(message_of) .collect::>() .join(""); vec![match kind { - MessageKind::User => Update::UserMessage { - id, - text, - append: false, - }, + MessageKind::User => unreachable!("handled above"), MessageKind::Agent => Update::AgentMessage { id, text, @@ -1428,11 +1440,55 @@ fn message_patch( }] } -fn text_of(content: ContentBlock) -> Option { - match content { - ContentBlock::Text(text) => Some(text.text), - _ => None, +fn user_message_of(blocks: Vec) -> (String, Vec) { + let mut text = String::new(); + let mut images = Vec::new(); + let mut image_ordinal = 0; + let mut separate_after_image = false; + + for block in blocks { + match block { + ContentBlock::Image(image) => { + image_ordinal += 1; + let uri = image.uri.filter(|uri| safe_media_uri(uri)); + let existing_line = uri + .as_deref() + .and_then(|uri| markdown::line_with_link(&text, uri)); + let line = + existing_line.unwrap_or_else(|| { + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + let line = text.bytes().filter(|&byte| byte == b'\n').count(); + let label = format!("Image #{image_ordinal}"); + text.push_str(&uri.as_ref().map_or_else( + || format!("[{label}]"), + |uri| format!("[{label}]({uri})"), + )); + separate_after_image = true; + line + }); + if let Some(image) = UserImage::new(image.data, image.mime_type.to_string(), line) { + images.push(image); + } + } + block => { + let Some(content) = message_of(block) else { + continue; + }; + if separate_after_image + && !text.ends_with('\n') + && !content.starts_with('\n') + && !content.is_empty() + { + text.push('\n'); + } + text.push_str(&content); + separate_after_image = false; + } + } } + (text, images) } fn message_of(content: ContentBlock) -> Option { @@ -1542,7 +1598,7 @@ mod tests { MAX_ATTACHMENTS, ModelChoice, attachments_from_paste, current_model_choice, detach_from_controlling_terminal, durable_session_id, effort_state, handle, message_of, osc52, prompt_blocks, readable, refresh_config_state, save_effort_default_to, - save_model_defaults_to, translate, translate_for_session, wire, + save_model_defaults_to, translate, translate_for_session, user_message_of, wire, }; use crate::tui::app::{App, SubmittedPrompt, Update}; @@ -1588,7 +1644,7 @@ mod tests { ); assert!(matches!( translate_for_session(user, "session").as_slice(), - [Update::UserMessage { id, text, append: false }] + [Update::UserMessage { id, text, append: false, .. }] if id == "user-1" && text == "steer" )); @@ -1815,6 +1871,59 @@ mod tests { assert_eq!(error, "attachments exceed the 20 MiB total limit"); } + #[test] + fn user_image_payload_is_structured_without_a_duplicate_label() { + let uri = "file:///tmp/image.png"; + let (text, images) = user_message_of(vec![ + ContentBlock::Text(TextContent::new(format!("describe [Image #1]({uri})"))), + ContentBlock::Image( + agent_client_protocol::schema::v2::ImageContent::new("AQID", "image/png") + .uri(Some(uri.into())), + ), + ]); + + assert_eq!(text, format!("describe [Image #1]({uri})")); + assert_eq!(images.len(), 1); + assert_eq!(images[0].data, "AQID"); + assert_eq!(images[0].mime_type, "image/png"); + } + + #[test] + fn user_image_blocks_preserve_text_image_text_order() { + let uri = "file:///tmp/image.png"; + let (text, images) = user_message_of(vec![ + ContentBlock::Text(TextContent::new("before")), + ContentBlock::Image( + agent_client_protocol::schema::v2::ImageContent::new("AQID", "image/png") + .uri(Some(uri.into())), + ), + ContentBlock::Text(TextContent::new("after")), + ]); + + assert_eq!(text, format!("before\n[Image #1]({uri})\nafter")); + assert_eq!(images.len(), 1); + assert_eq!(images[0].line, 1); + } + + #[test] + fn image_deduplication_requires_the_exact_trusted_link() { + let actual = "file:///tmp/actual.png"; + let (text, images) = user_message_of(vec![ + ContentBlock::Text(TextContent::new("[Image #1](file:///tmp/different.png)")), + ContentBlock::Image( + agent_client_protocol::schema::v2::ImageContent::new("AQID", "image/png") + .uri(Some(actual.into())), + ), + ]); + + assert_eq!( + text, + format!("[Image #1](file:///tmp/different.png)\n[Image #1]({actual})") + ); + assert_eq!(images.len(), 1); + assert_eq!(images[0].line, 1); + } + #[test] fn rendered_image_never_exposes_a_data_url() { let content = ContentBlock::Image( diff --git a/src/tui/ui.rs b/src/tui/ui.rs index ee4086e..c464f70 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -23,11 +23,16 @@ thread_local! { } use super::{ - app::{App, Block, CachedTranscriptBlock, Child, CodeHit, Phase, ToolCall}, - command, markdown, + app::{ + App, Block, CachedTranscriptBlock, CachedTranscriptImage, CachedTranscriptRow, Child, + CodeHit, Phase, ToolCall, UserMessage, + }, + command, + image::{ImageRuntime, RESERVED_ROWS}, + markdown, plan::PlanKind, theme, - wrap::{LinkedLine, wrap, wrap_linked_tagged}, + wrap::{LinkedLine, LinkedSpan, wrap, wrap_linked_tagged}, }; /// Width at which the graph moves beside the transcript instead of below it. @@ -41,7 +46,7 @@ const MAX_OUTPUT_ROWS: usize = 400; type TranscriptTag = (Option, Option); type TaggedTranscriptLine = (LinkedLine, TranscriptTag); -pub fn draw(frame: &mut Frame<'_>, app: &mut App) { +pub fn draw(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRuntime) { // Two border columns plus the `›` gutter; the prompt grows as the wrapped // text needs more rows, up to the cap. let prompt_width = frame.area().width.saturating_sub(4).max(1) as usize; @@ -64,7 +69,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) { .areas(frame.area()); draw_header(frame, app, header); - draw_body(frame, app, body); + draw_body(frame, app, images, body); if app.show_logs { draw_logs(frame, app, logs); } @@ -312,25 +317,25 @@ fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) { frame.render_widget(Paragraph::new(Line::from(spans)).style(theme::bar()), area); } -fn draw_body(frame: &mut Frame<'_>, app: &mut App, area: Rect) { +fn draw_body(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRuntime, area: Rect) { if !app.show_graph() { - draw_transcript(frame, app, area); + draw_transcript(frame, app, images, area); return; } if area.width >= SIDE_BY_SIDE_WIDTH { let [transcript, graph] = Layout::horizontal([Constraint::Min(40), Constraint::Length(GRAPH_WIDTH)]).areas(area); - draw_transcript(frame, app, transcript); + draw_transcript(frame, app, images, transcript); draw_graph(frame, app, graph); } else { let [transcript, graph] = Layout::vertical([Constraint::Min(6), Constraint::Percentage(45)]).areas(area); - draw_transcript(frame, app, transcript); + draw_transcript(frame, app, images, transcript); draw_graph(frame, app, graph); } } -fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, area: Rect) { +fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRuntime, area: Rect) { let [text_area, bar_area] = Layout::horizontal([Constraint::Min(1), Constraint::Length(1)]).areas(area); let inner = Rect { @@ -347,7 +352,7 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, area: Rect) { } let width = inner.width.max(1) as usize; - refresh_transcript_cache(app, width); + refresh_transcript_cache_with_images(app, images, width); let working_rows = if app.working() { wrap_linked_tagged( &[(LinkedLine::plain(working_line(app)), (None, None))], @@ -381,6 +386,7 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, area: Rect) { let mut visible = Vec::with_capacity(height); let end = offset.saturating_add(height); let separator = (Line::default(), (None, None), Vec::new()); + let mut visible_images: Vec<(usize, usize, i16)> = Vec::new(); let mut materialize = |row: &crate::tui::app::CachedTranscriptRow| { #[cfg(test)] MATERIALIZED_TRANSCRIPT_ROWS.with(|count| count.set(count.get() + 1)); @@ -416,6 +422,18 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, area: Rect) { } materialize(row); } + for placement in &block.images { + let image_start = content_start + placement.row; + let image_end = image_start + usize::from(RESERVED_ROWS); + if image_start < end && image_end > offset { + let y = image_start as isize - offset as isize; + visible_images.push(( + block_index, + placement.source, + y.clamp(i16::MIN as isize, i16::MAX as isize) as i16, + )); + } + } } block_index += 1; } @@ -435,6 +453,17 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, area: Rect) { } } frame.render_widget(Paragraph::new(visible), inner); + for (block_index, source_index, y) in visible_images { + let Some(Block::User(message)) = app.blocks.get(block_index) else { + continue; + }; + let Some(source) = message.images.get(source_index) else { + continue; + }; + if let Some(image) = images.prepare(source, inner.width.max(1)) { + images.render(frame, image, inner, y); + } + } if total > height { let mut state = ScrollbarState::new(bottom).position(offset); frame.render_stateful_widget( @@ -482,7 +511,7 @@ fn hint(left_key: &str, left: &str, right_key: &str, right: &str) -> Line<'stati /// Renders the transcript, tagging each line with the tool call it belongs to /// so a click on a card can be traced back to it. -fn refresh_transcript_cache(app: &mut App, width: usize) { +fn refresh_transcript_cache_with_images(app: &mut App, images: &mut ImageRuntime, width: usize) { if app.transcript_revisions.len() != app.blocks.len() || app.transcript_cache.len() != app.blocks.len() || app.transcript_prefixes.len() != app.blocks.len() + 1 @@ -520,11 +549,22 @@ fn refresh_transcript_cache(app: &mut App, width: usize) { let old_count = app.transcript_cache[block_index] .as_ref() .map_or(0, |cached| cached.rows.len()); - let rows = wrap_linked_tagged(&transcript_block_lines(app, block_index, show_graph), width); + let (rows, cached_images) = if let Block::User(message) = &app.blocks[block_index] { + user_block_rows(message, width, images.enabled()) + } else { + ( + wrap_linked_tagged(&transcript_block_lines(app, block_index, show_graph), width), + Vec::new(), + ) + }; if missing || rows.len() != old_count { first_changed_count = first_changed_count.min(block_index); } - app.transcript_cache[block_index] = Some(CachedTranscriptBlock { revision, rows }); + app.transcript_cache[block_index] = Some(CachedTranscriptBlock { + revision, + rows, + images: cached_images, + }); if dynamic { app.transcript_dynamic.insert(block_index); } else { @@ -540,6 +580,42 @@ fn refresh_transcript_cache(app: &mut App, width: usize) { } } +#[cfg(test)] +fn refresh_transcript_cache(app: &mut App, width: usize) { + let mut images = ImageRuntime::disabled(); + refresh_transcript_cache_with_images(app, &mut images, width); +} + +fn user_block_rows( + message: &UserMessage, + width: usize, + reserve_images: bool, +) -> (Vec, Vec) { + let mut rows = Vec::new(); + let mut placements = Vec::new(); + for (line_index, text) in message.text.split('\n').enumerate() { + rows.extend(wrap_linked_tagged( + &[(user_line(text, line_index == 0), (None, None))], + width, + )); + if reserve_images { + for (source, _) in message + .images + .iter() + .enumerate() + .filter(|(_, image)| image.line == line_index) + { + let row = rows.len(); + rows.extend( + (0..RESERVED_ROWS).map(|_| (Line::default(), (None, None), Vec::new())), + ); + placements.push(CachedTranscriptImage { source, row }); + } + } + } + (rows, placements) +} + fn transcript_block_lines( app: &App, block_index: usize, @@ -547,7 +623,7 @@ fn transcript_block_lines( ) -> Vec { let block = &app.blocks[block_index]; let (block_lines, call) = match block { - Block::User(text) => (uncopyable(plain_lines(user_lines(text))), None), + Block::User(_) => unreachable!("user blocks are laid out with image anchors"), Block::Agent(text) => (markdown::render_copyable(text), None), Block::Thought { text, @@ -613,19 +689,14 @@ fn plain_lines(lines: Vec>) -> Vec { lines.into_iter().map(LinkedLine::plain).collect() } -fn user_lines(text: &str) -> Vec> { - text.split('\n') - .enumerate() - .map(|(index, line)| { - Line::from(vec![ - Span::styled( - if index == 0 { "› " } else { " " }, - theme::bold(theme::user_color()), - ), - Span::styled(line.to_string(), theme::bold(theme::user_color())), - ]) - }) - .collect() +fn user_line(text: &str, first: bool) -> LinkedLine { + let style = theme::bold(theme::user_color()); + let mut spans = vec![LinkedSpan { + span: Span::styled(if first { "› " } else { " " }, style), + url: None, + }]; + spans.extend(markdown::inline_spans(text, style)); + LinkedLine { spans } } fn thought_lines( @@ -1159,15 +1230,21 @@ mod tests { use std::path::PathBuf; use agent_client_protocol::schema::v2::ToolKind; + use base64::Engine as _; use ratatui::{Terminal, backend::TestBackend}; + use ratatui_image::picker::Picker; use super::{ MAX_PROMPT_ROWS, ModelDialogRow, draw, graph_lines, model_dialog_rows, model_dialog_viewport, prompt_lines, refresh_transcript_cache, + refresh_transcript_cache_with_images, user_block_rows, user_line, }; use crate::{ events::RuntimeEvent, - tui::app::{Action, App, Block, EffortChoice, EffortDialog, ModelDialog, Update}, + tui::app::{ + Action, App, Block, EffortChoice, EffortDialog, ModelDialog, Update, UserImage, + UserMessage, + }, }; fn model_choice(provider: &str, model: &str) -> crate::tui::app::ModelChoice { @@ -1257,8 +1334,9 @@ mod tests { theme::set(appearance); let mut app = frozen(); let mut terminal = Terminal::new(TestBackend::new(100, 40)).expect("terminal"); + let mut images = crate::tui::image::ImageRuntime::disabled(); terminal - .draw(|frame| draw(frame, &mut app)) + .draw(|frame| draw(frame, &mut app, &mut images)) .expect("draw succeeds"); let buffer = terminal.backend().buffer().clone(); theme::set(Appearance::Dark); @@ -1393,8 +1471,9 @@ mod tests { fn render(app: &mut App, width: u16, height: u16) -> String { let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + let mut images = crate::tui::image::ImageRuntime::disabled(); terminal - .draw(|frame| draw(frame, app)) + .draw(|frame| draw(frame, app, &mut images)) .expect("draw succeeds"); let buffer = terminal.backend().buffer().clone(); (0..buffer.area.height) @@ -1445,12 +1524,15 @@ mod tests { app.apply(Update::UserMessage { id: "first".into(), text: "first pending".into(), + images: Vec::new(), append: false, }); let frame = render(&mut app, 80, 18); assert_eq!(frame.matches("· pending").count(), 1, "{frame}"); assert_eq!(app.pending_steers.len(), 1); - assert!(matches!(app.blocks.last(), Some(Block::User(text)) if text == "first pending")); + assert!( + matches!(app.blocks.last(), Some(Block::User(message)) if message.text == "first pending") + ); } #[test] @@ -1835,7 +1917,8 @@ mod tests { "0:0".into(), ); for index in 0..30 { - app.blocks.push(Block::User(format!("old row {index}"))); + app.blocks + .push(Block::User(format!("old row {index}").into())); } app.blocks.push(Block::Agent( "[visible link](https://example.com/target)".into(), @@ -1892,6 +1975,107 @@ mod tests { ); } + #[test] + fn user_attachment_links_remain_clickable() { + let lines = [user_line( + "inspect [Image #1](file:///tmp/image_(1).png)", + true, + )]; + let links = lines[0] + .spans + .iter() + .filter_map(|span| span.url.as_deref()) + .collect::>(); + assert_eq!(links, ["file:///tmp/image_(1).png"]); + let displayed = lines[0] + .spans + .iter() + .map(|span| span.span.content.as_ref()) + .collect::(); + assert_eq!(displayed, "› inspect Image #1"); + } + + #[test] + fn image_rows_preserve_text_image_text_display_order() { + let image = UserImage::new("AQID".into(), "image/png".into(), 1).unwrap(); + let message = UserMessage { + text: "before\n[Image #1]\nafter".into(), + images: vec![image], + }; + + let (rows, placements) = user_block_rows(&message, 40, true); + + assert_eq!(placements.len(), 1); + let after = &rows[placements[0].row + usize::from(super::RESERVED_ROWS)].0; + assert!( + after + .spans + .iter() + .any(|span| span.content.contains("after")) + ); + } + + #[test] + fn image_rows_are_fixed_and_decoding_is_lazy() { + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(400, 200) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let source = UserImage::new( + base64::engine::general_purpose::STANDARD.encode(png.into_inner()), + "image/png".into(), + 0, + ) + .unwrap(); + let mut app = App::new( + PathBuf::from("/tmp/kit"), + "openai-subscription".into(), + "gpt-5.4".into(), + "0:0".into(), + ); + app.blocks.push(Block::User(UserMessage { + text: "[Image #1](file:///tmp/image.png)".into(), + images: vec![source], + })); + let mut images = crate::tui::image::ImageRuntime::with_picker(Picker::halfblocks()); + + refresh_transcript_cache_with_images(&mut app, &mut images, 12); + assert_eq!(images.cached_entries(), 0, "layout must not decode images"); + let narrow = app.transcript_cache[0].as_ref().unwrap(); + assert_eq!(narrow.images.len(), 1); + assert!(narrow.rows.len() > 1); + let narrow_rows = narrow.rows.len(); + assert_eq!(app.transcript_prefixes.last().copied(), Some(narrow_rows)); + + refresh_transcript_cache_with_images(&mut app, &mut images, 40); + assert_eq!(images.cached_entries(), 0, "width changes stay lazy"); + let wide = app.transcript_cache[0].as_ref().unwrap(); + assert_eq!(wide.images.len(), 1); + assert_eq!(wide.rows.len(), narrow_rows); + + let mut terminal = Terminal::new(TestBackend::new(40, 20)).unwrap(); + terminal + .draw(|frame| draw(frame, &mut app, &mut images)) + .unwrap(); + assert_eq!(images.cached_entries(), 1, "visible image is prepared"); + let reserved_rows = app.transcript_cache[0].as_ref().unwrap().rows.len(); + + images.clear(); + terminal + .draw(|frame| draw(frame, &mut app, &mut images)) + .unwrap(); + assert_eq!( + images.cached_entries(), + 1, + "an evicted visible image is prepared again before rendering" + ); + assert_eq!( + app.transcript_cache[0].as_ref().unwrap().rows.len(), + reserved_rows, + "cache eviction cannot remove reserved transcript rows" + ); + } + #[test] fn transcript_width_change_rebuilds_cached_rows() { let mut app = App::new(