Files
ghostty/src/terminal/osc.zig
2026-01-23 14:35:10 -08:00

697 lines
21 KiB
Zig

//! 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 builtin = @import("builtin");
const build_options = @import("terminal_options");
const mem = std.mem;
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = mem.Allocator;
const LibEnum = @import("../lib/enum.zig").Enum;
const kitty_color = @import("kitty/color.zig");
const parsers = @import("osc/parsers.zig");
const encoding = @import("osc/encoding.zig");
pub const color = parsers.color;
const log = std.log.scoped(.osc);
pub const Command = union(Key) {
/// This generally shouldn't ever be set except as an initial zero value.
/// Ignore it.
invalid,
/// 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 digits).
///
/// 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: [:0]const u8,
/// Set the icon of the terminal window. The name of the icon is not
/// well defined, so this is currently ignored by Ghostty at the time
/// of writing this. We just parse it so that we don't get parse errors
/// in the log.
change_window_icon: [:0]const u8,
/// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
semantic_prompt: SemanticPrompt,
/// 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: [:0]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: [:0]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: [:0]const u8,
},
/// OSC color operations to set, reset, or report color settings. Some OSCs
/// allow multiple operations to be specified in a single OSC so we need a
/// list-like datastructure to manage them. We use std.SegmentedList because
/// it minimizes the number of allocations and copies because a large
/// majority of the time there will be only one operation per OSC.
///
/// Currently, these OSCs are handled by `color_operation`:
///
/// 4, 5, 10-19, 104, 105, 110-119
color_operation: struct {
op: color.Operation,
requests: color.List = .{},
terminator: Terminator = .st,
},
/// Kitty color protocol, OSC 21
/// https://sw.kovidgoyal.net/kitty/color-stack/#id1
kitty_color_protocol: kitty_color.OSC,
/// Show a desktop notification (OSC 9 or OSC 777)
show_desktop_notification: struct {
title: [:0]const u8,
body: [:0]const u8,
},
/// Start a hyperlink (OSC 8)
hyperlink_start: struct {
id: ?[:0]const u8 = null,
uri: [:0]const u8,
},
/// End a hyperlink (OSC 8)
hyperlink_end: void,
/// ConEmu sleep (OSC 9;1)
conemu_sleep: struct {
duration_ms: u16,
},
/// ConEmu show GUI message box (OSC 9;2)
conemu_show_message_box: [:0]const u8,
/// ConEmu change tab title (OSC 9;3)
conemu_change_tab_title: union(enum) {
reset,
value: [:0]const u8,
},
/// ConEmu progress report (OSC 9;4)
conemu_progress_report: ProgressReport,
/// ConEmu wait input (OSC 9;5)
conemu_wait_input,
/// ConEmu GUI macro (OSC 9;6)
conemu_guimacro: [:0]const u8,
/// ConEmu run process (OSC 9;7)
conemu_run_process: [:0]const u8,
/// ConEmu output environment variable (OSC 9;8)
conemu_output_environment_variable: [:0]const u8,
/// ConEmu XTerm keyboard and output emulation (OSC 9;10)
/// https://conemu.github.io/en/TerminalModes.html
conemu_xterm_emulation: struct {
/// null => do not change
/// false => turn off
/// true => turn on
keyboard: ?bool,
/// null => do not change
/// false => turn off
/// true => turn on
output: ?bool,
},
/// ConEmu comment (OSC 9;11)
conemu_comment: [:0]const u8,
/// Kitty text sizing protocol (OSC 66)
kitty_text_sizing: parsers.kitty_text_sizing.OSC,
pub const SemanticPrompt = parsers.semantic_prompt.Command;
pub const Key = LibEnum(
if (build_options.c_abi) .c else .zig,
// NOTE: Order matters, see LibEnum documentation.
&.{
"invalid",
"change_window_title",
"change_window_icon",
"semantic_prompt",
"clipboard_contents",
"report_pwd",
"mouse_shape",
"color_operation",
"kitty_color_protocol",
"show_desktop_notification",
"hyperlink_start",
"hyperlink_end",
"conemu_sleep",
"conemu_show_message_box",
"conemu_change_tab_title",
"conemu_progress_report",
"conemu_wait_input",
"conemu_guimacro",
"conemu_run_process",
"conemu_output_environment_variable",
"conemu_xterm_emulation",
"conemu_comment",
"kitty_text_sizing",
},
);
pub const ProgressReport = struct {
pub const State = enum(c_int) {
remove,
set,
@"error",
indeterminate,
pause,
};
state: State,
progress: ?u8 = null,
// sync with ghostty_action_progress_report_s
pub const C = extern struct {
state: c_int,
progress: i8,
};
pub fn cval(self: ProgressReport) C {
return .{
.state = @intFromEnum(self.state),
.progress = if (self.progress) |progress| @intCast(std.math.clamp(
progress,
0,
100,
)) else -1,
};
}
};
comptime {
assert(@sizeOf(Command) == switch (@sizeOf(usize)) {
4 => 44,
8 => 64,
else => unreachable,
});
// @compileLog(@sizeOf(Command));
}
};
/// 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,
pub const C = LibEnum(.c, &.{ "st", "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 fn cval(self: Terminator) C {
return switch (self) {
.st => .st,
.bel => .bel,
};
}
pub fn format(
self: Terminator,
comptime _: []const u8,
_: std.fmt.FormatOptions,
writer: *std.Io.Writer,
) !void {
try writer.writeAll(self.string());
}
};
pub const Parser = struct {
/// Maximum size of a "normal" OSC.
pub const MAX_BUF = 2048;
/// 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,
/// Current state of the parser.
state: State,
/// Buffer for temporary storage of OSC data
buffer: [MAX_BUF]u8,
/// Fixed writer for accumulating OSC data
fixed: ?std.Io.Writer,
/// Allocating writer for accumulating OSC data
allocating: ?std.Io.Writer.Allocating,
/// Pointer to the active writer for accumulating OSC data
writer: ?*std.Io.Writer,
/// The command that is the result of parsing.
command: Command,
pub const State = enum {
start,
invalid,
// OSC command prefixes. Not all of these are valid OSCs, but may be
// needed to "bridge" to a valid OSC (e.g. to support OSC 777 we need to
// have a state "77" even though there is no OSC 77).
@"0",
@"1",
@"2",
@"4",
@"5",
@"6",
@"7",
@"8",
@"9",
@"10",
@"11",
@"12",
@"13",
@"14",
@"15",
@"16",
@"17",
@"18",
@"19",
@"21",
@"22",
@"52",
@"66",
@"77",
@"104",
@"110",
@"111",
@"112",
@"113",
@"114",
@"115",
@"116",
@"117",
@"118",
@"119",
@"133",
@"777",
@"1337",
};
pub fn init(alloc: ?Allocator) Parser {
var result: Parser = .{
.alloc = alloc,
.state = .start,
.fixed = null,
.allocating = null,
.writer = null,
.command = .invalid,
// Keeping all our undefined values together so we can
// visually easily duplicate them in the Valgrind check below.
.buffer = undefined,
};
if (std.valgrind.runningOnValgrind() > 0) {
// Initialize our undefined fields so Valgrind can catch it.
// https://github.com/ziglang/zig/issues/19148
result.buffer = undefined;
}
return result;
}
/// This must be called to clean up any allocated memory.
pub fn deinit(self: *Parser) void {
self.reset();
}
/// Reset the parser state.
pub fn reset(self: *Parser) void {
// If we set up an allocating writer, free up that memory.
if (self.allocating) |*allocating| allocating.deinit();
// Handle any cleanup that individual OSCs require.
switch (self.command) {
.kitty_color_protocol => |*v| kitty_color_protocol: {
v.deinit(self.alloc orelse break :kitty_color_protocol);
},
.change_window_icon,
.change_window_title,
.clipboard_contents,
.color_operation,
.conemu_change_tab_title,
.conemu_comment,
.conemu_guimacro,
.conemu_output_environment_variable,
.conemu_progress_report,
.conemu_run_process,
.conemu_show_message_box,
.conemu_sleep,
.conemu_wait_input,
.conemu_xterm_emulation,
.hyperlink_end,
.hyperlink_start,
.invalid,
.mouse_shape,
.report_pwd,
.semantic_prompt,
.show_desktop_notification,
.kitty_text_sizing,
=> {},
}
self.state = .start;
self.fixed = null;
self.allocating = null;
self.writer = null;
self.command = .invalid;
if (std.valgrind.runningOnValgrind() > 0) {
// Initialize our undefined fields so Valgrind can catch it.
// https://github.com/ziglang/zig/issues/19148
self.buffer = undefined;
}
}
/// Make sure that we have an allocator. If we don't, set the state to
/// invalid so that any additional OSC data is discarded.
inline fn ensureAllocator(self: *Parser) bool {
if (self.alloc != null) return true;
log.warn("An allocator is required to process OSC {t} but none was provided.", .{self.state});
self.state = .invalid;
return false;
}
/// Set up a fixed Writer to collect the rest of the OSC data.
inline fn writeToFixed(self: *Parser) void {
self.fixed = .fixed(&self.buffer);
self.writer = &self.fixed.?;
}
/// Set up an allocating Writer to collect the rest of the OSC data. If we
/// don't have an allocator or setting up the allocator fails, fall back to
/// writing to a fixed buffer and hope that it's big enough.
inline fn writeToAllocating(self: *Parser) void {
const alloc = self.alloc orelse {
// We don't have an allocator - fall back to a fixed buffer and hope
// that it's big enough.
self.writeToFixed();
return;
};
self.allocating = std.Io.Writer.Allocating.initCapacity(alloc, 2048) catch {
// The allocator failed for some reason, fall back to a fixed buffer
// and hope that it's big enough.
self.writeToFixed();
return;
};
self.writer = &self.allocating.?.writer;
}
/// Consume the next character c and advance the parser state.
pub fn next(self: *Parser, c: u8) void {
// If the state becomes invalid for any reason, just discard
// any further input.
if (self.state == .invalid) return;
// If a writer has been initialized, we just accumulate the rest of the
// OSC sequence in the writer's buffer and skip the state machine.
if (self.writer) |writer| {
writer.writeByte(c) catch |err| switch (err) {
// We have overflowed our buffer or had some other error, set the
// state to invalid so that we discard any further input.
error.WriteFailed => self.state = .invalid,
};
return;
}
switch (self.state) {
// handled above, so should never be here
.invalid => unreachable,
.start => switch (c) {
'0' => self.state = .@"0",
'1' => self.state = .@"1",
'2' => self.state = .@"2",
'4' => self.state = .@"4",
'5' => self.state = .@"5",
'6' => self.state = .@"6",
'7' => self.state = .@"7",
'8' => self.state = .@"8",
'9' => self.state = .@"9",
else => self.state = .invalid,
},
.@"1" => switch (c) {
';' => self.writeToFixed(),
'0' => self.state = .@"10",
'1' => self.state = .@"11",
'2' => self.state = .@"12",
'3' => self.state = .@"13",
'4' => self.state = .@"14",
'5' => self.state = .@"15",
'6' => self.state = .@"16",
'7' => self.state = .@"17",
'8' => self.state = .@"18",
'9' => self.state = .@"19",
else => self.state = .invalid,
},
.@"10" => switch (c) {
';' => if (self.ensureAllocator()) self.writeToFixed(),
'4' => self.state = .@"104",
else => self.state = .invalid,
},
.@"104" => switch (c) {
';' => if (self.ensureAllocator()) self.writeToFixed(),
else => self.state = .invalid,
},
.@"11" => switch (c) {
';' => if (self.ensureAllocator()) self.writeToFixed(),
'0' => self.state = .@"110",
'1' => self.state = .@"111",
'2' => self.state = .@"112",
'3' => self.state = .@"113",
'4' => self.state = .@"114",
'5' => self.state = .@"115",
'6' => self.state = .@"116",
'7' => self.state = .@"117",
'8' => self.state = .@"118",
'9' => self.state = .@"119",
else => self.state = .invalid,
},
.@"4",
.@"12",
.@"14",
.@"15",
.@"16",
.@"17",
.@"18",
.@"19",
.@"21",
.@"110",
.@"111",
.@"112",
.@"113",
.@"114",
.@"115",
.@"116",
.@"117",
.@"118",
.@"119",
=> switch (c) {
';' => if (self.ensureAllocator()) self.writeToFixed(),
else => self.state = .invalid,
},
.@"13" => switch (c) {
';' => if (self.ensureAllocator()) self.writeToFixed(),
'3' => self.state = .@"133",
else => self.state = .invalid,
},
.@"2" => switch (c) {
';' => self.writeToFixed(),
'1' => self.state = .@"21",
'2' => self.state = .@"22",
else => self.state = .invalid,
},
.@"5" => switch (c) {
';' => if (self.ensureAllocator()) self.writeToFixed(),
'2' => self.state = .@"52",
else => self.state = .invalid,
},
.@"6" => switch (c) {
'6' => self.state = .@"66",
else => self.state = .invalid,
},
.@"52",
.@"66",
=> switch (c) {
';' => self.writeToAllocating(),
else => self.state = .invalid,
},
.@"7" => switch (c) {
';' => self.writeToFixed(),
'7' => self.state = .@"77",
else => self.state = .invalid,
},
.@"77" => switch (c) {
'7' => self.state = .@"777",
else => self.state = .invalid,
},
.@"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",
.@"9",
=> switch (c) {
';' => self.writeToFixed(),
else => self.state = .invalid,
},
}
}
/// 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.
///
/// The returned pointer is only valid until the next call to the parser.
/// Callers should copy out any data they wish to retain across calls.
pub fn end(self: *Parser, terminator_ch: ?u8) ?*Command {
return switch (self.state) {
.start => null,
.invalid => null,
.@"0",
.@"2",
=> parsers.change_window_title.parse(self, terminator_ch),
.@"1" => parsers.change_window_icon.parse(self, terminator_ch),
.@"4",
.@"5",
.@"10",
.@"11",
.@"12",
.@"13",
.@"14",
.@"15",
.@"16",
.@"17",
.@"18",
.@"19",
.@"104",
.@"110",
.@"111",
.@"112",
.@"113",
.@"114",
.@"115",
.@"116",
.@"117",
.@"118",
.@"119",
=> parsers.color.parse(self, terminator_ch),
.@"7" => parsers.report_pwd.parse(self, terminator_ch),
.@"8" => parsers.hyperlink.parse(self, terminator_ch),
.@"9" => parsers.osc9.parse(self, terminator_ch),
.@"21" => parsers.kitty_color.parse(self, terminator_ch),
.@"22" => parsers.mouse_shape.parse(self, terminator_ch),
.@"52" => parsers.clipboard_operation.parse(self, terminator_ch),
.@"6" => null,
.@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch),
.@"77" => null,
.@"133" => parsers.semantic_prompt.parse(self, terminator_ch),
.@"777" => parsers.rxvt_extension.parse(self, terminator_ch),
.@"1337" => parsers.iterm2.parse(self, terminator_ch),
};
}
};
test {
_ = parsers;
_ = encoding;
}