summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMitchell Hashimoto <m@mitchellh.com>2025-09-28 14:55:15 -0700
committerMitchell Hashimoto <m@mitchellh.com>2025-09-29 06:40:01 -0700
commit3bc07c24aaac4cf58cbb845bc54c8c1cbf2ffa0c (patch)
tree00968d6f93be25036f5261e540cba0361b97bb08
parentc5145d552e22afb3f657ddb13d7ec3b5e7ecea54 (diff)
lib-vt: OSC data extraction boilerplate
This also changes OSC strings to be null-terminated to ease lib-vt integration. This shouldn't have any practical effect on terminal performance, but it does lower the maximum length of OSC strings by 1 since we always reserve space for the null terminator.
-rw-r--r--example/c-vt/src/main.c15
-rw-r--r--include/ghostty/vt.h69
-rw-r--r--src/inspector/termio.zig8
-rw-r--r--src/lib_vt.zig1
-rw-r--r--src/terminal/c/main.zig1
-rw-r--r--src/terminal/c/osc.zig53
-rw-r--r--src/terminal/osc.zig98
7 files changed, 216 insertions, 29 deletions
diff --git a/example/c-vt/src/main.c b/example/c-vt/src/main.c
index 00ea3618f..b1297d7a7 100644
--- a/example/c-vt/src/main.c
+++ b/example/c-vt/src/main.c
@@ -1,5 +1,6 @@
#include <stddef.h>
#include <stdio.h>
+#include <string.h>
#include <ghostty/vt.h>
int main() {
@@ -8,10 +9,13 @@ int main() {
return 1;
}
- // Setup change window title command to change the title to "a"
+ // Setup change window title command to change the title to "hello"
ghostty_osc_next(parser, '0');
ghostty_osc_next(parser, ';');
- ghostty_osc_next(parser, 'a');
+ const char *title = "hello";
+ for (size_t i = 0; i < strlen(title); i++) {
+ ghostty_osc_next(parser, title[i]);
+ }
// End parsing and get command
GhosttyOscCommand command = ghostty_osc_end(parser, 0);
@@ -20,6 +24,13 @@ int main() {
GhosttyOscCommandType type = ghostty_osc_command_type(command);
printf("Command type: %d\n", type);
+ // Extract and print the title
+ if (ghostty_osc_command_data(command, GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR, &title)) {
+ printf("Extracted title: %s\n", title);
+ } else {
+ printf("Failed to extract title\n");
+ }
+
ghostty_osc_free(parser);
return 0;
}
diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h
index 5d80cb653..33ff2a961 100644
--- a/include/ghostty/vt.h
+++ b/include/ghostty/vt.h
@@ -32,6 +32,8 @@ extern "C" {
* be used to parse the contents of OSC sequences. This isn't a full VT
* parser; it is only the OSC parser component. This is useful if you have
* a parser already and want to only extract and handle OSC sequences.
+ *
+ * @ingroup osc
*/
typedef struct GhosttyOscParser *GhosttyOscParser;
@@ -41,6 +43,8 @@ typedef struct GhosttyOscParser *GhosttyOscParser;
* This handle represents a parsed OSC (Operating System Command) command.
* The command can be queried for its type and associated data using
* `ghostty_osc_command_type` and `ghostty_osc_command_data`.
+ *
+ * @ingroup osc
*/
typedef struct GhosttyOscCommand *GhosttyOscCommand;
@@ -56,6 +60,8 @@ typedef enum {
/**
* OSC command types.
+ *
+ * @ingroup osc
*/
typedef enum {
GHOSTTY_OSC_COMMAND_INVALID = 0,
@@ -81,6 +87,31 @@ typedef enum {
GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20,
} GhosttyOscCommandType;
+/**
+ * OSC command data types.
+ *
+ * These values specify what type of data to extract from an OSC command
+ * using `ghostty_osc_command_data`.
+ *
+ * @ingroup osc
+ */
+typedef enum {
+ /** Invalid data type. Never results in any data extraction. */
+ GHOSTTY_OSC_DATA_INVALID = 0,
+
+ /**
+ * Window title string data.
+ *
+ * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE
+ *
+ * Output type: const char ** (pointer to null-terminated string)
+ *
+ * Lifetime: Valid until the next call to any ghostty_osc_* function with
+ * the same parser instance. Memory is owned by the parser.
+ */
+ GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1,
+} GhosttyOscCommandData;
+
//-------------------------------------------------------------------
// Allocator Interface
@@ -227,6 +258,27 @@ typedef struct {
//-------------------------------------------------------------------
// Functions
+/** @defgroup osc OSC Parser
+ *
+ * OSC (Operating System Command) sequence parser and command handling.
+ *
+ * The parser operates in a streaming fashion, processing input byte-by-byte
+ * to handle OSC sequences that may arrive in fragments across multiple reads.
+ * This interface makes it easy to integrate into most environments and avoids
+ * over-allocating buffers.
+ *
+ * ## Basic Usage
+ *
+ * 1. Create a parser instance with ghostty_osc_new()
+ * 2. Feed bytes to the parser using ghostty_osc_next()
+ * 3. Finalize parsing with ghostty_osc_end() to get the command
+ * 4. Query command type and extract data using ghostty_osc_command_type()
+ * and ghostty_osc_command_data()
+ * 5. Free the parser with ghostty_osc_free() when done
+ *
+ * @{
+ */
+
/**
* Create a new OSC parser instance.
*
@@ -316,6 +368,23 @@ GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator);
*/
GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command);
+/**
+ * Extract data from an OSC command.
+ *
+ * Extracts typed data from the given OSC command based on the specified
+ * data type. The output pointer must be of the appropriate type for the
+ * requested data kind. Valid command types, output types, and memory
+ * safety information are documented in the `GhosttyOscCommandData` enum.
+ *
+ * @param command The OSC command handle to query (may be NULL)
+ * @param data The type of data to extract
+ * @param out Pointer to store the extracted data (type depends on data parameter)
+ * @return true if data extraction was successful, false otherwise
+ */
+bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out);
+
+/** @} */ // end of osc group
+
#ifdef __cplusplus
}
#endif
diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig
index 5ab9d3cd4..49ab00ecd 100644
--- a/src/inspector/termio.zig
+++ b/src/inspector/termio.zig
@@ -197,7 +197,9 @@ pub const VTEvent = struct {
) !void {
switch (@TypeOf(v)) {
void => {},
- []const u8 => try md.put("data", try alloc.dupeZ(u8, v)),
+ []const u8,
+ [:0]const u8,
+ => try md.put("data", try alloc.dupeZ(u8, v)),
else => |T| switch (@typeInfo(T)) {
.@"struct" => |info| inline for (info.fields) |field| {
try encodeMetadataSingle(
@@ -284,7 +286,9 @@ pub const VTEvent = struct {
try std.fmt.allocPrintZ(alloc, "{}", .{value}),
),
- []const u8 => try md.put(key, try alloc.dupeZ(u8, value)),
+ []const u8,
+ [:0]const u8,
+ => try md.put(key, try alloc.dupeZ(u8, value)),
else => |T| {
@compileLog(T);
diff --git a/src/lib_vt.zig b/src/lib_vt.zig
index b7ef9459a..763f17f98 100644
--- a/src/lib_vt.zig
+++ b/src/lib_vt.zig
@@ -77,6 +77,7 @@ comptime {
@export(&c.osc_reset, .{ .name = "ghostty_osc_reset" });
@export(&c.osc_end, .{ .name = "ghostty_osc_end" });
@export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" });
+ @export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" });
}
}
diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig
index f32dd226f..68fd77edd 100644
--- a/src/terminal/c/main.zig
+++ b/src/terminal/c/main.zig
@@ -7,6 +7,7 @@ pub const osc_reset = osc.reset;
pub const osc_next = osc.next;
pub const osc_end = osc.end;
pub const osc_command_type = osc.commandType;
+pub const osc_command_data = osc.commandData;
test {
_ = osc;
diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig
index d1998f4e1..8b6a8409c 100644
--- a/src/terminal/c/osc.zig
+++ b/src/terminal/c/osc.zig
@@ -49,6 +49,51 @@ pub fn commandType(command_: Command) callconv(.c) osc.Command.Key {
return command.*;
}
+/// C: GhosttyOscCommandData
+pub const CommandData = enum(c_int) {
+ invalid = 0,
+ change_window_title_str = 1,
+
+ /// Output type expected for querying the data of the given kind.
+ pub fn OutType(comptime self: CommandData) type {
+ return switch (self) {
+ .invalid => void,
+ .change_window_title_str => [*:0]const u8,
+ };
+ }
+};
+
+pub fn commandData(
+ command_: Command,
+ data: CommandData,
+ out: ?*anyopaque,
+) callconv(.c) bool {
+ return switch (data) {
+ inline else => |comptime_data| commandDataTyped(
+ command_,
+ comptime_data,
+ @ptrCast(@alignCast(out)),
+ ),
+ };
+}
+
+fn commandDataTyped(
+ command_: Command,
+ comptime data: CommandData,
+ out: *data.OutType(),
+) bool {
+ const command = command_.?;
+ switch (data) {
+ .invalid => return false,
+ .change_window_title_str => switch (command.*) {
+ .change_window_title => |v| out.* = v.ptr,
+ else => return false,
+ },
+ }
+
+ return true;
+}
+
test "alloc" {
const testing = std.testing;
var p: Parser = undefined;
@@ -64,7 +109,7 @@ test "command type null" {
try testing.expectEqual(.invalid, commandType(null));
}
-test "command type" {
+test "change window title" {
const testing = std.testing;
var p: Parser = undefined;
try testing.expectEqual(Result.success, new(
@@ -73,9 +118,15 @@ test "command type" {
));
defer free(p);
+ // Parse it
next(p, '0');
next(p, ';');
next(p, 'a');
const cmd = end(p, 0);
try testing.expectEqual(.change_window_title, commandType(cmd));
+
+ // Extract the title
+ var title: [*:0]const u8 = undefined;
+ try testing.expect(commandData(cmd, .change_window_title_str, @ptrCast(&title)));
+ try testing.expectEqualStrings("a", std.mem.span(title));
}
diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig
index 20b22d1ef..800257c3d 100644
--- a/src/terminal/osc.zig
+++ b/src/terminal/osc.zig
@@ -26,19 +26,19 @@ pub const Command = union(Key) {
/// Set the window title of the terminal
///
- /// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8
+ /// 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: []const u8,
+ 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: []const u8,
+ change_window_icon: [:0]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
@@ -54,7 +54,7 @@ pub const Command = union(Key) {
/// - secondary: a non-editable continuation line
/// - right: a right-aligned prompt that may need adjustment during reflow
prompt_start: struct {
- aid: ?[]const u8 = null,
+ aid: ?[:0]const u8 = null,
kind: enum { primary, continuation, secondary, right } = .primary,
redraw: bool = true,
},
@@ -96,7 +96,7 @@ pub const Command = union(Key) {
/// contents is set on the clipboard.
clipboard_contents: struct {
kind: u8,
- data: []const u8,
+ data: [:0]const u8,
},
/// OSC 7. Reports the current working directory of the shell. This is
@@ -106,7 +106,7 @@ pub const Command = union(Key) {
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,
+ value: [:0]const u8,
},
/// OSC 22. Set the mouse shape. There doesn't seem to be a standard
@@ -114,7 +114,7 @@ pub const Command = union(Key) {
/// 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,
+ value: [:0]const u8,
},
/// OSC color operations to set, reset, or report color settings. Some OSCs
@@ -138,14 +138,14 @@ pub const Command = union(Key) {
/// Show a desktop notification (OSC 9 or OSC 777)
show_desktop_notification: struct {
- title: []const u8,
- body: []const u8,
+ title: [:0]const u8,
+ body: [:0]const u8,
},
/// Start a hyperlink (OSC 8)
hyperlink_start: struct {
- id: ?[]const u8 = null,
- uri: []const u8,
+ id: ?[:0]const u8 = null,
+ uri: [:0]const u8,
},
/// End a hyperlink (OSC 8)
@@ -157,12 +157,12 @@ pub const Command = union(Key) {
},
/// ConEmu show GUI message box (OSC 9;2)
- conemu_show_message_box: []const u8,
+ conemu_show_message_box: [:0]const u8,
/// ConEmu change tab title (OSC 9;3)
conemu_change_tab_title: union(enum) {
reset,
- value: []const u8,
+ value: [:0]const u8,
},
/// ConEmu progress report (OSC 9;4)
@@ -172,7 +172,7 @@ pub const Command = union(Key) {
conemu_wait_input,
/// ConEmu GUI macro (OSC 9;6)
- conemu_guimacro: []const u8,
+ conemu_guimacro: [:0]const u8,
pub const Key = LibEnum(
if (build_options.c_abi) .c else .zig,
@@ -305,7 +305,7 @@ pub const Parser = struct {
/// Temporary state that is dependent on the current state.
temp_state: union {
/// Current string parameter being populated
- str: *[]const u8,
+ str: *[:0]const u8,
/// Current numeric parameter being populated
num: u16,
@@ -498,7 +498,10 @@ pub const Parser = struct {
// If our buffer is full then we're invalid, so we set our state
// accordingly and indicate the sequence is incomplete so that we
// don't accidentally issue a command when ending.
- if (self.buf_idx >= self.buf.len) {
+ //
+ // We always keep space for 1 byte at the end to null-terminate
+ // values.
+ if (self.buf_idx >= self.buf.len - 1) {
if (self.state != .invalid) {
log.warn(
"OSC sequence too long (> {d}), ignoring. state={}",
@@ -1037,7 +1040,8 @@ pub const Parser = struct {
.notification_title => switch (c) {
';' => {
- self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1];
+ self.buf[self.buf_idx - 1] = 0;
+ self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1 :0];
self.temp_state = .{ .str = &self.command.show_desktop_notification.body };
self.buf_start = self.buf_idx;
self.state = .string;
@@ -1406,7 +1410,8 @@ pub const Parser = struct {
fn endHyperlink(self: *Parser) void {
switch (self.command) {
.hyperlink_start => |*v| {
- const value = self.buf[self.buf_start..self.buf_idx];
+ self.buf[self.buf_idx] = 0;
+ const value = self.buf[self.buf_start..self.buf_idx :0];
if (v.id == null and value.len == 0) {
self.command = .{ .hyperlink_end = {} };
return;
@@ -1420,10 +1425,12 @@ pub const Parser = struct {
}
fn endHyperlinkOptionValue(self: *Parser) void {
- const value = if (self.buf_start == self.buf_idx)
+ const value: [:0]const u8 = if (self.buf_start == self.buf_idx)
""
- else
- self.buf[self.buf_start .. self.buf_idx - 1];
+ else buf: {
+ self.buf[self.buf_idx - 1] = 0;
+ break :buf self.buf[self.buf_start .. self.buf_idx - 1 :0];
+ };
if (mem.eql(u8, self.temp_state.key, "id")) {
switch (self.command) {
@@ -1438,7 +1445,11 @@ pub const Parser = struct {
}
fn endSemanticOptionValue(self: *Parser) void {
- const value = self.buf[self.buf_start..self.buf_idx];
+ const value = value: {
+ self.buf[self.buf_idx] = 0;
+ defer self.buf_idx += 1;
+ break :value self.buf[self.buf_start..self.buf_idx :0];
+ };
if (mem.eql(u8, self.temp_state.key, "aid")) {
switch (self.command) {
@@ -1495,7 +1506,9 @@ pub const Parser = struct {
}
fn endString(self: *Parser) void {
- self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx];
+ self.buf[self.buf_idx] = 0;
+ defer self.buf_idx += 1;
+ self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx :0];
}
fn endConEmuSleepValue(self: *Parser) void {
@@ -1589,8 +1602,15 @@ pub const Parser = struct {
}
fn endAllocableString(self: *Parser) void {
+ const alloc = self.alloc.?;
const list = self.buf_dynamic.?;
- self.temp_state.str.* = list.items;
+ list.append(alloc, 0) catch {
+ log.warn("allocation failed on allocable string termination", .{});
+ self.temp_state.str.* = "";
+ return;
+ };
+
+ self.temp_state.str.* = list.items[0 .. list.items.len - 1 :0];
}
/// End the sequence and return the command, if any. If the return value
@@ -1976,6 +1996,36 @@ test "OSC: longer than buffer" {
try testing.expect(p.complete == false);
}
+test "OSC: one shorter than buffer length" {
+ const testing = std.testing;
+
+ var p: Parser = .init();
+
+ const prefix = "0;";
+ const title = "a" ** (Parser.MAX_BUF - prefix.len - 1);
+ const input = prefix ++ title;
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end(null).?.*;
+ try testing.expect(cmd == .change_window_title);
+ try testing.expectEqualStrings(title, cmd.change_window_title);
+}
+
+test "OSC: exactly at buffer length" {
+ const testing = std.testing;
+
+ var p: Parser = .init();
+
+ const prefix = "0;";
+ const title = "a" ** (Parser.MAX_BUF - prefix.len);
+ const input = prefix ++ title;
+ for (input) |ch| p.next(ch);
+
+ // This should be null because we always reserve space for a null terminator.
+ try testing.expect(p.end(null) == null);
+ try testing.expect(p.complete == false);
+}
+
test "OSC: OSC 9;1 ConEmu sleep" {
const testing = std.testing;