lib-vt: report OSC and Kitty color queries (#13239)

Hooks up responses to OSC and Kitty color queries if `write_pty` is set
for libghostty.

Also found a memory leak: the OSC parser now releases color operation
request lists during reset.
This commit is contained in:
Mitchell Hashimoto
2026-07-07 13:10:16 -07:00
committed by GitHub
6 changed files with 320 additions and 8 deletions

View File

@@ -150,6 +150,49 @@ pub const Colors = struct {
};
};
/// Returns the current color for an xterm OSC color target.
///
/// Unsupported dynamic and special colors return null. The cursor color
/// follows xterm-style reporting and falls back to the foreground color when
/// no explicit cursor color is set.
pub fn colorForXterm(self: *const Terminal, target: osc.color.Target) ?color.RGB {
return switch (target) {
.palette => |i| self.colors.palette.current[i],
.dynamic => |dynamic| switch (dynamic) {
.foreground => self.colors.foreground.get(),
.background => self.colors.background.get(),
.cursor => self.colors.cursor.get() orelse
self.colors.foreground.get(),
.pointer_foreground,
.pointer_background,
.tektronix_foreground,
.tektronix_background,
.highlight_background,
.tektronix_cursor,
.highlight_foreground,
=> null,
},
.special => null,
};
}
/// Returns the current color for a Kitty color protocol key.
///
/// Only palette, foreground, background, and cursor colors are backed by
/// Terminal state. Unsupported keys, or supported dynamic colors without a
/// value, return null.
pub fn colorForKitty(self: *const Terminal, key: kitty.color.Kind) ?color.RGB {
return switch (key) {
.palette => |palette| self.colors.palette.current[palette],
.special => |special| switch (special) {
.foreground => self.colors.foreground.get(),
.background => self.colors.background.get(),
.cursor => self.colors.cursor.get(),
else => null,
},
};
}
/// This is a set of dirty flags the renderer can use to determine
/// what parts of the screen need to be redrawn. It is up to the renderer
/// to clear these flags.

View File

@@ -2179,6 +2179,46 @@ test "set write_pty callback" {
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
}
test "write_pty receives OSC color query response" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
const set_fg = "\x1B]10;rgb:01/02/03\x1B\\";
vt_write(t, set_fg, set_fg.len);
try testing.expect(S.last_data == null);
const query_fg = "\x1B]10;?\x1B\\";
vt_write(t, query_fg, query_fg.len);
try testing.expect(S.last_data != null);
try testing.expectEqualStrings("\x1B]10;rgb:0101/0202/0303\x1B\\", S.last_data.?);
}
test "set write_pty without callback ignores queries" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(

View File

@@ -516,6 +516,24 @@ pub const RGB = packed struct(u24) {
return self.r == other.r and self.g == other.g and self.b == other.b;
}
pub fn encodeRgb8(self: RGB, writer: *std.Io.Writer) !void {
try writer.print(
"rgb:{x:0>2}/{x:0>2}/{x:0>2}",
.{ self.r, self.g, self.b },
);
}
pub fn encodeRgb16(self: RGB, writer: *std.Io.Writer) !void {
try writer.print(
"rgb:{x:0>4}/{x:0>4}/{x:0>4}",
.{
@as(u16, self.r) * 257,
@as(u16, self.g) * 257,
@as(u16, self.b) * 257,
},
);
}
/// Calculates the contrast ratio between two colors. The contrast
/// ration is a value between 1 and 21 where 1 is the lowest contrast
/// and 21 is the highest contrast.
@@ -913,6 +931,19 @@ test "RGB.parse" {
try testing.expectError(error.InvalidFormat, RGB.parse("nosuchcolor"));
}
test "RGB: encode" {
const rgb: RGB = .{ .r = 0x01, .g = 0x23, .b = 0xff };
var buf: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try rgb.encodeRgb8(&writer);
try std.testing.expectEqualStrings("rgb:01/23/ff", writer.buffered());
writer = .fixed(&buf);
try rgb.encodeRgb16(&writer);
try std.testing.expectEqualStrings("rgb:0101/2323/ffff", writer.buffered());
}
test "DynamicPalette: init" {
const testing = std.testing;

View File

@@ -51,6 +51,23 @@ pub const Kind = union(enum) {
return .{ .palette = std.fmt.parseUnsigned(u8, key, 10) catch return null };
}
/// Returns true when a terminal has built-in state for this key.
///
/// Unsupported special colors may still be valid Kitty protocol keys, but
/// libghostty-vt cannot report them because Terminal does not store them.
pub fn hasTerminalQueryColor(self: Kind) bool {
return switch (self) {
.palette => true,
.special => |special| switch (special) {
.foreground,
.background,
.cursor,
=> true,
else => false,
},
};
}
pub fn format(
self: Kind,
writer: *std.Io.Writer,
@@ -74,4 +91,8 @@ test "OSC: kitty color protocol kind string" {
const actual = try std.fmt.bufPrint(&buf, "{f}", .{Kind{ .palette = 42 }});
try testing.expectEqualStrings("42", actual);
}
try testing.expect((Kind{ .palette = 42 }).hasTerminalQueryColor());
try testing.expect((Kind{ .special = .foreground }).hasTerminalQueryColor());
try testing.expect(!(Kind{ .special = .selection_background }).hasTerminalQueryColor());
}

View File

@@ -405,10 +405,12 @@ pub const Parser = struct {
.kitty_color_protocol => |*v| kitty_color_protocol: {
v.deinit(self.alloc orelse break :kitty_color_protocol);
},
.color_operation => |*v| color_operation: {
v.requests.deinit(self.alloc orelse break :color_operation);
},
.change_window_icon,
.change_window_title,
.clipboard_contents,
.color_operation,
.conemu_change_tab_title,
.conemu_comment,
.conemu_guimacro,

View File

@@ -8,7 +8,9 @@ const device_status = @import("device_status.zig");
const stream = @import("stream.zig");
const Action = stream.Action;
const Screen = @import("Screen.zig");
const color = @import("color.zig");
const modes = @import("modes.zig");
const osc = @import("osc.zig");
const osc_color = @import("osc/parsers/color.zig");
const kitty_color = @import("kitty/color.zig");
const size_report = @import("size_report.zig");
@@ -257,7 +259,7 @@ pub const Handler = struct {
.end_hyperlink => self.terminal.screens.active.endHyperlink(),
.semantic_prompt => try self.terminal.semanticPrompt(value),
.mouse_shape => self.terminal.mouse_shape = value,
.color_operation => try self.colorOperation(value.op, &value.requests),
.color_operation => try self.colorOperation(&value.requests, value.terminator),
.kitty_color_report => try self.kittyColorOperation(value),
// APC
@@ -598,12 +600,17 @@ pub const Handler = struct {
fn colorOperation(
self: *Handler,
op: osc_color.Operation,
requests: *const osc_color.List,
terminator: osc.Terminator,
) !void {
_ = op;
if (requests.count() == 0) return;
var stack = std.heap.stackFallback(1024, self.terminal.gpa());
const alloc = stack.get();
var response: std.Io.Writer.Allocating = .init(alloc);
defer response.deinit();
const writer = &response.writer;
var it = requests.constIterator(0);
while (it.next()) |req| {
switch (req.*) {
@@ -661,17 +668,67 @@ pub const Handler = struct {
mask.* = .initEmpty();
},
.query,
.reset_special,
=> {},
.query => |target| {
if (self.effects.write_pty == null) continue;
const c = self.terminal.colorForXterm(target) orelse continue;
try writeXtermColorReport(writer, target, c, terminator);
},
.reset_special => {},
}
}
if (response.written().len > 0) {
const resp = try response.toOwnedSliceSentinel(0);
defer alloc.free(resp);
self.writePty(resp);
}
}
fn writeXtermColorReport(
writer: *std.Io.Writer,
target: osc_color.Target,
c: color.RGB,
terminator: osc.Terminator,
) !void {
switch (target) {
.palette => |i| {
try writer.print("\x1b]4;{d};", .{i});
try c.encodeRgb16(writer);
try writer.writeAll(terminator.string());
},
.dynamic => |dynamic| switch (dynamic) {
.foreground,
.background,
.cursor,
=> {
try writer.print("\x1b]{d};", .{@intFromEnum(dynamic)});
try c.encodeRgb16(writer);
try writer.writeAll(terminator.string());
},
.pointer_foreground,
.pointer_background,
.tektronix_foreground,
.tektronix_background,
.highlight_background,
.tektronix_cursor,
.highlight_foreground,
=> {},
},
.special => {},
}
}
fn kittyColorOperation(
self: *Handler,
request: kitty_color.OSC,
) !void {
var stack = std.heap.stackFallback(1024, self.terminal.gpa());
const alloc = stack.get();
var response: std.Io.Writer.Allocating = .init(alloc);
defer response.deinit();
const writer = &response.writer;
for (request.list.items) |item| {
switch (item) {
.set => |v| switch (v.key) {
@@ -698,9 +755,28 @@ pub const Handler = struct {
else => {},
},
},
.query => {},
.query => |key| {
if (self.effects.write_pty == null) continue;
const c = self.terminal.colorForKitty(key) orelse {
if (!key.hasTerminalQueryColor()) continue;
if (response.written().len == 0) try writer.writeAll("\x1b]21");
try writer.print(";{f}=", .{key});
continue;
};
if (response.written().len == 0) try writer.writeAll("\x1b]21");
try writer.print(";{f}=", .{key});
try c.encodeRgb8(writer);
},
}
}
if (response.written().len > 0) {
try writer.writeAll(request.terminator.string());
const resp = try response.toOwnedSliceSentinel(0);
defer alloc.free(resp);
self.writePty(resp);
}
}
fn apcEnd(self: *Handler) void {
@@ -1021,6 +1097,8 @@ test "ignores query actions" {
s.nextSlice("\x1B[c"); // Device attributes
s.nextSlice("\x1B[5n"); // Device status report
s.nextSlice("\x1B[6n"); // Cursor position report
s.nextSlice("\x1B]4;0;?\x1B\\"); // OSC color query
s.nextSlice("\x1B]21;foreground=?\x1B\\"); // Kitty color query
// Terminal should still be functional
s.nextSlice("Test");
@@ -1137,6 +1215,63 @@ test "OSC 12 set and reset cursor color" {
// After reset, cursor might be null (using default)
}
test "OSC color query responses" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
const S = struct {
var last_response: ?[:0]const u8 = null;
fn reset() void {
if (last_response) |old| testing.allocator.free(old);
last_response = null;
}
fn writePty(_: *Handler, data: [:0]const u8) void {
reset();
last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
}
};
S.last_response = null;
defer S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1b]10;?\x1b\\");
try testing.expect(S.last_response == null);
s.nextSlice("\x1b]11;?\x1b\\");
try testing.expect(S.last_response == null);
s.nextSlice("\x1b]4;2;rgb:12/34/56;2;?\x1b\\");
try testing.expectEqualStrings(
"\x1b]4;2;rgb:1212/3434/5656\x1b\\",
S.last_response.?,
);
s.nextSlice("\x1b]10;rgb:01/02/03\x1b\\");
s.nextSlice("\x1b]11;rgb:04/05/06\x1b\\");
s.nextSlice("\x1b]12;rgb:07/08/09\x1b\\");
s.nextSlice("\x1b]10;?;?;?\x1b\\");
try testing.expectEqualStrings(
"\x1b]10;rgb:0101/0202/0303\x1b\\" ++
"\x1b]11;rgb:0404/0505/0606\x1b\\" ++
"\x1b]12;rgb:0707/0808/0909\x1b\\",
S.last_response.?,
);
s.nextSlice("\x1b]112\x1b\\");
s.nextSlice("\x1b]12;?\x07");
try testing.expectEqualStrings(
"\x1b]12;rgb:0101/0202/0303\x07",
S.last_response.?,
);
}
test "kitty color protocol set palette" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
@@ -1231,6 +1366,46 @@ test "kitty color protocol reset foreground" {
try testing.expect(t.colors.foreground.get() == null);
}
test "kitty color protocol query responses" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
const S = struct {
var last_response: ?[:0]const u8 = null;
fn reset() void {
if (last_response) |old| testing.allocator.free(old);
last_response = null;
}
fn writePty(_: *Handler, data: [:0]const u8) void {
reset();
last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
}
};
S.last_response = null;
defer S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1b]21;background=?\x1b\\");
try testing.expectEqualStrings(
"\x1b]21;background=\x1b\\",
S.last_response.?,
);
s.nextSlice("\x1b]21;foreground=rgb:12/34/56;2=rgb:aa/bb/cc\x1b\\");
s.nextSlice("\x1b]21;foreground=?;background=?;2=?\x1b\\");
try testing.expectEqualStrings(
"\x1b]21;foreground=rgb:12/34/56;background=;2=rgb:aa/bb/cc\x1b\\",
S.last_response.?,
);
}
test "palette dirty flag set on color change" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);