aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/osc.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 21:51:57 +0300
committerGitHub <noreply@github.com>2026-09-10 21:51:57 +0300
commit425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch)
treee52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/terminal/osc.zig
parent90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff)
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/osc.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/osc.zig264
1 files changed, 247 insertions, 17 deletions
diff --git a/vendor/libghostty-vt/src/terminal/osc.zig b/vendor/libghostty-vt/src/terminal/osc.zig
index 26ce1267..dbc35492 100644
--- a/vendor/libghostty-vt/src/terminal/osc.zig
+++ b/vendor/libghostty-vt/src/terminal/osc.zig
@@ -46,12 +46,13 @@ pub const Command = union(Key) {
/// 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.
+ /// Set or get clipboard contents. If data is "?", then the current
+ /// clipboard contents are sent to the pty. Otherwise, the contents
+ /// are set on the clipboard.
clipboard_contents: struct {
kind: u8,
data: [:0]const u8,
+ terminator: Terminator = .st,
},
/// OSC 7. Reports the current working directory of the shell. This is
@@ -163,12 +164,17 @@ pub const Command = union(Key) {
/// https://uapi-group.org/specifications/specs/osc_context/
context_signal: parsers.context_signal.Command,
+ /// Kitty desktop notifications (OSC 99)
+ kitty_desktop_notification: KittyDesktopNotification,
+
pub const SemanticPrompt = parsers.semantic_prompt.Command;
pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC;
pub const KittyDndProtocol = parsers.kitty_dnd_protocol.OSC;
+ pub const KittyDesktopNotification = parsers.kitty_desktop_notification.OSC;
+
pub const Key = LibEnum(
lib.target,
// NOTE: Order matters, see LibEnum documentation.
@@ -199,23 +205,21 @@ pub const Command = union(Key) {
"kitty_clipboard_protocol",
"kitty_dnd_protocol",
"context_signal",
+ "kitty_desktop_notification",
},
);
pub const ProgressReport = struct {
- pub const State = enum(c_int) {
- remove,
- set,
- @"error",
- indeterminate,
- pause,
-
- test "ghostty.h Command.ProgressReport.State" {
- if (comptime build_options.artifact == .lib) return error.SkipZigTest;
- try lib.checkGhosttyHEnum(State, "GHOSTTY_PROGRESS_STATE_");
- }
+ const state_keys = &.{
+ "remove",
+ "set",
+ "error",
+ "indeterminate",
+ "pause",
};
+ pub const State = LibEnum(lib.target, state_keys);
+
state: State,
progress: ?u8 = null,
@@ -235,6 +239,12 @@ pub const Command = union(Key) {
)) else -1,
};
}
+
+ test "ghostty.h Command.ProgressReport.State" {
+ if (comptime build_options.artifact == .lib) return error.SkipZigTest;
+ const CState = LibEnum(.c, state_keys);
+ try lib.checkGhosttyHEnum(CState, "GHOSTTY_PROGRESS_STATE_");
+ }
};
comptime {
@@ -297,11 +307,20 @@ pub const Parser = struct {
/// Maximum size of a "normal" OSC.
pub const MAX_BUF = 2048;
+ /// Maximum size of an OSC that requires dynamically allocated storage.
+ /// OSC input is untrusted, so these captures must have a finite bound.
+ pub const MAX_ALLOCATING_BUF = 8 * 1024 * 1024;
+
/// 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,
+ /// Maximum number of bytes retained by an allocating capture.
+ /// This is configurable primarily so callers and tests can choose a
+ /// smaller policy than the default.
+ max_allocating_bytes: usize,
+
/// Current state of the parser.
state: State,
@@ -352,6 +371,7 @@ pub const Parser = struct {
@"66",
@"72",
@"77",
+ @"99",
@"104",
@"110",
@"111",
@@ -373,6 +393,7 @@ pub const Parser = struct {
pub fn init(alloc: ?Allocator) Parser {
var result: Parser = .{
.alloc = alloc,
+ .max_allocating_bytes = MAX_ALLOCATING_BUF,
.state = .start,
.capture = null,
.command = .invalid,
@@ -431,6 +452,7 @@ pub const Parser = struct {
.kitty_text_sizing,
.kitty_clipboard_protocol,
.kitty_dnd_protocol,
+ .kitty_desktop_notification,
.context_signal,
=> {},
}
@@ -458,6 +480,7 @@ pub const Parser = struct {
const Capture = struct {
writer: *std.Io.Writer,
backing: Backing,
+ max_bytes: usize,
const Backing = union(enum) {
fixed: std.Io.Writer,
@@ -473,22 +496,83 @@ pub const Parser = struct {
new.* = .{
.backing = .{ .fixed = .fixed(buf) },
.writer = &new.*.?.backing.fixed,
+ .max_bytes = buf.len,
};
}
pub inline fn allocating(
new: *?Capture,
alloc: Allocator,
+ max_bytes: usize,
) error{OutOfMemory}!void {
new.* = .{
.backing = .{ .allocating = try std.Io.Writer.Allocating.initCapacity(
alloc,
- 2048,
+ @min(MAX_BUF, max_bytes),
) },
.writer = &new.*.?.backing.allocating.writer,
+ .max_bytes = max_bytes,
};
}
+ /// Append one byte without permitting the backing allocation to grow
+ /// beyond max_bytes. Allocating.Writer normally grows super-linearly,
+ /// so grow it explicitly to keep the allocation itself bounded too.
+ pub inline fn writeByte(self: *Capture, byte: u8) error{WriteFailed}!void {
+ if (self.writer.buffered().len >= self.max_bytes) return error.WriteFailed;
+
+ switch (self.backing) {
+ .fixed => {},
+ .allocating => |*w| {
+ if (w.writer.end >= w.writer.buffer.len) {
+ const new_capacity = @min(
+ self.max_bytes,
+ @max(w.writer.buffer.len *| 2, 1),
+ );
+ w.writer.buffer = w.allocator.realloc(
+ w.writer.buffer,
+ new_capacity,
+ ) catch return error.WriteFailed;
+ }
+ },
+ }
+
+ try self.writer.writeByte(byte);
+ }
+
+ /// Append a slice without permitting the backing allocation to
+ /// grow beyond max_bytes. This matches the byte-at-a-time
+ /// semantics of writeByte: bytes are retained up to exactly
+ /// max_bytes and the first byte that doesn't fit fails the
+ /// write.
+ pub fn writeSlice(
+ self: *Capture,
+ bytes: []const u8,
+ ) error{WriteFailed}!void {
+ const avail = self.max_bytes - self.writer.buffered().len;
+ const n = @min(bytes.len, avail);
+
+ switch (self.backing) {
+ .fixed => {},
+ .allocating => |*w| {
+ const needed = w.writer.end + n;
+ if (needed > w.writer.buffer.len) {
+ const new_capacity = @min(
+ self.max_bytes,
+ @max(w.writer.buffer.len *| 2, needed),
+ );
+ w.writer.buffer = w.allocator.realloc(
+ w.writer.buffer,
+ new_capacity,
+ ) catch return error.WriteFailed;
+ }
+ },
+ }
+
+ try self.writer.writeAll(bytes[0..n]);
+ if (n < bytes.len) return error.WriteFailed;
+ }
+
pub fn deinit(self: *Capture) void {
switch (self.backing) {
.fixed => {},
@@ -532,6 +616,7 @@ pub const Parser = struct {
Capture.allocating(
&self.capture,
alloc,
+ self.max_allocating_bytes,
) catch {
// The allocator failed for some reason, fall back to a fixed buffer
// and hope that it's big enough.
@@ -542,6 +627,33 @@ pub const Parser = struct {
}
}
+ /// Consume a slice of bytes, advancing the parser state. This is
+ /// equivalent to calling `next` for each byte in order, but is much
+ /// faster once a data capture is active because the remaining bytes
+ /// are appended to the capture in bulk.
+ pub fn nextSlice(self: *Parser, input: []const u8) void {
+ if (self.state == .invalid) return;
+
+ // Run the state machine byte-at-a-time until a capture begins.
+ // The command prefix before a capture starts is only a handful
+ // of bytes so this loop is short in practice.
+ var offset: usize = 0;
+ while (self.capture == null) {
+ if (offset >= input.len) return;
+ self.next(input[offset]);
+ offset += 1;
+ if (self.state == .invalid) return;
+ }
+
+ const rem = input[offset..];
+ if (rem.len == 0) return;
+ self.capture.?.writeSlice(rem) 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,
+ };
+ }
+
/// 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
@@ -551,7 +663,7 @@ pub const Parser = struct {
// 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.capture) |*cap| {
- cap.writer.writeByte(c) catch |err| switch (err) {
+ cap.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,
@@ -740,11 +852,24 @@ pub const Parser = struct {
else => self.state = .invalid,
},
+ .@"9",
+ => switch (c) {
+ ';' => self.captureTrailing(.fixed),
+ '9' => self.state = .@"99",
+ else => self.state = .invalid,
+ },
+
+ .@"99",
+ => switch (c) {
+ // OSC 99 encoded payloads can exceed the fixed buffer.
+ ';' => self.captureTrailing(.allocating),
+ else => self.state = .invalid,
+ },
+
.@"0",
.@"22",
.@"777",
.@"8",
- .@"9",
=> switch (c) {
';' => self.captureTrailing(.fixed),
else => self.state = .invalid,
@@ -825,6 +950,8 @@ pub const Parser = struct {
.@"77" => null,
+ .@"99" => parsers.kitty_desktop_notification.parse(self, terminator_ch),
+
.@"133" => parsers.semantic_prompt.parse(self, terminator_ch),
.@"552" => null,
@@ -842,3 +969,106 @@ test {
_ = parsers;
_ = encoding;
}
+
+test "Parser allocating captures have a hard limit" {
+ const testing = std.testing;
+ const prefixes = [_][]const u8{ "52;", "66;", "72;", "99;", "5522;" };
+ const limit = Parser.MAX_BUF + 1;
+
+ for (prefixes) |prefix| {
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+ p.max_allocating_bytes = limit;
+
+ for (prefix) |ch| p.next(ch);
+ for (0..limit) |_| p.next('a');
+
+ const cap = &p.capture.?;
+ try testing.expectEqual(@as(usize, limit), cap.trailing().len);
+ try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len);
+
+ p.next('a');
+ try testing.expectEqual(Parser.State.invalid, p.state);
+ try testing.expectEqual(@as(usize, limit), cap.trailing().len);
+ try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len);
+ }
+}
+
+test "Parser nextSlice allocating captures have a hard limit" {
+ const testing = std.testing;
+ const limit = Parser.MAX_BUF + 1;
+
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+ p.max_allocating_bytes = limit;
+
+ const data = try testing.allocator.alloc(u8, limit);
+ defer testing.allocator.free(data);
+ @memset(data, 'a');
+
+ // Exactly at the limit stays valid and bounded.
+ p.nextSlice("52;");
+ p.nextSlice(data);
+ const cap = &p.capture.?;
+ try testing.expect(p.state != .invalid);
+ try testing.expectEqual(@as(usize, limit), cap.trailing().len);
+ try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len);
+
+ // One more byte overflows: the state becomes invalid and the
+ // retained bytes and allocation stay bounded.
+ p.nextSlice("a");
+ try testing.expectEqual(Parser.State.invalid, p.state);
+ try testing.expectEqual(@as(usize, limit), cap.trailing().len);
+ try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len);
+ try testing.expect(p.end(null) == null);
+}
+
+test "Parser nextSlice overflowing slice is truncated at the limit" {
+ const testing = std.testing;
+
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+ p.max_allocating_bytes = 4;
+
+ p.nextSlice("52;abcdef");
+ try testing.expectEqual(Parser.State.invalid, p.state);
+ try testing.expect(p.end(null) == null);
+
+ const cap = &p.capture.?;
+ try testing.expectEqualStrings("abcd", cap.trailing());
+ try testing.expectEqual(@as(usize, 4), cap.writer.buffer.len);
+}
+
+test "Parser nextSlice matches per-byte parsing" {
+ const testing = std.testing;
+ const input = "52;c;aGVsbG8=";
+
+ // Every two-way split of the input must parse identically to
+ // the byte-at-a-time path.
+ for (0..input.len + 1) |split| {
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+ p.nextSlice(input[0..split]);
+ p.nextSlice(input[split..]);
+
+ const cmd = p.end(null).?.*;
+ try testing.expect(cmd == .clipboard_contents);
+ try testing.expectEqual(@as(u8, 'c'), cmd.clipboard_contents.kind);
+ try testing.expectEqualStrings("aGVsbG8=", cmd.clipboard_contents.data);
+ }
+}
+
+test "Parser allocating capture limit includes parser-added bytes" {
+ const testing = std.testing;
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+ p.max_allocating_bytes = 4;
+
+ for ("52;abcd") |ch| p.next(ch);
+ try testing.expect(p.end(null) == null);
+ try testing.expectEqual(Parser.State.invalid, p.state);
+
+ const cap = &p.capture.?;
+ try testing.expectEqual(@as(usize, 4), cap.trailing().len);
+ try testing.expectEqual(@as(usize, 4), cap.writer.buffer.len);
+}