From b91cc867a815f1d26bd5b34d17b70b64abff88d1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Mar 2026 06:50:58 -0700 Subject: [PATCH 01/12] vt: add ghostty_terminal_set for configuring effects callbacks Add a typed option setter ghostty_terminal_set() following the existing setopt pattern used by the key encoder and render state APIs. This is the first step toward exposing stream_terminal Handler.Effects through the C API. The initial implementation includes a write_pty callback and a shared userdata pointer. The write_pty callback is invoked synchronously during ghostty_terminal_vt_write() when the terminal needs to send a response back to the pty, such as DECRQM mode reports or device status responses. Trampolines are always installed at terminal creation time and no-op when no C callback is set, so callers can configure callbacks at any point without reinitializing the stream. The C callback state is grouped into an internal Effects struct on the TerminalWrapper to simplify adding more callbacks in the future. --- include/ghostty/vt/terminal.h | 77 ++++++++++++++++++++++++++++++++--- src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 74 ++++++++++++++++++++++++++++++++- 4 files changed, 146 insertions(+), 7 deletions(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index b8e929684..064ee81dd 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -134,6 +134,52 @@ typedef struct { uint64_t len; } GhosttyTerminalScrollbar; +/** + * Callback function type for write_pty. + * + * Called when the terminal needs to write data back to the pty, for + * example in response to a device status report or mode query. The + * data is only valid for the duration of the call; callers must copy + * it if it needs to persist. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param data Pointer to the response bytes + * @param len Length of the response in bytes + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal, + void* userdata, + const uint8_t* data, + size_t len); + +/** + * Terminal option identifiers. + * + * These values are used with ghostty_terminal_set() to configure + * terminal callbacks and associated state. + * + * @ingroup terminal + */ +typedef enum { + /** + * Opaque userdata pointer passed to all callbacks. + * + * Input type: void** + */ + GHOSTTY_TERMINAL_OPT_USERDATA = 0, + + /** + * Callback invoked when the terminal needs to write data back + * to the pty (e.g. in response to a DECRQM query or device + * status report). Set to NULL to ignore such sequences. + * + * Input type: GhosttyTerminalWritePtyFn* + */ + GHOSTTY_TERMINAL_OPT_WRITE_PTY = 1, +} GhosttyTerminalOption; + /** * Terminal data types. * @@ -290,15 +336,36 @@ GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal, uint16_t cols, uint16_t rows); +/** + * Set an option on the terminal. + * + * Configures terminal callbacks and associated state such as the + * write_pty callback and userdata pointer. A NULL value pointer + * clears the option to its default (NULL/disabled). + * + * Callbacks are invoked synchronously during ghostty_terminal_vt_write(). + * Callbacks must not call ghostty_terminal_vt_write() on the same + * terminal (no reentrancy). + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * @param option The option to set + * @param value Pointer to the value to set (type depends on the option), + * or NULL to clear the option + * + * @ingroup terminal + */ +void ghostty_terminal_set(GhosttyTerminal terminal, + GhosttyTerminalOption option, + const void* value); + /** * Write VT-encoded data to the terminal for processing. * * Feeds raw bytes through the terminal's VT stream parser, updating - * terminal state accordingly. Only read-only sequences are processed; - * sequences that require output (queries) are ignored. - * - * In the future, a callback-based API will be added to allow handling - * of output or side effect sequences. + * terminal state accordingly. By default, sequences that require output + * (queries, device status reports) are silently ignored. Use + * ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_WRITE_PTY to install + * a callback that receives response data. * * This never fails. Any erroneous input or errors in processing the * input are logged internally but do not cause this function to fail diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 81375ec8a..0a749be87 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -206,6 +206,7 @@ comptime { @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); @export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" }); @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); + @export(&c.terminal_set, .{ .name = "ghostty_terminal_set" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); @export(&c.terminal_mode_get, .{ .name = "ghostty_terminal_mode_get" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 7597228d1..d854ac60e 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -132,6 +132,7 @@ pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; pub const terminal_reset = terminal.reset; pub const terminal_resize = terminal.resize; +pub const terminal_set = terminal.set; pub const terminal_vt_write = terminal.vt_write; pub const terminal_scroll_viewport = terminal.scroll_viewport; pub const terminal_mode_get = terminal.mode_get; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index bebcf4ea1..70997cc08 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -16,14 +16,35 @@ const grid_ref_c = @import("grid_ref.zig"); const style_c = @import("style.zig"); const Result = @import("result.zig").Result; +const Handler = @import("../stream_terminal.zig").Handler; + const log = std.log.scoped(.terminal_c); +/// C function pointer type for the write_pty callback. +pub const CWritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(.c) void; + /// Wrapper around ZigTerminal that tracks additional state for C API usage, /// such as the persistent VT stream needed to handle escape sequences split /// across multiple vt_write calls. const TerminalWrapper = struct { terminal: *ZigTerminal, stream: Stream, + effects: Effects = .{}, +}; + +/// 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. +const Effects = struct { + userdata: ?*anyopaque = null, + write_pty: ?CWritePtyFn = null, + + fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.write_pty orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len); + } }; /// C: GhosttyTerminal @@ -80,8 +101,10 @@ fn new_( }); errdefer t.deinit(alloc); - // Setup our stream - const handler: Stream.Handler = t.vtHandler(); + // Setup our stream with trampolines always installed so that + // setting C callbacks at any time takes effect immediately. + var handler: Stream.Handler = t.vtHandler(); + handler.effects.write_pty = &Effects.writePtyTrampoline; wrapper.* = .{ .terminal = t, @@ -100,6 +123,53 @@ pub fn vt_write( wrapper.stream.nextSlice(ptr[0..len]); } +/// C: GhosttyTerminalOption +pub const Option = enum(c_int) { + userdata = 0, + write_pty = 1, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { + return switch (self) { + .userdata => ?*anyopaque, + .write_pty => ?CWritePtyFn, + }; + } +}; + +pub fn set( + terminal_: Terminal, + option: Option, + value: ?*const anyopaque, +) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + log.warn("terminal_set invalid option value={d}", .{@intFromEnum(option)}); + return; + }; + } + + return switch (option) { + inline else => |comptime_option| setTyped( + terminal_, + comptime_option, + @ptrCast(@alignCast(value)), + ), + }; +} + +fn setTyped( + terminal_: Terminal, + comptime option: Option, + value: ?*const option.InType(), +) void { + const wrapper = terminal_ orelse return; + switch (option) { + .userdata => wrapper.effects.userdata = if (value) |v| v.* else null, + .write_pty => wrapper.effects.write_pty = if (value) |v| v.* else null, + } +} + /// C: GhosttyTerminalScrollViewport pub const ScrollViewport = ZigTerminal.ScrollViewport.C; From b49e9f37ff71cc007dafa592c5d7385d70a2dab1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Mar 2026 06:56:51 -0700 Subject: [PATCH 02/12] vt: add bell effect callback and move types into Effects Add GHOSTTY_TERMINAL_OPT_BELL so C consumers can receive bell notifications during VT processing. The bell trampoline follows the same pattern as write_pty. Move the C function pointer typedefs (WritePtyFn, BellFn) into the Effects struct namespace to keep callback types co-located with their storage and trampolines. --- include/ghostty/vt/terminal.h | 21 +++++++++++++++++++++ src/terminal/c/terminal.zig | 25 ++++++++++++++++++++----- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 064ee81dd..c7990336b 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -154,6 +154,19 @@ typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal, const uint8_t* data, size_t len); +/** + * Callback function type for bell. + * + * Called when the terminal receives a BEL character (0x07). + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, + void* userdata); + /** * Terminal option identifiers. * @@ -178,6 +191,14 @@ typedef enum { * Input type: GhosttyTerminalWritePtyFn* */ GHOSTTY_TERMINAL_OPT_WRITE_PTY = 1, + + /** + * Callback invoked when the terminal receives a BEL character + * (0x07). Set to NULL to ignore bell events. + * + * Input type: GhosttyTerminalBellFn* + */ + GHOSTTY_TERMINAL_OPT_BELL = 2, } GhosttyTerminalOption; /** diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 70997cc08..10e5a2960 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -20,9 +20,6 @@ const Handler = @import("../stream_terminal.zig").Handler; const log = std.log.scoped(.terminal_c); -/// C function pointer type for the write_pty callback. -pub const CWritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(.c) void; - /// Wrapper around ZigTerminal that tracks additional state for C API usage, /// such as the persistent VT stream needed to handle escape sequences split /// across multiple vt_write calls. @@ -37,7 +34,14 @@ const TerminalWrapper = struct { /// no-op when the corresponding callback is null. const Effects = struct { userdata: ?*anyopaque = null, - write_pty: ?CWritePtyFn = null, + write_pty: ?WritePtyFn = null, + bell: ?BellFn = null, + + /// C function pointer type for the write_pty callback. + pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(.c) void; + + /// C function pointer type for the bell callback. + pub const BellFn = *const fn (Terminal, ?*anyopaque) callconv(.c) void; fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); @@ -45,6 +49,13 @@ const Effects = struct { const func = wrapper.effects.write_pty orelse return; func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len); } + + fn bellTrampoline(handler: *Handler) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.bell orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata); + } }; /// C: GhosttyTerminal @@ -105,6 +116,7 @@ fn new_( // setting C callbacks at any time takes effect immediately. var handler: Stream.Handler = t.vtHandler(); handler.effects.write_pty = &Effects.writePtyTrampoline; + handler.effects.bell = &Effects.bellTrampoline; wrapper.* = .{ .terminal = t, @@ -127,12 +139,14 @@ pub fn vt_write( pub const Option = enum(c_int) { userdata = 0, write_pty = 1, + bell = 2, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { return switch (self) { .userdata => ?*anyopaque, - .write_pty => ?CWritePtyFn, + .write_pty => ?Effects.WritePtyFn, + .bell => ?Effects.BellFn, }; } }; @@ -167,6 +181,7 @@ fn setTyped( switch (option) { .userdata => wrapper.effects.userdata = if (value) |v| v.* else null, .write_pty => wrapper.effects.write_pty = if (value) |v| v.* else null, + .bell => wrapper.effects.bell = if (value) |v| v.* else null, } } From c13a9bb49ced768d7da3f6aff2e6d31fbed4641e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Mar 2026 06:58:17 -0700 Subject: [PATCH 03/12] vt: add tests for write_pty and bell effect callbacks Test that the write_pty callback receives correct DECRQM response data and userdata, that queries are silently ignored without a callback, and that setting null clears the callback. Test that the bell callback fires on single and multiple BEL characters with correct userdata, and that BEL without a callback is safe. --- src/terminal/c/terminal.zig | 151 ++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 10e5a2960..7a214d5b7 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -864,6 +864,157 @@ test "grid_ref null terminal" { }, &out_ref)); } +test "set write_pty callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + var last_userdata: ?*anyopaque = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + last_userdata = null; + } + + fn writePty(_: Terminal, ud: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + last_userdata = ud; + } + }; + defer S.deinit(); + + // Set userdata and write_pty callback + var sentinel: u8 = 42; + const ud: ?*anyopaque = @ptrCast(&sentinel); + set(t, .userdata, @ptrCast(&ud)); + const cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&cb)); + + // DECRQM for wraparound mode (mode 7, set by default) should trigger write_pty + vt_write(t, "\x1B[?7$p", 6); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1B[?7;1$y", S.last_data.?); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); +} + +test "set write_pty without callback ignores queries" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // Without setting a callback, DECRQM should be silently ignored (no crash) + vt_write(t, "\x1B[?7$p", 6); +} + +test "set write_pty null clears callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var called: bool = false; + fn writePty(_: Terminal, _: ?*anyopaque, _: [*]const u8, _: usize) callconv(.c) void { + called = true; + } + }; + S.called = false; + + // Set then clear the callback + const cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&cb)); + set(t, .write_pty, null); + + vt_write(t, "\x1B[?7$p", 6); + try testing.expect(!S.called); +} + +test "set bell callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var bell_count: usize = 0; + var last_userdata: ?*anyopaque = null; + + fn bell(_: Terminal, ud: ?*anyopaque) callconv(.c) void { + bell_count += 1; + last_userdata = ud; + } + }; + S.bell_count = 0; + S.last_userdata = null; + + // Set userdata and bell callback + var sentinel: u8 = 99; + const ud: ?*anyopaque = @ptrCast(&sentinel); + set(t, .userdata, @ptrCast(&ud)); + const cb: ?Effects.BellFn = &S.bell; + set(t, .bell, @ptrCast(&cb)); + + // Single BEL + vt_write(t, "\x07", 1); + try testing.expectEqual(@as(usize, 1), S.bell_count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + + // Multiple BELs + vt_write(t, "\x07\x07", 2); + try testing.expectEqual(@as(usize, 3), S.bell_count); +} + +test "bell without callback is silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // BEL without a callback should not crash + vt_write(t, "\x07", 1); +} + test "grid_ref out of bounds" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From f9c34b40f067cb40d63ce78aac00ead3ec77fabb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Mar 2026 07:03:29 -0700 Subject: [PATCH 04/12] vt: add enquiry and xtversion effect callbacks Add GHOSTTY_TERMINAL_OPT_ENQUIRY and GHOSTTY_TERMINAL_OPT_XTVERSION so C consumers can respond to ENQ (0x05) and XTVERSION (CSI > q) queries. Both callbacks return a GhosttyString rather than using out-pointers. Introduce GhosttyString in types.h as a borrowed byte string (ptr + len) backed by lib.String on the Zig side. This will be reusable for future callbacks that need to return string data. Without an xtversion callback the trampoline returns an empty string, which causes the handler to report the default "libghostty" version. Without an enquiry callback no response is sent. --- include/ghostty/vt/terminal.h | 50 ++++++++++ include/ghostty/vt/types.h | 17 ++++ src/terminal/c/terminal.zig | 183 ++++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index c7990336b..32956d6a3 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -167,6 +167,40 @@ typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal, typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, void* userdata); +/** + * Callback function type for enquiry (ENQ, 0x05). + * + * Called when the terminal receives an ENQ character. Return the + * response bytes as a GhosttyString. The memory must remain valid + * until the callback returns. Return a zero-length string to send + * no response. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @return The response bytes to write back to the pty + * + * @ingroup terminal + */ +typedef GhosttyString (*GhosttyTerminalEnquiryFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for XTVERSION. + * + * Called when the terminal receives an XTVERSION query (CSI > q). + * Return the version string (e.g. "myterm 1.0") as a GhosttyString. + * The memory must remain valid until the callback returns. Return a + * zero-length string to report the default "libghostty" version. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @return The version string to report + * + * @ingroup terminal + */ +typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal, + void* userdata); + /** * Terminal option identifiers. * @@ -199,6 +233,22 @@ typedef enum { * Input type: GhosttyTerminalBellFn* */ GHOSTTY_TERMINAL_OPT_BELL = 2, + + /** + * Callback invoked when the terminal receives an ENQ character + * (0x05). Set to NULL to send no response. + * + * Input type: GhosttyTerminalEnquiryFn* + */ + GHOSTTY_TERMINAL_OPT_ENQUIRY = 3, + + /** + * Callback invoked when the terminal receives an XTVERSION query + * (CSI > q). Set to NULL to report the default "libghostty" string. + * + * Input type: GhosttyTerminalXtversionFn* + */ + GHOSTTY_TERMINAL_OPT_XTVERSION = 4, } GhosttyTerminalOption; /** diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 12ace266e..b5b0fa651 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -7,6 +7,9 @@ #ifndef GHOSTTY_VT_TYPES_H #define GHOSTTY_VT_TYPES_H +#include +#include + /** * Result codes for libghostty-vt operations. */ @@ -21,6 +24,20 @@ typedef enum { GHOSTTY_OUT_OF_SPACE = -3, } GhosttyResult; +/** + * A borrowed byte string (pointer + length). + * + * The memory is not owned by this struct. The pointer is only valid + * for the lifetime documented by the API that produces or consumes it. + */ +typedef struct { + /** Pointer to the string bytes. */ + const uint8_t* ptr; + + /** Length of the string in bytes. */ + size_t len; +} GhosttyString; + /** * Initialize a sized struct to zero and set its size field. * diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 7a214d5b7..0dc0b0d6e 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -1,5 +1,6 @@ const std = @import("std"); const testing = std.testing; +const lib = @import("../../lib/main.zig"); const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; const ZigTerminal = @import("../Terminal.zig"); @@ -36,6 +37,8 @@ const Effects = struct { userdata: ?*anyopaque = null, write_pty: ?WritePtyFn = null, bell: ?BellFn = null, + enquiry: ?EnquiryFn = null, + xtversion: ?XtversionFn = null, /// C function pointer type for the write_pty callback. pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(.c) void; @@ -43,6 +46,17 @@ const Effects = struct { /// C function pointer type for the bell callback. pub const BellFn = *const fn (Terminal, ?*anyopaque) callconv(.c) void; + /// C function pointer type for the enquiry callback. + /// Returns the response bytes. The memory must remain valid + /// until the callback returns. + pub const EnquiryFn = *const fn (Terminal, ?*anyopaque) callconv(.c) lib.String; + + /// C function pointer type for the xtversion callback. + /// Returns the version string (e.g. "ghostty 1.2.3"). The memory + /// must remain valid until the callback returns. An empty string + /// (len=0) causes the default "libghostty" to be reported. + pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(.c) lib.String; + fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); @@ -56,6 +70,24 @@ const Effects = struct { const func = wrapper.effects.bell orelse return; func(@ptrCast(wrapper), wrapper.effects.userdata); } + + fn enquiryTrampoline(handler: *Handler) []const u8 { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.enquiry orelse return ""; + const result = func(@ptrCast(wrapper), wrapper.effects.userdata); + if (result.len == 0) return ""; + return result.ptr[0..result.len]; + } + + fn xtversionTrampoline(handler: *Handler) []const u8 { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.xtversion orelse return ""; + const result = func(@ptrCast(wrapper), wrapper.effects.userdata); + if (result.len == 0) return ""; + return result.ptr[0..result.len]; + } }; /// C: GhosttyTerminal @@ -117,6 +149,8 @@ fn new_( var handler: Stream.Handler = t.vtHandler(); handler.effects.write_pty = &Effects.writePtyTrampoline; handler.effects.bell = &Effects.bellTrampoline; + handler.effects.enquiry = &Effects.enquiryTrampoline; + handler.effects.xtversion = &Effects.xtversionTrampoline; wrapper.* = .{ .terminal = t, @@ -140,6 +174,8 @@ pub const Option = enum(c_int) { userdata = 0, write_pty = 1, bell = 2, + enquiry = 3, + xtversion = 4, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -147,6 +183,8 @@ pub const Option = enum(c_int) { .userdata => ?*anyopaque, .write_pty => ?Effects.WritePtyFn, .bell => ?Effects.BellFn, + .enquiry => ?Effects.EnquiryFn, + .xtversion => ?Effects.XtversionFn, }; } }; @@ -182,6 +220,8 @@ fn setTyped( .userdata => wrapper.effects.userdata = if (value) |v| v.* else null, .write_pty => wrapper.effects.write_pty = if (value) |v| v.* else null, .bell => wrapper.effects.bell = if (value) |v| v.* else null, + .enquiry => wrapper.effects.enquiry = if (value) |v| v.* else null, + .xtversion => wrapper.effects.xtversion = if (value) |v| v.* else null, } } @@ -1015,6 +1055,149 @@ test "bell without callback is silent" { vt_write(t, "\x07", 1); } +test "set enquiry callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + const response = "OK"; + fn enquiry(_: Terminal, _: ?*anyopaque) callconv(.c) lib.String { + return .{ .ptr = response, .len = response.len }; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const enq_cb: ?Effects.EnquiryFn = &S.enquiry; + set(t, .enquiry, @ptrCast(&enq_cb)); + + // ENQ (0x05) should trigger the enquiry callback and write response via write_pty + vt_write(t, "\x05", 1); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("OK", S.last_data.?); +} + +test "enquiry without callback is silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // ENQ without a callback should not crash + vt_write(t, "\x05", 1); +} + +test "set xtversion callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + const version = "myterm 1.0"; + fn xtversion(_: Terminal, _: ?*anyopaque) callconv(.c) lib.String { + return .{ .ptr = version, .len = version.len }; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const xtv_cb: ?Effects.XtversionFn = &S.xtversion; + set(t, .xtversion, @ptrCast(&xtv_cb)); + + // XTVERSION: CSI > q + vt_write(t, "\x1B[>q", 4); + try testing.expect(S.last_data != null); + // Response should be DCS >| version ST + try testing.expectEqualStrings("\x1BP>|myterm 1.0\x1B\\", S.last_data.?); +} + +test "xtversion without callback reports default" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + }; + defer S.deinit(); + + // Set write_pty but not xtversion — should get default "libghostty" + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + + vt_write(t, "\x1B[>q", 4); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1BP>|libghostty\x1B\\", S.last_data.?); +} + test "grid_ref out of bounds" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 6f18d44ed69a5194a74ab72b6d86aaaf545f1dd6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Mar 2026 07:06:09 -0700 Subject: [PATCH 05/12] vt: add title_changed effect callback Add GHOSTTY_TERMINAL_OPT_TITLE_CHANGED so C consumers are notified when the terminal title changes via OSC 0 or OSC 2 sequences. The callback has the same fire-and-forget shape as bell. --- include/ghostty/vt/terminal.h | 24 ++++++++++++ src/terminal/c/terminal.zig | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 32956d6a3..42141172d 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -201,6 +201,21 @@ typedef GhosttyString (*GhosttyTerminalEnquiryFn)(GhosttyTerminal terminal, typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal, void* userdata); +/** + * Callback function type for title_changed. + * + * Called when the terminal title changes via escape sequences + * (e.g. OSC 0 or OSC 2). The new title can be queried from the + * terminal after the callback returns. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal, + void* userdata); + /** * Terminal option identifiers. * @@ -249,6 +264,15 @@ typedef enum { * Input type: GhosttyTerminalXtversionFn* */ GHOSTTY_TERMINAL_OPT_XTVERSION = 4, + + /** + * Callback invoked when the terminal title changes via escape + * sequences (e.g. OSC 0 or OSC 2). Set to NULL to ignore title + * change events. + * + * Input type: GhosttyTerminalTitleChangedFn* + */ + GHOSTTY_TERMINAL_OPT_TITLE_CHANGED = 5, } GhosttyTerminalOption; /** diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 0dc0b0d6e..7e457e1e4 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -39,6 +39,7 @@ const Effects = struct { bell: ?BellFn = null, enquiry: ?EnquiryFn = null, xtversion: ?XtversionFn = null, + title_changed: ?TitleChangedFn = null, /// C function pointer type for the write_pty callback. pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(.c) void; @@ -57,6 +58,9 @@ const Effects = struct { /// (len=0) causes the default "libghostty" to be reported. pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(.c) lib.String; + /// C function pointer type for the title_changed callback. + pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(.c) void; + fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); @@ -88,6 +92,13 @@ const Effects = struct { if (result.len == 0) return ""; return result.ptr[0..result.len]; } + + fn titleChangedTrampoline(handler: *Handler) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.title_changed orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata); + } }; /// C: GhosttyTerminal @@ -151,6 +162,7 @@ fn new_( handler.effects.bell = &Effects.bellTrampoline; handler.effects.enquiry = &Effects.enquiryTrampoline; handler.effects.xtversion = &Effects.xtversionTrampoline; + handler.effects.title_changed = &Effects.titleChangedTrampoline; wrapper.* = .{ .terminal = t, @@ -176,6 +188,7 @@ pub const Option = enum(c_int) { bell = 2, enquiry = 3, xtversion = 4, + title_changed = 5, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -185,6 +198,7 @@ pub const Option = enum(c_int) { .bell => ?Effects.BellFn, .enquiry => ?Effects.EnquiryFn, .xtversion => ?Effects.XtversionFn, + .title_changed => ?Effects.TitleChangedFn, }; } }; @@ -222,6 +236,7 @@ fn setTyped( .bell => wrapper.effects.bell = if (value) |v| v.* else null, .enquiry => wrapper.effects.enquiry = if (value) |v| v.* else null, .xtversion => wrapper.effects.xtversion = if (value) |v| v.* else null, + .title_changed => wrapper.effects.title_changed = if (value) |v| v.* else null, } } @@ -1198,6 +1213,64 @@ test "xtversion without callback reports default" { try testing.expectEqualStrings("\x1BP>|libghostty\x1B\\", S.last_data.?); } +test "set title_changed callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var title_count: usize = 0; + var last_userdata: ?*anyopaque = null; + + fn titleChanged(_: Terminal, ud: ?*anyopaque) callconv(.c) void { + title_count += 1; + last_userdata = ud; + } + }; + S.title_count = 0; + S.last_userdata = null; + + var sentinel: u8 = 77; + const ud: ?*anyopaque = @ptrCast(&sentinel); + set(t, .userdata, @ptrCast(&ud)); + const cb: ?Effects.TitleChangedFn = &S.titleChanged; + set(t, .title_changed, @ptrCast(&cb)); + + // OSC 2 ; title ST — set window title + vt_write(t, "\x1B]2;Hello\x1B\\", 10); + try testing.expectEqual(@as(usize, 1), S.title_count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + + // Another title change + vt_write(t, "\x1B]2;World\x1B\\", 10); + try testing.expectEqual(@as(usize, 2), S.title_count); +} + +test "title_changed without callback is silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // OSC 2 without a callback should not crash + vt_write(t, "\x1B]2;Hello\x1B\\", 10); +} + test "grid_ref out of bounds" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 424e9b57cabb1e6040167df561e03335cb2714df Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Mar 2026 07:09:50 -0700 Subject: [PATCH 06/12] vt: add size effect callback for XTWINOPS queries Add GHOSTTY_TERMINAL_OPT_SIZE so C consumers can respond to XTWINOPS size queries (CSI 14/16/18 t). The callback receives a GhosttySizeReportSize out-pointer and returns true if the size is available, or false to silently ignore the query. The trampoline converts the bool + out-pointer pattern to the optional that the Zig handler expects. --- include/ghostty/vt/terminal.h | 27 +++++++++++ src/terminal/c/terminal.zig | 86 +++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 42141172d..c4d7f19e5 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -216,6 +217,24 @@ typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal, typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal, void* userdata); +/** + * Callback function type for size queries (XTWINOPS). + * + * Called in response to XTWINOPS size queries (CSI 14/16/18 t). + * Return true and fill *out_size with the current terminal geometry, + * or return false to silently ignore the query. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_size Pointer to store the terminal size information + * @return true if size was filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalSizeFn)(GhosttyTerminal terminal, + void* userdata, + GhosttySizeReportSize* out_size); + /** * Terminal option identifiers. * @@ -273,6 +292,14 @@ typedef enum { * Input type: GhosttyTerminalTitleChangedFn* */ GHOSTTY_TERMINAL_OPT_TITLE_CHANGED = 5, + + /** + * Callback invoked in response to XTWINOPS size queries + * (CSI 14/16/18 t). Set to NULL to silently ignore size queries. + * + * Input type: GhosttyTerminalSizeFn* + */ + GHOSTTY_TERMINAL_OPT_SIZE = 6, } GhosttyTerminalOption; /** diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 7e457e1e4..ccbf1daf1 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -11,6 +11,7 @@ const kitty = @import("../kitty/key.zig"); const modes = @import("../modes.zig"); const point = @import("../point.zig"); const size = @import("../size.zig"); +const size_report = @import("../size_report.zig"); const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); const grid_ref_c = @import("grid_ref.zig"); @@ -40,6 +41,7 @@ const Effects = struct { enquiry: ?EnquiryFn = null, xtversion: ?XtversionFn = null, title_changed: ?TitleChangedFn = null, + size_cb: ?SizeFn = null, /// C function pointer type for the write_pty callback. pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(.c) void; @@ -61,6 +63,11 @@ const Effects = struct { /// C function pointer type for the title_changed callback. pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(.c) 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. + pub const SizeFn = *const fn (Terminal, ?*anyopaque, *size_report.Size) callconv(.c) bool; + fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); @@ -99,6 +106,15 @@ const Effects = struct { const func = wrapper.effects.title_changed orelse return; func(@ptrCast(wrapper), wrapper.effects.userdata); } + + fn sizeTrampoline(handler: *Handler) ?size_report.Size { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.size_cb orelse return null; + var s: size_report.Size = undefined; + if (func(@ptrCast(wrapper), wrapper.effects.userdata, &s)) return s; + return null; + } }; /// C: GhosttyTerminal @@ -163,6 +179,7 @@ fn new_( handler.effects.enquiry = &Effects.enquiryTrampoline; handler.effects.xtversion = &Effects.xtversionTrampoline; handler.effects.title_changed = &Effects.titleChangedTrampoline; + handler.effects.size = &Effects.sizeTrampoline; wrapper.* = .{ .terminal = t, @@ -189,6 +206,7 @@ pub const Option = enum(c_int) { enquiry = 3, xtversion = 4, title_changed = 5, + size_cb = 6, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -199,6 +217,7 @@ pub const Option = enum(c_int) { .enquiry => ?Effects.EnquiryFn, .xtversion => ?Effects.XtversionFn, .title_changed => ?Effects.TitleChangedFn, + .size_cb => ?Effects.SizeFn, }; } }; @@ -237,6 +256,7 @@ fn setTyped( .enquiry => wrapper.effects.enquiry = if (value) |v| v.* else null, .xtversion => wrapper.effects.xtversion = if (value) |v| v.* else null, .title_changed => wrapper.effects.title_changed = if (value) |v| v.* else null, + .size_cb => wrapper.effects.size_cb = if (value) |v| v.* else null, } } @@ -1271,6 +1291,72 @@ test "title_changed without callback is silent" { vt_write(t, "\x1B]2;Hello\x1B\\", 10); } +test "set size callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + fn sizeCb(_: Terminal, _: ?*anyopaque, out_size: *size_report.Size) callconv(.c) bool { + out_size.* = .{ + .rows = 24, + .columns = 80, + .cell_width = 8, + .cell_height = 16, + }; + return true; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const size_cb_fn: ?Effects.SizeFn = &S.sizeCb; + set(t, .size_cb, @ptrCast(&size_cb_fn)); + + // CSI 18 t — report text area size in characters + vt_write(t, "\x1B[18t", 5); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[8;24;80t", S.last_data.?); +} + +test "size without callback is silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // CSI 18 t without a size callback should not crash + vt_write(t, "\x1B[18t", 5); +} + test "grid_ref out of bounds" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 02d48c360b5c9e837844bac4ad3fdf6c8fa69b5c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Mar 2026 07:20:31 -0700 Subject: [PATCH 07/12] vt: expose color_scheme effect callback Change device_status.ColorScheme from a plain Zig enum to lib.Enum so it uses c_int backing when targeting the C ABI. Add a color_scheme callback to the C terminal effects, following the bool + out-pointer pattern used by the size callback. The trampoline converts between the C calling convention and the internal stream handler color_scheme effect, returning null when no callback is set. Add device_status.h header with GhosttyColorScheme enum and wire it through terminal.h as GHOSTTY_TERMINAL_OPT_COLOR_SCHEME (= 7) with GhosttyTerminalColorSchemeFn. --- include/ghostty/vt.h | 1 + include/ghostty/vt/device_status.h | 28 ++++++++++++++++++++++++++++ include/ghostty/vt/terminal.h | 29 +++++++++++++++++++++++++++++ src/terminal/c/terminal.zig | 20 ++++++++++++++++++++ src/terminal/device_status.zig | 11 +++++++---- 5 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 include/ghostty/vt/device_status.h diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 55ceb430d..536f27111 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -109,6 +109,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/device_status.h b/include/ghostty/vt/device_status.h new file mode 100644 index 000000000..d34b9ec6d --- /dev/null +++ b/include/ghostty/vt/device_status.h @@ -0,0 +1,28 @@ +/** + * @file device_status.h + * + * Device status types used by the terminal. + */ + +#ifndef GHOSTTY_VT_DEVICE_STATUS_H +#define GHOSTTY_VT_DEVICE_STATUS_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Color scheme reported in response to a CSI ? 996 n query. + * + * @ingroup terminal + */ +typedef enum { + GHOSTTY_COLOR_SCHEME_LIGHT = 0, + GHOSTTY_COLOR_SCHEME_DARK = 1, +} GhosttyColorScheme; + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_DEVICE_STATUS_H */ diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index c4d7f19e5..a4a921dfe 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -217,6 +218,24 @@ typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal, typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal, void* userdata); +/** + * Callback function type for color scheme queries (CSI ? 996 n). + * + * Called when the terminal receives a color scheme device status report + * query. Return true and fill *out_scheme with the current color scheme, + * or return false to silently ignore the query. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_scheme Pointer to store the current color scheme + * @return true if the color scheme was filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalColorSchemeFn)(GhosttyTerminal terminal, + void* userdata, + GhosttyColorScheme* out_scheme); + /** * Callback function type for size queries (XTWINOPS). * @@ -300,6 +319,16 @@ typedef enum { * Input type: GhosttyTerminalSizeFn* */ GHOSTTY_TERMINAL_OPT_SIZE = 6, + + /** + * Callback invoked in response to a color scheme device status + * report query (CSI ? 996 n). Return true and fill the out pointer + * to report the current scheme, or return false to silently ignore. + * Set to NULL to ignore color scheme queries. + * + * Input type: GhosttyTerminalColorSchemeFn* + */ + GHOSTTY_TERMINAL_OPT_COLOR_SCHEME = 7, } GhosttyTerminalOption; /** diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index ccbf1daf1..4378091a3 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -11,6 +11,7 @@ const kitty = @import("../kitty/key.zig"); const modes = @import("../modes.zig"); const point = @import("../point.zig"); const size = @import("../size.zig"); +const device_status = @import("../device_status.zig"); const size_report = @import("../size_report.zig"); const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); @@ -38,6 +39,7 @@ const Effects = struct { userdata: ?*anyopaque = null, write_pty: ?WritePtyFn = null, bell: ?BellFn = null, + color_scheme: ?ColorSchemeFn = null, enquiry: ?EnquiryFn = null, xtversion: ?XtversionFn = null, title_changed: ?TitleChangedFn = null, @@ -49,6 +51,11 @@ const Effects = struct { /// C function pointer type for the bell callback. pub const BellFn = *const fn (Terminal, ?*anyopaque) callconv(.c) void; + /// C function pointer type for the color_scheme callback. + /// Returns true and fills out_scheme if a color scheme is available, + /// or returns false to silently ignore the query. + pub const ColorSchemeFn = *const fn (Terminal, ?*anyopaque, *device_status.ColorScheme) callconv(.c) bool; + /// C function pointer type for the enquiry callback. /// Returns the response bytes. The memory must remain valid /// until the callback returns. @@ -82,6 +89,15 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata); } + fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.color_scheme orelse return null; + var scheme: device_status.ColorScheme = undefined; + if (func(@ptrCast(wrapper), wrapper.effects.userdata, &scheme)) return scheme; + return null; + } + fn enquiryTrampoline(handler: *Handler) []const u8 { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); @@ -176,6 +192,7 @@ fn new_( var handler: Stream.Handler = t.vtHandler(); handler.effects.write_pty = &Effects.writePtyTrampoline; handler.effects.bell = &Effects.bellTrampoline; + handler.effects.color_scheme = &Effects.colorSchemeTrampoline; handler.effects.enquiry = &Effects.enquiryTrampoline; handler.effects.xtversion = &Effects.xtversionTrampoline; handler.effects.title_changed = &Effects.titleChangedTrampoline; @@ -207,6 +224,7 @@ pub const Option = enum(c_int) { xtversion = 4, title_changed = 5, size_cb = 6, + color_scheme = 7, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -214,6 +232,7 @@ pub const Option = enum(c_int) { .userdata => ?*anyopaque, .write_pty => ?Effects.WritePtyFn, .bell => ?Effects.BellFn, + .color_scheme => ?Effects.ColorSchemeFn, .enquiry => ?Effects.EnquiryFn, .xtversion => ?Effects.XtversionFn, .title_changed => ?Effects.TitleChangedFn, @@ -253,6 +272,7 @@ fn setTyped( .userdata => wrapper.effects.userdata = if (value) |v| v.* else null, .write_pty => wrapper.effects.write_pty = if (value) |v| v.* else null, .bell => wrapper.effects.bell = if (value) |v| v.* else null, + .color_scheme => wrapper.effects.color_scheme = if (value) |v| v.* else null, .enquiry => wrapper.effects.enquiry = if (value) |v| v.* else null, .xtversion => wrapper.effects.xtversion = if (value) |v| v.* else null, .title_changed => wrapper.effects.title_changed = if (value) |v| v.* else null, diff --git a/src/terminal/device_status.zig b/src/terminal/device_status.zig index 3d0106e42..61dd569f3 100644 --- a/src/terminal/device_status.zig +++ b/src/terminal/device_status.zig @@ -1,10 +1,13 @@ const std = @import("std"); +const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; /// The color scheme reported in response to a CSI ? 996 n query. -pub const ColorScheme = enum { - light, - dark, -}; +pub const ColorScheme = lib.Enum(lib_target, &.{ + "light", + "dark", +}); /// An enum(u16) of the available device status requests. pub const Request = dsr_enum: { From b8fcb57923ae3a5d39630650a05217f3536c87f8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Mar 2026 11:17:55 -0700 Subject: [PATCH 08/12] vt: expose device_attributes effect in the C API Rename device_status.h to device.h and add C-compatible structs for device attributes (DA1/DA2/DA3) responses. The new header includes defines for all known conformance levels, DA1 feature codes, and DA2 device type identifiers. Add a GhosttyTerminalDeviceAttributesFn callback that C consumers can set via GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES. The callback follows the existing bool + out-pointer pattern used by color_scheme and size callbacks. When the callback is unset or returns false, the trampoline returns a default VT220 response (conformance level 62, ANSI color). The DA1 primary features use a fixed [64]uint16_t inline array with a num_features count rather than a pointer, so the entire struct is value-typed and can be safely copied without lifetime concerns. --- include/ghostty/vt.h | 2 +- include/ghostty/vt/device.h | 150 ++++++++++++++ include/ghostty/vt/device_status.h | 28 --- include/ghostty/vt/terminal.h | 33 ++- src/terminal/c/terminal.zig | 315 +++++++++++++++++++++++++++++ 5 files changed, 498 insertions(+), 30 deletions(-) create mode 100644 include/ghostty/vt/device.h delete mode 100644 include/ghostty/vt/device_status.h diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 536f27111..2a52f4b08 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -109,7 +109,7 @@ extern "C" { #include #include #include -#include +#include #include #include #include diff --git a/include/ghostty/vt/device.h b/include/ghostty/vt/device.h new file mode 100644 index 000000000..fdf6bca7d --- /dev/null +++ b/include/ghostty/vt/device.h @@ -0,0 +1,150 @@ +/** + * @file device.h + * + * Device types used by the terminal for device status and device attribute + * queries. + */ + +#ifndef GHOSTTY_VT_DEVICE_H +#define GHOSTTY_VT_DEVICE_H + +#include +#include + +/* DA1 conformance levels (Pp parameter). */ +#define GHOSTTY_DA_CONFORMANCE_VT100 1 +#define GHOSTTY_DA_CONFORMANCE_VT101 1 +#define GHOSTTY_DA_CONFORMANCE_VT102 6 +#define GHOSTTY_DA_CONFORMANCE_VT125 12 +#define GHOSTTY_DA_CONFORMANCE_VT131 7 +#define GHOSTTY_DA_CONFORMANCE_VT132 4 +#define GHOSTTY_DA_CONFORMANCE_VT220 62 +#define GHOSTTY_DA_CONFORMANCE_VT240 62 +#define GHOSTTY_DA_CONFORMANCE_VT320 63 +#define GHOSTTY_DA_CONFORMANCE_VT340 63 +#define GHOSTTY_DA_CONFORMANCE_VT420 64 +#define GHOSTTY_DA_CONFORMANCE_VT510 65 +#define GHOSTTY_DA_CONFORMANCE_VT520 65 +#define GHOSTTY_DA_CONFORMANCE_VT525 65 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_2 62 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_3 63 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_4 64 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_5 65 + +/* DA1 feature codes (Ps parameters). */ +#define GHOSTTY_DA_FEATURE_COLUMNS_132 1 +#define GHOSTTY_DA_FEATURE_PRINTER 2 +#define GHOSTTY_DA_FEATURE_REGIS 3 +#define GHOSTTY_DA_FEATURE_SIXEL 4 +#define GHOSTTY_DA_FEATURE_SELECTIVE_ERASE 6 +#define GHOSTTY_DA_FEATURE_USER_DEFINED_KEYS 8 +#define GHOSTTY_DA_FEATURE_NATIONAL_REPLACEMENT 9 +#define GHOSTTY_DA_FEATURE_TECHNICAL_CHARACTERS 15 +#define GHOSTTY_DA_FEATURE_LOCATOR 16 +#define GHOSTTY_DA_FEATURE_TERMINAL_STATE 17 +#define GHOSTTY_DA_FEATURE_WINDOWING 18 +#define GHOSTTY_DA_FEATURE_HORIZONTAL_SCROLLING 21 +#define GHOSTTY_DA_FEATURE_ANSI_COLOR 22 +#define GHOSTTY_DA_FEATURE_RECTANGULAR_EDITING 28 +#define GHOSTTY_DA_FEATURE_ANSI_TEXT_LOCATOR 29 +#define GHOSTTY_DA_FEATURE_CLIPBOARD 52 + +/* DA2 device type identifiers (Pp parameter). */ +#define GHOSTTY_DA_DEVICE_TYPE_VT100 0 +#define GHOSTTY_DA_DEVICE_TYPE_VT220 1 +#define GHOSTTY_DA_DEVICE_TYPE_VT240 2 +#define GHOSTTY_DA_DEVICE_TYPE_VT330 18 +#define GHOSTTY_DA_DEVICE_TYPE_VT340 19 +#define GHOSTTY_DA_DEVICE_TYPE_VT320 24 +#define GHOSTTY_DA_DEVICE_TYPE_VT382 32 +#define GHOSTTY_DA_DEVICE_TYPE_VT420 41 +#define GHOSTTY_DA_DEVICE_TYPE_VT510 61 +#define GHOSTTY_DA_DEVICE_TYPE_VT520 64 +#define GHOSTTY_DA_DEVICE_TYPE_VT525 65 + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Color scheme reported in response to a CSI ? 996 n query. + * + * @ingroup terminal + */ +typedef enum { + GHOSTTY_COLOR_SCHEME_LIGHT = 0, + GHOSTTY_COLOR_SCHEME_DARK = 1, +} GhosttyColorScheme; + +/** + * Primary device attributes (DA1) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI c query. + * The conformance_level is the Pp parameter and features contains the Ps + * feature codes. + * + * @ingroup terminal + */ +typedef struct { + /** Conformance level (Pp parameter). E.g. 62 for VT220. */ + uint16_t conformance_level; + + /** DA1 feature codes. Only the first num_features entries are valid. */ + uint16_t features[64]; + + /** Number of valid entries in the features array. */ + size_t num_features; +} GhosttyDeviceAttributesPrimary; + +/** + * Secondary device attributes (DA2) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI > c query. + * Response format: CSI > Pp ; Pv ; Pc c + * + * @ingroup terminal + */ +typedef struct { + /** Terminal type identifier (Pp). E.g. 1 for VT220. */ + uint16_t device_type; + + /** Firmware/patch version number (Pv). */ + uint16_t firmware_version; + + /** ROM cartridge registration number (Pc). Always 0 for emulators. */ + uint16_t rom_cartridge; +} GhosttyDeviceAttributesSecondary; + +/** + * Tertiary device attributes (DA3) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI = c query. + * Response format: DCS ! | D...D ST (DECRPTUI). + * + * @ingroup terminal + */ +typedef struct { + /** Unit ID encoded as 8 uppercase hex digits in the response. */ + uint32_t unit_id; +} GhosttyDeviceAttributesTertiary; + +/** + * Device attributes response data for all three DA levels. + * + * Filled by the device_attributes callback in response to CSI c, + * CSI > c, or CSI = c queries. The terminal uses whichever sub-struct + * matches the request type. + * + * @ingroup terminal + */ +typedef struct { + GhosttyDeviceAttributesPrimary primary; + GhosttyDeviceAttributesSecondary secondary; + GhosttyDeviceAttributesTertiary tertiary; +} GhosttyDeviceAttributes; + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_DEVICE_H */ diff --git a/include/ghostty/vt/device_status.h b/include/ghostty/vt/device_status.h deleted file mode 100644 index d34b9ec6d..000000000 --- a/include/ghostty/vt/device_status.h +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @file device_status.h - * - * Device status types used by the terminal. - */ - -#ifndef GHOSTTY_VT_DEVICE_STATUS_H -#define GHOSTTY_VT_DEVICE_STATUS_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * Color scheme reported in response to a CSI ? 996 n query. - * - * @ingroup terminal - */ -typedef enum { - GHOSTTY_COLOR_SCHEME_LIGHT = 0, - GHOSTTY_COLOR_SCHEME_DARK = 1, -} GhosttyColorScheme; - -#ifdef __cplusplus -} -#endif - -#endif /* GHOSTTY_VT_DEVICE_STATUS_H */ diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index a4a921dfe..050ebd841 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -12,7 +12,7 @@ #include #include #include -#include +#include #include #include #include @@ -236,6 +236,27 @@ typedef bool (*GhosttyTerminalColorSchemeFn)(GhosttyTerminal terminal, void* userdata, GhosttyColorScheme* out_scheme); +/** + * Callback function type for device attributes queries (DA1/DA2/DA3). + * + * Called when the terminal receives a device attributes query (CSI c, + * CSI > c, or CSI = c). Return true and fill *out_attrs with the + * response data, or return false to silently ignore the query. + * + * The terminal uses whichever sub-struct (primary, secondary, tertiary) + * matches the request type, but all three should be filled for simplicity. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_attrs Pointer to store the device attributes response + * @return true if attributes were filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalDeviceAttributesFn)(GhosttyTerminal terminal, + void* userdata, + GhosttyDeviceAttributes* out_attrs); + /** * Callback function type for size queries (XTWINOPS). * @@ -329,6 +350,16 @@ typedef enum { * Input type: GhosttyTerminalColorSchemeFn* */ GHOSTTY_TERMINAL_OPT_COLOR_SCHEME = 7, + + /** + * Callback invoked in response to a device attributes query + * (CSI c, CSI > c, or CSI = c). Return true and fill the out + * pointer with response data, or return false to silently ignore. + * Set to NULL to ignore device attributes queries. + * + * Input type: GhosttyTerminalDeviceAttributesFn* + */ + GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES = 8, } GhosttyTerminalOption; /** diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 4378091a3..15850acf8 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -11,6 +11,7 @@ const kitty = @import("../kitty/key.zig"); const modes = @import("../modes.zig"); const point = @import("../point.zig"); const size = @import("../size.zig"); +const device_attributes = @import("../device_attributes.zig"); const device_status = @import("../device_status.zig"); const size_report = @import("../size_report.zig"); const cell_c = @import("cell.zig"); @@ -40,6 +41,7 @@ const Effects = struct { write_pty: ?WritePtyFn = null, bell: ?BellFn = null, color_scheme: ?ColorSchemeFn = null, + device_attributes_cb: ?DeviceAttributesFn = null, enquiry: ?EnquiryFn = null, xtversion: ?XtversionFn = null, title_changed: ?TitleChangedFn = null, @@ -75,6 +77,35 @@ const Effects = struct { /// or returns false to silently ignore the query. pub const SizeFn = *const fn (Terminal, ?*anyopaque, *size_report.Size) callconv(.c) bool; + /// C function pointer type for the device_attributes callback. + /// Returns true and fills out_attrs if attributes are available, + /// or returns false to silently ignore the query. + pub const DeviceAttributesFn = *const fn (Terminal, ?*anyopaque, *CDeviceAttributes) callconv(.c) bool; + + /// C-compatible device attributes struct. + /// C: GhosttyDeviceAttributes + pub const CDeviceAttributes = extern struct { + primary: Primary, + secondary: Secondary, + tertiary: Tertiary, + + pub const Primary = extern struct { + conformance_level: u16, + features: [64]u16, + num_features: usize, + }; + + pub const Secondary = extern struct { + device_type: u16, + firmware_version: u16, + rom_cartridge: u16, + }; + + pub const Tertiary = extern struct { + unit_id: u32, + }; + }; + fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); @@ -98,6 +129,38 @@ const Effects = struct { return null; } + fn deviceAttributesTrampoline(handler: *Handler) device_attributes.Attributes { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.device_attributes_cb orelse return .{}; + + // Get our attributes from the callback. + var c_attrs: CDeviceAttributes = undefined; + if (!func(@ptrCast(wrapper), wrapper.effects.userdata, &c_attrs)) return .{}; + + // Note below we use a lot of enumFromInt but its always safe + // because all our types are non-exhaustive enums. + + const n: usize = @min(c_attrs.primary.num_features, 64); + var features: [64]device_attributes.Primary.Feature = undefined; + for (0..n) |i| features[i] = @enumFromInt(c_attrs.primary.features[i]); + + return .{ + .primary = .{ + .conformance_level = @enumFromInt(c_attrs.primary.conformance_level), + .features = features[0..n], + }, + .secondary = .{ + .device_type = @enumFromInt(c_attrs.secondary.device_type), + .firmware_version = c_attrs.secondary.firmware_version, + .rom_cartridge = c_attrs.secondary.rom_cartridge, + }, + .tertiary = .{ + .unit_id = c_attrs.tertiary.unit_id, + }, + }; + } + fn enquiryTrampoline(handler: *Handler) []const u8 { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); @@ -193,6 +256,7 @@ fn new_( handler.effects.write_pty = &Effects.writePtyTrampoline; handler.effects.bell = &Effects.bellTrampoline; handler.effects.color_scheme = &Effects.colorSchemeTrampoline; + handler.effects.device_attributes = &Effects.deviceAttributesTrampoline; handler.effects.enquiry = &Effects.enquiryTrampoline; handler.effects.xtversion = &Effects.xtversionTrampoline; handler.effects.title_changed = &Effects.titleChangedTrampoline; @@ -225,6 +289,7 @@ pub const Option = enum(c_int) { title_changed = 5, size_cb = 6, color_scheme = 7, + device_attributes = 8, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -233,6 +298,7 @@ pub const Option = enum(c_int) { .write_pty => ?Effects.WritePtyFn, .bell => ?Effects.BellFn, .color_scheme => ?Effects.ColorSchemeFn, + .device_attributes => ?Effects.DeviceAttributesFn, .enquiry => ?Effects.EnquiryFn, .xtversion => ?Effects.XtversionFn, .title_changed => ?Effects.TitleChangedFn, @@ -273,6 +339,7 @@ fn setTyped( .write_pty => wrapper.effects.write_pty = if (value) |v| v.* else null, .bell => wrapper.effects.bell = if (value) |v| v.* else null, .color_scheme => wrapper.effects.color_scheme = if (value) |v| v.* else null, + .device_attributes => wrapper.effects.device_attributes_cb = if (value) |v| v.* else null, .enquiry => wrapper.effects.enquiry = if (value) |v| v.* else null, .xtversion => wrapper.effects.xtversion = if (value) |v| v.* else null, .title_changed => wrapper.effects.title_changed = if (value) |v| v.* else null, @@ -1377,6 +1444,254 @@ test "size without callback is silent" { vt_write(t, "\x1B[18t", 5); } +test "set device_attributes callback primary" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + fn da(_: Terminal, _: ?*anyopaque, out: *Effects.CDeviceAttributes) callconv(.c) bool { + out.* = .{ + .primary = .{ + .conformance_level = 64, + .features = .{22, 52} ++ .{0} ** 62, + .num_features = 2, + }, + .secondary = .{ + .device_type = 1, + .firmware_version = 10, + .rom_cartridge = 0, + }, + .tertiary = .{ .unit_id = 0 }, + }; + return true; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const da_cb: ?Effects.DeviceAttributesFn = &S.da; + set(t, .device_attributes, @ptrCast(&da_cb)); + + // CSI c — primary DA + vt_write(t, "\x1B[c", 3); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[?64;22;52c", S.last_data.?); +} + +test "set device_attributes callback secondary" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + fn da(_: Terminal, _: ?*anyopaque, out: *Effects.CDeviceAttributes) callconv(.c) bool { + out.* = .{ + .primary = .{ + .conformance_level = 62, + .features = .{22} ++ .{0} ** 63, + .num_features = 1, + }, + .secondary = .{ + .device_type = 1, + .firmware_version = 10, + .rom_cartridge = 0, + }, + .tertiary = .{ .unit_id = 0 }, + }; + return true; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const da_cb: ?Effects.DeviceAttributesFn = &S.da; + set(t, .device_attributes, @ptrCast(&da_cb)); + + // CSI > c — secondary DA + vt_write(t, "\x1B[>c", 4); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[>1;10;0c", S.last_data.?); +} + +test "set device_attributes callback tertiary" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + fn da(_: Terminal, _: ?*anyopaque, out: *Effects.CDeviceAttributes) callconv(.c) bool { + out.* = .{ + .primary = .{ + .conformance_level = 62, + .features = .{0} ** 64, + .num_features = 0, + }, + .secondary = .{ + .device_type = 1, + .firmware_version = 0, + .rom_cartridge = 0, + }, + .tertiary = .{ .unit_id = 0xAABBCCDD }, + }; + return true; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const da_cb: ?Effects.DeviceAttributesFn = &S.da; + set(t, .device_attributes, @ptrCast(&da_cb)); + + // CSI = c — tertiary DA + vt_write(t, "\x1B[=c", 4); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1bP!|AABBCCDD\x1b\\", S.last_data.?); +} + +test "device_attributes without callback uses default" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + + // Without setting a device_attributes callback, DA1 should return the default + vt_write(t, "\x1B[c", 3); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[?62;22c", S.last_data.?); +} + +test "device_attributes callback returns false uses default" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + fn da(_: Terminal, _: ?*anyopaque, _: *Effects.CDeviceAttributes) callconv(.c) bool { + return false; + } + }; + defer S.deinit(); + + const write_cb: ?Effects.WritePtyFn = &S.writePty; + set(t, .write_pty, @ptrCast(&write_cb)); + const da_cb: ?Effects.DeviceAttributesFn = &S.da; + set(t, .device_attributes, @ptrCast(&da_cb)); + + // Callback returns false, should use default response + vt_write(t, "\x1B[c", 3); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1b[?62;22c", S.last_data.?); +} + test "grid_ref out of bounds" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From bbfe1c278722c54ededd474b9567b58fd3ad801a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Mar 2026 11:35:20 -0700 Subject: [PATCH 09/12] vt: use struct literal for handler effects assignment Assign handler.effects as a struct literal instead of setting fields individually. This lets the compiler catch missing fields if new effects are added to the Effects struct. Also sort the callback function typedefs in vt/terminal.h alphabetically (Bell, ColorScheme, DeviceAttributes, Enquiry, Size, TitleChanged, WritePty, Xtversion). --- include/ghostty/vt/terminal.h | 138 +++++++++++++++++----------------- src/terminal/c/terminal.zig | 18 +++-- 2 files changed, 79 insertions(+), 77 deletions(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 050ebd841..8f52565b3 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -136,26 +136,6 @@ typedef struct { uint64_t len; } GhosttyTerminalScrollbar; -/** - * Callback function type for write_pty. - * - * Called when the terminal needs to write data back to the pty, for - * example in response to a device status report or mode query. The - * data is only valid for the duration of the call; callers must copy - * it if it needs to persist. - * - * @param terminal The terminal handle - * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA - * @param data Pointer to the response bytes - * @param len Length of the response in bytes - * - * @ingroup terminal - */ -typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal, - void* userdata, - const uint8_t* data, - size_t len); - /** * Callback function type for bell. * @@ -169,55 +149,6 @@ typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal, typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, void* userdata); -/** - * Callback function type for enquiry (ENQ, 0x05). - * - * Called when the terminal receives an ENQ character. Return the - * response bytes as a GhosttyString. The memory must remain valid - * until the callback returns. Return a zero-length string to send - * no response. - * - * @param terminal The terminal handle - * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA - * @return The response bytes to write back to the pty - * - * @ingroup terminal - */ -typedef GhosttyString (*GhosttyTerminalEnquiryFn)(GhosttyTerminal terminal, - void* userdata); - -/** - * Callback function type for XTVERSION. - * - * Called when the terminal receives an XTVERSION query (CSI > q). - * Return the version string (e.g. "myterm 1.0") as a GhosttyString. - * The memory must remain valid until the callback returns. Return a - * zero-length string to report the default "libghostty" version. - * - * @param terminal The terminal handle - * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA - * @return The version string to report - * - * @ingroup terminal - */ -typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal, - void* userdata); - -/** - * Callback function type for title_changed. - * - * Called when the terminal title changes via escape sequences - * (e.g. OSC 0 or OSC 2). The new title can be queried from the - * terminal after the callback returns. - * - * @param terminal The terminal handle - * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA - * - * @ingroup terminal - */ -typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal, - void* userdata); - /** * Callback function type for color scheme queries (CSI ? 996 n). * @@ -257,6 +188,23 @@ typedef bool (*GhosttyTerminalDeviceAttributesFn)(GhosttyTerminal terminal, void* userdata, GhosttyDeviceAttributes* out_attrs); +/** + * Callback function type for enquiry (ENQ, 0x05). + * + * Called when the terminal receives an ENQ character. Return the + * response bytes as a GhosttyString. The memory must remain valid + * until the callback returns. Return a zero-length string to send + * no response. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @return The response bytes to write back to the pty + * + * @ingroup terminal + */ +typedef GhosttyString (*GhosttyTerminalEnquiryFn)(GhosttyTerminal terminal, + void* userdata); + /** * Callback function type for size queries (XTWINOPS). * @@ -275,6 +223,58 @@ typedef bool (*GhosttyTerminalSizeFn)(GhosttyTerminal terminal, void* userdata, GhosttySizeReportSize* out_size); +/** + * Callback function type for title_changed. + * + * Called when the terminal title changes via escape sequences + * (e.g. OSC 0 or OSC 2). The new title can be queried from the + * terminal after the callback returns. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for write_pty. + * + * Called when the terminal needs to write data back to the pty, for + * example in response to a device status report or mode query. The + * data is only valid for the duration of the call; callers must copy + * it if it needs to persist. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param data Pointer to the response bytes + * @param len Length of the response in bytes + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal, + void* userdata, + const uint8_t* data, + size_t len); + +/** + * Callback function type for XTVERSION. + * + * Called when the terminal receives an XTVERSION query (CSI > q). + * Return the version string (e.g. "myterm 1.0") as a GhosttyString. + * The memory must remain valid until the callback returns. Return a + * zero-length string to report the default "libghostty" version. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @return The version string to report + * + * @ingroup terminal + */ +typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal, + void* userdata); + /** * Terminal option identifiers. * diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 15850acf8..d0825d3cb 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -253,14 +253,16 @@ fn new_( // Setup our stream with trampolines always installed so that // setting C callbacks at any time takes effect immediately. var handler: Stream.Handler = t.vtHandler(); - handler.effects.write_pty = &Effects.writePtyTrampoline; - handler.effects.bell = &Effects.bellTrampoline; - handler.effects.color_scheme = &Effects.colorSchemeTrampoline; - handler.effects.device_attributes = &Effects.deviceAttributesTrampoline; - handler.effects.enquiry = &Effects.enquiryTrampoline; - handler.effects.xtversion = &Effects.xtversionTrampoline; - handler.effects.title_changed = &Effects.titleChangedTrampoline; - handler.effects.size = &Effects.sizeTrampoline; + handler.effects = .{ + .write_pty = &Effects.writePtyTrampoline, + .bell = &Effects.bellTrampoline, + .color_scheme = &Effects.colorSchemeTrampoline, + .device_attributes = &Effects.deviceAttributesTrampoline, + .enquiry = &Effects.enquiryTrampoline, + .xtversion = &Effects.xtversionTrampoline, + .title_changed = &Effects.titleChangedTrampoline, + .size = &Effects.sizeTrampoline, + }; wrapper.* = .{ .terminal = t, From 4128e6a38c5c5c20d5119a46c691bf978b74c41a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Mar 2026 11:42:10 -0700 Subject: [PATCH 10/12] vt: add effects documentation section with example Add a comprehensive "Effects" section to the terminal module documentation in terminal.h explaining the callback system that lets embedding applications react to terminal-initiated events (bell, title changes, pty writes, device queries, etc.). The section includes a reference table of all available effects and their triggers, plus @snippet references to the new example. Add c-vt-effects example project demonstrating how to register write_pty, bell, and title_changed callbacks, attach userdata, and feed VT data that triggers each effect. --- example/c-vt-effects/README.md | 18 ++++++ example/c-vt-effects/build.zig | 42 ++++++++++++ example/c-vt-effects/build.zig.zon | 24 +++++++ example/c-vt-effects/src/main.c | 100 +++++++++++++++++++++++++++++ include/ghostty/vt/terminal.h | 54 ++++++++++++++++ 5 files changed, 238 insertions(+) create mode 100644 example/c-vt-effects/README.md create mode 100644 example/c-vt-effects/build.zig create mode 100644 example/c-vt-effects/build.zig.zon create mode 100644 example/c-vt-effects/src/main.c diff --git a/example/c-vt-effects/README.md b/example/c-vt-effects/README.md new file mode 100644 index 000000000..5f5a22b14 --- /dev/null +++ b/example/c-vt-effects/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Terminal Effects + +This contains a simple example of how to register and use terminal +effect callbacks (`write_pty`, `bell`, `title_changed`) with the +`ghostty-vt` C library. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-effects/build.zig b/example/c-vt-effects/build.zig new file mode 100644 index 000000000..c3b1af73b --- /dev/null +++ b/example/c-vt-effects/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_effects", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-effects/build.zig.zon b/example/c-vt-effects/build.zig.zon new file mode 100644 index 000000000..0275f4f68 --- /dev/null +++ b/example/c-vt-effects/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_effects, + .version = "0.0.0", + .fingerprint = 0xc02634cd65f5b583, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-effects/src/main.c b/example/c-vt-effects/src/main.c new file mode 100644 index 000000000..c6688cf5c --- /dev/null +++ b/example/c-vt-effects/src/main.c @@ -0,0 +1,100 @@ +#include +#include +#include +#include +#include +#include + +//! [effects-write-pty] +void on_write_pty(GhosttyTerminal terminal, + void* userdata, + const uint8_t* data, + size_t len) { + (void)terminal; + (void)userdata; + printf(" write_pty (%zu bytes): ", len); + fwrite(data, 1, len, stdout); + printf("\n"); +} +//! [effects-write-pty] + +//! [effects-bell] +void on_bell(GhosttyTerminal terminal, void* userdata) { + (void)terminal; + int* count = (int*)userdata; + (*count)++; + printf(" bell! (count=%d)\n", *count); +} +//! [effects-bell] + +//! [effects-title-changed] +void on_title_changed(GhosttyTerminal terminal, void* userdata) { + (void)userdata; + // Query the cursor position to confirm the terminal processed the + // title change (the title itself is tracked by the embedder via the + // OSC parser or its own state). + uint16_t col = 0; + ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_CURSOR_X, &col); + printf(" title changed (cursor at col %u)\n", col); +} +//! [effects-title-changed] + +//! [effects-register] +int main() { + // Create a terminal + GhosttyTerminal terminal = NULL; + GhosttyTerminalOptions opts = { + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }; + if (ghostty_terminal_new(NULL, &terminal, opts) != GHOSTTY_SUCCESS) { + fprintf(stderr, "Failed to create terminal\n"); + return 1; + } + + // Set up userdata — a simple bell counter + int bell_count = 0; + void* ud = &bell_count; + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_USERDATA, &ud); + + // Register effect callbacks + GhosttyTerminalWritePtyFn write_fn = on_write_pty; + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_WRITE_PTY, &write_fn); + + GhosttyTerminalBellFn bell_fn = on_bell; + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_BELL, &bell_fn); + + GhosttyTerminalTitleChangedFn title_fn = on_title_changed; + ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, &title_fn); + + // Feed VT data that triggers effects: + + // 1. Bell (BEL = 0x07) + printf("Sending BEL:\n"); + const uint8_t bel = 0x07; + ghostty_terminal_vt_write(terminal, &bel, 1); + + // 2. Title change (OSC 2 ; ST) + printf("Sending title change:\n"); + const char* title_seq = "\x1B]2;Hello Effects\x1B\\"; + ghostty_terminal_vt_write(terminal, (const uint8_t*)title_seq, + strlen(title_seq)); + + // 3. Device status report (DECRQM for wraparound mode ?7) + // triggers write_pty with the response + printf("Sending DECRQM query:\n"); + const char* decrqm = "\x1B[?7$p"; + ghostty_terminal_vt_write(terminal, (const uint8_t*)decrqm, + strlen(decrqm)); + + // 4. Another bell to show the counter increments + printf("Sending another BEL:\n"); + ghostty_terminal_vt_write(terminal, &bel, 1); + + printf("Total bells: %d\n", bell_count); + + ghostty_terminal_free(terminal); + return 0; +} +//! [effects-register] diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 8f52565b3..a2c89d302 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -34,6 +34,60 @@ extern "C" { * Once a terminal session is up and running, you can configure a key encoder * to write keyboard input via ghostty_key_encoder_setopt_from_terminal(). * + * ## Effects + * + * By default, the terminal sequence processing with ghostty_terminal_vt_write() + * only process sequences that directly affect terminal state and + * ignores sequences that have side effect behavior or require responses. + * These sequences include things like bell characters, title changes, device + * attributes queries, and more. To handle these sequences, the embedder + * must configure "effects." + * + * Effects are callbacks that the terminal invokes in response to VT + * sequences processed during ghostty_terminal_vt_write(). They let the + * embedding application react to terminal-initiated events such as bell + * characters, title changes, device status report responses, and more. + * + * Each effect is registered with ghostty_terminal_set() using the + * corresponding `GhosttyTerminalOption` identifier. A `NULL` value + * pointer clears the callback and disables the effect. + * + * A userdata pointer can be attached via `GHOSTTY_TERMINAL_OPT_USERDATA` + * and is passed to every callback, allowing callers to route events + * back to their own application state without global variables. + * You cannot specify different userdata for different callbacks. + * + * All callbacks are invoked synchronously during + * ghostty_terminal_vt_write(). Callbacks **must not** call + * ghostty_terminal_vt_write() on the same terminal (no reentrancy). + * And callbacks must be very careful to not block for too long or perform + * expensive operations, since they are blocking further IO processing. + * + * The available effects are: + * + * | Option | Callback Type | Trigger | + * |-----------------------------------------|-----------------------------------|-------------------------------------------| + * | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty | + * | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) | + * | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 | + * | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) | + * | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) | + * | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) | + * | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) | + * | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)| + * + * ### Defining a write_pty callback + * @snippet c-vt-effects/src/main.c effects-write-pty + * + * ### Defining a bell callback + * @snippet c-vt-effects/src/main.c effects-bell + * + * ### Defining a title_changed callback + * @snippet c-vt-effects/src/main.c effects-title-changed + * + * ### Registering effects and processing VT data + * @snippet c-vt-effects/src/main.c effects-register + * * @{ */ From e36b7453146099b3473b4d8a5c6638f6431448c4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto <m@mitchellh.com> Date: Tue, 24 Mar 2026 11:48:22 -0700 Subject: [PATCH 11/12] fmt --- src/terminal/c/terminal.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index d0825d3cb..370371f13 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -1476,7 +1476,7 @@ test "set device_attributes callback primary" { out.* = .{ .primary = .{ .conformance_level = 64, - .features = .{22, 52} ++ .{0} ** 62, + .features = .{ 22, 52 } ++ .{0} ** 62, .num_features = 2, }, .secondary = .{ From d2c6a3c775bc6952480442fe11ec2984a86b5c8c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto <m@mitchellh.com> Date: Tue, 24 Mar 2026 12:06:33 -0700 Subject: [PATCH 12/12] vt: store DA1 feature buffer in wrapper struct The DA1 trampoline was converting C feature codes into a local stack buffer and returning a slice pointing into it. This is unsound because the slice outlives the stack frame once the trampoline returns, leaving reportDeviceAttributes reading invalid memory. Move the scratch buffer into the wrapper effects struct so that its lifetime extends beyond the trampoline call, keeping the returned slice valid for the caller. --- src/terminal/c/terminal.zig | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 370371f13..f89630ba7 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -47,6 +47,13 @@ const Effects = struct { title_changed: ?TitleChangedFn = null, size_cb: ?SizeFn = null, + /// Scratch buffer for DA1 feature codes. The device attributes + /// trampoline converts C feature codes into this buffer and returns + /// a slice pointing into it. Storing it here ensures the slice + /// remains valid after the trampoline returns, since the caller + /// (`reportDeviceAttributes`) reads it before any re-entrant call. + da_features_buf: [64]device_attributes.Primary.Feature = undefined, + /// C function pointer type for the write_pty callback. pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(.c) void; @@ -142,13 +149,12 @@ const Effects = struct { // because all our types are non-exhaustive enums. const n: usize = @min(c_attrs.primary.num_features, 64); - var features: [64]device_attributes.Primary.Feature = undefined; - for (0..n) |i| features[i] = @enumFromInt(c_attrs.primary.features[i]); + for (0..n) |i| wrapper.effects.da_features_buf[i] = @enumFromInt(c_attrs.primary.features[i]); return .{ .primary = .{ .conformance_level = @enumFromInt(c_attrs.primary.conformance_level), - .features = features[0..n], + .features = wrapper.effects.da_features_buf[0..n], }, .secondary = .{ .device_type = @enumFromInt(c_attrs.secondary.device_type),