From c67a1db991842f0c0962291581c947011eaa0334 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 27 Jan 2026 10:30:23 -0600 Subject: [PATCH 1/9] osc: parse the Kitty desktop notification protocol (OSC 99) This includes only parsing of the OSC. You cannot use OSC 99 to send notifications. Uses lazy parsing of the metadata modelled on the new OSC 133 behavior. --- src/terminal/osc.zig | 23 +- src/terminal/osc/parsers.zig | 1 + .../parsers/kitty_desktop_notification.zig | 1293 +++++++++++++++++ src/terminal/stream.zig | 1 + 4 files changed, 1317 insertions(+), 1 deletion(-) create mode 100644 src/terminal/osc/parsers/kitty_desktop_notification.zig diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 618777241..7a8944540 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -159,6 +159,9 @@ pub const Command = union(Key) { /// Kitty drag and drop protocol (OSC 72) kitty_dnd_protocol: KittyDndProtocol, + /// Kitty desktop notifications (OSC 99) + kitty_desktop_notification: parsers.kitty_desktop_notification.OSC, + /// OSC 3008. Hierarchical context signalling (UAPI spec). /// https://uapi-group.org/specifications/specs/osc_context/ context_signal: parsers.context_signal.Command, @@ -198,6 +201,7 @@ pub const Command = union(Key) { "kitty_text_sizing", "kitty_clipboard_protocol", "kitty_dnd_protocol", + "kitty_desktop_notification", "context_signal", }, ); @@ -364,6 +368,7 @@ pub const Parser = struct { @"66", @"72", @"77", + @"99", @"104", @"110", @"111", @@ -444,6 +449,7 @@ pub const Parser = struct { .kitty_text_sizing, .kitty_clipboard_protocol, .kitty_dnd_protocol, + .kitty_desktop_notification, .context_signal, => {}, } @@ -783,11 +789,24 @@ pub const Parser = struct { else => self.state = .invalid, }, + .@"9", + => switch (c) { + ';' => self.captureTrailing(.fixed), + '9' => self.state = .@"99", + else => self.state = .invalid, + }, + + .@"99", + => switch (c) { + // OSC 99 can be up to 4096 bytes fully encoded. + ';' => self.captureTrailing(.allocating), + else => self.state = .invalid, + }, + .@"0", .@"22", .@"777", .@"8", - .@"9", => switch (c) { ';' => self.captureTrailing(.fixed), else => self.state = .invalid, @@ -868,6 +887,8 @@ pub const Parser = struct { .@"77" => null, + .@"99" => parsers.kitty_desktop_notification.parse(self, terminator_ch), + .@"133" => parsers.semantic_prompt.parse(self, terminator_ch), .@"552" => null, diff --git a/src/terminal/osc/parsers.zig b/src/terminal/osc/parsers.zig index 8cb91fb94..148bfa335 100644 --- a/src/terminal/osc/parsers.zig +++ b/src/terminal/osc/parsers.zig @@ -10,6 +10,7 @@ pub const iterm2 = @import("parsers/iterm2.zig"); pub const kitty_clipboard_protocol = @import("parsers/kitty_clipboard_protocol.zig"); pub const kitty_color = @import("parsers/kitty_color.zig"); pub const kitty_dnd_protocol = @import("parsers/kitty_dnd_protocol.zig"); +pub const kitty_desktop_notification = @import("parsers/kitty_desktop_notification.zig"); pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig"); pub const mouse_shape = @import("parsers/mouse_shape.zig"); pub const osc9 = @import("parsers/osc9.zig"); diff --git a/src/terminal/osc/parsers/kitty_desktop_notification.zig b/src/terminal/osc/parsers/kitty_desktop_notification.zig new file mode 100644 index 000000000..b2bc78b95 --- /dev/null +++ b/src/terminal/osc/parsers/kitty_desktop_notification.zig @@ -0,0 +1,1293 @@ +//! Kitty's desktop notification protocol (OSC 99) +//! Specification: https://sw.kovidgoyal.net/kitty/desktop-notifications/ + +const std = @import("std"); +const build_options = @import("terminal_options"); + +const assert = @import("../../../quirks.zig").inlineAssert; + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; +const encoding = @import("../encoding.zig"); +const lib = @import("../../../lib/main.zig"); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; + +const log = std.log.scoped(.kitty_desktop_notification); + +pub const OSC = struct { + /// The raw metadata that was received. It can be parsed by using the `readOption` method. + metadata: []const u8, + /// The raw payload. It may be Base64 encoded, check the `e` option. + payload: []const u8, + + /// Decode an option from the metadata. + pub fn readOption(self: OSC, comptime key: Option) key.Type() { + return key.read(self.metadata); + } +}; + +pub const Action = packed struct { + focus: bool, + report: bool, + + pub const default: Action = .{ + .focus = true, + .report = false, + }; + + pub fn init(str: []const u8) Action { + return parsePackedStruct(Action, str); + } +}; + +pub const Occasion = enum { + always, + invisible, + unfocused, + + pub const default: Occasion = .always; + + pub fn init(str: []const u8) Occasion { + return std.meta.stringToEnum(Occasion, str) orelse .default; + } +}; + +pub const Payload = enum { + alive, + body, + buttons, + close, + icon, + query, + title, + /// This is a special value to indicate that an unknown payload value was + /// specified and it should be ignored. + unknown, + + pub const default: Payload = .title; + + pub fn init(str: []const u8) Payload { + if (str.len == 1 and str[0] == '?') return .query; + // The string `query` is not allowed, it should be a single question + // mark if you want a query. + if (std.mem.eql(u8, "query", str)) return .unknown; + return std.meta.stringToEnum(Payload, str) orelse .unknown; + } +}; + +pub const Urgency = enum { + low, + normal, + high, + + pub const default: Urgency = .normal; + + pub fn init(str: []const u8) Urgency { + if (str.len != 1) return .default; + return switch (str[0]) { + '0' => .low, + '1' => .normal, + '2' => .high, + else => .default, + }; + } +}; + +pub const Option = enum { + a, + c, + d, + e, + f, + g, + i, + n, + o, + p, + s, + t, + u, + w, + + pub fn Type(comptime key: Option) type { + return switch (key) { + .a => Action, + .c => bool, + .d => bool, + .e => bool, + .f => ?[]const u8, + .g => ?[]const u8, + .i => ?[]const u8, + .n => Iterator(.n), + .o => Occasion, + .p => Payload, + .s => []const u8, + .t => Iterator(.t), + .u => Urgency, + .w => i32, + }; + } + + pub fn default(comptime key: Option) key.Type() { + return switch (key) { + .a => .default, + .c => false, + .d => true, + .e => false, + .f => null, + .g => null, + .i => null, + .n => unreachable, + .o => .default, + .p => .default, + .s => "system", + .t => unreachable, + .u => .default, + .w => -1, + }; + } + + /// Read the option value from the raw metadata string. + /// + /// Any errors in the raw string will return null since the OSC 99 + /// specification says to ignore unknown or malformed options. + pub fn read( + comptime key: Option, + metadata: []const u8, + ) key.Type() { + switch (key) { + inline .t, .n => return .init(metadata), + else => {}, + } + + const value = value: { + var pos: usize = 0; + while (pos < metadata.len) { + // skip any whitespace + while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; + // bail if we are out of metadata + if (pos >= metadata.len) return key.default(); + if (metadata[pos] != @tagName(key)[0]) { + // this isn't the key we are looking for, skip to the next option, or bail if + // there is no next option + pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return key.default(); + pos += 1; + continue; + } + // skip past the key + pos += 1; + // skip any whitespace + while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; + // bail if we are out of metadata + if (pos >= metadata.len) return key.default(); + // a valid option has an '=' + if (metadata[pos] != '=') return key.default(); + // the end of the value is bounded by a ':' or the end of the metadata + const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len; + // return the value after stripping any leading or trailing whitespace + break :value std.mem.trim(u8, metadata[pos + 1 .. end], &std.ascii.whitespace); + } + // the key was not found + return key.default(); + }; + + if (!isValidMetadataValue(value)) return key.default(); + + // return the parsed value + return switch (key) { + .a => .init(value), + .c => parseBool(value) orelse key.default(), + .d => parseBool(value) orelse key.default(), + .e => parseBool(value) orelse key.default(), + .f => value, + .g => parseIdentifier(value), + .i => parseIdentifier(value), + .n => unreachable, + .o => .init(value), + .p => .init(value), + .s => value, + .t => unreachable, + .u => .init(value), + .w => value: { + // Zig's integer parser allows '_', we don't + if (std.mem.indexOfScalar(u8, value, '_')) |_| break :value key.default(); + const tmp = std.fmt.parseInt(i32, value, 10) catch break :value key.default(); + // negative values less than -1 are not allowed + if (tmp < -1) break :value key.default(); + break :value tmp; + }, + }; + } +}; + +/// Parse the protocol's booleans +fn parseBool(str: []const u8) ?bool { + if (str.len != 1) return null; + return switch (str[0]) { + '0' => false, + '1' => true, + else => null, + }; +} + +/// This is similar to the packed struct parsed used in the configs. The +/// differences are that a literal `true` or `false` value does not turn on/off +/// all the values, and the negation prefix is `-` not `no-`. +pub fn parsePackedStruct(comptime T: type, str: []const u8) T { + const info = @typeInfo(T).@"struct"; + comptime assert(info.layout == .@"packed"); + + var result: T = .default; + + // We split each value by "," + var iter = std.mem.splitSequence(u8, str, ","); + loop: while (iter.next()) |raw| { + // Determine the field we're looking for and the value. If the + // field is prefixed with "-" then we set the value to false. + const part, const value = part: { + const negation_prefix = "-"; + const trimmed = std.mem.trim(u8, raw, &std.ascii.whitespace); + if (std.mem.startsWith(u8, trimmed, negation_prefix)) { + break :part .{ trimmed[negation_prefix.len..], false }; + } else { + break :part .{ trimmed, true }; + } + }; + + inline for (info.fields) |field| { + assert(field.type == bool); + if (std.mem.eql(u8, field.name, part)) { + @field(result, field.name) = value; + continue :loop; + } + } + + // No field matched + return .default; + } + + return result; +} + +fn isValidMetadataValueCharacter(c: u8) bool { + return switch (c) { + 'a'...'z', + 'A'...'Z', + '0'...'9', + '-', + '_', + '/', + '+', + '.', + ',', + '(', + ')', + '{', + '}', + '[', + ']', + '*', + '&', + '^', + '%', + '$', + '#', + '@', + '!', + '`', + '~', + // Including `=` is "technically" against the spec but is needed since + // Base64 encoded values (with padding) are valid for some options. + '=', + // Including `?` is "technically" against the spec but is needed since + // it is a valid value for the `p` option. + '?', + => true, + else => false, + }; +} + +const invalid_metadata_value_characters: []const u8 = i: { + @setEvalBranchQuota(2000); + var count = 0; + for (0..256) |i| { + if (!isValidMetadataValueCharacter(i)) count += 1; + } + var tmp: [count]u8 = undefined; + var index = 0; + for (0..256) |i| { + if (!isValidMetadataValueCharacter(i)) { + tmp[index] = i; + index += 1; + } + } + const result = tmp; + break :i &result; +}; + +fn isValidMetadataValue(str: []const u8) bool { + if (std.mem.indexOfAny(u8, str, invalid_metadata_value_characters)) |_| { + return false; + } else { + return true; + } +} + +fn isValidIdentifierCharacter(c: u8) bool { + return switch (c) { + 'a'...'z', + 'A'...'Z', + '0'...'9', + '-', + '_', + '+', + => true, + else => false, + }; +} + +const invalid_identifier_characters: []const u8 = i: { + @setEvalBranchQuota(2000); + var count = 0; + for (0..256) |i| { + if (!isValidIdentifierCharacter(i)) count += 1; + } + var tmp: [count]u8 = undefined; + var index = 0; + for (0..256) |i| { + if (!isValidIdentifierCharacter(i)) { + tmp[index] = i; + index += 1; + } + } + const result = tmp; + break :i &result; +}; + +fn isValidIdentifier(str: []const u8) bool { + if (std.mem.indexOfAny(u8, str, invalid_identifier_characters)) |_| { + return false; + } else { + return true; + } +} + +fn parseIdentifier(str: []const u8) ?[]const u8 { + if (isValidIdentifier(str)) return str; + return null; +} + +/// Used when an option can appear multiple times in the metadata +pub fn Iterator(comptime key: Option) type { + return struct { + metadata: []const u8, + pos: usize, + + pub fn init(metadata: []const u8) Iterator(key) { + return .{ + .metadata = metadata, + .pos = 0, + }; + } + + pub fn next(self: *Iterator(key)) ?[]const u8 { + if (self.pos >= self.metadata.len) return null; + while (self.pos < self.metadata.len) { + // skip any whitespace + while (self.pos < self.metadata.len and std.ascii.isWhitespace(self.metadata[self.pos])) self.pos += 1; + // bail if we are out of metadata + if (self.pos >= self.metadata.len) return null; + if (self.metadata[self.pos] != @tagName(key)[0]) { + // this isn't the key we are looking for, skip to the next option, or bail if + // there is no next option + self.pos = std.mem.indexOfScalarPos(u8, self.metadata, self.pos, ':') orelse { + self.pos = self.metadata.len; + return null; + }; + self.pos += 1; + continue; + } + // skip past the key + self.pos += 1; + // skip any whitespace + while (self.pos < self.metadata.len and std.ascii.isWhitespace(self.metadata[self.pos])) self.pos += 1; + // bail if we are out of metadata + if (self.pos >= self.metadata.len) return null; + // a valid option has an '=' + if (self.metadata[self.pos] != '=') return null; + // the end of the value is bounded by a ':' or the end of the metadata + const end = std.mem.indexOfScalarPos(u8, self.metadata, self.pos, ':') orelse self.metadata.len; + const start = self.pos + 1; + self.pos = end + 1; + // strip any leading or trailing whitespace + const value = std.mem.trim(u8, self.metadata[start..end], &std.ascii.whitespace); + // if this is not a valid value, skip it + if (!isValidMetadataValue(value)) continue; + // return the value + return value; + } + // the key was not found + return null; + } + }; +} + +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + assert(parser.state == .@"99"); + + const cap = if (parser.capture) |*c| c else { + parser.state = .invalid; + return null; + }; + + const data = cap.trailing(); + + const payload_start = std.mem.indexOfScalar(u8, data, ';') orelse { + log.warn("missing semicolon before payload", .{}); + parser.state = .invalid; + return null; + }; + + const metadata = data[0..payload_start]; + const payload = data[payload_start + 1 .. data.len]; + + // Payload has to be a URL-safe UTF-8 string. + if (!encoding.isSafeUtf8(payload)) { + log.warn("payload is not escape code safe UTF-8", .{}); + parser.state = .invalid; + return null; + } + + parser.command = .{ + .kitty_desktop_notification = .{ + .metadata = metadata, + .payload = payload, + }, + }; + + return &parser.command; +} + +test "OSC 99: empty metadata and payload" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("", cmd.kitty_desktop_notification.metadata); + try testing.expectEqualStrings("", cmd.kitty_desktop_notification.payload); + try testing.expectEqualDeep(Option.a.default(), cmd.kitty_desktop_notification.readOption(.a)); + try testing.expect(cmd.kitty_desktop_notification.readOption(.c) == false); + try testing.expect(cmd.kitty_desktop_notification.readOption(.d) == true); + try testing.expect(cmd.kitty_desktop_notification.readOption(.e) == false); + try testing.expect(cmd.kitty_desktop_notification.readOption(.f) == null); + try testing.expect(cmd.kitty_desktop_notification.readOption(.g) == null); + try testing.expect(cmd.kitty_desktop_notification.readOption(.i) == null); + { + var it = cmd.kitty_desktop_notification.readOption(.n); + try testing.expect(it.next() == null); + } + try testing.expectEqual(.always, cmd.kitty_desktop_notification.readOption(.o)); + try testing.expectEqual(.title, cmd.kitty_desktop_notification.readOption(.p)); + try testing.expectEqualStrings("system", cmd.kitty_desktop_notification.readOption(.s)); + { + var it = cmd.kitty_desktop_notification.readOption(.t); + try testing.expect(it.next() == null); + } + try testing.expectEqual(.normal, cmd.kitty_desktop_notification.readOption(.u)); + try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w)); +} + +test "OSC 99: empty metadata with payload" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;;bobr"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("", cmd.kitty_desktop_notification.metadata); + try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.payload); + try testing.expectEqualDeep(Option.a.default(), cmd.kitty_desktop_notification.readOption(.a)); + try testing.expect(cmd.kitty_desktop_notification.readOption(.c) == false); + try testing.expect(cmd.kitty_desktop_notification.readOption(.d) == true); + try testing.expect(cmd.kitty_desktop_notification.readOption(.e) == false); + try testing.expect(cmd.kitty_desktop_notification.readOption(.f) == null); + try testing.expect(cmd.kitty_desktop_notification.readOption(.g) == null); + try testing.expect(cmd.kitty_desktop_notification.readOption(.i) == null); + { + var it = cmd.kitty_desktop_notification.readOption(.n); + try testing.expect(it.next() == null); + } + try testing.expectEqual(.always, cmd.kitty_desktop_notification.readOption(.o)); + try testing.expectEqual(.title, cmd.kitty_desktop_notification.readOption(.p)); + try testing.expectEqualStrings("system", cmd.kitty_desktop_notification.readOption(.s)); + { + var it = cmd.kitty_desktop_notification.readOption(.t); + try testing.expect(it.next() == null); + } + try testing.expectEqual(.normal, cmd.kitty_desktop_notification.readOption(.u)); + try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w)); +} + +test "OSC 99: single parameter i" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i=bobr;kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); + try testing.expectEqualStrings("kurwa", cmd.kitty_desktop_notification.payload); +} + +test "OSC 99: repeated parameter i" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i=bobr:i=foobar;kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); + try testing.expectEqualStrings("kurwa", cmd.kitty_desktop_notification.payload); +} + +test "OSC 99: multiple types" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;t=bobr: t = kurwa : t = ghostty ;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + var it = cmd.kitty_desktop_notification.readOption(.t); + try testing.expectEqualStrings("bobr", it.next().?); + try testing.expectEqualStrings("kurwa", it.next().?); + try testing.expectEqualStrings("ghostty", it.next().?); + try testing.expect(it.next() == null); +} + +test "OSC 99: a 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;a=report,focus;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualDeep(Action{ .report = true, .focus = true }, cmd.kitty_desktop_notification.readOption(.a)); +} + +test "OSC 99: a 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;a=report,-focus;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualDeep(Action{ .report = true, .focus = false }, cmd.kitty_desktop_notification.readOption(.a)); +} + +test "OSC 99: a 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;a=-report,focus;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualDeep(Action{ .report = false, .focus = true }, cmd.kitty_desktop_notification.readOption(.a)); +} + +test "OSC 99: a 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;a=-report,-focus;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualDeep(Action{ .report = false, .focus = false }, cmd.kitty_desktop_notification.readOption(.a)); +} + +test "OSC 99: c 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;c=0;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.c) == false); +} + +test "OSC 99: c 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;c=1;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.c) == true); +} + +test "OSC 99: c 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;c=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.c) == false); +} + +test "OSC 99: d 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;d=0;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.d) == false); +} + +test "OSC 99: d 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;d=1;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.d) == true); +} + +test "OSC 99: d 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;d=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.d) == true); +} + +test "OSC 99: e 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;e=0;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.e) == false); +} + +test "OSC 99: e 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;e=1;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.e) == true); +} + +test "OSC 99: e 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;e=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.e) == false); +} + +test "OSC 99: f 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;f=R2hvc3R0eQ==;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_desktop_notification.readOption(.f).?); +} + +test "OSC 99: f 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;c=0:f= R2hvc3R0eQ== ;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_desktop_notification.readOption(.f).?); +} + +test "OSC 99: g 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;c=0:g=7f8a9129-a35d-4e9f-8043-ce2700e15e2c;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("7f8a9129-a35d-4e9f-8043-ce2700e15e2c", cmd.kitty_desktop_notification.readOption(.g).?); +} + +test "OSC 99: g 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;c=0:g=aaa*bbb;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.g) == null); +} + +test "OSC 99: i 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.i) == null); +} + +test "OSC 99: i 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); +} + +test "OSC 99: i 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i=;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("", cmd.kitty_desktop_notification.readOption(.i).?); +} + +test "OSC 99: i 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i= :;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("", cmd.kitty_desktop_notification.readOption(.i).?); +} + +test "OSC 99: i 5" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i= bobr ;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); +} + +test "OSC 99: i 6" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i= bobr : i=kurwa ;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); +} + +test "OSC 99: n 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;n=R2hvc3R0eQ==;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + var it = cmd.kitty_desktop_notification.readOption(.n); + try testing.expectEqualStrings("R2hvc3R0eQ==", it.next().?); + try testing.expect(it.next() == null); +} + +test "OSC 99: n 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;n=R2hvc3R0eQ==:n=R2hvc3R0eQ==;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + var it = cmd.kitty_desktop_notification.readOption(.n); + try testing.expectEqualStrings("R2hvc3R0eQ==", it.next().?); + try testing.expectEqualStrings("R2hvc3R0eQ==", it.next().?); + try testing.expect(it.next() == null); +} + +test "OSC 99: o 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;o= ;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.always, cmd.kitty_desktop_notification.readOption(.o)); +} + +test "OSC 99: o 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;o=always;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.always, cmd.kitty_desktop_notification.readOption(.o)); +} + +test "OSC 99: o 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;o=unfocused;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.unfocused, cmd.kitty_desktop_notification.readOption(.o)); +} + +test "OSC 99: o 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;o=invisible;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.invisible, cmd.kitty_desktop_notification.readOption(.o)); +} + +test "OSC 99: o 5" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;o=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.always, cmd.kitty_desktop_notification.readOption(.o)); +} + +test "OSC 99: p 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=alive;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.alive, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=body;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.body, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=buttons;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.buttons, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=close;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.close, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 5" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=icon;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.icon, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 6" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=?;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.query, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 7" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=title;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.title, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 8" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=query;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.unknown, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 9" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.unknown, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: s 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;s=R2hvc3R0eQ==;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_desktop_notification.readOption(.s)); +} + +test "OSC 99: t 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;t=R2hvc3R0eQ==;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + var it = cmd.kitty_desktop_notification.readOption(.t); + try testing.expectEqualStrings("R2hvc3R0eQ==", it.next().?); + try testing.expect(it.next() == null); +} + +test "OSC 99: t 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;t=R2hvc3R0eQ==:t=R2hvc3R0eQ==;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + var it = cmd.kitty_desktop_notification.readOption(.t); + try testing.expectEqualStrings("R2hvc3R0eQ==", it.next().?); + try testing.expectEqualStrings("R2hvc3R0eQ==", it.next().?); + try testing.expect(it.next() == null); +} + +test "OSC 99: u 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;u=0;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.low, cmd.kitty_desktop_notification.readOption(.u)); +} + +test "OSC 99: u 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;u=1;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.normal, cmd.kitty_desktop_notification.readOption(.u)); +} + +test "OSC 99: u 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;u=2;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.high, cmd.kitty_desktop_notification.readOption(.u)); +} + +test "OSC 99: u 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;u=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.normal, cmd.kitty_desktop_notification.readOption(.u)); +} + +test "OSC 99: w 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;w=0;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(0, cmd.kitty_desktop_notification.readOption(.w)); +} + +test "OSC 99: w 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;w=-1;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w)); +} + +test "OSC 99: w 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;w=-42;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w)); +} + +test "OSC 99: w 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;w=4294967296;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w)); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 4308adf9f..cb684fa50 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -2570,6 +2570,7 @@ pub fn Stream(comptime H: type) type { .conemu_run_process, .kitty_text_sizing, .kitty_dnd_protocol, + .kitty_desktop_notification, .context_signal, => { log.debug("unimplemented OSC callback: {}", .{cmd}); From 93bf7d8104a033a643e78a747ca06500f51539e7 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 27 Jan 2026 15:11:33 -0600 Subject: [PATCH 2/9] osc: address review comments on OSC 99 (Kitty desktop notifications) * Add comments to Option keys to clarify their usage without having to refer to the spec online. * Use `indexOfNone` to simplify code. --- .../parsers/kitty_desktop_notification.zig | 119 ++++-------------- 1 file changed, 24 insertions(+), 95 deletions(-) diff --git a/src/terminal/osc/parsers/kitty_desktop_notification.zig b/src/terminal/osc/parsers/kitty_desktop_notification.zig index b2bc78b95..2ce8215e5 100644 --- a/src/terminal/osc/parsers/kitty_desktop_notification.zig +++ b/src/terminal/osc/parsers/kitty_desktop_notification.zig @@ -94,19 +94,34 @@ pub const Urgency = enum { }; pub const Option = enum { + /// What action(s) should be taken when a notification is clicked. a, + /// Should a notification be sent to the application when the notification + /// is closed? c, + /// Are we done with the notification, and it is ready to be sent? d, + /// Is the payload encoded with Base64? e, + /// The nname of the application that is sending the notification. f, + /// Identifier for icon data. g, + /// Identifier for the notification. i, + /// Icon name. n, + /// When to honor the notification request. o, + /// Type of the payload. p, + /// The sound name to play with the notification. s, + /// The type of the notification. t, + /// The urgency of the notification. u, + /// When to auto-close the notification. w, pub fn Type(comptime key: Option) type { @@ -269,107 +284,21 @@ pub fn parsePackedStruct(comptime T: type, str: []const u8) T { return result; } -fn isValidMetadataValueCharacter(c: u8) bool { - return switch (c) { - 'a'...'z', - 'A'...'Z', - '0'...'9', - '-', - '_', - '/', - '+', - '.', - ',', - '(', - ')', - '{', - '}', - '[', - ']', - '*', - '&', - '^', - '%', - '$', - '#', - '@', - '!', - '`', - '~', - // Including `=` is "technically" against the spec but is needed since - // Base64 encoded values (with padding) are valid for some options. - '=', - // Including `?` is "technically" against the spec but is needed since - // it is a valid value for the `p` option. - '?', - => true, - else => false, - }; -} - -const invalid_metadata_value_characters: []const u8 = i: { - @setEvalBranchQuota(2000); - var count = 0; - for (0..256) |i| { - if (!isValidMetadataValueCharacter(i)) count += 1; - } - var tmp: [count]u8 = undefined; - var index = 0; - for (0..256) |i| { - if (!isValidMetadataValueCharacter(i)) { - tmp[index] = i; - index += 1; - } - } - const result = tmp; - break :i &result; -}; +/// Characters that are valid in a metadata value. Including `=` is +/// "technically" against the spec but is needed since Base64 encoded values +/// (with padding) are valid for some options. Including `?` is "technically" +/// against the spec but is needed since it is a valid value for the `p` option. +const valid_metadata_value_characters: []const u8 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/+.,(){}[]*&^%$#@!`~=?"; fn isValidMetadataValue(str: []const u8) bool { - if (std.mem.indexOfAny(u8, str, invalid_metadata_value_characters)) |_| { - return false; - } else { - return true; - } + return std.mem.indexOfNone(u8, str, valid_metadata_value_characters) == null; } -fn isValidIdentifierCharacter(c: u8) bool { - return switch (c) { - 'a'...'z', - 'A'...'Z', - '0'...'9', - '-', - '_', - '+', - => true, - else => false, - }; -} - -const invalid_identifier_characters: []const u8 = i: { - @setEvalBranchQuota(2000); - var count = 0; - for (0..256) |i| { - if (!isValidIdentifierCharacter(i)) count += 1; - } - var tmp: [count]u8 = undefined; - var index = 0; - for (0..256) |i| { - if (!isValidIdentifierCharacter(i)) { - tmp[index] = i; - index += 1; - } - } - const result = tmp; - break :i &result; -}; +/// Characters that are valid in identifiers. +const valid_identifier_characters: []const u8 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+"; fn isValidIdentifier(str: []const u8) bool { - if (std.mem.indexOfAny(u8, str, invalid_identifier_characters)) |_| { - return false; - } else { - return true; - } + return std.mem.indexOfNone(u8, str, valid_identifier_characters) == null; } fn parseIdentifier(str: []const u8) ?[]const u8 { From 202e639d97bada1220d542821b2063e41f46d29f Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 27 Jan 2026 15:14:08 -0600 Subject: [PATCH 3/9] osc: clean up comments in OSC 99 --- src/terminal/osc/parsers/kitty_desktop_notification.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/terminal/osc/parsers/kitty_desktop_notification.zig b/src/terminal/osc/parsers/kitty_desktop_notification.zig index 2ce8215e5..76670cb33 100644 --- a/src/terminal/osc/parsers/kitty_desktop_notification.zig +++ b/src/terminal/osc/parsers/kitty_desktop_notification.zig @@ -284,10 +284,10 @@ pub fn parsePackedStruct(comptime T: type, str: []const u8) T { return result; } -/// Characters that are valid in a metadata value. Including `=` is -/// "technically" against the spec but is needed since Base64 encoded values -/// (with padding) are valid for some options. Including `?` is "technically" -/// against the spec but is needed since it is a valid value for the `p` option. +/// Characters that are valid in a metadata value. Including `=` is technically +/// against the spec but is needed since Base64 encoded values (with padding) +/// are valid for some options. Including `?` is technically against the spec +/// but is needed since it is a valid value for the `p` option. const valid_metadata_value_characters: []const u8 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/+.,(){}[]*&^%$#@!`~=?"; fn isValidMetadataValue(str: []const u8) bool { From 301b69df43484b8709da6c073454fab388850af7 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 29 Jan 2026 14:41:56 -0600 Subject: [PATCH 4/9] osc: save terminator from OSC 99 in case we need to send a response --- src/terminal/osc/parsers/kitty_desktop_notification.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/terminal/osc/parsers/kitty_desktop_notification.zig b/src/terminal/osc/parsers/kitty_desktop_notification.zig index 76670cb33..5c58add95 100644 --- a/src/terminal/osc/parsers/kitty_desktop_notification.zig +++ b/src/terminal/osc/parsers/kitty_desktop_notification.zig @@ -8,6 +8,7 @@ const assert = @import("../../../quirks.zig").inlineAssert; const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; +const Terminator = @import("../../osc.zig").Terminator; const encoding = @import("../encoding.zig"); const lib = @import("../../../lib/main.zig"); const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; @@ -19,6 +20,8 @@ pub const OSC = struct { metadata: []const u8, /// The raw payload. It may be Base64 encoded, check the `e` option. payload: []const u8, + /// The terminator that was used in case we need to send a response. + terminator: Terminator, /// Decode an option from the metadata. pub fn readOption(self: OSC, comptime key: Option) key.Type() { @@ -361,7 +364,7 @@ pub fn Iterator(comptime key: Option) type { }; } -pub fn parse(parser: *Parser, _: ?u8) ?*Command { +pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { assert(parser.state == .@"99"); const cap = if (parser.capture) |*c| c else { @@ -391,6 +394,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { .kitty_desktop_notification = .{ .metadata = metadata, .payload = payload, + .terminator = .init(terminator_ch), }, }; @@ -429,6 +433,7 @@ test "OSC 99: empty metadata and payload" { } try testing.expectEqual(.normal, cmd.kitty_desktop_notification.readOption(.u)); try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w)); + try testing.expectEqual(.st, cmd.kitty_desktop_notification.terminator); } test "OSC 99: empty metadata with payload" { From 9a8e7ae1869dd75a312b2cc009a1294c52361e34 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 29 Jan 2026 14:49:58 -0600 Subject: [PATCH 5/9] core: add alias for Kitty desktop notification OSC struct --- src/terminal/osc.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 7a8944540..63d65b743 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -160,7 +160,7 @@ pub const Command = union(Key) { kitty_dnd_protocol: KittyDndProtocol, /// Kitty desktop notifications (OSC 99) - kitty_desktop_notification: parsers.kitty_desktop_notification.OSC, + kitty_desktop_notification: KittyDesktopNotification, /// OSC 3008. Hierarchical context signalling (UAPI spec). /// https://uapi-group.org/specifications/specs/osc_context/ @@ -172,6 +172,8 @@ pub const Command = union(Key) { pub const KittyDndProtocol = parsers.kitty_dnd_protocol.OSC; + pub const KittyDesktopNotification = parsers.kitty_desktop_notification.OSC; + pub const Key = LibEnum( lib.target, // NOTE: Order matters, see LibEnum documentation. From aa4ec3508ecb789c69585dd073ca474e2297f6be Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 1 Feb 2026 13:37:00 -0600 Subject: [PATCH 6/9] osc 99: address review feedback * Fix typos. * Eliminate metadata parsing code duplication. * Improve documentation. * Ensure assert is comptime only. * Derive valid metadata characters from valid identifier characters. --- .../parsers/kitty_desktop_notification.zig | 55 +++++-------------- 1 file changed, 14 insertions(+), 41 deletions(-) diff --git a/src/terminal/osc/parsers/kitty_desktop_notification.zig b/src/terminal/osc/parsers/kitty_desktop_notification.zig index 5c58add95..edde49cec 100644 --- a/src/terminal/osc/parsers/kitty_desktop_notification.zig +++ b/src/terminal/osc/parsers/kitty_desktop_notification.zig @@ -106,9 +106,9 @@ pub const Option = enum { d, /// Is the payload encoded with Base64? e, - /// The nname of the application that is sending the notification. + /// The name of the application that is sending the notification. f, - /// Identifier for icon data. + /// Identifier for icon data. Only used when the payload is icon data. g, /// Identifier for the notification. i, @@ -173,45 +173,15 @@ pub const Option = enum { comptime key: Option, metadata: []const u8, ) key.Type() { - switch (key) { + var it: Iterator(key) = switch (key) { inline .t, .n => return .init(metadata), - else => {}, - } - - const value = value: { - var pos: usize = 0; - while (pos < metadata.len) { - // skip any whitespace - while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; - // bail if we are out of metadata - if (pos >= metadata.len) return key.default(); - if (metadata[pos] != @tagName(key)[0]) { - // this isn't the key we are looking for, skip to the next option, or bail if - // there is no next option - pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return key.default(); - pos += 1; - continue; - } - // skip past the key - pos += 1; - // skip any whitespace - while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; - // bail if we are out of metadata - if (pos >= metadata.len) return key.default(); - // a valid option has an '=' - if (metadata[pos] != '=') return key.default(); - // the end of the value is bounded by a ':' or the end of the metadata - const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len; - // return the value after stripping any leading or trailing whitespace - break :value std.mem.trim(u8, metadata[pos + 1 .. end], &std.ascii.whitespace); - } - // the key was not found - return key.default(); + inline else => .init(metadata), }; - if (!isValidMetadataValue(value)) return key.default(); + const value = it.next() orelse return key.default(); - // return the parsed value + // return the parsed value, the iterator guarantees that it's a valid + // metadata value return switch (key) { .a => .init(value), .c => parseBool(value) orelse key.default(), @@ -248,7 +218,7 @@ fn parseBool(str: []const u8) ?bool { }; } -/// This is similar to the packed struct parsed used in the configs. The +/// This is similar to the packed struct parser used in the configs. The /// differences are that a literal `true` or `false` value does not turn on/off /// all the values, and the negation prefix is `-` not `no-`. pub fn parsePackedStruct(comptime T: type, str: []const u8) T { @@ -273,7 +243,7 @@ pub fn parsePackedStruct(comptime T: type, str: []const u8) T { }; inline for (info.fields) |field| { - assert(field.type == bool); + comptime assert(field.type == bool); if (std.mem.eql(u8, field.name, part)) { @field(result, field.name) = value; continue :loop; @@ -291,7 +261,7 @@ pub fn parsePackedStruct(comptime T: type, str: []const u8) T { /// against the spec but is needed since Base64 encoded values (with padding) /// are valid for some options. Including `?` is technically against the spec /// but is needed since it is a valid value for the `p` option. -const valid_metadata_value_characters: []const u8 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/+.,(){}[]*&^%$#@!`~=?"; +const valid_metadata_value_characters: []const u8 = valid_identifier_characters ++ "/.,(){}[]*&^%$#@!`~=?"; fn isValidMetadataValue(str: []const u8) bool { return std.mem.indexOfNone(u8, str, valid_metadata_value_characters) == null; @@ -309,7 +279,7 @@ fn parseIdentifier(str: []const u8) ?[]const u8 { return null; } -/// Used when an option can appear multiple times in the metadata +/// Used to iterate over matching key/values in the metadata pub fn Iterator(comptime key: Option) type { return struct { metadata: []const u8, @@ -322,7 +292,10 @@ pub fn Iterator(comptime key: Option) type { }; } + /// Return the value of the next matching key. The value is guaranteed + /// to be `null` or a valid metadata value. pub fn next(self: *Iterator(key)) ?[]const u8 { + // bail if we are out of metadata if (self.pos >= self.metadata.len) return null; while (self.pos < self.metadata.len) { // skip any whitespace From 07f33ad91453ebf9bbfdc24a26d935bcb949d08a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 16 Feb 2026 20:27:54 -0600 Subject: [PATCH 7/9] osc 99 & 5522: share metadata parsing code Reduce redundant code by sharing the metadata parsing code between the OSC 99 & OSC 5522 parsers. --- src/terminal/osc/lib.zig | 61 +++++++++++++++++ .../osc/parsers/kitty_clipboard_protocol.zig | 38 ++--------- .../parsers/kitty_desktop_notification.zig | 65 ++----------------- 3 files changed, 72 insertions(+), 92 deletions(-) create mode 100644 src/terminal/osc/lib.zig diff --git a/src/terminal/osc/lib.zig b/src/terminal/osc/lib.zig new file mode 100644 index 000000000..dfa1a59b7 --- /dev/null +++ b/src/terminal/osc/lib.zig @@ -0,0 +1,61 @@ +const std = @import("std"); + +/// Used to iterate over matching key/values in the metadata +pub fn Iterator(comptime Option: type, comptime isValidMetadataValue: fn ([]const u8) bool, comptime key: Option) type { + return struct { + const Self = @This(); + + metadata: []const u8, + pos: usize, + + pub fn init(metadata: []const u8) Self { + return .{ + .metadata = metadata, + .pos = 0, + }; + } + + /// Return the value of the next matching key. The value is guaranteed + /// to be `null` or a valid metadata value. + pub fn next(self: *Self) ?[]const u8 { + // bail if we are out of metadata + if (self.pos >= self.metadata.len) return null; + while (self.pos < self.metadata.len) { + // skip any whitespace + while (self.pos < self.metadata.len and std.ascii.isWhitespace(self.metadata[self.pos])) self.pos += 1; + // bail if we are out of metadata + if (self.pos >= self.metadata.len) return null; + if (!std.mem.startsWith(u8, self.metadata[self.pos..], @tagName(key))) { + // this isn't the key we are looking for, skip to the next option, or bail if + // there is no next option + self.pos = std.mem.indexOfScalarPos(u8, self.metadata, self.pos, ':') orelse { + self.pos = self.metadata.len; + return null; + }; + self.pos += 1; + continue; + } + // skip past the key + self.pos += @tagName(key).len; + // skip any whitespace + while (self.pos < self.metadata.len and std.ascii.isWhitespace(self.metadata[self.pos])) self.pos += 1; + // bail if we are out of metadata + if (self.pos >= self.metadata.len) return null; + // a valid option has an '=' + if (self.metadata[self.pos] != '=') return null; + // the end of the value is bounded by a ':' or the end of the metadata + const end = std.mem.indexOfScalarPos(u8, self.metadata, self.pos, ':') orelse self.metadata.len; + const start = self.pos + 1; + self.pos = end + 1; + // strip any leading or trailing whitespace + const value = std.mem.trim(u8, self.metadata[start..end], &std.ascii.whitespace); + // if this is not a valid value, skip it + if (!@call(.always_inline, isValidMetadataValue, .{value})) continue; + // return the value + return value; + } + // the key was not found + return null; + } + }; +} diff --git a/src/terminal/osc/parsers/kitty_clipboard_protocol.zig b/src/terminal/osc/parsers/kitty_clipboard_protocol.zig index c257edd10..db6efc048 100644 --- a/src/terminal/osc/parsers/kitty_clipboard_protocol.zig +++ b/src/terminal/osc/parsers/kitty_clipboard_protocol.zig @@ -10,6 +10,7 @@ const assert = @import("../../../quirks.zig").inlineAssert; const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; const Terminator = @import("../../osc.zig").Terminator; +const Iterator = @import("../lib.zig").Iterator; const encoding = @import("../encoding.zig"); const log = std.log.scoped(.kitty_clipboard_protocol); @@ -96,37 +97,8 @@ pub const Option = enum { comptime key: Option, metadata: []const u8, ) ?key.Type() { - const value: []const u8 = value: { - var pos: usize = 0; - while (pos < metadata.len) { - // skip any whitespace - while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; - // bail if we are out of metadata - if (pos >= metadata.len) return null; - if (!std.mem.startsWith(u8, metadata[pos..], @tagName(key))) { - // this isn't the key we are looking for, skip to the next option, or bail if - // there is no next option - pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null; - pos += 1; - continue; - } - // skip past the key - pos += @tagName(key).len; - // skip any whitespace - while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; - // bail if we are out of metadata - if (pos >= metadata.len) return null; - // a valid option has an '=' - if (metadata[pos] != '=') return null; - // the end of the value is bounded by a ':' or the end of the metadata - const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len; - const start = pos + 1; - // strip any leading or trailing whitespace - break :value std.mem.trim(u8, metadata[start..end], &std.ascii.whitespace); - } - // the key was not found - return null; - }; + var it: Iterator(Option, isValidMetadataValue, key) = .init(metadata); + const value = it.next() orelse return null; // return the parsed value return switch (key) { @@ -155,6 +127,10 @@ fn parseIdentifier(str: []const u8) ?[]const u8 { return null; } +fn isValidMetadataValue(_: []const u8) bool { + return true; +} + pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { assert(parser.state == .@"5522"); diff --git a/src/terminal/osc/parsers/kitty_desktop_notification.zig b/src/terminal/osc/parsers/kitty_desktop_notification.zig index edde49cec..9d22c86af 100644 --- a/src/terminal/osc/parsers/kitty_desktop_notification.zig +++ b/src/terminal/osc/parsers/kitty_desktop_notification.zig @@ -9,6 +9,7 @@ const assert = @import("../../../quirks.zig").inlineAssert; const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; const Terminator = @import("../../osc.zig").Terminator; +const Iterator = @import("../lib.zig").Iterator; const encoding = @import("../encoding.zig"); const lib = @import("../../../lib/main.zig"); const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; @@ -136,11 +137,11 @@ pub const Option = enum { .f => ?[]const u8, .g => ?[]const u8, .i => ?[]const u8, - .n => Iterator(.n), + .n => Iterator(Option, isValidMetadataValue, .n), .o => Occasion, .p => Payload, .s => []const u8, - .t => Iterator(.t), + .t => Iterator(Option, isValidMetadataValue, .t), .u => Urgency, .w => i32, }; @@ -173,7 +174,7 @@ pub const Option = enum { comptime key: Option, metadata: []const u8, ) key.Type() { - var it: Iterator(key) = switch (key) { + var it: Iterator(Option, isValidMetadataValue, key) = switch (key) { inline .t, .n => return .init(metadata), inline else => .init(metadata), }; @@ -279,64 +280,6 @@ fn parseIdentifier(str: []const u8) ?[]const u8 { return null; } -/// Used to iterate over matching key/values in the metadata -pub fn Iterator(comptime key: Option) type { - return struct { - metadata: []const u8, - pos: usize, - - pub fn init(metadata: []const u8) Iterator(key) { - return .{ - .metadata = metadata, - .pos = 0, - }; - } - - /// Return the value of the next matching key. The value is guaranteed - /// to be `null` or a valid metadata value. - pub fn next(self: *Iterator(key)) ?[]const u8 { - // bail if we are out of metadata - if (self.pos >= self.metadata.len) return null; - while (self.pos < self.metadata.len) { - // skip any whitespace - while (self.pos < self.metadata.len and std.ascii.isWhitespace(self.metadata[self.pos])) self.pos += 1; - // bail if we are out of metadata - if (self.pos >= self.metadata.len) return null; - if (self.metadata[self.pos] != @tagName(key)[0]) { - // this isn't the key we are looking for, skip to the next option, or bail if - // there is no next option - self.pos = std.mem.indexOfScalarPos(u8, self.metadata, self.pos, ':') orelse { - self.pos = self.metadata.len; - return null; - }; - self.pos += 1; - continue; - } - // skip past the key - self.pos += 1; - // skip any whitespace - while (self.pos < self.metadata.len and std.ascii.isWhitespace(self.metadata[self.pos])) self.pos += 1; - // bail if we are out of metadata - if (self.pos >= self.metadata.len) return null; - // a valid option has an '=' - if (self.metadata[self.pos] != '=') return null; - // the end of the value is bounded by a ':' or the end of the metadata - const end = std.mem.indexOfScalarPos(u8, self.metadata, self.pos, ':') orelse self.metadata.len; - const start = self.pos + 1; - self.pos = end + 1; - // strip any leading or trailing whitespace - const value = std.mem.trim(u8, self.metadata[start..end], &std.ascii.whitespace); - // if this is not a valid value, skip it - if (!isValidMetadataValue(value)) continue; - // return the value - return value; - } - // the key was not found - return null; - } - }; -} - pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { assert(parser.state == .@"99"); From 073bffcff4aeb1f748edda397b4f2d367296c9f2 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 16 Feb 2026 20:40:14 -0600 Subject: [PATCH 8/9] osc 99: eliminate unnecessary inline switch branches --- src/terminal/osc/parsers/kitty_desktop_notification.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminal/osc/parsers/kitty_desktop_notification.zig b/src/terminal/osc/parsers/kitty_desktop_notification.zig index 9d22c86af..2c7857493 100644 --- a/src/terminal/osc/parsers/kitty_desktop_notification.zig +++ b/src/terminal/osc/parsers/kitty_desktop_notification.zig @@ -175,8 +175,8 @@ pub const Option = enum { metadata: []const u8, ) key.Type() { var it: Iterator(Option, isValidMetadataValue, key) = switch (key) { - inline .t, .n => return .init(metadata), - inline else => .init(metadata), + .t, .n => return .init(metadata), + else => .init(metadata), }; const value = it.next() orelse return key.default(); From ca9e5b1301354018f92152c1282a922baacfa0e1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 13:59:41 -0700 Subject: [PATCH 9/9] terminal/osc: kitty notification parsing feedback --- include/ghostty/vt/osc.h | 1 + src/terminal/c/types.zig | 1 + src/terminal/osc.zig | 12 +- src/terminal/osc/kitty_metadata.zig | 94 +++++++++++++++ src/terminal/osc/lib.zig | 61 ---------- .../osc/parsers/kitty_clipboard_protocol.zig | 8 +- .../parsers/kitty_desktop_notification.zig | 110 +++++++++++++----- 7 files changed, 188 insertions(+), 99 deletions(-) create mode 100644 src/terminal/osc/kitty_metadata.zig delete mode 100644 src/terminal/osc/lib.zig diff --git a/include/ghostty/vt/osc.h b/include/ghostty/vt/osc.h index f7626383c..7a681c3b4 100644 --- a/include/ghostty/vt/osc.h +++ b/include/ghostty/vt/osc.h @@ -66,6 +66,7 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_OSC_COMMAND_KITTY_CLIPBOARD_PROTOCOL = 23, GHOSTTY_OSC_COMMAND_KITTY_DND_PROTOCOL = 24, GHOSTTY_OSC_COMMAND_CONTEXT_SIGNAL = 25, + GHOSTTY_OSC_COMMAND_KITTY_DESKTOP_NOTIFICATION = 26, GHOSTTY_OSC_COMMAND_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyOscCommandType; diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index bc214c576..95a166c3d 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -1000,6 +1000,7 @@ test "manifest uses public enum names" { try std.testing.expectEqual(@as(i64, 23), osc_values.get("KITTY_CLIPBOARD_PROTOCOL").?.integer); try std.testing.expectEqual(@as(i64, 24), osc_values.get("KITTY_DND_PROTOCOL").?.integer); try std.testing.expectEqual(@as(i64, 25), osc_values.get("CONTEXT_SIGNAL").?.integer); + try std.testing.expectEqual(@as(i64, 26), osc_values.get("KITTY_DESKTOP_NOTIFICATION").?.integer); try std.testing.expect(osc_values.contains("TYPE_MAX_VALUE")); try std.testing.expect(!osc_values.contains("MAX_VALUE")); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 63d65b743..7edabcdc0 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -159,13 +159,13 @@ pub const Command = union(Key) { /// Kitty drag and drop protocol (OSC 72) kitty_dnd_protocol: KittyDndProtocol, - /// Kitty desktop notifications (OSC 99) - kitty_desktop_notification: KittyDesktopNotification, - /// OSC 3008. Hierarchical context signalling (UAPI spec). /// https://uapi-group.org/specifications/specs/osc_context/ context_signal: parsers.context_signal.Command, + /// Kitty desktop notifications (OSC 99) + kitty_desktop_notification: KittyDesktopNotification, + pub const SemanticPrompt = parsers.semantic_prompt.Command; pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC; @@ -203,8 +203,8 @@ pub const Command = union(Key) { "kitty_text_sizing", "kitty_clipboard_protocol", "kitty_dnd_protocol", - "kitty_desktop_notification", "context_signal", + "kitty_desktop_notification", }, ); @@ -800,7 +800,7 @@ pub const Parser = struct { .@"99", => switch (c) { - // OSC 99 can be up to 4096 bytes fully encoded. + // OSC 99 encoded payloads can exceed the fixed buffer. ';' => self.captureTrailing(.allocating), else => self.state = .invalid, }, @@ -911,7 +911,7 @@ test { test "Parser allocating captures have a hard limit" { const testing = std.testing; - const prefixes = [_][]const u8{ "52;", "66;", "72;", "5522;" }; + const prefixes = [_][]const u8{ "52;", "66;", "72;", "99;", "5522;" }; const limit = Parser.MAX_BUF + 1; for (prefixes) |prefix| { diff --git a/src/terminal/osc/kitty_metadata.zig b/src/terminal/osc/kitty_metadata.zig new file mode 100644 index 000000000..469c97a0f --- /dev/null +++ b/src/terminal/osc/kitty_metadata.zig @@ -0,0 +1,94 @@ +//! Helpers for parsing metadata shared by Kitty OSC protocols. +//! +//! Kitty OSC 99 and OSC 5522 encode metadata as colon-separated `key=value` +//! fields. The iterator in this module lazily searches that metadata for one +//! key, preserving the order of repeated values without allocating. +//! +//! Parsing is intentionally tolerant. Whitespace around keys and values is +//! trimmed, while malformed fields, non-matching keys, and invalid values are +//! skipped. Returned values are slices of the original metadata and remain +//! valid only as long as that input remains valid. + +const std = @import("std"); + +/// Return an iterator over values whose key exactly matches `key`. +/// +/// If `valid_value_characters` is non-null, every byte in a returned value must +/// appear in that character set. Passing null disables value validation. Both +/// arguments are comptime-known so each protocol can specialize the iterator +/// for its metadata grammar without storing a key or validator at runtime. +pub fn ValueIterator( + comptime key: []const u8, + comptime valid_value_characters: ?[]const u8, +) type { + return struct { + const Self = @This(); + + metadata: []const u8, + pos: usize, + + /// Initialize an iterator borrowing `metadata`. + pub fn init(metadata: []const u8) Self { + return .{ + .metadata = metadata, + .pos = 0, + }; + } + + /// Return the next valid matching value, or null when none remain. + /// The returned slice borrows the metadata passed to `init`. + pub fn next(self: *Self) ?[]const u8 { + while (self.pos < self.metadata.len) { + const end = std.mem.indexOfScalarPos( + u8, + self.metadata, + self.pos, + ':', + ) orelse self.metadata.len; + const field = self.metadata[self.pos..end]; + self.pos = if (end < self.metadata.len) end + 1 else end; + + const equals = std.mem.indexOfScalar(u8, field, '=') orelse + continue; + const field_key = std.mem.trim( + u8, + field[0..equals], + &std.ascii.whitespace, + ); + if (!std.mem.eql(u8, field_key, key)) continue; + + const value = std.mem.trim( + u8, + field[equals + 1 ..], + &std.ascii.whitespace, + ); + if (valid_value_characters) |valid| { + if (std.mem.indexOfNone(u8, value, valid) != null) continue; + } + + return value; + } + + return null; + } + }; +} + +test "ValueIterator skips malformed and prefix-matching keys" { + const testing = std.testing; + var it: ValueIterator("id", null) = .init( + "id-extra=wrong:id: id = first :id=second", + ); + + try testing.expectEqualStrings("first", it.next().?); + try testing.expectEqualStrings("second", it.next().?); + try testing.expect(it.next() == null); +} + +test "ValueIterator skips values containing disallowed characters" { + const testing = std.testing; + var it: ValueIterator("i", "abc") = .init("i=a?:i=abc"); + + try testing.expectEqualStrings("abc", it.next().?); + try testing.expect(it.next() == null); +} diff --git a/src/terminal/osc/lib.zig b/src/terminal/osc/lib.zig deleted file mode 100644 index dfa1a59b7..000000000 --- a/src/terminal/osc/lib.zig +++ /dev/null @@ -1,61 +0,0 @@ -const std = @import("std"); - -/// Used to iterate over matching key/values in the metadata -pub fn Iterator(comptime Option: type, comptime isValidMetadataValue: fn ([]const u8) bool, comptime key: Option) type { - return struct { - const Self = @This(); - - metadata: []const u8, - pos: usize, - - pub fn init(metadata: []const u8) Self { - return .{ - .metadata = metadata, - .pos = 0, - }; - } - - /// Return the value of the next matching key. The value is guaranteed - /// to be `null` or a valid metadata value. - pub fn next(self: *Self) ?[]const u8 { - // bail if we are out of metadata - if (self.pos >= self.metadata.len) return null; - while (self.pos < self.metadata.len) { - // skip any whitespace - while (self.pos < self.metadata.len and std.ascii.isWhitespace(self.metadata[self.pos])) self.pos += 1; - // bail if we are out of metadata - if (self.pos >= self.metadata.len) return null; - if (!std.mem.startsWith(u8, self.metadata[self.pos..], @tagName(key))) { - // this isn't the key we are looking for, skip to the next option, or bail if - // there is no next option - self.pos = std.mem.indexOfScalarPos(u8, self.metadata, self.pos, ':') orelse { - self.pos = self.metadata.len; - return null; - }; - self.pos += 1; - continue; - } - // skip past the key - self.pos += @tagName(key).len; - // skip any whitespace - while (self.pos < self.metadata.len and std.ascii.isWhitespace(self.metadata[self.pos])) self.pos += 1; - // bail if we are out of metadata - if (self.pos >= self.metadata.len) return null; - // a valid option has an '=' - if (self.metadata[self.pos] != '=') return null; - // the end of the value is bounded by a ':' or the end of the metadata - const end = std.mem.indexOfScalarPos(u8, self.metadata, self.pos, ':') orelse self.metadata.len; - const start = self.pos + 1; - self.pos = end + 1; - // strip any leading or trailing whitespace - const value = std.mem.trim(u8, self.metadata[start..end], &std.ascii.whitespace); - // if this is not a valid value, skip it - if (!@call(.always_inline, isValidMetadataValue, .{value})) continue; - // return the value - return value; - } - // the key was not found - return null; - } - }; -} diff --git a/src/terminal/osc/parsers/kitty_clipboard_protocol.zig b/src/terminal/osc/parsers/kitty_clipboard_protocol.zig index db6efc048..3b32aae8b 100644 --- a/src/terminal/osc/parsers/kitty_clipboard_protocol.zig +++ b/src/terminal/osc/parsers/kitty_clipboard_protocol.zig @@ -10,7 +10,7 @@ const assert = @import("../../../quirks.zig").inlineAssert; const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; const Terminator = @import("../../osc.zig").Terminator; -const Iterator = @import("../lib.zig").Iterator; +const kitty_metadata = @import("../kitty_metadata.zig"); const encoding = @import("../encoding.zig"); const log = std.log.scoped(.kitty_clipboard_protocol); @@ -97,7 +97,7 @@ pub const Option = enum { comptime key: Option, metadata: []const u8, ) ?key.Type() { - var it: Iterator(Option, isValidMetadataValue, key) = .init(metadata); + var it: kitty_metadata.ValueIterator(@tagName(key), null) = .init(metadata); const value = it.next() orelse return null; // return the parsed value @@ -127,10 +127,6 @@ fn parseIdentifier(str: []const u8) ?[]const u8 { return null; } -fn isValidMetadataValue(_: []const u8) bool { - return true; -} - pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { assert(parser.state == .@"5522"); diff --git a/src/terminal/osc/parsers/kitty_desktop_notification.zig b/src/terminal/osc/parsers/kitty_desktop_notification.zig index 2c7857493..27fc86702 100644 --- a/src/terminal/osc/parsers/kitty_desktop_notification.zig +++ b/src/terminal/osc/parsers/kitty_desktop_notification.zig @@ -2,20 +2,20 @@ //! Specification: https://sw.kovidgoyal.net/kitty/desktop-notifications/ const std = @import("std"); -const build_options = @import("terminal_options"); const assert = @import("../../../quirks.zig").inlineAssert; const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; const Terminator = @import("../../osc.zig").Terminator; -const Iterator = @import("../lib.zig").Iterator; +const kitty_metadata = @import("../kitty_metadata.zig"); const encoding = @import("../encoding.zig"); -const lib = @import("../../../lib/main.zig"); -const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; const log = std.log.scoped(.kitty_desktop_notification); +pub const MAX_PLAIN_PAYLOAD_BYTES = 2048; +pub const MAX_ENCODED_PAYLOAD_BYTES = 4096; + pub const OSC = struct { /// The raw metadata that was received. It can be parsed by using the `readOption` method. metadata: []const u8, @@ -137,11 +137,17 @@ pub const Option = enum { .f => ?[]const u8, .g => ?[]const u8, .i => ?[]const u8, - .n => Iterator(Option, isValidMetadataValue, .n), + .n => kitty_metadata.ValueIterator( + @tagName(key), + valid_metadata_value_characters, + ), .o => Occasion, .p => Payload, .s => []const u8, - .t => Iterator(Option, isValidMetadataValue, .t), + .t => kitty_metadata.ValueIterator( + @tagName(key), + valid_metadata_value_characters, + ), .u => Urgency, .w => i32, }; @@ -168,13 +174,16 @@ pub const Option = enum { /// Read the option value from the raw metadata string. /// - /// Any errors in the raw string will return null since the OSC 99 - /// specification says to ignore unknown or malformed options. + /// Unknown and malformed values are ignored. Optional values return null; + /// all other values return the protocol default. pub fn read( comptime key: Option, metadata: []const u8, ) key.Type() { - var it: Iterator(Option, isValidMetadataValue, key) = switch (key) { + var it: kitty_metadata.ValueIterator( + @tagName(key), + valid_metadata_value_characters, + ) = switch (key) { .t, .n => return .init(metadata), else => .init(metadata), }; @@ -222,7 +231,7 @@ fn parseBool(str: []const u8) ?bool { /// This is similar to the packed struct parser used in the configs. The /// differences are that a literal `true` or `false` value does not turn on/off /// all the values, and the negation prefix is `-` not `no-`. -pub fn parsePackedStruct(comptime T: type, str: []const u8) T { +fn parsePackedStruct(comptime T: type, str: []const u8) T { const info = @typeInfo(T).@"struct"; comptime assert(info.layout == .@"packed"); @@ -262,14 +271,10 @@ pub fn parsePackedStruct(comptime T: type, str: []const u8) T { /// against the spec but is needed since Base64 encoded values (with padding) /// are valid for some options. Including `?` is technically against the spec /// but is needed since it is a valid value for the `p` option. -const valid_metadata_value_characters: []const u8 = valid_identifier_characters ++ "/.,(){}[]*&^%$#@!`~=?"; - -fn isValidMetadataValue(str: []const u8) bool { - return std.mem.indexOfNone(u8, str, valid_metadata_value_characters) == null; -} +const valid_metadata_value_characters: []const u8 = valid_identifier_characters ++ "/,(){}[]*&^%$#@!`~=?"; /// Characters that are valid in identifiers. -const valid_identifier_characters: []const u8 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+"; +const valid_identifier_characters: []const u8 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+."; fn isValidIdentifier(str: []const u8) bool { return std.mem.indexOfNone(u8, str, valid_identifier_characters) == null; @@ -299,7 +304,20 @@ pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { const metadata = data[0..payload_start]; const payload = data[payload_start + 1 .. data.len]; - // Payload has to be a URL-safe UTF-8 string. + const max_payload_bytes: usize = if (Option.e.read(metadata)) + MAX_ENCODED_PAYLOAD_BYTES + else + MAX_PLAIN_PAYLOAD_BYTES; + if (payload.len > max_payload_bytes) { + log.warn( + "payload is too large: size={d} max={d}", + .{ payload.len, max_payload_bytes }, + ); + parser.state = .invalid; + return null; + } + + // Payload has to be an escape-code-safe UTF-8 string. if (!encoding.isSafeUtf8(payload)) { log.warn("payload is not escape code safe UTF-8", .{}); parser.state = .invalid; @@ -386,18 +404,58 @@ test "OSC 99: empty metadata with payload" { try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w)); } +test "OSC 99: payload size limits" { + const testing = std.testing; + const cases = [_]struct { + metadata: []const u8, + payload_size: usize, + valid: bool, + }{ + .{ .metadata = "", .payload_size = MAX_PLAIN_PAYLOAD_BYTES, .valid = true }, + .{ .metadata = "", .payload_size = MAX_PLAIN_PAYLOAD_BYTES + 1, .valid = false }, + .{ .metadata = "e=1", .payload_size = MAX_ENCODED_PAYLOAD_BYTES, .valid = true }, + .{ .metadata = "e=1", .payload_size = MAX_ENCODED_PAYLOAD_BYTES + 1, .valid = false }, + }; + + for (cases) |case| { + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + for ("99;") |ch| p.next(ch); + for (case.metadata) |ch| p.next(ch); + p.next(';'); + for (0..case.payload_size) |_| p.next('a'); + + try testing.expectEqual(case.valid, p.end('\x1b') != null); + } +} + +test "OSC 99: unknown prefix does not hide dotted identifier" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "99;invalid=wrong:i=org.ghostty;payload"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expectEqualStrings( + "org.ghostty", + cmd.kitty_desktop_notification.readOption(.i).?, + ); +} + test "OSC 99: single parameter i" { const testing = std.testing; var p: Parser = .init(null); - const input = "99;i=bobr;kurwa"; + const input = "99;i=bobr;payload"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .kitty_desktop_notification); try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); - try testing.expectEqualStrings("kurwa", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("payload", cmd.kitty_desktop_notification.payload); } test "OSC 99: repeated parameter i" { @@ -405,13 +463,13 @@ test "OSC 99: repeated parameter i" { var p: Parser = .init(null); - const input = "99;i=bobr:i=foobar;kurwa"; + const input = "99;i=bobr:i=foobar;payload"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .kitty_desktop_notification); try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); - try testing.expectEqualStrings("kurwa", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("payload", cmd.kitty_desktop_notification.payload); } test "OSC 99: multiple types" { @@ -419,16 +477,16 @@ test "OSC 99: multiple types" { var p: Parser = .init(null); - const input = "99;t=bobr: t = kurwa : t = ghostty ;foobar"; + const input = "99;t=mail: t = chat : t = alert ;notification"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .kitty_desktop_notification); - try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("notification", cmd.kitty_desktop_notification.payload); var it = cmd.kitty_desktop_notification.readOption(.t); - try testing.expectEqualStrings("bobr", it.next().?); - try testing.expectEqualStrings("kurwa", it.next().?); - try testing.expectEqualStrings("ghostty", it.next().?); + try testing.expectEqualStrings("mail", it.next().?); + try testing.expectEqualStrings("chat", it.next().?); + try testing.expectEqualStrings("alert", it.next().?); try testing.expect(it.next() == null); }