terminal: port device_attributes to stream_terminal Effects

Add a device_attributes effect callback to the stream_terminal
Handler. The callback returns a device_attributes.Attributes
struct which the handler encodes and writes back to the pty.

Add Attributes.encode which dispatches to the correct sub-type
encoder based on the request type (primary, secondary, tertiary).

In readonly mode the callback is null so all DA queries are
silently ignored, matching the previous behavior where
device_attributes was in the ignored actions list.

Tests cover all three DA types with default attributes, custom
attributes, and readonly mode.
This commit is contained in:
Mitchell Hashimoto
2026-03-23 14:54:53 -07:00
parent b31dcf9a4c
commit ba3f9bb400
2 changed files with 187 additions and 7 deletions

View File

@@ -12,7 +12,7 @@ pub const Req = lib.Enum(lib_target, &.{
});
/// Response data for all device attribute queries.
pub const Response = struct {
pub const Attributes = struct {
/// Reply to CSI c (DA1).
primary: Primary = .{},
@@ -21,6 +21,19 @@ pub const Response = struct {
/// Reply to CSI = c (DA3).
tertiary: Tertiary = .{},
/// Encode the response for the given request type into the writer.
pub fn encode(
self: Attributes,
req: Req,
writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
switch (req) {
.primary => try self.primary.encode(writer),
.secondary => try self.secondary.encode(writer),
.tertiary => try self.tertiary.encode(writer),
}
}
};
/// Primary device attributes (DA1).

View File

@@ -1,6 +1,7 @@
const std = @import("std");
const testing = std.testing;
const csi = @import("csi.zig");
const device_attributes = @import("device_attributes.zig");
const device_status = @import("device_status.zig");
const stream = @import("stream.zig");
const Action = stream.Action;
@@ -49,6 +50,11 @@ pub const Handler = struct {
/// ignore the query.
color_scheme: ?*const fn (*Handler) ?device_status.ColorScheme,
/// Called in response to a device attributes query (CSI c,
/// CSI > c, CSI = c). Returns the response to encode and
/// write back to the pty.
device_attributes: ?*const fn (*Handler) device_attributes.Attributes,
/// Called in response to ENQ (0x05). Returns the raw response
/// bytes to write back to the pty. The returned memory must be
/// valid for the lifetime of the call.
@@ -75,12 +81,13 @@ pub const Handler = struct {
/// effects beyond that.
pub const readonly: Effects = .{
.bell = null,
.write_pty = null,
.title_changed = null,
.xtversion = null,
.size = null,
.enquiry = null,
.color_scheme = null,
.device_attributes = null,
.enquiry = null,
.size = null,
.title_changed = null,
.write_pty = null,
.xtversion = null,
};
};
@@ -225,6 +232,7 @@ pub const Handler = struct {
// Effect-based handlers
.bell => self.bell(),
.device_attributes => self.reportDeviceAttributes(value),
.device_status => self.deviceStatus(value.request),
.enquiry => self.reportEnquiry(),
.kitty_keyboard_query => self.queryKittyKeyboard(),
@@ -249,7 +257,6 @@ pub const Handler = struct {
=> {},
// Have no terminal-modifying effect
.device_attributes,
.report_pwd,
.show_desktop_notification,
.progress_report,
@@ -270,6 +277,23 @@ pub const Handler = struct {
func(self);
}
fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void {
const func = self.effects.device_attributes orelse return;
const attrs = func(self);
var stack = std.heap.stackFallback(128, self.terminal.gpa());
const alloc = stack.get();
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
attrs.encode(req, &aw.writer) catch return;
const written = aw.toOwnedSliceSentinel(0) catch return;
defer alloc.free(written);
self.writePty(written);
}
fn deviceStatus(self: *Handler, req: device_status.Request) void {
switch (req) {
.operating_status => self.writePty("\x1B[0n"),
@@ -1902,3 +1926,146 @@ test "device status: readonly ignores all" {
defer testing.allocator.free(str);
try testing.expectEqualStrings("Test", str);
}
test "device attributes: primary DA" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var written: ?[]const u8 = null;
fn writePty(_: *Handler, data: [:0]const u8) void {
if (written) |old| testing.allocator.free(old);
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
}
fn da(_: *Handler) device_attributes.Attributes {
return .{};
}
};
S.written = null;
defer if (S.written) |old| testing.allocator.free(old);
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.device_attributes = &S.da;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1B[c");
try testing.expectEqualStrings("\x1b[?62;22c", S.written.?);
}
test "device attributes: secondary DA" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var written: ?[]const u8 = null;
fn writePty(_: *Handler, data: [:0]const u8) void {
if (written) |old| testing.allocator.free(old);
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
}
fn da(_: *Handler) device_attributes.Attributes {
return .{};
}
};
S.written = null;
defer if (S.written) |old| testing.allocator.free(old);
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.device_attributes = &S.da;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1B[>c");
try testing.expectEqualStrings("\x1b[>1;0;0c", S.written.?);
}
test "device attributes: tertiary DA" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var written: ?[]const u8 = null;
fn writePty(_: *Handler, data: [:0]const u8) void {
if (written) |old| testing.allocator.free(old);
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
}
fn da(_: *Handler) device_attributes.Attributes {
return .{};
}
};
S.written = null;
defer if (S.written) |old| testing.allocator.free(old);
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.device_attributes = &S.da;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1B[=c");
try testing.expectEqualStrings("\x1bP!|00000000\x1b\\", S.written.?);
}
test "device attributes: readonly ignores" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// All DA queries should be silently ignored without effects
s.nextSlice("\x1B[c");
s.nextSlice("\x1B[>c");
s.nextSlice("\x1B[=c");
// Terminal should still be functional
s.nextSlice("Test");
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("Test", str);
}
test "device attributes: custom response" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var written: ?[]const u8 = null;
fn writePty(_: *Handler, data: [:0]const u8) void {
if (written) |old| testing.allocator.free(old);
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
}
fn da(_: *Handler) device_attributes.Attributes {
return .{
.primary = .{
.conformance_level = .vt420,
.features = &.{ .ansi_color, .clipboard },
},
.secondary = .{
.device_type = .vt420,
.firmware_version = 100,
},
};
}
};
S.written = null;
defer if (S.written) |old| testing.allocator.free(old);
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.device_attributes = &S.da;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1B[c");
try testing.expectEqualStrings("\x1b[?64;22;52c", S.written.?);
s.nextSlice("\x1B[>c");
try testing.expectEqualStrings("\x1b[>41;100;0c", S.written.?);
}