libghostty: effect for unknown sequence (APC only this PR) (#13702)

This introduces a new effect for Zig/C callers to detect unknown
sequences.

This PR starts only with APC, but the API shape is such that we can add
other types (OSC next) in future PRs. The goal of this is to have zero
overhead in the disabled/undetected (both) case, and minimal overhead in
the detected case.

This is important in particular for libghostty consumers because it
allows them to implement their own custom protocols and/or support
features libghostty doesn't support. It isn't possible to support them
at the same performance libghostty does but supporting them in general
is usually valuable.

From a Zig API to enable this, users must set `unknown_max_bytes` for
the APC handler AND update their stream handler to recognize unknown
sequences. The built-in `stream_terminal` stream type has an exposed
callback for this, so both must be set.
This commit is contained in:
Mitchell Hashimoto
2026-08-08 19:52:49 -07:00
committed by GitHub
12 changed files with 732 additions and 68 deletions

View File

@@ -70,6 +70,32 @@ GhosttyClipboardWriteResult on_clipboard_write(
}
//! [effects-clipboard-write]
//! [effects-unknown-sequence]
void on_unknown_sequence(
GhosttyTerminal terminal,
void* userdata,
const GhosttyTerminalUnknownSequence* sequence) {
(void)terminal;
(void)userdata;
switch (sequence->tag) {
case GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_APC: {
const GhosttyTerminalUnknownStringSequence* apc = &sequence->value.apc;
printf(" unknown APC (truncated=%s, content=%zu bytes): ",
apc->truncated ? "yes" : "no",
apc->content.len);
if (apc->content.len > 0) {
fwrite(apc->content.ptr, 1, apc->content.len, stdout);
}
printf("\n");
break;
}
default:
break;
}
}
//! [effects-unknown-sequence]
//! [effects-register]
int main() {
// Create a terminal
@@ -92,6 +118,14 @@ int main() {
(const void *)on_title_changed);
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE,
(const void *)on_clipboard_write);
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE,
(const void *)on_unknown_sequence);
// Unknown sequence capture is independently bounded and disabled by
// default. This limit will apply to every supported unknown sequence type.
size_t unknown_max_bytes = 256;
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES,
&unknown_max_bytes);
// Feed VT data that triggers effects:
@@ -120,7 +154,13 @@ int main() {
ghostty_terminal_vt_write(terminal, (const uint8_t*)clipboard_seq,
strlen(clipboard_seq));
// 5. Another bell to show the counter increments
// 5. Unsupported APC sequence
printf("Sending unknown APC:\n");
const char* unknown_apc = "\x1B_private-command;payload\x1B\\";
ghostty_terminal_vt_write(terminal, (const uint8_t*)unknown_apc,
strlen(unknown_apc));
// 6. Another bell to show the counter increments
printf("Sending another BEL:\n");
ghostty_terminal_vt_write(terminal, &bel, 1);

View File

@@ -97,6 +97,7 @@ extern "C" {
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 |
* | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 |
* | `GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT` | `GhosttyTerminalProgressReportFn` | Progress report via OSC 9;4 |
* | `GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE` | `GhosttyTerminalUnknownSequenceFn` | Unsupported sequence identifier |
*
* ### Defining a write_pty callback
* @snippet c-vt-effects/src/main.c effects-write-pty
@@ -110,6 +111,9 @@ extern "C" {
* ### Defining a clipboard_write callback
* @snippet c-vt-effects/src/main.c effects-clipboard-write
*
* ### Defining an unknown_sequence callback
* @snippet c-vt-effects/src/main.c effects-unknown-sequence
*
* ### Registering effects and processing VT data
* @snippet c-vt-effects/src/main.c effects-register
*
@@ -331,6 +335,88 @@ typedef struct {
typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal,
void* userdata);
/**
* Unsupported terminal sequence tags.
*
* Only APC sequences are currently reported. Additional sequence types may
* be added without changing the callback shape.
*
* @ingroup terminal
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** Application Program Command (APC). */
GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_APC = 0,
GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalUnknownSequenceTag;
/**
* An unsupported string terminal sequence.
*
* The content is borrowed and valid only for the callback duration. It
* contains the bytes between the sequence introducer and terminator, may
* contain arbitrary binary data, and is not null-terminated.
*
* @ingroup terminal
*/
typedef struct {
/** Whether content was shortened by the byte limit or allocation failure. */
bool truncated;
/** Retained sequence content. */
GhosttyString content;
} GhosttyTerminalUnknownStringSequence;
/**
* Unsupported terminal sequence value.
*
* @ingroup terminal
*/
typedef union {
/** Application Program Command (APC). */
GhosttyTerminalUnknownStringSequence apc;
/**
* Padding for ABI compatibility. Do not use.
*
* 128 bytes leaves room for future structured sequence payloads, such as
* CSI with borrowed parameter, separator, and intermediate arrays, without
* changing the tagged union's ABI.
*/
uint64_t _padding[16];
} GhosttyTerminalUnknownSequenceValue;
/**
* An unsupported terminal sequence.
*
* @ingroup terminal
*/
typedef struct {
GhosttyTerminalUnknownSequenceTag tag;
GhosttyTerminalUnknownSequenceValue value;
} GhosttyTerminalUnknownSequence;
/**
* Callback function type for unsupported terminal sequences.
*
* Called synchronously for normally terminated sequences whose identifier is
* not supported by the active terminal handler. Aborted sequences, malformed
* recognized commands, and explicitly disabled known protocols are ignored.
*
* Capture must also be enabled with a nonzero
* GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES value. Installing this callback alone
* does not retain sequence content or allocate memory.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
* @param sequence Borrowed unsupported sequence
*
* @ingroup terminal
*/
typedef void (*GhosttyTerminalUnknownSequenceFn)(
GhosttyTerminal terminal,
void* userdata,
const GhosttyTerminalUnknownSequence* sequence);
/**
* Clipboard destination for a clipboard write.
*
@@ -1103,6 +1189,27 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: GhosttyTerminalModeConfig*
*/
GHOSTTY_TERMINAL_OPT_MODE = 34,
/**
* Callback invoked for unsupported terminal sequence identifiers. Set to
* NULL to ignore unsupported sequences. Capture must also be enabled with
* GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES.
*
* Input type: GhosttyTerminalUnknownSequenceFn
*/
GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE = 35,
/**
* Set the maximum content bytes retained for each unsupported terminal
* sequence. A NULL value pointer or zero disables capture and prevents
* unknown-sequence callbacks.
*
* When this limit is hit, the unknown sequence callback will still
* be invoked but `truncated` will be set to true.
*
* Input type: size_t*
*/
GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES = 36,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;

View File

@@ -52,10 +52,10 @@ const Handler = struct {
.apc_start => self.apc.start(),
.apc_put => self.apc.feed(self.alloc, value),
.apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes),
.apc_end => if (self.apc.end()) |cmd| {
var c = cmd;
std.mem.doNotOptimizeAway(&c);
c.deinit(self.alloc);
.apc_end => if (self.apc.end()) |result| {
var r = result;
std.mem.doNotOptimizeAway(&r);
r.deinit(self.alloc);
},
else => {},
}

View File

@@ -80,6 +80,7 @@ pub const Terminal = terminal.Terminal;
pub const TerminalStream = terminal.TerminalStream;
pub const Stream = terminal.Stream;
pub const StreamAction = terminal.StreamAction;
pub const UnknownSequence = terminal.UnknownSequence;
pub const Cursor = Screen.Cursor;
pub const CursorStyle = Screen.CursorStyle;
pub const CursorStyleReq = terminal.CursorStyle;

View File

@@ -13,6 +13,10 @@ const log = std.log.scoped(.terminal_apc);
pub const Handler = struct {
state: State = .inactive,
/// Maximum content bytes retained for unsupported APC identifiers. Zero
/// drops and ignores unknown APC values.
unknown_max_bytes: usize = 0,
/// Maximum bytes each APC protocol can buffer. This is to prevent
/// malicious input from causing us to allocate too much memory.
/// If you want to be lazy and set a single value for all protocols,
@@ -23,8 +27,7 @@ pub const Handler = struct {
}),
/// Protocols recognized by this APC handler. When a protocol is absent,
/// matching APC sequences are ignored so callers see the same behavior as
/// an unsupported protocol: no command execution and no response.
/// matching APC sequences are ignored and are not reported as unknown.
enabled: std.EnumSet(Protocol) = .initFull(),
pub fn deinit(self: *Handler) void {
@@ -50,51 +53,70 @@ pub const Handler = struct {
// recognize it so there is no need to store the data in memory.
.ignore => return,
// Unsupported APC content is retained only when enabled.
.unknown => |*unknown| unknown.append(&.{byte}),
// We identify the APC command by the first byte.
.identify => |*id| id: {
// Kitty graphics is detected immediately on the `G` byte,
// since commands begin immediately after with no termination
// character after the 'G'.
if (comptime build_options.kitty_graphics) {
if (id.len == 0 and
byte == 'G' and
self.enabled.contains(.kitty))
{
self.state = .{ .kitty = .init(
alloc,
self.max_bytes.get(.kitty) orelse
Protocol.defaultMaxBytes(.kitty),
) };
break :id;
if (id.len == 0 and byte == 'G') {
if (comptime build_options.kitty_graphics) {
if (self.enabled.contains(.kitty)) {
self.state = .{ .kitty = .init(
alloc,
self.max_bytes.get(.kitty) orelse
Protocol.defaultMaxBytes(.kitty),
) };
} else {
self.state = .ignore;
}
} else {
self.state = .ignore;
}
break :id;
}
// If we hit `;` then identify...
if (byte == ';') {
const str = id.buf[0..id.len];
if (std.mem.eql(u8, str, "25a1") and
self.enabled.contains(.glyph))
{
self.state = .{ .glyph = .init(
alloc,
self.max_bytes.get(.glyph) orelse
Protocol.defaultMaxBytes(.glyph),
) };
if (std.mem.eql(u8, str, glyph.identifier)) {
if (self.enabled.contains(.glyph)) {
self.state = .{ .glyph = .init(
alloc,
self.max_bytes.get(.glyph) orelse
Protocol.defaultMaxBytes(.glyph),
) };
} else {
self.state = .ignore;
}
} else {
self.state = .ignore;
self.beginUnknown(alloc, str, &.{byte});
}
break :id;
}
// If we're out of space to buffer then we're done.
// If we're out of identification space, the identifier is
// unsupported. Preserve the buffered prefix before replacing
// the identify union state.
if (id.len >= id.buf.len) {
self.state = .ignore;
self.beginUnknown(alloc, id.buf[0..id.len], &.{byte});
break :id;
}
const expected_idx: usize = id.len;
id.buf[id.len] = byte;
id.len += 1;
// Once the buffered input is no longer a prefix of a known
// protocol, it is an unsupported identifier.
if (self.unknown_max_bytes > 0 and
byte != glyph.identifier[expected_idx])
{
self.beginUnknown(alloc, id.buf[0..id.len], &.{});
}
},
.kitty => |*p| if (comptime build_options.kitty_graphics) {
@@ -113,6 +135,27 @@ pub const Handler = struct {
}
}
/// Transition from protocol identification to bounded unknown capture.
fn beginUnknown(
self: *Handler,
alloc: Allocator,
prefix: []const u8,
suffix: []const u8,
) void {
const max_bytes = self.unknown_max_bytes;
if (max_bytes == 0) {
self.state = .ignore;
return;
}
// Build the replacement before overwriting identify because prefix
// points into that union field.
var unknown: UnknownBuilder = .init(alloc, max_bytes);
unknown.append(prefix);
unknown.append(suffix);
self.state = .{ .unknown = unknown };
}
/// Feed a slice of bytes to the handler. This is equivalent to
/// calling feed for each byte in order, but protocol payload bytes
/// are passed through in bulk so large payloads (e.g. Kitty graphics
@@ -126,6 +169,12 @@ pub const Handler = struct {
// We're ignoring this APC command; drop the whole slice.
.ignore => return,
// We're capturing an unknown APC command, so store it.
.unknown => |*unknown| {
unknown.append(rem);
return;
},
// Identification consumes at most a few bytes; step
// through them one at a time until the state changes.
.identify => {
@@ -154,6 +203,8 @@ pub const Handler = struct {
}
}
/// Complete the current APC. The caller owns a returned result and must
/// call `Command.deinit` with the allocator used while feeding the APC.
pub fn end(self: *Handler) ?Command {
defer {
self.state.deinit();
@@ -163,6 +214,7 @@ pub const Handler = struct {
return switch (self.state) {
.inactive => unreachable,
.ignore, .identify => null,
.unknown => |*unknown| .{ .unknown = unknown.toOwned() },
.kitty => |*p| kitty: {
if (comptime !build_options.kitty_graphics) unreachable;
@@ -207,7 +259,7 @@ pub const State = union(enum) {
///
identify: struct {
len: u3 = 0,
buf: [4]u8 = undefined,
buf: [glyph.identifier.len]u8 = undefined,
},
/// Kitty graphics protocol
@@ -219,9 +271,15 @@ pub const State = union(enum) {
/// Glyph protocol
glyph: glyph.CommandParser,
/// An unsupported APC retained for the optional unknown callback.
/// Keep this after recognized protocol states so their tag values and
/// generated dispatch stay stable when unknown capture is unused.
unknown: UnknownBuilder,
pub fn deinit(self: *State) void {
switch (self.*) {
.inactive, .ignore, .identify => {},
.unknown => |*v| v.deinit(),
.glyph => |*v| v.deinit(),
.kitty => |*v| if (comptime build_options.kitty_graphics)
v.deinit()
@@ -231,6 +289,95 @@ pub const State = union(enum) {
}
};
/// UnknownBuilder is responsible for accumulating bytes for an
/// unidentified APC command if unknown capture is enabled.
const UnknownBuilder = struct {
data: std.ArrayList(u8) = .empty,
alloc: Allocator,
max_bytes: usize,
truncated: bool = false,
fn init(alloc: Allocator, max_bytes: usize) UnknownBuilder {
return .{
.alloc = alloc,
.max_bytes = max_bytes,
};
}
fn deinit(self: *UnknownBuilder) void {
self.data.deinit(self.alloc);
self.data = .empty;
}
// Append some bytes to the unknown capture. This flags as truncated
// if allocation fails or we reach our byte limit, therefore
// it can't fail.
fn append(self: *UnknownBuilder, bytes: []const u8) void {
if (bytes.len == 0) return;
const current = self.data.items.len;
// Determine how many bytes we can store in this append and
// if it is less than our input, then we have to note we're
// truncating.
const retained = @min(bytes.len, self.max_bytes -| current);
if (retained < bytes.len) self.truncated = true;
// If we require more bytes than our capacity allows then we
// need to grow.
const required = current + retained;
if (required > self.data.capacity) {
const capacity = @min(
self.max_bytes,
@max(required, @max(self.data.capacity *| 2, 1)),
);
self.data.ensureTotalCapacityPrecise(
self.alloc,
capacity,
) catch {
self.truncated = true;
return;
};
}
self.data.appendSliceAssumeCapacity(bytes[0..retained]);
}
/// Convert the current capture state to an Unknown where allocator
/// ownership shifts to Unknown. Removes any accumulated unknown
/// capture in this struct.
///
/// This can't fail because if there is an allocator issue we return
/// an empty truncate-flagged Unknown.
fn toOwned(self: *UnknownBuilder) Unknown {
// toOwnedSlice allows us to reuse data but this makes error
// handling a little simpler.
const content = self.data.toOwnedSlice(self.alloc) catch {
self.data.deinit(self.alloc);
self.data = .empty;
return .{
.content = self.data.items,
.truncated = true,
};
};
return .{
.content = content,
.truncated = self.truncated,
};
}
};
/// An unsupported APC returned by `Handler.end`.
pub const Unknown = struct {
content: []u8,
truncated: bool,
pub fn deinit(self: *Unknown, alloc: Allocator) void {
if (self.content.len > 0) alloc.free(self.content);
self.* = undefined;
}
};
/// Possible APC command types.
pub const Protocol = enum {
kitty,
@@ -261,14 +408,15 @@ pub const Protocol = enum {
}
};
/// Possible APC commands.
pub const Command = union(Protocol) {
/// A recognized or unsupported APC command.
pub const Command = union(enum) {
kitty: if (build_options.kitty_graphics)
kitty_gfx.Command
else
void,
glyph: glyph.Request,
unknown: Unknown,
pub fn deinit(self: *Command, alloc: Allocator) void {
switch (self.*) {
@@ -278,6 +426,7 @@ pub const Command = union(Protocol) {
unreachable,
.glyph => |*v| v.deinit(alloc),
.unknown => |*v| v.deinit(alloc),
}
}
};
@@ -292,6 +441,64 @@ test "unknown APC command" {
try testing.expect(h.end() == null);
}
test "capture unknown APC command" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{ .unknown_max_bytes = 5 };
defer h.deinit();
h.start();
h.feedSlice(alloc, "abcd;payload");
var result = h.end().?;
defer result.deinit(alloc);
const unknown = &result.unknown;
try testing.expectEqualStrings("abcd;", unknown.content);
try testing.expect(unknown.truncated);
}
test "capture short unknown APC command" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{ .unknown_max_bytes = 16 };
defer h.deinit();
h.start();
h.feed(alloc, 'X');
var result = h.end().?;
const unknown = &result.unknown;
try testing.expectEqualStrings("X", unknown.content);
try testing.expect(!unknown.truncated);
result.deinit(alloc);
h.unknown_max_bytes = 1;
h.start();
h.feedSlice(alloc, "XYZ");
result = h.end().?;
const truncated = &result.unknown;
try testing.expectEqualStrings("X", truncated.content);
try testing.expect(truncated.truncated);
result.deinit(alloc);
}
test "disabled known APC protocol is not unknown" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{ .unknown_max_bytes = 64 };
defer h.deinit();
h.enable(.glyph, false);
h.start();
h.feedSlice(alloc, "25a1;q;cp=E0A0");
try testing.expect(h.end() == null);
// An incomplete known protocol identifier is malformed, not unknown.
h.start();
h.feedSlice(alloc, "25a");
try testing.expect(h.end() == null);
}
test "garbage Kitty command" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
@@ -374,9 +581,9 @@ test "valid Kitty command" {
const input = "Gf=24,s=10,v=20,hello=world";
for (input) |c| h.feed(alloc, c);
var cmd = h.end().?;
defer cmd.deinit(alloc);
try testing.expect(cmd == .kitty);
var result = h.end().?;
defer result.deinit(alloc);
try testing.expect(result == .kitty);
}
test "identify with unrecognized command" {
@@ -436,10 +643,10 @@ test "valid glyph command" {
h.start();
for ("25a1;q;cp=E0A0") |c| h.feed(alloc, c);
var cmd = h.end().?;
defer cmd.deinit(alloc);
try testing.expect(cmd == .glyph);
try testing.expect(cmd.glyph == .query);
var result = h.end().?;
defer result.deinit(alloc);
try testing.expect(result == .glyph);
try testing.expect(result.glyph == .query);
}
test "feedSlice valid Kitty command" {
@@ -452,12 +659,12 @@ test "feedSlice valid Kitty command" {
h.start();
h.feedSlice(alloc, "Gf=24,s=10,v=20;aGVsbG8=");
var cmd = h.end().?;
defer cmd.deinit(alloc);
try testing.expect(cmd == .kitty);
var result = h.end().?;
defer result.deinit(alloc);
try testing.expect(result == .kitty);
// The payload is base64-decoded by the parser on completion.
try testing.expectEqualStrings("hello", cmd.kitty.data);
try testing.expectEqualStrings("hello", result.kitty.data);
}
test "feedSlice identify split across slices" {
@@ -472,12 +679,12 @@ test "feedSlice identify split across slices" {
h.feedSlice(alloc, "f=24,s=10,");
h.feedSlice(alloc, "v=20;aGVsbG8=");
var cmd = h.end().?;
defer cmd.deinit(alloc);
try testing.expect(cmd == .kitty);
var result = h.end().?;
defer result.deinit(alloc);
try testing.expect(result == .kitty);
// The payload is base64-decoded by the parser on completion.
try testing.expectEqualStrings("hello", cmd.kitty.data);
try testing.expectEqualStrings("hello", result.kitty.data);
}
test "feedSlice unknown APC command is ignored" {
@@ -500,10 +707,10 @@ test "feedSlice valid glyph command" {
h.start();
h.feedSlice(alloc, "25a1;q;cp=E0A0");
var cmd = h.end().?;
defer cmd.deinit(alloc);
try testing.expect(cmd == .glyph);
try testing.expect(cmd.glyph == .query);
var result = h.end().?;
defer result.deinit(alloc);
try testing.expect(result == .glyph);
try testing.expect(result.glyph == .query);
}
test "feedSlice kitty max bytes exceeded" {

View File

@@ -150,6 +150,9 @@
const std = @import("std");
/// APC identifier for the glyph protocol.
pub const identifier = "25a1";
pub const request = @import("glyph/request.zig");
pub const response = @import("glyph/response.zig");
pub const execute = @import("glyph/execute.zig").execute;

View File

@@ -148,6 +148,36 @@ pub const ProgressReport = extern struct {
progress: i8,
};
/// A borrowed unsupported string sequence.
///
/// C: GhosttyTerminalUnknownStringSequence
pub const UnknownStringSequence = extern struct {
truncated: bool,
content: lib.String,
};
/// An unsupported terminal sequence reported to the C callback.
///
/// C: GhosttyTerminalUnknownSequence
pub const UnknownSequence = union(Tag) {
apc: UnknownStringSequence,
/// C: GhosttyTerminalUnknownSequenceTag
pub const Tag = lib.Enum(lib.target, &.{"apc"});
const c_union = lib.TaggedUnion(
lib.target,
@This(),
// A future borrowed CSI payload may need parameter, separator, and
// intermediate arrays. Reserve 128 bytes so that representation and
// other structured sequence types can be added without an ABI break.
[16]u64,
);
pub const C = c_union.C;
pub const CValue = c_union.CValue;
pub const cval = c_union.cval;
};
/// A terminal mode and boolean value used for mode configuration.
///
/// C: GhosttyTerminalModeConfig
@@ -161,9 +191,10 @@ pub const ModeConfig = extern struct {
}
};
/// C callback state for terminal effects. Trampolines are always
/// installed on the stream handler; they check these fields and
/// no-op when the corresponding callback is null.
/// C callback state for terminal effects. Most trampolines are always
/// installed on the stream handler; they check these fields and no-op when
/// the corresponding callback is null. The unknown-sequence trampoline is
/// installed dynamically to preserve its null fast path.
const Effects = struct {
userdata: ?*anyopaque = null,
write_pty: ?WritePtyFn = null,
@@ -178,6 +209,7 @@ const Effects = struct {
progress_report: ?ProgressReportFn = null,
size_cb: ?SizeFn = null,
clipboard_write: ?ClipboardWriteFn = null,
unknown_sequence: ?UnknownSequenceFn = null,
/// Scratch buffer for DA1 feature codes. The device attributes
/// trampoline converts C feature codes into this buffer and returns
@@ -225,6 +257,10 @@ const Effects = struct {
/// C function pointer type for the progress_report callback.
pub const ProgressReportFn = *const fn (Terminal, ?*anyopaque, *const ProgressReport) callconv(lib.calling_conv) void;
/// C function pointer type for the unknown_sequence callback. The request
/// and its content are borrowed for the callback duration.
pub const UnknownSequenceFn = *const fn (Terminal, ?*anyopaque, *const UnknownSequence.C) callconv(lib.calling_conv) void;
/// C function pointer type for the size callback.
/// Returns true and fills out_size if size is available,
/// or returns false to silently ignore the query.
@@ -408,6 +444,26 @@ const Effects = struct {
func(@ptrCast(wrapper), wrapper.effects.userdata, &c_report);
}
fn unknownSequenceTrampoline(
handler: *Handler,
sequence: Handler.UnknownSequence,
) void {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.unknown_sequence orelse return;
const value = UnknownSequence.cval(switch (sequence) {
.apc => |apc_value| .{
.apc = .{
.truncated = apc_value.truncated,
.content = .{
.ptr = apc_value.content.ptr,
.len = apc_value.content.len,
},
},
},
});
func(@ptrCast(wrapper), wrapper.effects.userdata, &value);
}
fn sizeTrampoline(handler: *Handler) ?size_report.Size {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.size_cb orelse return null;
@@ -905,6 +961,8 @@ pub const Option = enum(c_int) {
title_report = 32,
mode_default = 33,
mode = 34,
unknown_sequence = 35,
unknown_max_bytes = 36,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -922,6 +980,7 @@ pub const Option = enum(c_int) {
.progress_report => ?Effects.ProgressReportFn,
.size_cb => ?Effects.SizeFn,
.clipboard_write => ?Effects.ClipboardWriteFn,
.unknown_sequence => ?Effects.UnknownSequenceFn,
.title, .pwd => ?*const lib.String,
.color_foreground, .color_background, .color_cursor => ?*const color.RGB.C,
.color_palette => ?*const color.PaletteC,
@@ -937,6 +996,7 @@ pub const Option = enum(c_int) {
.scrollback_max_bytes,
.scrollback_max_lines,
.continuation_max_bytes,
.unknown_max_bytes,
=> ?*const usize,
.selection => ?*const selection_c.CSelection,
.default_cursor_style => ?*const TerminalCursorStyle,
@@ -988,6 +1048,13 @@ fn setTyped(
.progress_report => wrapper.effects.progress_report = value,
.size_cb => wrapper.effects.size_cb = value,
.clipboard_write => wrapper.effects.clipboard_write = value,
.unknown_sequence => {
wrapper.effects.unknown_sequence = value;
wrapper.stream.handler.unknown_sequence = if (value != null)
&Effects.unknownSequenceTrampoline
else
null;
},
.title_report => wrapper.stream.handler.title_report = if (value) |ptr|
ptr.*
else
@@ -1106,6 +1173,8 @@ fn setTyped(
wrapper,
if (value) |ptr| ptr.* else default_continuation_max_bytes,
),
.unknown_max_bytes => wrapper.stream.handler.apc_handler.unknown_max_bytes =
if (value) |ptr| ptr.* else 0,
.mode, .mode_default => {
const config = (value orelse return .invalid_value).*;
const mode = config.toMode() orelse return .invalid_value;
@@ -3861,6 +3930,114 @@ test "set progress_report callback" {
try testing.expectEqual(@as(usize, cases.len), S.count);
}
test "set unknown_sequence callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var count: usize = 0;
var last_terminal: Terminal = null;
var last_userdata: ?*anyopaque = null;
var last_tag: UnknownSequence.Tag = .apc;
var last_truncated: bool = false;
var content: [64]u8 = undefined;
var content_len: usize = 0;
fn unknownSequence(
terminal_: Terminal,
ud: ?*anyopaque,
sequence: *const UnknownSequence.C,
) callconv(lib.calling_conv) void {
count += 1;
last_terminal = terminal_;
last_userdata = ud;
last_tag = sequence.tag;
const apc_value = sequence.value.apc;
last_truncated = apc_value.truncated;
content_len = @min(apc_value.content.len, content.len);
@memcpy(content[0..content_len], apc_value.content.ptr[0..content_len]);
}
};
S.count = 0;
S.last_terminal = null;
S.last_userdata = null;
S.last_tag = .apc;
S.last_truncated = false;
S.content_len = 0;
var sentinel: u8 = 101;
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
const max_bytes: usize = 8;
try testing.expectEqual(Result.success, set(
t,
.unknown_max_bytes,
@ptrCast(&max_bytes),
));
try testing.expectEqual(max_bytes, t.?.stream.handler.apc_handler.unknown_max_bytes);
// A byte limit without a callback performs no external effect.
const before_callback = "\x1B_abc;xy\x1B\\";
vt_write(t, before_callback, before_callback.len);
try testing.expectEqual(@as(usize, 0), S.count);
try testing.expect(t.?.stream.handler.unknown_sequence == null);
try testing.expectEqual(Result.success, set(
t,
.unknown_sequence,
@ptrCast(&S.unknownSequence),
));
try testing.expect(t.?.stream.handler.unknown_sequence != null);
// Split a complete APC across writes to exercise persistent parser state.
const seq_a = "\x1B_abc;";
const seq_b = "xy\x1B\\";
vt_write(t, seq_a, seq_a.len);
try testing.expectEqual(@as(usize, 0), S.count);
vt_write(t, seq_b, seq_b.len);
try testing.expectEqual(@as(usize, 1), S.count);
try testing.expectEqual(t, S.last_terminal);
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
try testing.expectEqual(UnknownSequence.Tag.apc, S.last_tag);
try testing.expect(!S.last_truncated);
try testing.expectEqualStrings("abc;xy", S.content[0..S.content_len]);
// Content beyond the generic limit is omitted and marked truncated.
const truncated = "\x1B_abcdefghijkl\x1B\\";
vt_write(t, truncated, truncated.len);
try testing.expectEqual(@as(usize, 2), S.count);
try testing.expect(S.last_truncated);
try testing.expectEqualStrings("abcdefgh", S.content[0..S.content_len]);
// CAN aborts the APC and must not invoke the callback.
const aborted = "\x1B_abcdef\x18";
vt_write(t, aborted, aborted.len);
try testing.expectEqual(@as(usize, 2), S.count);
// Clearing the callback restores the null fast path immediately.
try testing.expectEqual(Result.success, set(t, .unknown_sequence, null));
try testing.expect(t.?.stream.handler.unknown_sequence == null);
vt_write(t, before_callback, before_callback.len);
try testing.expectEqual(@as(usize, 2), S.count);
// A NULL limit disables capture even after reinstalling the callback.
try testing.expectEqual(Result.success, set(
t,
.unknown_sequence,
@ptrCast(&S.unknownSequence),
));
try testing.expectEqual(Result.success, set(t, .unknown_max_bytes, null));
try testing.expectEqual(@as(usize, 0), t.?.stream.handler.apc_handler.unknown_max_bytes);
vt_write(t, before_callback, before_callback.len);
try testing.expectEqual(@as(usize, 2), S.count);
}
test "set pwd_changed callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(

View File

@@ -75,6 +75,8 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: {
.{ "GhosttyTerminalProgressReport", StructInfo.init(terminal.ProgressReport) },
.{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) },
.{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) },
.{ "GhosttyTerminalUnknownSequence", StructInfo.init(terminal.UnknownSequence.C) },
.{ "GhosttyTerminalUnknownStringSequence", StructInfo.init(terminal.UnknownStringSequence) },
.{ "GhosttyWriter", StructInfo.init(io.Writer) },
});
};
@@ -223,6 +225,8 @@ test "json parses" {
try std.testing.expect(root.contains("GhosttyClipboardWrite"));
try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions"));
try std.testing.expect(root.contains("GhosttyTerminalModeConfig"));
try std.testing.expect(root.contains("GhosttyTerminalUnknownSequence"));
try std.testing.expect(root.contains("GhosttyTerminalUnknownStringSequence"));
try std.testing.expect(root.contains("GhosttyReader"));
try std.testing.expect(root.contains("GhosttyWriter"));
@@ -238,6 +242,16 @@ test "json parses" {
try std.testing.expect(clipboard_write_fields.contains("contents"));
try std.testing.expect(clipboard_write_fields.contains("contents_len"));
const unknown_sequence = root.get("GhosttyTerminalUnknownSequence").?.object;
const unknown_sequence_fields = unknown_sequence.get("fields").?.object;
try std.testing.expect(unknown_sequence_fields.contains("tag"));
try std.testing.expect(unknown_sequence_fields.contains("value"));
const unknown_string = root.get("GhosttyTerminalUnknownStringSequence").?.object;
const unknown_string_fields = unknown_string.get("fields").?.object;
try std.testing.expect(unknown_string_fields.contains("truncated"));
try std.testing.expect(unknown_string_fields.contains("content"));
const reader_fields = root.get("GhosttyReader").?.object
.get("fields").?.object;
try std.testing.expect(reader_fields.contains("read"));

View File

@@ -59,6 +59,7 @@ pub const Terminal = @import("Terminal.zig");
pub const TerminalStream = stream_terminal.Stream;
pub const Stream = stream.Stream;
pub const StreamAction = stream.Action;
pub const UnknownSequence = stream_terminal.Handler.UnknownSequence;
pub const Cursor = Screen.Cursor;
pub const CursorStyle = Screen.CursorStyle;
pub const CursorStyleReq = ansi.CursorStyle;

View File

@@ -110,7 +110,7 @@ pub const Action = union(Key) {
dcs_put: u8,
dcs_unhook,
apc_start,
apc_end,
apc_end: ApcEnd,
apc_put: u8,
apc_put_slice: ApcPutSlice,
end_hyperlink,
@@ -291,6 +291,11 @@ pub const Action = union(Key) {
}
};
pub const ApcEnd = extern struct {
/// False when CAN, SUB, or another aborting transition ended the APC.
terminated: bool,
};
pub const InvokeCharset = lib.Struct(lib.target, struct {
bank: charsets.ActiveSlot,
charset: charsets.Slots,
@@ -766,8 +771,29 @@ pub fn Stream(comptime H: type) type {
if (self.parser.state == .sos_pm_apc_string) {
offset += self.consumeApcString(input[offset..]);
if (offset >= input.len) return input.len;
// The next byte exits the string state; let
// nextNonUtf8 below handle it.
// Fast-path normal string termination. This matches
// Parser.next's exit and entry actions while avoiding
// the generic action loop for every completed APC.
switch (input[offset]) {
std.ascii.control_code.esc => {
self.parser.clear();
self.parser.state = .escape;
self.handler.vt(.apc_end, .{ .terminated = true });
offset += 1;
continue;
},
0x9C => {
self.parser.state = .ground;
self.handler.vt(.apc_end, .{ .terminated = true });
offset += 1;
continue;
},
else => {},
}
// Aborting transitions need the scalar path so the
// handler can distinguish them from terminators.
}
}
@@ -1109,7 +1135,9 @@ pub fn Stream(comptime H: type) type {
.dcs_unhook => self.handler.vt(.dcs_unhook, {}),
.apc_start => self.handler.vt(.apc_start, {}),
.apc_put => |code| self.handler.vt(.apc_put, code),
.apc_end => self.handler.vt(.apc_end, {}),
.apc_end => self.handler.vt(.apc_end, .{
.terminated = c == std.ascii.control_code.esc or c == 0x9C,
}),
}
}
}
@@ -3989,6 +4017,18 @@ test "stream: apc bulk slice" {
}
}
test "stream: apc bulk slice C1 ST" {
var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} });
s.nextSlice("\x1b_Gpayload\x9c");
try testing.expectEqual(@as(usize, 1), s.handler.started);
try testing.expectEqual(@as(usize, 1), s.handler.ended);
try testing.expectEqualStrings(
"Gpayload",
s.handler.buf[0..s.handler.len],
);
}
test "stream: apc bulk slice split across inputs" {
var s: Stream(ApcTestHandler) = .init(.{ .handler = .{} });
s.nextSlice("\x1b_Gf=24,s=10");

View File

@@ -70,6 +70,12 @@ pub const Handler = struct {
/// The DCS command handler maintains state for DCS queries.
dcs_handler: dcs.Handler = .{},
/// Called for sequence identifiers not supported by this library.
/// Currently, only APC is reported. Content is borrowed and only valid
/// for the duration of the callback. Set `apc_handler.unknown_max_bytes`
/// before starting the Stream to enable APC capture.
unknown_sequence: ?*const fn (*Handler, UnknownSequence) void = null,
pub const Effects = struct {
/// Called when the terminal needs to write data back to the pty,
/// e.g. in response to a DECRQM query. The data is only valid
@@ -157,6 +163,18 @@ pub const Handler = struct {
};
};
/// A sequence unsupported by the active handler. Payload data is borrowed
/// only for the duration of the handler callback.
pub const UnknownSequence = union(enum) {
apc: String,
/// Content between a string sequence's introducer and terminator.
pub const String = struct {
content: []const u8,
truncated: bool,
};
};
pub fn init(terminal: *Terminal) Handler {
return .{
.terminal = terminal,
@@ -211,6 +229,11 @@ pub const Handler = struct {
};
}
fn unknownSequence(self: *Handler, value: UnknownSequence) void {
const func = self.unknown_sequence orelse return;
func(self, value);
}
inline fn vtFallible(
self: *Handler,
comptime action: Action.Tag,
@@ -326,7 +349,7 @@ pub const Handler = struct {
.apc_start => self.apc_handler.start(),
.apc_put => self.apc_handler.feed(self.terminal.gpa(), value),
.apc_put_slice => self.apc_handler.feedSlice(self.terminal.gpa(), value.bytes),
.apc_end => self.apcEnd(),
.apc_end => self.apcEnd(value.terminated),
// Effect-based handlers
.bell => self.bell(),
@@ -948,13 +971,18 @@ pub const Handler = struct {
}
}
fn apcEnd(self: *Handler) void {
fn apcEnd(self: *Handler, terminated: bool) void {
const io = self.terminal.io();
const alloc = self.terminal.gpa();
var cmd = self.apc_handler.end() orelse return;
defer cmd.deinit(alloc);
switch (cmd) {
var result = self.apc_handler.end() orelse return;
defer result.deinit(alloc);
switch (result) {
.unknown => |*unknown| {
if (terminated) self.unknownSequence(.{ .apc = .{
.content = unknown.content,
.truncated = unknown.truncated,
} });
},
.kitty => |*kitty_cmd| if (comptime build_options.kitty_graphics) {
if (self.terminal.kittyGraphics(
io,
@@ -1016,6 +1044,51 @@ test "resize clears synchronized output on unchanged cell dimensions" {
try testing.expectEqual(@as(u32, 432), t.height_px);
}
test "unknown APC effect callback" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var count: usize = 0;
var content: [16]u8 = undefined;
var content_len: usize = undefined;
var truncated: bool = undefined;
fn unknownSequence(_: *Handler, value: Handler.UnknownSequence) void {
switch (value) {
.apc => |apc_value| {
content_len = apc_value.content.len;
@memcpy(content[0..apc_value.content.len], apc_value.content);
truncated = apc_value.truncated;
},
}
count += 1;
}
};
S.count = 0;
var handler: Handler = .init(&t);
handler.unknown_sequence = &S.unknownSequence;
handler.apc_handler.unknown_max_bytes = 8;
var s: Stream = .init(.{
.allocator = testing.allocator,
.handler = handler,
});
defer s.deinit();
// Unknown OSC commands retain their legacy behavior and are ignored.
s.nextSlice("\x1B]999;abcdef\x07");
s.nextSlice("\x1B_abcd;payload\x1B\\");
try testing.expectEqual(@as(usize, 1), S.count);
try testing.expectEqualStrings("abcd;pay", S.content[0..S.content_len]);
try testing.expect(S.truncated);
// Aborted unknown APCs are suppressed.
s.nextSlice("\x1B_Xpayload\x18");
try testing.expectEqual(@as(usize, 1), S.count);
}
test "resize reports mode 2048 geometry" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);

View File

@@ -476,11 +476,12 @@ pub const StreamHandler = struct {
}
pub fn apcEnd(self: *StreamHandler) !void {
var cmd = self.apc.end() orelse return;
defer cmd.deinit(self.alloc);
var result = self.apc.end() orelse return;
defer result.deinit(self.alloc);
// log.warn("APC command: {}", .{cmd});
switch (cmd) {
// log.warn("APC command: {}", .{result});
switch (result) {
.unknown => return,
.kitty => |*kitty_cmd| {
if (self.terminal.kittyGraphics(global.io(), self.alloc, kitty_cmd)) |resp| {
var buf: [1024]u8 = undefined;