terminal: C API for unknown sequences

This commit is contained in:
Mitchell Hashimoto
2026-08-08 16:49:26 -07:00
parent b537282411
commit 6b990de5be
4 changed files with 342 additions and 4 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

@@ -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"));