terminal: add APC handler to stream_terminal (#12116)

Wire up the APC handler to `terminal.TerminalStream` to process APC
sequences, enabling support for kitty graphics commands in libghostty,
in theory.

The "in theory" is because we still don't export a way to actually
enable Kitty graphics in libghostty because we have some other things in
the way: PNG decoding and OS filesystem access that need to be more
conditionally compiled before we can enable the feature. However, this
is a step in the right direction, and we can at least verify that the
APC handler works via a test in Ghostty GUI.
This commit is contained in:
Mitchell Hashimoto
2026-04-04 21:21:10 -07:00
committed by GitHub
2 changed files with 95 additions and 15 deletions

View File

@@ -10,7 +10,7 @@ const log = std.log.scoped(.terminal_apc);
/// The start/feed/end functions are meant to be called from the terminal.Stream
/// apcStart, apcPut, and apcEnd functions, respectively.
pub const Handler = struct {
state: State = .{ .inactive = {} },
state: State = .inactive,
pub fn deinit(self: *Handler) void {
self.state.deinit();
@@ -36,17 +36,17 @@ pub const Handler = struct {
'G' => self.state = if (comptime build_options.kitty_graphics)
.{ .kitty = kitty_gfx.CommandParser.init(alloc) }
else
.{ .ignore = {} },
.ignore,
// Unknown
else => self.state = .{ .ignore = {} },
else => self.state = .ignore,
}
},
.kitty => |*p| if (comptime build_options.kitty_graphics) {
p.feed(byte) catch |err| {
log.warn("kitty graphics protocol error: {}", .{err});
self.state = .{ .ignore = {} };
self.state = .ignore;
};
} else unreachable,
}
@@ -55,7 +55,7 @@ pub const Handler = struct {
pub fn end(self: *Handler) ?Command {
defer {
self.state.deinit();
self.state = .{ .inactive = {} };
self.state = .inactive;
}
return switch (self.state) {

View File

@@ -1,5 +1,7 @@
const std = @import("std");
const build_options = @import("terminal_options");
const testing = std.testing;
const apc = @import("apc.zig");
const csi = @import("csi.zig");
const device_attributes = @import("device_attributes.zig");
const device_status = @import("device_status.zig");
@@ -35,6 +37,12 @@ pub const Handler = struct {
/// effects.
effects: Effects = .readonly,
/// The APC command handler maintains the APC state. APC is like
/// CSI or OSC, but it is a private escape sequence that is used
/// to send commands to the terminal emulator. This is used by
/// the kitty graphics protocol.
apc_handler: apc.Handler = .{},
pub const Effects = struct {
/// Called when the terminal needs to write data back to the pty,
/// e.g. in response to a DECRQM query. The data is only valid
@@ -98,9 +106,7 @@ pub const Handler = struct {
}
pub fn deinit(self: *Handler) void {
// Currently does nothing but may in the future so callers should
// call this.
_ = self;
self.apc_handler.deinit();
}
pub fn vt(
@@ -230,6 +236,11 @@ pub const Handler = struct {
.color_operation => try self.colorOperation(value.op, &value.requests),
.kitty_color_report => try self.kittyColorOperation(value),
// APC
.apc_start => self.apc_handler.start(),
.apc_put => self.apc_handler.feed(self.terminal.gpa(), value),
.apc_end => self.apcEnd(),
// Effect-based handlers
.bell => self.bell(),
.device_attributes => self.reportDeviceAttributes(value),
@@ -249,13 +260,6 @@ pub const Handler = struct {
.dcs_unhook,
=> {},
// APC can modify terminal state (Kitty graphics) but we don't
// currently support it in the readonly stream.
.apc_start,
.apc_end,
.apc_put,
=> {},
// Have no terminal-modifying effect
.report_pwd,
.show_desktop_notification,
@@ -650,6 +654,33 @@ pub const Handler = struct {
}
}
}
fn apcEnd(self: *Handler) void {
const alloc = self.terminal.gpa();
var cmd = self.apc_handler.end() orelse return;
defer cmd.deinit(alloc);
switch (cmd) {
.kitty => |*kitty_cmd| if (comptime build_options.kitty_graphics) {
if (self.terminal.kittyGraphics(
alloc,
kitty_cmd,
)) |resp| resp: {
// Don't waste time encoding if we can't write responses
// anyways.
if (self.effects.write_pty == null) break :resp;
// Encode and write the response if we have one.
var buf: [1024]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
resp.encode(&writer) catch return;
writer.writeByte(0) catch return;
const final = writer.buffered();
if (final.len > 3) self.writePty(final[0 .. final.len - 1 :0]);
}
},
}
}
};
test "basic print" {
@@ -2069,3 +2100,52 @@ test "device attributes: custom response" {
s.nextSlice("\x1B[>c");
try testing.expectEqualStrings("\x1b[>41;100;0c", S.written.?);
}
test "kitty graphics APC response" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
const S = struct {
var written: ?[]const u8 = null;
fn writePty(_: *Handler, data: [:0]const u8) void {
if (written) |old| testing.allocator.free(old);
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
}
};
S.written = null;
defer if (S.written) |old| testing.allocator.free(old);
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Send a kitty graphics transmit command with image id 1
s.nextSlice("\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;////////\x1b\\");
// Should have written a response back
try testing.expectEqualStrings("\x1b_Gi=1;OK\x1b\\", S.written.?);
}
test "kitty graphics via APC" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
const handler: Handler = .init(&t);
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Send a kitty graphics transmit command via APC:
// ESC _ G <payload> ESC \
// a=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;//////// (1x2 RGB direct)
s.nextSlice("\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;////////\x1b\\");
const storage = &t.screens.active.kitty_images;
const img = storage.imageById(1).?;
try testing.expectEqual(.rgb, img.format);
}