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(