diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 43824ce01..93d413854 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -155,6 +155,10 @@ pub const Command = union(Key) { kitty_clipboard_protocol: KittyClipboardProtocol, + /// OSC 3008. Hierarchical context signalling (UAPI spec). + /// https://uapi-group.org/specifications/specs/osc_context/ + context_signal: parsers.context_signal.Command, + pub const SemanticPrompt = parsers.semantic_prompt.Command; pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC; @@ -187,6 +191,7 @@ pub const Command = union(Key) { "conemu_comment", "kitty_text_sizing", "kitty_clipboard_protocol", + "context_signal", }, ); @@ -226,7 +231,6 @@ pub const Command = union(Key) { 8 => 64, else => unreachable, }); - // @compileLog(@sizeOf(Command)); } }; @@ -311,12 +315,16 @@ pub const Parser = struct { @"0", @"1", @"2", + @"3", @"4", @"5", @"6", @"7", @"8", @"9", + @"30", + @"300", + @"3008", @"10", @"11", @"12", @@ -411,6 +419,7 @@ pub const Parser = struct { .show_desktop_notification, .kitty_text_sizing, .kitty_clipboard_protocol, + .context_signal, => {}, } @@ -488,6 +497,7 @@ pub const Parser = struct { '0' => self.state = .@"0", '1' => self.state = .@"1", '2' => self.state = .@"2", + '3' => self.state = .@"3", '4' => self.state = .@"4", '5' => self.state = .@"5", '6' => self.state = .@"6", @@ -497,6 +507,26 @@ pub const Parser = struct { else => self.state = .invalid, }, + .@"3" => switch (c) { + '0' => self.state = .@"30", + else => self.state = .invalid, + }, + + .@"30" => switch (c) { + '0' => self.state = .@"300", + else => self.state = .invalid, + }, + + .@"300" => switch (c) { + '8' => self.state = .@"3008", + else => self.state = .invalid, + }, + + .@"3008" => switch (c) { + ';' => self.writeToFixed(), + else => self.state = .invalid, + }, + .@"1" => switch (c) { ';' => self.writeToFixed(), '0' => self.state = .@"10", @@ -704,6 +734,13 @@ pub const Parser = struct { .@"55" => null, + .@"3", + .@"30", + .@"300", + => null, + + .@"3008" => parsers.context_signal.parse(self, terminator_ch), + .@"6" => null, .@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch), diff --git a/src/terminal/osc/parsers.zig b/src/terminal/osc/parsers.zig index 764de28aa..a56b38e88 100644 --- a/src/terminal/osc/parsers.zig +++ b/src/terminal/osc/parsers.zig @@ -1,6 +1,7 @@ const std = @import("std"); pub const change_window_icon = @import("parsers/change_window_icon.zig"); +pub const context_signal = @import("parsers/context_signal.zig"); 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"); diff --git a/src/terminal/osc/parsers/context_signal.zig b/src/terminal/osc/parsers/context_signal.zig new file mode 100644 index 000000000..40decb4e7 --- /dev/null +++ b/src/terminal/osc/parsers/context_signal.zig @@ -0,0 +1,587 @@ +//! OSC 3008: Hierarchical Context Signalling (UAPI spec) +//! Specification: https://uapi-group.org/specifications/specs/osc_context/ +//! +//! OSC 3008 allows programs to signal context changes to the terminal emulator. +//! Each context has an identifier and metadata fields. Contexts are hierarchical +//! and form a stack. + +const std = @import("std"); +const Parser = @import("../../osc.zig").Parser; +const OSCCommand = @import("../../osc.zig").Command; + +const log = std.log.scoped(.osc_context_signal); + +/// Maximum length of a context identifier (per spec). +const max_context_id_len = 64; + +/// A single OSC 3008 context signal command. +pub const Command = struct { + action: Action, + /// The context identifier. Must be 1-64 characters in the 32..126 byte range. + id: []const u8, + /// Raw unparsed metadata fields after the context ID. + /// Fields are semicolon-separated key=value pairs. + /// Parsed lazily via `readField`. + fields_raw: []const u8, + + pub const Action = enum { + /// OSC 3008;start= — initiates, updates, or returns to a context. + start, + /// OSC 3008;end= — terminates a context. + end, + }; + + /// Read a metadata field value from the raw fields string. + /// Returns null if the field is not present or malformed. + pub fn readField( + self: Command, + comptime field: Field, + ) field.Type() { + return field.read(self.fields_raw); + } +}; + +/// Context types defined by the specification. +pub const ContextType = enum { + boot, + container, + vm, + elevate, + chpriv, + subcontext, + remote, + shell, + command, + app, + service, + session, + + pub fn parse(value: []const u8) ?ContextType { + const map = std.StaticStringMap(ContextType).initComptime(.{ + .{ "boot", .boot }, + .{ "container", .container }, + .{ "vm", .vm }, + .{ "elevate", .elevate }, + .{ "chpriv", .chpriv }, + .{ "subcontext", .subcontext }, + .{ "remote", .remote }, + .{ "shell", .shell }, + .{ "command", .command }, + .{ "app", .app }, + .{ "service", .service }, + .{ "session", .session }, + }); + return map.get(value); + } +}; + +/// Exit status for the `exit` end-sequence field. +pub const ExitStatus = enum { + success, + failure, + crash, + interrupt, + + pub fn parse(value: []const u8) ?ExitStatus { + const map = std.StaticStringMap(ExitStatus).initComptime(.{ + .{ "success", .success }, + .{ "failure", .failure }, + .{ "crash", .crash }, + .{ "interrupt", .interrupt }, + }); + return map.get(value); + } +}; + +/// Metadata fields that can appear in OSC 3008 sequences. +/// Fields are read lazily from the raw string using the `read` method. +pub const Field = enum { + // Start sequence fields + type, + user, + hostname, + machineid, + bootid, + pid, + pidfdid, + comm, + cwd, + cmdline, + vm, + container, + targetuser, + targethost, + sessionid, + + // End sequence fields + exit, + status, + signal, + + pub fn Type(comptime self: Field) type { + return switch (self) { + .type => ?ContextType, + .exit => ?ExitStatus, + .pid, .pidfdid => ?u64, + .status => ?u64, + // All other fields are string values + .user, + .hostname, + .machineid, + .bootid, + .comm, + .cwd, + .cmdline, + .vm, + .container, + .targetuser, + .targethost, + .sessionid, + .signal, + => ?[]const u8, + }; + } + + fn key(comptime self: Field) []const u8 { + return switch (self) { + .type => "type", + .user => "user", + .hostname => "hostname", + .machineid => "machineid", + .bootid => "bootid", + .pid => "pid", + .pidfdid => "pidfdid", + .comm => "comm", + .cwd => "cwd", + .cmdline => "cmdline", + .vm => "vm", + .container => "container", + .targetuser => "targetuser", + .targethost => "targethost", + .sessionid => "sessionid", + .exit => "exit", + .status => "status", + .signal => "signal", + }; + } + + /// Read the field value from the raw fields string. + /// + /// The raw fields string contains semicolon-separated key=value pairs + /// e.g. "type=container;user=lennart;hostname=zeta". + /// + /// Unknown or malformed fields are ignored per the specification. + pub fn read( + comptime self: Field, + raw: []const u8, + ) self.Type() { + var remaining = raw; + while (remaining.len > 0) { + const len = std.mem.indexOfScalar( + u8, + remaining, + ';', + ) orelse remaining.len; + + const full = remaining[0..len]; + + // Parse key=value + const value = value: { + if (std.mem.indexOfScalar( + u8, + full, + '=', + )) |eql_idx| { + if (std.mem.eql( + u8, + full[0..eql_idx], + self.key(), + )) { + break :value full[eql_idx + 1 ..]; + } + } + + // No match, advance past the semicolon + if (len < remaining.len) { + remaining = remaining[len + 1 ..]; + continue; + } + + break; + }; + + return switch (self) { + .type => ContextType.parse(value), + .exit => ExitStatus.parse(value), + .pid, .pidfdid, .status => std.fmt.parseInt( + u64, + value, + 10, + ) catch null, + // String fields + .user, + .hostname, + .machineid, + .bootid, + .comm, + .cwd, + .cmdline, + .vm, + .container, + .targetuser, + .targethost, + .sessionid, + .signal, + => if (value.len > 0) value else null, + }; + } + + // Not found + return null; + } +}; + +/// Parse OSC 3008: hierarchical context signalling. +/// +/// Expected data format (after "3008;" prefix has been consumed by the state machine): +/// start=[;=]* +/// end=[;=]* +pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + if (data.len == 0) { + parser.state = .invalid; + return null; + } + + // Determine the action (start= or end=) + const action: Command.Action = action: { + if (std.mem.startsWith(u8, data, "start=")) break :action .start; + if (std.mem.startsWith(u8, data, "end=")) break :action .end; + + log.warn("OSC 3008: expected 'start=' or 'end=' prefix, got: {s}", .{ + data[0..@min(data.len, 10)], + }); + parser.state = .invalid; + return null; + }; + + // Skip past the "start=" or "end=" prefix + const prefix_len: usize = switch (action) { + .start => "start=".len, + .end => "end=".len, + }; + const rest = data[prefix_len..]; + + if (rest.len == 0) { + log.warn("OSC 3008: missing context ID", .{}); + parser.state = .invalid; + return null; + } + + // Extract the context ID (up to the first semicolon or end of data) + const id_end = std.mem.indexOfScalar(u8, rest, ';') orelse rest.len; + const id = rest[0..id_end]; + + // Validate context ID length (1-64 chars per spec) + if (id.len == 0 or id.len > max_context_id_len) { + log.warn("OSC 3008: context ID length {d} out of range (1-{d})", .{ + id.len, + max_context_id_len, + }); + parser.state = .invalid; + return null; + } + + // Validate context ID characters (32..126 byte range per spec) + for (id) |c| { + if (c < 0x20 or c > 0x7e) { + log.warn("OSC 3008: invalid character 0x{x:0>2} in context ID", .{c}); + parser.state = .invalid; + return null; + } + } + + // Extract raw metadata fields (everything after the ID) + const fields_raw = if (id_end < rest.len) rest[id_end + 1 ..] else ""; + + parser.command = .{ + .context_signal = .{ + .action = action, + .id = id, + .fields_raw = fields_raw, + }, + }; + + return &parser.command; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "OSC 3008: basic start command" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "3008;start=abc123"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .context_signal); + try testing.expect(cmd.context_signal.action == .start); + try testing.expectEqualStrings("abc123", cmd.context_signal.id); + try testing.expectEqualStrings("", cmd.context_signal.fields_raw); +} + +test "OSC 3008: basic end command" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "3008;end=abc123"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .context_signal); + try testing.expect(cmd.context_signal.action == .end); + try testing.expectEqualStrings("abc123", cmd.context_signal.id); + try testing.expectEqualStrings("", cmd.context_signal.fields_raw); +} + +test "OSC 3008: start with metadata fields" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "3008;start=bed86fab93af4328bbed0a1224af6d40;type=container;user=lennart;hostname=zeta"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .context_signal); + try testing.expect(cmd.context_signal.action == .start); + try testing.expectEqualStrings("bed86fab93af4328bbed0a1224af6d40", cmd.context_signal.id); + + // Read individual fields + try testing.expect(cmd.context_signal.readField(.type).? == .container); + try testing.expectEqualStrings("lennart", cmd.context_signal.readField(.user).?); + try testing.expectEqualStrings("zeta", cmd.context_signal.readField(.hostname).?); +} + +test "OSC 3008: start with all common fields" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "3008;start=myctx;type=shell;user=root;hostname=myhost;machineid=3deb5353d3ba43d08201c136a47ead7b;bootid=d4a3d0fdf2e24fdea6d971ce73f4fbf2;pid=1062862;pidfdid=1063162;comm=bash"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .context_signal); + try testing.expect(cmd.context_signal.readField(.type).? == .shell); + try testing.expectEqualStrings("root", cmd.context_signal.readField(.user).?); + try testing.expectEqualStrings("myhost", cmd.context_signal.readField(.hostname).?); + try testing.expectEqualStrings("3deb5353d3ba43d08201c136a47ead7b", cmd.context_signal.readField(.machineid).?); + try testing.expectEqualStrings("d4a3d0fdf2e24fdea6d971ce73f4fbf2", cmd.context_signal.readField(.bootid).?); + try testing.expectEqual(@as(u64, 1062862), cmd.context_signal.readField(.pid).?); + try testing.expectEqual(@as(u64, 1063162), cmd.context_signal.readField(.pidfdid).?); + try testing.expectEqualStrings("bash", cmd.context_signal.readField(.comm).?); +} + +test "OSC 3008: end with exit metadata" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "3008;end=myctx;exit=success;status=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .context_signal); + try testing.expect(cmd.context_signal.action == .end); + try testing.expectEqualStrings("myctx", cmd.context_signal.id); + try testing.expect(cmd.context_signal.readField(.exit).? == .success); + try testing.expectEqual(@as(u64, 0), cmd.context_signal.readField(.status).?); +} + +test "OSC 3008: end with failure exit" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "3008;end=myctx;exit=failure;status=1;signal=SIGKILL"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .context_signal); + try testing.expect(cmd.context_signal.readField(.exit).? == .failure); + try testing.expectEqual(@as(u64, 1), cmd.context_signal.readField(.status).?); + try testing.expectEqualStrings("SIGKILL", cmd.context_signal.readField(.signal).?); +} + +test "OSC 3008: unknown fields are ignored" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "3008;start=myctx;type=shell;unknownfield=value;user=root"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .context_signal); + try testing.expect(cmd.context_signal.readField(.type).? == .shell); + try testing.expectEqualStrings("root", cmd.context_signal.readField(.user).?); +} + +test "OSC 3008: missing field returns null" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "3008;start=myctx;user=lennart"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .context_signal); + try testing.expect(cmd.context_signal.readField(.type) == null); + try testing.expect(cmd.context_signal.readField(.hostname) == null); + try testing.expect(cmd.context_signal.readField(.pid) == null); +} + +test "OSC 3008: invalid prefix" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "3008;bogus=abc123"; + for (input) |ch| p.next(ch); + try testing.expect(p.end(null) == null); +} + +test "OSC 3008: empty data" { + const testing = std.testing; + + // Can't really produce empty data after "3008;" because the state machine + // won't write a writer for that case, but we test the edge case where + // only "start=" is present with no ID. + var p: Parser = .init(null); + const input = "3008;start="; + for (input) |ch| p.next(ch); + try testing.expect(p.end(null) == null); +} + +test "OSC 3008: max length context ID" { + const testing = std.testing; + + var p: Parser = .init(null); + const id = "a" ** 64; + const input = "3008;start=" ++ id; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .context_signal); + try testing.expectEqualStrings(id, cmd.context_signal.id); +} + +test "OSC 3008: over-length context ID" { + const testing = std.testing; + + var p: Parser = .init(null); + const id = "a" ** 65; + const input = "3008;start=" ++ id; + for (input) |ch| p.next(ch); + try testing.expect(p.end(null) == null); +} + +test "OSC 3008: context type enum coverage" { + const testing = std.testing; + + const types = [_]struct { str: []const u8, expected: ContextType }{ + .{ .str = "boot", .expected = .boot }, + .{ .str = "container", .expected = .container }, + .{ .str = "vm", .expected = .vm }, + .{ .str = "elevate", .expected = .elevate }, + .{ .str = "chpriv", .expected = .chpriv }, + .{ .str = "subcontext", .expected = .subcontext }, + .{ .str = "remote", .expected = .remote }, + .{ .str = "shell", .expected = .shell }, + .{ .str = "command", .expected = .command }, + .{ .str = "app", .expected = .app }, + .{ .str = "service", .expected = .service }, + .{ .str = "session", .expected = .session }, + }; + + for (types) |t| { + try testing.expectEqual(t.expected, ContextType.parse(t.str).?); + } + + try testing.expect(ContextType.parse("invalid") == null); +} + +test "OSC 3008: exit status enum coverage" { + const testing = std.testing; + + try testing.expect(ExitStatus.parse("success").? == .success); + try testing.expect(ExitStatus.parse("failure").? == .failure); + try testing.expect(ExitStatus.parse("crash").? == .crash); + try testing.expect(ExitStatus.parse("interrupt").? == .interrupt); + try testing.expect(ExitStatus.parse("invalid") == null); +} + +test "OSC 3008: spec example - container start" { + const testing = std.testing; + + // From the spec: a new container "foobar" invoked by user "lennart" on host "zeta" + var p: Parser = .init(null); + const input = "3008;start=bed86fab93af4328bbed0a1224af6d40;type=container;user=lennart;hostname=zeta;machineid=3deb5353d3ba43d08201c136a47ead7b;bootid=d4a3d0fdf2e24fdea6d971ce73f4fbf2;pid=1062862;pidfdid=1063162;comm=systemd-nspawn;container=foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .context_signal); + try testing.expect(cmd.context_signal.action == .start); + try testing.expectEqualStrings("bed86fab93af4328bbed0a1224af6d40", cmd.context_signal.id); + try testing.expect(cmd.context_signal.readField(.type).? == .container); + try testing.expectEqualStrings("lennart", cmd.context_signal.readField(.user).?); + try testing.expectEqualStrings("zeta", cmd.context_signal.readField(.hostname).?); + try testing.expectEqualStrings("systemd-nspawn", cmd.context_signal.readField(.comm).?); + try testing.expectEqualStrings("foobar", cmd.context_signal.readField(.container).?); + try testing.expectEqual(@as(u64, 1062862), cmd.context_signal.readField(.pid).?); +} + +test "OSC 3008: spec example - context end" { + const testing = std.testing; + + // From the spec: context end + var p: Parser = .init(null); + const input = "3008;end=bed86fab93af4328bbed0a1224af6d40"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .context_signal); + try testing.expect(cmd.context_signal.action == .end); + try testing.expectEqualStrings("bed86fab93af4328bbed0a1224af6d40", cmd.context_signal.id); +} + +test "OSC 3008: cwd and cmdline fields" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "3008;start=myctx;type=command;cwd=/home/user;cmdline=ls -la"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .context_signal); + try testing.expectEqualStrings("/home/user", cmd.context_signal.readField(.cwd).?); + try testing.expectEqualStrings("ls -la", cmd.context_signal.readField(.cmdline).?); +} + +test "OSC 3008: start command with no fields" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "3008;start=simpleid"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .context_signal); + try testing.expect(cmd.context_signal.action == .start); + try testing.expectEqualStrings("simpleid", cmd.context_signal.id); + try testing.expect(cmd.context_signal.readField(.type) == null); + try testing.expect(cmd.context_signal.readField(.user) == null); + try testing.expect(cmd.context_signal.readField(.exit) == null); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 60840d84b..df1074d27 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -2048,6 +2048,7 @@ pub fn Stream(comptime Handler: type) type { .conemu_run_process, .kitty_text_sizing, .kitty_clipboard_protocol, + .context_signal, => { log.debug("unimplemented OSC callback: {}", .{cmd}); },