//! OSC (Operating System Command) related functions and types. OSC is //! another set of control sequences for terminal programs that start with //! "ESC ]". Unlike CSI or standard ESC sequences, they may contain strings //! and other irregular formatting so a dedicated parser is created to handle it. const osc = @This(); const std = @import("std"); const mem = std.mem; const assert = std.debug.assert; const Allocator = mem.Allocator; const log = std.log.scoped(.osc); pub const Command = union(enum) { /// Set the window title of the terminal /// /// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8 /// with each code unit further encoded with two hex digets). /// /// If title mode 2 is set or the terminal is setup for unconditional /// utf-8 titles text is interpreted as utf-8. Else text is interpreted /// as latin1. change_window_title: []const u8, /// First do a fresh-line. Then start a new command, and enter prompt mode: /// Subsequent text (until a OSC "133;B" or OSC "133;I" command) is a /// prompt string (as if followed by OSC 133;P;k=i\007). Note: I've noticed /// not all shells will send the prompt end code. prompt_start: struct { aid: ?[]const u8 = null, kind: enum { primary, right, continuation } = .primary, redraw: bool = true, }, /// End of prompt and start of user input, terminated by a OSC "133;C" /// or another prompt (OSC "133;P"). prompt_end: void, /// The OSC "133;C" command can be used to explicitly end /// the input area and begin the output area. However, some applications /// don't provide a convenient way to emit that command. /// That is why we also specify an implicit way to end the input area /// at the end of the line. In the case of multiple input lines: If the /// cursor is on a fresh (empty) line and we see either OSC "133;P" or /// OSC "133;I" then this is the start of a continuation input line. /// If we see anything else, it is the start of the output area (or end /// of command). end_of_input: void, /// End of current command. /// /// The exit-code need not be specified if if there are no options, /// or if the command was cancelled (no OSC "133;C"), such as by typing /// an interrupt/cancel character (typically ctrl-C) during line-editing. /// Otherwise, it must be an integer code, where 0 means the command /// succeeded, and other values indicate failure. In additing to the /// exit-code there may be an err= option, which non-legacy terminals /// should give precedence to. The err=_value_ option is more general: /// an empty string is success, and any non-empty value (which need not /// be an integer) is an error code. So to indicate success both ways you /// could send OSC "133;D;0;err=\007", though `OSC "133;D;0\007" is shorter. end_of_command: struct { exit_code: ?u8 = null, // TODO: err option }, /// Reset the color for the cursor. This reverts changes made with /// change/read cursor color. reset_cursor_color: void, /// Set or get clipboard contents. If data is null, then the current /// clipboard contents are sent to the pty. If data is set, this /// contents is set on the clipboard. clipboard_contents: struct { kind: u8, data: []const u8, }, /// OSC 7. Reports the current working directory of the shell. This is /// a moderately flawed escape sequence but one that many major terminals /// support so we also support it. To understand the flaws, read through /// this terminal-wg issue: https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/20 report_pwd: struct { /// The reported pwd value. This is not checked for validity. It should /// be a file URL but it is up to the caller to utilize this value. value: []const u8, }, /// OSC 22. Set the mouse shape. There doesn't seem to be a standard /// naming scheme for cursors but it looks like terminals such as Foot /// are moving towards using the W3C CSS cursor names. For OSC parsing, /// we just parse whatever string is given. mouse_shape: struct { value: []const u8, }, /// OSC 10 and OSC 11 default color report. report_default_color: struct { /// OSC 10 requests the foreground color, OSC 11 the background color. kind: DefaultColorKind, /// We must reply with the same string terminator (ST) as used in the /// request. terminator: Terminator = .st, }, pub const DefaultColorKind = enum { foreground, background, pub fn code(self: DefaultColorKind) []const u8 { return switch (self) { .foreground => "10", .background => "11", }; } }; }; /// The terminator used to end an OSC command. For OSC commands that demand /// a response, we try to match the terminator used in the request since that /// is most likely to be accepted by the calling program. pub const Terminator = enum { /// The preferred string terminator is ESC followed by \ st, /// Some applications and terminals use BELL (0x07) as the string terminator. bel, /// Initialize the terminator based on the last byte seen. If the /// last byte is a BEL then we use BEL, otherwise we just assume ST. pub fn init(ch: ?u8) Terminator { return switch (ch orelse return .st) { 0x07 => .bel, else => .st, }; } /// The terminator as a string. This is static memory so it doesn't /// need to be freed. pub fn string(self: Terminator) []const u8 { return switch (self) { .st => "\x1b\\", .bel => "\x07", }; } }; pub const Parser = struct { /// Optional allocator used to accept data longer than MAX_BUF. /// This only applies to some commands (e.g. OSC 52) that can /// reasonably exceed MAX_BUF. alloc: ?Allocator = null, /// Current state of the parser. state: State = .empty, /// Current command of the parser, this accumulates. command: Command = undefined, /// Buffer that stores the input we see for a single OSC command. /// Slices in Command are offsets into this buffer. buf: [MAX_BUF]u8 = undefined, buf_start: usize = 0, buf_idx: usize = 0, buf_dynamic: ?*std.ArrayListUnmanaged(u8) = null, /// True when a command is complete/valid to return. complete: bool = false, /// Temporary state that is dependent on the current state. temp_state: union { /// Current string parameter being populated str: *[]const u8, /// Current numeric parameter being populated num: u16, /// Temporary state for key/value pairs key: []const u8, } = undefined, // Maximum length of a single OSC command. This is the full OSC command // sequence length (excluding ESC ]). This is arbitrary, I couldn't find // any definitive resource on how long this should be. const MAX_BUF = 2048; pub const State = enum { empty, invalid, // Command prefixes. We could just accumulate and compare (mem.eql) // but the state space is small enough that we just build it up this way. @"0", @"1", @"10", @"11", @"13", @"133", @"2", @"22", @"5", @"52", @"7", // OSC 10 is used to query the default foreground color, and to set the default foreground color. // Only querying is currently supported. query_default_fg, // OSC 11 is used to query the default background color, and to set the default background color. // Only querying is currently supported. query_default_bg, // We're in a semantic prompt OSC command but we aren't sure // what the command is yet, i.e. `133;` semantic_prompt, semantic_option_start, semantic_option_key, semantic_option_value, semantic_exit_code_start, semantic_exit_code, // Get/set clipboard states clipboard_kind, clipboard_kind_end, // Expect a string parameter. param_str must be set as well as // buf_start. string, // A string that can grow beyond MAX_BUF. This uses the allocator. // If the parser has no allocator then it is treated as if the // buffer is full. allocable_string, }; /// This must be called to clean up any allocated memory. pub fn deinit(self: *Parser) void { self.reset(); } /// Reset the parser start. pub fn reset(self: *Parser) void { self.state = .empty; self.buf_start = 0; self.buf_idx = 0; self.complete = false; if (self.buf_dynamic) |ptr| { const alloc = self.alloc.?; ptr.deinit(alloc); alloc.destroy(ptr); self.buf_dynamic = null; } } /// Consume the next character c and advance the parser state. pub fn next(self: *Parser, c: u8) void { // If our buffer is full then we're invalid. if (self.buf_idx >= self.buf.len) { self.state = .invalid; return; } // We store everything in the buffer so we can do a better job // logging if we get to an invalid command. self.buf[self.buf_idx] = c; self.buf_idx += 1; // log.warn("state = {} c = {x}", .{ self.state, c }); switch (self.state) { // If we get something during the invalid state, we've // ruined our entry. .invalid => self.complete = false, .empty => switch (c) { '0' => self.state = .@"0", '1' => self.state = .@"1", '2' => self.state = .@"2", '5' => self.state = .@"5", '7' => self.state = .@"7", else => self.state = .invalid, }, .@"0" => switch (c) { ';' => { self.command = .{ .change_window_title = undefined }; self.state = .string; self.temp_state = .{ .str = &self.command.change_window_title }; self.buf_start = self.buf_idx; }, else => self.state = .invalid, }, .@"1" => switch (c) { '0' => self.state = .@"10", '1' => self.state = .@"11", '3' => self.state = .@"13", else => self.state = .invalid, }, .@"10" => switch (c) { ';' => self.state = .query_default_fg, else => self.state = .invalid, }, .@"11" => switch (c) { ';' => self.state = .query_default_bg, '2' => { self.complete = true; self.command = .{ .reset_cursor_color = {} }; self.state = .invalid; }, else => self.state = .invalid, }, .@"13" => switch (c) { '3' => self.state = .@"133", else => self.state = .invalid, }, .@"133" => switch (c) { ';' => self.state = .semantic_prompt, else => self.state = .invalid, }, .@"2" => switch (c) { '2' => self.state = .@"22", ';' => { self.command = .{ .change_window_title = undefined }; self.state = .string; self.temp_state = .{ .str = &self.command.change_window_title }; self.buf_start = self.buf_idx; }, else => self.state = .invalid, }, .@"22" => switch (c) { ';' => { self.command = .{ .mouse_shape = undefined }; self.state = .string; self.temp_state = .{ .str = &self.command.mouse_shape.value }; self.buf_start = self.buf_idx; }, else => self.state = .invalid, }, .@"5" => switch (c) { '2' => self.state = .@"52", else => self.state = .invalid, }, .@"7" => switch (c) { ';' => { self.command = .{ .report_pwd = .{ .value = "" } }; self.state = .string; self.temp_state = .{ .str = &self.command.report_pwd.value }; self.buf_start = self.buf_idx; }, else => self.state = .invalid, }, .@"52" => switch (c) { ';' => { self.command = .{ .clipboard_contents = undefined }; self.state = .clipboard_kind; }, else => self.state = .invalid, }, .clipboard_kind => switch (c) { ';' => { self.command.clipboard_contents.kind = 'c'; self.temp_state = .{ .str = &self.command.clipboard_contents.data }; self.buf_start = self.buf_idx; self.prepAllocableString(); }, else => { self.command.clipboard_contents.kind = c; self.state = .clipboard_kind_end; }, }, .clipboard_kind_end => switch (c) { ';' => { self.temp_state = .{ .str = &self.command.clipboard_contents.data }; self.buf_start = self.buf_idx; self.prepAllocableString(); }, else => self.state = .invalid, }, .query_default_fg => switch (c) { '?' => { self.command = .{ .report_default_color = .{ .kind = .foreground } }; self.complete = true; }, else => self.state = .invalid, }, .query_default_bg => switch (c) { '?' => { self.command = .{ .report_default_color = .{ .kind = .background } }; self.complete = true; }, else => self.state = .invalid, }, .semantic_prompt => switch (c) { 'A' => { self.state = .semantic_option_start; self.command = .{ .prompt_start = .{} }; self.complete = true; }, 'B' => { self.state = .semantic_option_start; self.command = .{ .prompt_end = {} }; self.complete = true; }, 'C' => { self.state = .semantic_option_start; self.command = .{ .end_of_input = {} }; self.complete = true; }, 'D' => { self.state = .semantic_exit_code_start; self.command = .{ .end_of_command = .{} }; self.complete = true; }, else => self.state = .invalid, }, .semantic_option_start => switch (c) { ';' => { self.state = .semantic_option_key; self.buf_start = self.buf_idx; }, else => self.state = .invalid, }, .semantic_option_key => switch (c) { '=' => { self.temp_state = .{ .key = self.buf[self.buf_start .. self.buf_idx - 1] }; self.state = .semantic_option_value; self.buf_start = self.buf_idx; }, else => {}, }, .semantic_option_value => switch (c) { ';' => { self.endSemanticOptionValue(); self.state = .semantic_option_key; self.buf_start = self.buf_idx; }, else => {}, }, .semantic_exit_code_start => switch (c) { ';' => { // No longer complete, if ';' shows up we expect some code. self.complete = false; self.state = .semantic_exit_code; self.temp_state = .{ .num = 0 }; self.buf_start = self.buf_idx; }, else => self.state = .invalid, }, .semantic_exit_code => switch (c) { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => { self.complete = true; const idx = self.buf_idx - self.buf_start; if (idx > 0) self.temp_state.num *|= 10; self.temp_state.num +|= c - '0'; }, ';' => { self.endSemanticExitCode(); self.state = .semantic_option_key; self.buf_start = self.buf_idx; }, else => self.state = .invalid, }, .allocable_string => { const alloc = self.alloc.?; const list = self.buf_dynamic.?; list.append(alloc, c) catch { self.state = .invalid; return; }; // Never consume buffer space for allocable strings self.buf_idx -= 1; // We can complete at any time self.complete = true; }, .string => self.complete = true, } } fn prepAllocableString(self: *Parser) void { assert(self.buf_dynamic == null); // We need an allocator. If we don't have an allocator, we // pretend we're just a fixed buffer string and hope we fit! const alloc = self.alloc orelse { self.state = .string; return; }; // Allocate our dynamic buffer const list = alloc.create(std.ArrayListUnmanaged(u8)) catch { self.state = .string; return; }; list.* = .{}; self.buf_dynamic = list; self.state = .allocable_string; } fn endSemanticOptionValue(self: *Parser) void { const value = self.buf[self.buf_start..self.buf_idx]; if (mem.eql(u8, self.temp_state.key, "aid")) { switch (self.command) { .prompt_start => |*v| v.aid = value, else => {}, } } else if (mem.eql(u8, self.temp_state.key, "redraw")) { // Kitty supports a "redraw" option for prompt_start. I can't find // this documented anywhere but can see in the code that this is used // by shell environments to tell the terminal that the shell will NOT // redraw the prompt so we should attempt to resize it. switch (self.command) { .prompt_start => |*v| { const valid = if (value.len == 1) valid: { switch (value[0]) { '0' => v.redraw = false, '1' => v.redraw = true, else => break :valid false, } break :valid true; } else false; if (!valid) { log.info("OSC 133 A invalid redraw value: {s}", .{value}); } }, else => {}, } } else if (mem.eql(u8, self.temp_state.key, "k")) { // The "k" marks the kind of prompt, or "primary" if we don't know. // This can be used to distinguish between the first prompt, // a continuation, etc. switch (self.command) { .prompt_start => |*v| if (value.len == 1) { v.kind = switch (value[0]) { 'c', 's' => .continuation, 'r' => .right, 'i' => .primary, else => .primary, }; }, else => {}, } } else log.info("unknown semantic prompts option: {s}", .{self.temp_state.key}); } fn endSemanticExitCode(self: *Parser) void { switch (self.command) { .end_of_command => |*v| v.exit_code = @truncate(self.temp_state.num), else => {}, } } fn endString(self: *Parser) void { self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx]; } fn endAllocableString(self: *Parser) void { const list = self.buf_dynamic.?; self.temp_state.str.* = list.items; } /// End the sequence and return the command, if any. If the return value /// is null, then no valid command was found. The optional terminator_ch /// is the final character in the OSC sequence. This is used to determine /// the response terminator. pub fn end(self: *Parser, terminator_ch: ?u8) ?Command { if (!self.complete) { log.warn("invalid OSC command: {s}", .{self.buf[0..self.buf_idx]}); return null; } // Other cleanup we may have to do depending on state. switch (self.state) { .semantic_exit_code => self.endSemanticExitCode(), .semantic_option_value => self.endSemanticOptionValue(), .string => self.endString(), .allocable_string => self.endAllocableString(), else => {}, } switch (self.command) { .report_default_color => |*c| c.terminator = Terminator.init(terminator_ch), else => {}, } return self.command; } }; test "OSC: change_window_title" { const testing = std.testing; var p: Parser = .{}; p.next('0'); p.next(';'); p.next('a'); p.next('b'); const cmd = p.end(null).?; try testing.expect(cmd == .change_window_title); try testing.expectEqualStrings("ab", cmd.change_window_title); } test "OSC: change_window_title with 2" { const testing = std.testing; var p: Parser = .{}; p.next('2'); p.next(';'); p.next('a'); p.next('b'); const cmd = p.end(null).?; try testing.expect(cmd == .change_window_title); try testing.expectEqualStrings("ab", cmd.change_window_title); } test "OSC: change_window_title with utf8" { const testing = std.testing; var p: Parser = .{}; p.next('2'); p.next(';'); // '—' EM DASH U+2014 (E2 80 94) p.next(0xE2); p.next(0x80); p.next(0x94); p.next(' '); // '‐' HYPHEN U+2010 (E2 80 90) // Intententionally chosen to conflict with the 0x90 C1 control p.next(0xE2); p.next(0x80); p.next(0x90); const cmd = p.end(null).?; try testing.expect(cmd == .change_window_title); try testing.expectEqualStrings("— ‐", cmd.change_window_title); } test "OSC: prompt_start" { const testing = std.testing; var p: Parser = .{}; const input = "133;A"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .prompt_start); try testing.expect(cmd.prompt_start.aid == null); try testing.expect(cmd.prompt_start.redraw); } test "OSC: prompt_start with single option" { const testing = std.testing; var p: Parser = .{}; const input = "133;A;aid=14"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .prompt_start); try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); } test "OSC: prompt_start with redraw disabled" { const testing = std.testing; var p: Parser = .{}; const input = "133;A;redraw=0"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .prompt_start); try testing.expect(!cmd.prompt_start.redraw); } test "OSC: prompt_start with redraw invalid value" { const testing = std.testing; var p: Parser = .{}; const input = "133;A;redraw=42"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .prompt_start); try testing.expect(cmd.prompt_start.redraw); try testing.expect(cmd.prompt_start.kind == .primary); } test "OSC: prompt_start with continuation" { const testing = std.testing; var p: Parser = .{}; const input = "133;A;k=c"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .prompt_start); try testing.expect(cmd.prompt_start.kind == .continuation); } test "OSC: end_of_command no exit code" { const testing = std.testing; var p: Parser = .{}; const input = "133;D"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .end_of_command); } test "OSC: end_of_command with exit code" { const testing = std.testing; var p: Parser = .{}; const input = "133;D;25"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .end_of_command); try testing.expectEqual(@as(u8, 25), cmd.end_of_command.exit_code.?); } test "OSC: prompt_end" { const testing = std.testing; var p: Parser = .{}; const input = "133;B"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .prompt_end); } test "OSC: end_of_input" { const testing = std.testing; var p: Parser = .{}; const input = "133;C"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .end_of_input); } test "OSC: reset_cursor_color" { const testing = std.testing; var p: Parser = .{}; const input = "112"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .reset_cursor_color); } test "OSC: get/set clipboard" { const testing = std.testing; var p: Parser = .{}; const input = "52;s;?"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .clipboard_contents); try testing.expect(cmd.clipboard_contents.kind == 's'); try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); } test "OSC: get/set clipboard (optional parameter)" { const testing = std.testing; var p: Parser = .{}; const input = "52;;?"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .clipboard_contents); try testing.expect(cmd.clipboard_contents.kind == 'c'); try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); } test "OSC: get/set clipboard with allocator" { const testing = std.testing; const alloc = testing.allocator; var p: Parser = .{ .alloc = alloc }; defer p.deinit(); const input = "52;s;?"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .clipboard_contents); try testing.expect(cmd.clipboard_contents.kind == 's'); try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); } test "OSC: report pwd" { const testing = std.testing; var p: Parser = .{}; const input = "7;file:///tmp/example"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .report_pwd); try testing.expect(std.mem.eql(u8, "file:///tmp/example", cmd.report_pwd.value)); } test "OSC: pointer cursor" { const testing = std.testing; var p: Parser = .{}; const input = "22;pointer"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .mouse_shape); try testing.expect(std.mem.eql(u8, "pointer", cmd.mouse_shape.value)); } test "OSC: report pwd empty" { const testing = std.testing; var p: Parser = .{}; const input = "7;"; for (input) |ch| p.next(ch); try testing.expect(p.end(null) == null); } test "OSC: longer than buffer" { const testing = std.testing; var p: Parser = .{}; const input = "a" ** (Parser.MAX_BUF + 2); for (input) |ch| p.next(ch); try testing.expect(p.end(null) == null); } test "OSC: report default foreground color" { const testing = std.testing; var p: Parser = .{}; const input = "10;?"; for (input) |ch| p.next(ch); // This corresponds to ST = ESC followed by \ const cmd = p.end('\x1b').?; try testing.expect(cmd == .report_default_color); try testing.expectEqual(cmd.report_default_color.kind, .foreground); try testing.expectEqual(cmd.report_default_color.terminator, .st); } test "OSC: report default background color" { const testing = std.testing; var p: Parser = .{}; const input = "11;?"; for (input) |ch| p.next(ch); // This corresponds to ST = BEL character const cmd = p.end('\x07').?; try testing.expect(cmd == .report_default_color); try testing.expectEqual(cmd.report_default_color.kind, .background); try testing.expectEqual(cmd.report_default_color.terminator, .bel); }