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.
This commit is contained in:
Mitchell Hashimoto
2026-03-24 11:17:55 -07:00
parent 02d48c360b
commit b8fcb57923
5 changed files with 498 additions and 30 deletions

View File

@@ -109,7 +109,7 @@ extern "C" {
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/build_info.h>
#include <ghostty/vt/color.h>
#include <ghostty/vt/device_status.h>
#include <ghostty/vt/device.h>
#include <ghostty/vt/focus.h>
#include <ghostty/vt/formatter.h>
#include <ghostty/vt/render.h>

150
include/ghostty/vt/device.h Normal file
View File

@@ -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 <stddef.h>
#include <stdint.h>
/* 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 */

View File

@@ -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 */

View File

@@ -12,7 +12,7 @@
#include <stdint.h>
#include <ghostty/vt/types.h>
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/device_status.h>
#include <ghostty/vt/device.h>
#include <ghostty/vt/modes.h>
#include <ghostty/vt/size_report.h>
#include <ghostty/vt/grid_ref.h>
@@ -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;
/**

View File

@@ -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(