From e1b82ff3981200fcd722734493b9ddc11ab82543 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 22 Jan 2026 22:06:39 -0600 Subject: [PATCH] osc: parse iTerm2 OSC 1337 extensions Add a framework for parsing iTerm2's OSC 1337 extensions. Implement a couple (`Copy` and `CurrentDir`) that map easily onto existing OSC commands. --- src/terminal/osc.zig | 17 +- src/terminal/osc/parsers.zig | 2 + src/terminal/osc/parsers/iterm2.zig | 435 ++++++++++++++++++++++++++++ 3 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 src/terminal/osc/parsers/iterm2.zig diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 3fccb2812..368da4afc 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -406,6 +406,7 @@ pub const Parser = struct { @"119", @"133", @"777", + @"1337", }; pub fn init(alloc: ?Allocator) Parser { @@ -663,8 +664,20 @@ pub const Parser = struct { else => self.state = .invalid, }, - .@"0", .@"133", + => switch (c) { + ';' => self.writeToFixed(), + '7' => self.state = .@"1337", + else => self.state = .invalid, + }, + + .@"1337", + => switch (c) { + ';' => self.writeToFixed(), + else => self.state = .invalid, + }, + + .@"0", .@"22", .@"777", .@"8", @@ -741,6 +754,8 @@ pub const Parser = struct { .@"133" => parsers.semantic_prompt.parse(self, terminator_ch), .@"777" => parsers.rxvt_extension.parse(self, terminator_ch), + + .@"1337" => parsers.iterm2.parse(self, terminator_ch), }; } }; diff --git a/src/terminal/osc/parsers.zig b/src/terminal/osc/parsers.zig index 9c1c39b2c..f3028ec79 100644 --- a/src/terminal/osc/parsers.zig +++ b/src/terminal/osc/parsers.zig @@ -5,6 +5,7 @@ pub const change_window_title = @import("parsers/change_window_title.zig"); pub const clipboard_operation = @import("parsers/clipboard_operation.zig"); pub const color = @import("parsers/color.zig"); pub const hyperlink = @import("parsers/hyperlink.zig"); +pub const iterm2 = @import("parsers/iterm2.zig"); pub const kitty_color = @import("parsers/kitty_color.zig"); pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig"); pub const mouse_shape = @import("parsers/mouse_shape.zig"); @@ -19,6 +20,7 @@ test { _ = clipboard_operation; _ = color; _ = hyperlink; + _ = iterm2; _ = kitty_color; _ = kitty_text_sizing; _ = mouse_shape; diff --git a/src/terminal/osc/parsers/iterm2.zig b/src/terminal/osc/parsers/iterm2.zig new file mode 100644 index 000000000..bd64977cf --- /dev/null +++ b/src/terminal/osc/parsers/iterm2.zig @@ -0,0 +1,435 @@ +const std = @import("std"); + +const assert = @import("../../../quirks.zig").inlineAssert; +const simd = @import("../../../simd/main.zig"); + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; + +const log = std.log.scoped(.osc_iterm2); + +const Key = enum { + AddAnnotation, + AddHiddenAnnotation, + Block, + Button, + ClearCapturedOutput, + ClearScrollback, + Copy, + CopyToClipboard, + CurrentDir, + CursorShape, + Custom, + Disinter, + EndCopy, + File, + FileEnd, + FilePart, + HighlightCursorLine, + MultipartFile, + OpenURL, + PopKeyLabels, + PushKeyLabels, + RemoteHost, + ReportCellSize, + ReportVariable, + RequestAttention, + RequestUpload, + SetBackgroundImageFile, + SetBadgeFormat, + SetColors, + SetKeyLabel, + SetMark, + SetProfile, + SetUserVar, + ShellIntegrationVersion, + StealFocus, + UnicodeVersion, +}; + +// Instead of using `std.meta.stringToEnum` we set up a StaticStringMap so +// that we can get ASCII case-insensitive lookups. +const Map = std.StaticStringMapWithEql(Key, std.ascii.eqlIgnoreCase); +const map: Map = .initComptime( + map: { + const fields = @typeInfo(Key).@"enum".fields; + var tmp: [fields.len]struct { [:0]const u8, Key } = undefined; + for (fields, 0..) |field, i| { + tmp[i] = .{ field.name, @enumFromInt(field.value) }; + } + break :map tmp; + }, +); + +/// Parse OSC 1337 +/// https://iterm2.com/documentation-escape-codes.html +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + assert(parser.state == .@"1337"); + + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + + const key_str: [:0]u8, const value_: ?[:0]u8 = kv: { + const index = std.mem.indexOfScalar(u8, data, '=') orelse { + break :kv .{ data[0 .. data.len - 1 :0], null }; + }; + data[index] = 0; + break :kv .{ data[0..index :0], data[index + 1 .. data.len - 1 :0] }; + }; + + const key = map.get(key_str) orelse { + parser.command = .invalid; + return null; + }; + + switch (key) { + .Copy => { + var value = value_ orelse { + parser.command = .invalid; + return null; + }; + + // Sending a blank entry to clear the clipboard is an OSC 52-ism, + // make sure that is invalid here. + if (value.len == 0) { + parser.command = .invalid; + return null; + } + + // base64 value must be prefixed by a colon + if (value[0] != ':') { + parser.command = .invalid; + return null; + } + + value = value[1..value.len :0]; + + // Sending a blank entry to clear the clipboard is an OSC 52-ism, + // make sure that is invalid here. + if (value.len == 0) { + parser.command = .invalid; + return null; + } + + // Sending a '?' to query the clipboard is an OSC 52-ism, make sure + // that is invalid here. + if (value.len == 1 and value[0] == '?') { + parser.command = .invalid; + return null; + } + + // It would be better to check for valid base64 data here, but that + // would mean parsing the base64 data twice in the "normal" case. + + parser.command = .{ + .clipboard_contents = .{ + .kind = 'c', + .data = value, + }, + }; + return &parser.command; + }, + + .CurrentDir => { + const value = value_ orelse { + parser.command = .invalid; + return null; + }; + if (value.len == 0) { + parser.command = .invalid; + return null; + } + parser.command = .{ + .report_pwd = .{ + .value = value, + }, + }; + return &parser.command; + }, + + .AddAnnotation, + .AddHiddenAnnotation, + .Block, + .Button, + .ClearCapturedOutput, + .ClearScrollback, + .CopyToClipboard, + .CursorShape, + .Custom, + .Disinter, + .EndCopy, + .File, + .FileEnd, + .FilePart, + .HighlightCursorLine, + .MultipartFile, + .OpenURL, + .PopKeyLabels, + .PushKeyLabels, + .RemoteHost, + .ReportCellSize, + .ReportVariable, + .RequestAttention, + .RequestUpload, + .SetBackgroundImageFile, + .SetBadgeFormat, + .SetColors, + .SetKeyLabel, + .SetMark, + .SetProfile, + .SetUserVar, + .ShellIntegrationVersion, + .StealFocus, + .UnicodeVersion, + => { + log.debug("unimplemented OSC 1337: {t}", .{key}); + parser.command = .invalid; + return null; + }, + } + return &parser.command; +} + +test "OSC: 1337: test valid unimplemented key with no value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;SetBadgeFormat"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test valid unimplemented key with empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;SetBadgeFormat="; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test valid unimplemented key with non-empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;SetBadgeFormat=abc123"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test valid key with lower case and with no value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;setbadgeformat"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test valid key with lower case and with empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;setbadgeformat="; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test valid key with lower case and with non-empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;setbadgeformat=abc123"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test invalid key with no value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;BobrKurwa"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test invalid key with empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;BobrKurwa="; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test invalid key with non-empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;BobrKurwa=abc123"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with no value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;Copy"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;Copy="; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with only prefix colon" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;Copy=:"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with question mark" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;Copy=:?"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with non-empty value that is invalid base64" { + // For performance reasons, we don't check for valid base64 data + // right now. + return error.SkipZigTest; + + // const testing = std.testing; + + // var p: Parser = .init(testing.allocator); + // defer p.deinit(); + + // const input = "1337;Copy=:abc123"; + // for (input) |ch| p.next(ch); + + // try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with non-empty value that is valid base64 but not prefixed with a colon" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;Copy=YWJjMTIz"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with non-empty value that is valid base64" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;Copy=:YWJjMTIz"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expectEqual('c', cmd.clipboard_contents.kind); + try testing.expectEqualStrings("YWJjMTIz", cmd.clipboard_contents.data); +} + +test "OSC: 1337: test CurrentDir with no value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;CurrentDir"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test CurrentDir with empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;CurrentDir="; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test CurrentDir with non-empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;CurrentDir=abc123"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .report_pwd); + try testing.expectEqualStrings("abc123", cmd.report_pwd.value); +}