diff --git a/example/c-vt-effects/src/main.c b/example/c-vt-effects/src/main.c index c40f8fa88..bc5ece06d 100644 --- a/example/c-vt-effects/src/main.c +++ b/example/c-vt-effects/src/main.c @@ -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); diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 2c783974b..8a914a47d 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -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; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 63c996c0a..52619bc58 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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( diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 40a5b63e0..df84d3ede 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -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"));