From 29e3de737e9cc4c4d6a3ac9624bbd26c87bf0eb2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Apr 2026 21:36:54 -0700 Subject: [PATCH 1/6] terminal: make wuffs runtime-swappable, enable Kitty graphics for libvt Introduce terminal/sys.zig which provides runtime-swappable function pointers for operations that depend on external implementations. This allows embedders of the terminal package to swap out implementations at startup without hard dependencies on specific libraries. The first function exposed is decode_png, which defaults to a wuffs implementation. The kitty graphics image loader now calls through sys.decode_png instead of importing wuffs directly. This allows us to enable Kitty graphics support in libghostty-vt for all targets except wasm32-freestanding. --- src/lib_vt.zig | 18 +++++++++ src/terminal/build_options.zig | 17 ++++++++- src/terminal/kitty/graphics_image.zig | 11 ++++-- src/terminal/main.zig | 1 + src/terminal/sys.zig | 54 +++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 src/terminal/sys.zig diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 3edef835a..665058b68 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -19,6 +19,24 @@ const builtin = @import("builtin"); // or are too Ghostty-internal. const terminal = @import("terminal/main.zig"); +/// System interface for the terminal package. +/// +/// This module provides runtime-swappable function pointers for operations +/// that depend on external implementations. Embedders can use this to +/// provide or override default behaviors. These must be set at startup +/// before any terminal functionality is used. +/// +/// This lets libghostty-vt have no runtime dependencies on external +/// libraries, while still allowing rich functionality that may require +/// external libraries (e.g. image decoding or regular expresssions). +/// +/// Setting these will enable various features of the terminal package. +/// For example, setting a PNG decoder will enable support for PNG images in +/// the Kitty Graphics Protocol. +/// +/// Additional functionality will be added here over time as needed. +pub const sys = terminal.sys; + pub const apc = terminal.apc; pub const dcs = terminal.dcs; pub const osc = terminal.osc; diff --git a/src/terminal/build_options.zig b/src/terminal/build_options.zig index 6c0a4df63..5f851c55c 100644 --- a/src/terminal/build_options.zig +++ b/src/terminal/build_options.zig @@ -47,8 +47,23 @@ pub const Options = struct { opts.addOption(bool, "simd", self.simd); opts.addOption(bool, "slow_runtime_safety", self.slow_runtime_safety); + // Kitty graphics is almost always true. This used to be conditional on + // some other factors but we've since generalized the implementation + // to support optional PNG decoding, OS capabilities like filesystems, + // etc. So its safe to always enable it and just have the + // implementation deal with unsupported features as needed. + // + // We disable it on wasm32-freestanding because we at the least + // require the ability to get timestamps and there is no way to + // do that with freestanding targets. + const target = m.resolved_target.?.result; + opts.addOption( + bool, + "kitty_graphics", + !(target.cpu.arch == .wasm32 and target.os.tag == .freestanding), + ); + // These are synthesized based on other options. - opts.addOption(bool, "kitty_graphics", self.oniguruma); opts.addOption(bool, "tmux_control_mode", self.oniguruma); // Version information. diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index d2877cfc2..bf11507b4 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -8,7 +8,7 @@ const posix = std.posix; const fastmem = @import("../../fastmem.zig"); const command = @import("graphics_command.zig"); const PageList = @import("../PageList.zig"); -const wuffs = @import("wuffs"); +const sys = @import("../sys.zig"); const temp_dir = struct { const TempDir = @import("../../os/TempDir.zig"); @@ -426,13 +426,14 @@ pub const LoadingImage = struct { fn decodePng(self: *LoadingImage, alloc: Allocator) !void { assert(self.image.format == .png); - const result = wuffs.png.decode( + const decode_png_fn = sys.decode_png orelse + return error.UnsupportedFormat; + const result = decode_png_fn( alloc, self.data.items, ) catch |err| switch (err) { - error.WuffsError => return error.InvalidData, + error.InvalidData => return error.InvalidData, error.OutOfMemory => return error.OutOfMemory, - error.Overflow => return error.InvalidData, }; defer alloc.free(result.data); @@ -799,6 +800,8 @@ test "image load: rgb, not compressed, regular file" { } test "image load: png, not compressed, regular file" { + if (sys.decode_png == null) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 9f5b65e34..87a9aded9 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -23,6 +23,7 @@ pub const search = @import("search.zig"); pub const sgr = @import("sgr.zig"); pub const size = @import("size.zig"); pub const size_report = @import("size_report.zig"); +pub const sys = @import("sys.zig"); pub const tmux = if (options.tmux_control_mode) @import("tmux.zig") else struct {}; pub const x11_color = @import("x11_color.zig"); diff --git a/src/terminal/sys.zig b/src/terminal/sys.zig new file mode 100644 index 000000000..f0c64da50 --- /dev/null +++ b/src/terminal/sys.zig @@ -0,0 +1,54 @@ +//! System interface for the terminal package. +//! +//! This provides runtime-swappable function pointers for operations that +//! depend on external implementations (e.g. image decoding). Each function +//! pointer is initialized with a default implementation if available. +//! +//! This exists so that the terminal package doesn't have hard dependencies +//! on specific libraries and enables embedders of the terminal package to +//! swap out implementations as needed at startup to provide their own +//! implementations. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const build_options = @import("terminal_options"); + +/// Decode PNG data into RGBA pixels. If null, PNG decoding is unsupported +/// and the exact semantics are up to callers. For example, the Kitty Graphics +/// Protocol will work but cannot accept PNG images. +pub var decode_png: ?DecodePngFn = png: { + if (build_options.artifact == .lib) break :png null; + break :png &decodePngWuffs; +}; + +pub const DecodeError = Allocator.Error || error{InvalidData}; +pub const DecodePngFn = *const fn (Allocator, []const u8) DecodeError!Image; + +/// The result of decoding an image. The caller owns the returned data +/// and must free it with the same allocator that was passed to the +/// decode function. +pub const Image = struct { + width: u32, + height: u32, + data: []u8, +}; + +fn decodePngWuffs( + alloc: Allocator, + data: []const u8, +) DecodeError!Image { + const wuffs = @import("wuffs"); + const result = wuffs.png.decode( + alloc, + data, + ) catch |err| switch (err) { + error.WuffsError => return error.InvalidData, + error.OutOfMemory => return error.OutOfMemory, + error.Overflow => return error.InvalidData, + }; + + return .{ + .width = result.width, + .height = result.height, + .data = result.data, + }; +} From 6a99c248d0c2a952bf0ba1333247f3fa4e381184 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Apr 2026 07:05:51 -0700 Subject: [PATCH 2/6] terminal/kitty: add Limits to restrict capabilities of image transfer --- src/terminal/kitty/graphics_exec.zig | 4 +- src/terminal/kitty/graphics_image.zig | 230 ++++++++++++++++++++++++-- 2 files changed, 220 insertions(+), 14 deletions(-) diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 5b3ab915d..faac9ab75 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -112,7 +112,7 @@ fn query(alloc: Allocator, cmd: *const Command) Response { }; // Attempt to load the image. If we cannot, then set an appropriate error. - var loading = LoadingImage.init(alloc, cmd) catch |err| { + var loading = LoadingImage.init(alloc, cmd, .all) catch |err| { encodeError(&result, err); return result; }; @@ -322,7 +322,7 @@ fn loadAndAddImage( } break :loading loading.*; - } else try .init(alloc, cmd); + } else try .init(alloc, cmd, .all); // We only want to deinit on error. If we're chunking, then we don't // want to deinit at all. If we're not chunking, then we'll deinit diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index bf11507b4..0c9e618f5 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -44,10 +44,38 @@ pub const LoadingImage = struct { /// used if q isn't set on subsequent chunks. quiet: command.Command.Quiet, + /// The limits of the Kitty Graphics protocol we should allow. + /// + /// This can be used to restrict the type of images and other + /// parameters for resource or security reasons. Note that depending + /// on how libghostty is compiled, some of these may be fully unsupported + /// and ignored (e.g. "file" on wasm32-freestanding). + pub const Limits = packed struct { + file: bool, + temporary_file: bool, + shared_memory: bool, + + pub const all: Limits = .{ + .file = true, + .temporary_file = true, + .shared_memory = true, + }; + + pub const direct: Limits = .{ + .file = false, + .temporary_file = false, + .shared_memory = false, + }; + }; + /// Initialize a chunked immage from the first image transmission. /// If this is a multi-chunk image, this should only be the FIRST /// chunk. - pub fn init(alloc: Allocator, cmd: *const command.Command) !LoadingImage { + pub fn init( + alloc: Allocator, + cmd: *const command.Command, + limits: Limits, + ) !LoadingImage { // Build our initial image from the properties sent via the control. // These can be overwritten by the data loading process. For example, // PNG loading sets the width/height from the data. @@ -72,6 +100,26 @@ pub const LoadingImage = struct { return result; } + // Verify our capabilities and limits allow this. + { + // Special case if we don't support decoding PNGs and the format + // is a PNG we can save a lot of memory/effort buffering the + // data but failing up front. + if (t.format == .png and + sys.decode_png == null) + { + return error.UnsupportedMedium; + } + + // Verify the medium is allowed + switch (t.medium) { + .direct => unreachable, + .file => if (!limits.file) return error.UnsupportedMedium, + .temporary_file => if (!limits.temporary_file) return error.UnsupportedMedium, + .shared_memory => if (!limits.shared_memory) return error.UnsupportedMedium, + } + } + // Otherwise, the payload data is guaranteed to be a path. if (comptime builtin.os.tag != .windows) { @@ -523,7 +571,7 @@ test "image load with invalid RGB data" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); } @@ -541,7 +589,7 @@ test "image load with image too wide" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); } @@ -560,7 +608,7 @@ test "image load with image too tall" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); } @@ -584,7 +632,7 @@ test "image load: rgb, zlib compressed, direct" { ), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -612,7 +660,7 @@ test "image load: rgb, not compressed, direct" { ), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -641,7 +689,7 @@ test "image load: rgb, zlib compressed, direct, chunked" { .data = try alloc.dupe(u8, data[0..1024]), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); // Read our remaining chunks @@ -677,7 +725,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk" } }, }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); // Read our remaining chunks @@ -721,7 +769,7 @@ test "image load: temporary file without correct path" { .data = try alloc.dupe(u8, path), }; defer cmd.deinit(alloc); - try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd)); + try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd, .all)); // Temporary file should still be there try tmp_dir.dir.access(path, .{}); @@ -754,7 +802,7 @@ test "image load: rgb, not compressed, temporary file" { .data = try alloc.dupe(u8, path), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -791,7 +839,7 @@ test "image load: rgb, not compressed, regular file" { .data = try alloc.dupe(u8, path), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -828,7 +876,7 @@ test "image load: png, not compressed, regular file" { .data = try alloc.dupe(u8, path), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -836,3 +884,161 @@ test "image load: png, not compressed, regular file" { try testing.expect(img.format == .rgba); try tmp_dir.dir.access(path, .{}); } + +test "limits: direct medium always allowed" { + const testing = std.testing; + const alloc = testing.allocator; + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .direct, + .width = 1, + .height = 1, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, "AAAA"), + }; + defer cmd.deinit(alloc); + + // Direct medium should work even with the most restrictive limits + var loading = try LoadingImage.init(alloc, &cmd, .direct); + defer loading.deinit(alloc); +} + +test "limits: file medium blocked by limits" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + try testing.expectError(error.UnsupportedMedium, LoadingImage.init(alloc, &cmd, .direct)); +} + +test "limits: file medium allowed by limits" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd, .{ + .file = true, + .temporary_file = false, + .shared_memory = false, + }); + defer loading.deinit(alloc); +} + +test "limits: temporary file medium blocked by limits" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "tty-graphics-protocol-image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .temporary_file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + try testing.expectError(error.UnsupportedMedium, LoadingImage.init(alloc, &cmd, .{ + .file = true, + .temporary_file = false, + .shared_memory = true, + })); + + // File should still exist since we blocked before reading + try tmp_dir.dir.access("tty-graphics-protocol-image.data", .{}); +} + +test "limits: temporary file medium allowed by limits" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "tty-graphics-protocol-image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .temporary_file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd, .{ + .file = false, + .temporary_file = true, + .shared_memory = false, + }); + defer loading.deinit(alloc); +} From 64dcb91c1f3f1122706f70b888948d19fb1d7c42 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Apr 2026 07:11:45 -0700 Subject: [PATCH 3/6] terminal/kitty: add loading limits to kitty graphics protocol Add a Limits type to LoadingImage that controls which transmission mediums (file, temporary_file, shared_memory) are allowed when loading images. This defaults to "direct" (most restrictive) on ImageStorage and is set to "all" by Termio, allowing apprt embedders like libghostty to restrict medium types for resource or security reasons. The limits are stored on ImageStorage, plumbed through Screen.Options for screen initialization and inheritance, and enforced in graphics_exec during both query and transmit. Two new Terminal methods (setKittyGraphicsSizeLimit, setKittyGraphicsLoadingLimits) centralize updating all screens, replacing the manual iteration previously done in Termio. --- src/terminal/Screen.zig | 7 ++++++ src/terminal/Terminal.zig | 33 ++++++++++++++++++++++++- src/terminal/kitty/graphics.zig | 1 + src/terminal/kitty/graphics_exec.zig | 13 +++++++--- src/terminal/kitty/graphics_storage.zig | 6 ++++- src/termio/Termio.zig | 26 +++++-------------- 6 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 77e05b092..f93ec999c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -257,6 +257,12 @@ pub const Options = struct { /// screen. Kitty image storage is per-screen. kitty_image_storage_limit: usize = 320 * 1000 * 1000, // 320MB + /// The limits for what medium types are allowed for Kitty image loading. + kitty_image_loading_limits: if (build_options.kitty_graphics) + kitty.graphics.LoadingImage.Limits + else + void = if (build_options.kitty_graphics) .direct else {}, + /// A simple, default terminal. If you rely on specific dimensions or /// scrollback (or lack of) then do not use this directly. This is just /// for callers that need some defaults. @@ -313,6 +319,7 @@ pub fn init( &result, opts.kitty_image_storage_limit, ) catch unreachable; + result.kitty_images.image_limits = opts.kitty_image_loading_limits; } return result; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 99536e7ab..0dfde8236 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2693,6 +2693,34 @@ pub fn kittyGraphics( return kitty.graphics.execute(alloc, self, cmd); } +/// Set the storage size limit for Kitty graphics across all screens. +pub fn setKittyGraphicsSizeLimit( + self: *Terminal, + alloc: Allocator, + limit: usize, +) !void { + if (comptime !build_options.kitty_graphics) return; + var it = self.screens.all.iterator(); + while (it.next()) |entry| { + const screen: *Screen = entry.value.*; + try screen.kitty_images.setLimit(alloc, screen, limit); + } +} + +/// Set the allowed medium types for Kitty graphics image loading +/// across all screens. +pub fn setKittyGraphicsLoadingLimits( + self: *Terminal, + limits: kitty.graphics.LoadingImage.Limits, +) void { + if (comptime !build_options.kitty_graphics) return; + var it = self.screens.all.iterator(); + while (it.next()) |entry| { + const screen: *Screen = entry.value.*; + screen.kitty_images.image_limits = limits; + } +} + /// Set a style attribute. pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { try self.screens.active.setAttribute(attr); @@ -2941,12 +2969,15 @@ pub fn switchScreen(self: *Terminal, key: ScreenSet.Key) !?*Screen { .alternate => 0, }, - // Inherit our Kitty image storage limit from the primary + // Inherit our Kitty image settings from the primary // screen if we have to initialize. .kitty_image_storage_limit = if (comptime build_options.kitty_graphics) primary.kitty_images.total_limit else 0, + .kitty_image_loading_limits = if (comptime build_options.kitty_graphics) + primary.kitty_images.image_limits + else {}, }, ); }; diff --git a/src/terminal/kitty/graphics.zig b/src/terminal/kitty/graphics.zig index c710f81a1..6659cd310 100644 --- a/src/terminal/kitty/graphics.zig +++ b/src/terminal/kitty/graphics.zig @@ -25,6 +25,7 @@ pub const unicode = @import("graphics_unicode.zig"); pub const Command = command.Command; pub const CommandParser = command.Parser; pub const Image = image.Image; +pub const LoadingImage = image.LoadingImage; pub const ImageStorage = storage.ImageStorage; pub const RenderPlacement = render.Placement; pub const Response = command.Response; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index faac9ab75..a6a879e58 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -44,7 +44,7 @@ pub fn execute( var quiet = cmd.quiet; const resp_: ?Response = switch (cmd.control) { - .query => query(alloc, cmd), + .query => query(alloc, terminal, cmd), .display => display(alloc, terminal, cmd), .delete => delete(alloc, terminal, cmd), @@ -94,7 +94,11 @@ pub fn execute( /// This command is used to attempt to load an image and respond with /// success/error but does not persist any of the command to the terminal /// state. -fn query(alloc: Allocator, cmd: *const Command) Response { +fn query( + alloc: Allocator, + terminal: *const Terminal, + cmd: *const Command, +) Response { const t = cmd.control.query; // Query requires image ID. We can't actually send a response without @@ -112,7 +116,8 @@ fn query(alloc: Allocator, cmd: *const Command) Response { }; // Attempt to load the image. If we cannot, then set an appropriate error. - var loading = LoadingImage.init(alloc, cmd, .all) catch |err| { + const storage = &terminal.screens.active.kitty_images; + var loading = LoadingImage.init(alloc, cmd, storage.image_limits) catch |err| { encodeError(&result, err); return result; }; @@ -322,7 +327,7 @@ fn loadAndAddImage( } break :loading loading.*; - } else try .init(alloc, cmd, .all); + } else try .init(alloc, cmd, storage.image_limits); // We only want to deinit on error. If we're chunking, then we don't // want to deinit at all. If we're not chunking, then we'll deinit diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 8ff68e3fa..65c26dc85 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -51,6 +51,9 @@ pub const ImageStorage = struct { /// Non-null if there is an in-progress loading image. loading: ?*LoadingImage = null, + /// The limits of what medium types are allowed for image loading. + image_limits: LoadingImage.Limits = .direct, + /// The total bytes of image data that have been loaded and the limit. /// If the limit is reached, the oldest images will be evicted to make /// space. Unused images take priority. @@ -89,8 +92,9 @@ pub const ImageStorage = struct { ) !void { // Special case disabling by quickly deleting all if (limit == 0) { + const image_limits = self.image_limits; self.deinit(alloc, s); - self.* = .{}; + self.* = .{ .image_limits = image_limits }; } // If we re lowering our limit, check if we need to evict. diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 75ccb94b5..1b446e268 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -259,16 +259,9 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { }); errdefer term.deinit(alloc); - // Set the image size limits - var it = term.screens.all.iterator(); - while (it.next()) |entry| { - const screen: *terminalpkg.Screen = entry.value.*; - try screen.kitty_images.setLimit( - alloc, - screen, - opts.config.image_storage_limit, - ); - } + // Set the Kitty image settings + try term.setKittyGraphicsSizeLimit(alloc, opts.config.image_storage_limit); + term.setKittyGraphicsLoadingLimits(.all); // Set our default cursor style term.screens.active.cursor.cursor_style = opts.config.cursor_style; @@ -463,16 +456,9 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi break :cursor color.toTerminalRGB() orelse break :cursor null; }; - // Set the image size limits - var it = self.terminal.screens.all.iterator(); - while (it.next()) |entry| { - const screen: *terminalpkg.Screen = entry.value.*; - try screen.kitty_images.setLimit( - self.alloc, - screen, - config.image_storage_limit, - ); - } + // Set the image limits + try self.terminal.setKittyGraphicsSizeLimit(self.alloc, config.image_storage_limit); + self.terminal.setKittyGraphicsLoadingLimits(.all); } /// Resize the terminal. From 935d37fbf1eea969245e144757116e8fbe93192a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Apr 2026 07:20:07 -0700 Subject: [PATCH 4/6] terminal: add kitty image limits to Terminal.Options Move kitty_image_storage_limit and kitty_image_loading_limits into Terminal.Options so callers can set them at construction time rather than calling setter functions after init. The values flow through to Screen.Options during ScreenSet initialization. Termio now passes both at construction, keeping the setter functions for the updateConfig path. --- src/terminal/Terminal.zig | 15 +++++++++++++++ src/termio/Termio.zig | 6 ++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 0dfde8236..b128cdd3d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -191,6 +191,19 @@ pub const Options = struct { /// The default mode state. When the terminal gets a reset, it /// will revert back to this state. default_modes: modespkg.ModePacked = .{}, + + /// The total storage limit for Kitty images in bytes. Has no effect + /// if kitty images are disabled at build-time. + kitty_image_storage_limit: usize = 320 * 1000 * 1000, // 320MB + + /// The limits for what medium types are allowed for Kitty image loading. + /// Has no effect if kitty images are disabled otherwise. For example, + // if no `sys.decode_png` hook is specified, png formats are disabled + // no matter what. + kitty_image_loading_limits: if (build_options.kitty_graphics) + kitty.graphics.LoadingImage.Limits + else + void = if (build_options.kitty_graphics) .direct else {}, }; /// Initialize a new terminal. @@ -205,6 +218,8 @@ pub fn init( .cols = cols, .rows = rows, .max_scrollback = opts.max_scrollback, + .kitty_image_storage_limit = opts.kitty_image_storage_limit, + .kitty_image_loading_limits = opts.kitty_image_loading_limits, }); errdefer screen_set.deinit(alloc); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 1b446e268..1d1bfe25a 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -255,14 +255,12 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { }, .palette = .init(opts.config.palette), }, + .kitty_image_storage_limit = opts.config.image_storage_limit, + .kitty_image_loading_limits = .all, }; }); errdefer term.deinit(alloc); - // Set the Kitty image settings - try term.setKittyGraphicsSizeLimit(alloc, opts.config.image_storage_limit); - term.setKittyGraphicsLoadingLimits(.all); - // Set our default cursor style term.screens.active.cursor.cursor_style = opts.config.cursor_style; From 306acc494128e54e1702e872d15cbf661b3c9e0a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Apr 2026 07:22:43 -0700 Subject: [PATCH 5/6] terminal/kitty: use direct medium for tests if we're not using files --- src/terminal/kitty/graphics_image.zig | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index 0c9e618f5..f1f055fa0 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -571,7 +571,7 @@ test "image load with invalid RGB data" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); } @@ -589,7 +589,7 @@ test "image load with image too wide" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); } @@ -608,7 +608,7 @@ test "image load with image too tall" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); } @@ -632,7 +632,7 @@ test "image load: rgb, zlib compressed, direct" { ), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -660,7 +660,7 @@ test "image load: rgb, not compressed, direct" { ), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -689,7 +689,7 @@ test "image load: rgb, zlib compressed, direct, chunked" { .data = try alloc.dupe(u8, data[0..1024]), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); // Read our remaining chunks @@ -725,7 +725,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk" } }, }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd, .all); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); // Read our remaining chunks From 810ebae8e8eca363b46553b62db7fc7bfe69e24b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Apr 2026 06:56:03 -0700 Subject: [PATCH 6/6] terminal: lower default kitty image storage limit for libghostty The default kitty image storage limit was 320 MB for all build artifacts. For libghostty, this is overly generous since it is an embedded library where conservative memory usage is preferred. Lower the default to 10 MB when building as the lib artifact while keeping the 320 MB default for the full Ghostty application. --- src/terminal/Screen.zig | 5 ++++- src/terminal/Terminal.zig | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index f93ec999c..b56701838 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -255,7 +255,10 @@ pub const Options = struct { /// The total storage limit for Kitty images in bytes for this /// screen. Kitty image storage is per-screen. - kitty_image_storage_limit: usize = 320 * 1000 * 1000, // 320MB + kitty_image_storage_limit: usize = switch (build_options.artifact) { + .ghostty => 320 * 1000 * 1000, // 320MB + .lib => 10 * 1000 * 1000, // 10MB + }, /// The limits for what medium types are allowed for Kitty image loading. kitty_image_loading_limits: if (build_options.kitty_graphics) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index b128cdd3d..f6268c719 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -194,7 +194,14 @@ pub const Options = struct { /// The total storage limit for Kitty images in bytes. Has no effect /// if kitty images are disabled at build-time. - kitty_image_storage_limit: usize = 320 * 1000 * 1000, // 320MB + kitty_image_storage_limit: usize = switch (build_options.artifact) { + .ghostty => 320 * 1000 * 1000, // 320MB + + // libghostty we start with a much lower limit since this is an + // embedded library and we want to be more conservative with memory + // usage by default. + .lib => 10 * 1000 * 1000, // 10MB + }, /// The limits for what medium types are allowed for Kitty image loading. /// Has no effect if kitty images are disabled otherwise. For example,