lib-vt: handle DECRQSS (#13471)

Move DECRQSS response encoding into the terminal DCS handler so both the
full termio path and libghostty-vt terminal stream emit the same
replies. The C API stream now maintains and releases DCS parser state
and forwards responses through write_pty.
This commit is contained in:
Mitchell Hashimoto
2026-07-26 15:21:27 -07:00
committed by GitHub
4 changed files with 318 additions and 72 deletions

View File

@@ -2471,6 +2471,41 @@ test "set write_pty callback" {
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
}
test "write_pty receives DECRQSS response" {
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) |data| testing.allocator.free(data);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |data| testing.allocator.free(data);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
const query = "\x1B[1m\x1BP$qm\x1B\\";
vt_write(t, query, query.len);
try testing.expectEqualStrings("\x1BP1$r0;1m\x1B\\", S.last_data.?);
}
test "write_pty receives OSC color query response" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(

View File

@@ -254,6 +254,63 @@ pub const Command = union(enum) {
decscusr,
decstbm,
decslrm,
/// Fixed upper bound for an encoded DECRPSS response. The comptime
/// calculated max at the time of writing this was around 63 so this
/// leaves a ton of space for future stuff. We don't do the comptime
/// calculation cause it complicated the implementation a bit too
/// much.
pub const max_response_bytes = 256;
/// Encode the response for this request.
pub fn encode(
self: DECRQSS,
t: *terminal.Terminal,
response: []u8,
) ![]const u8 {
var writer: std.Io.Writer = .fixed(response);
const prefix_fmt = "\x1bP{d}$r";
const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len;
writer.end = prefix_len;
switch (self) {
.none => {},
.sgr => {
const buf = try t.printAttributes(writer.buffer[writer.end..]);
writer.end += buf.len;
try writer.writeByte('m');
},
.decscusr => {
const blink = t.modes.get(.cursor_blinking);
const style: u8 = switch (t.screens.active.cursor.cursor_style) {
.block, .block_hollow => if (blink) 1 else 2,
.underline => if (blink) 3 else 4,
.bar => if (blink) 5 else 6,
};
try writer.print("{d} q", .{style});
},
.decstbm => try writer.print("{d};{d}r", .{
t.scrolling_region.top + 1,
t.scrolling_region.bottom + 1,
}),
.decslrm => if (t.modes.get(.enable_left_and_right_margin)) {
try writer.print("{d};{d}s", .{
t.scrolling_region.left + 1,
t.scrolling_region.right + 1,
});
},
}
const valid = writer.end > prefix_len;
try writer.writeAll("\x1b\\");
_ = try std.fmt.bufPrint(
response[0..prefix_len],
prefix_fmt,
.{@intFromBool(valid)},
);
return writer.buffered();
}
};
};
@@ -405,6 +462,89 @@ test "DECRQSS invalid command" {
try testing.expect(h.unhook() == null);
}
test "DECRQSS response encoding" {
const testing = std.testing;
var t: terminal.Terminal = try .init(
testing.io,
testing.allocator,
.{ .cols = 80, .rows = 24 },
);
defer t.deinit(testing.allocator);
const S = struct {
fn expectResponse(
term: *terminal.Terminal,
request: Command.DECRQSS,
expected: []const u8,
) !void {
var buf: [Command.DECRQSS.max_response_bytes]u8 = undefined;
const encoded = try request.encode(term, &buf);
try testing.expectEqualStrings(expected, encoded);
}
};
try S.expectResponse(&t, .none, "\x1BP0$r\x1B\\");
try S.expectResponse(&t, .sgr, "\x1BP1$r0m\x1B\\");
try t.setAttribute(.bold);
try t.setAttribute(.{ .underline = .curly });
try S.expectResponse(&t, .sgr, "\x1BP1$r0;1;4:3m\x1B\\");
t.setCursorStyle(.steady_underline);
try S.expectResponse(&t, .decscusr, "\x1BP1$r4 q\x1B\\");
t.scrolling_region.top = 4;
t.scrolling_region.bottom = 19;
try S.expectResponse(&t, .decstbm, "\x1BP1$r5;20r\x1B\\");
try S.expectResponse(&t, .decslrm, "\x1BP0$r\x1B\\");
t.modes.set(.enable_left_and_right_margin, true);
t.scrolling_region.left = 2;
t.scrolling_region.right = 69;
try S.expectResponse(&t, .decslrm, "\x1BP1$r3;70s\x1B\\");
}
test "DECRQSS largest response fits fixed buffer" {
const testing = std.testing;
var t: terminal.Terminal = try .init(
testing.io,
testing.allocator,
.{ .cols = 80, .rows = 24 },
);
defer t.deinit(testing.allocator);
try t.setAttribute(.bold);
try t.setAttribute(.faint);
try t.setAttribute(.italic);
try t.setAttribute(.{ .underline = .dashed });
try t.setAttribute(.blink);
try t.setAttribute(.inverse);
try t.setAttribute(.invisible);
try t.setAttribute(.strikethrough);
try t.setAttribute(.{ .direct_color_fg = .{
.r = 255,
.g = 255,
.b = 255,
} });
try t.setAttribute(.{ .direct_color_bg = .{
.r = 255,
.g = 255,
.b = 255,
} });
var buf: [Command.DECRQSS.max_response_bytes]u8 = undefined;
const encoded = try Command.DECRQSS.sgr.encode(&t, &buf);
const expected =
"\x1BP1$r0;1;2;3;4:5;5;7;8;9" ++
";38:2::255:255:255" ++
";48:2::255:255:255m\x1B\\";
try testing.expectEqualStrings(expected, encoded);
try testing.expect(encoded.len <= Command.DECRQSS.max_response_bytes);
}
test "tmux enter and implicit exit" {
if (comptime !build_options.tmux_control_mode) return error.SkipZigTest;

View File

@@ -4,6 +4,7 @@ const testing = std.testing;
const apc = @import("apc.zig");
const clipboard = @import("clipboard.zig");
const csi = @import("csi.zig");
const dcs = @import("dcs.zig");
const device_attributes = @import("device_attributes.zig");
const device_status = @import("device_status.zig");
const stream = @import("stream.zig");
@@ -60,6 +61,9 @@ pub const Handler = struct {
/// the kitty graphics protocol.
apc_handler: apc.Handler = .{},
/// The DCS command handler maintains state for DCS queries.
dcs_handler: dcs.Handler = .{},
pub const Effects = struct {
/// Called when the terminal needs to write data back to the pty,
/// e.g. in response to a DECRQM query. The data is only valid
@@ -145,6 +149,7 @@ pub const Handler = struct {
pub fn deinit(self: *Handler) void {
self.apc_handler.deinit();
self.dcs_handler.deinit();
}
/// Resize the terminal and apply any side effects (if supported)
@@ -327,12 +332,9 @@ pub const Handler = struct {
log.warn("error handling clipboard write err={}", .{err});
},
// No supported DCS commands have any terminal-modifying effects,
// but they may in the future. For now we just ignore it.
.dcs_hook,
.dcs_put,
.dcs_unhook,
=> {},
.dcs_hook => try self.dcsHook(value),
.dcs_put => try self.dcsPut(value),
.dcs_unhook => try self.dcsUnhook(),
// Have no terminal-modifying effect
.show_desktop_notification,
@@ -348,6 +350,47 @@ pub const Handler = struct {
func(self, data);
}
fn dcsHook(self: *Handler, value: Action.Value(.dcs_hook)) !void {
var cmd = self.dcs_handler.hook(
self.terminal.gpa(),
value,
) orelse return;
defer cmd.deinit();
try self.dcsCommand(&cmd);
}
fn dcsPut(self: *Handler, value: u8) !void {
var cmd = self.dcs_handler.put(value) orelse return;
defer cmd.deinit();
try self.dcsCommand(&cmd);
}
fn dcsUnhook(self: *Handler) !void {
var cmd = self.dcs_handler.unhook() orelse return;
defer cmd.deinit();
try self.dcsCommand(&cmd);
}
fn dcsCommand(self: *Handler, cmd: *dcs.Command) !void {
switch (cmd.*) {
.decrqss => |request| {
var response: [
dcs.Command.DECRQSS.max_response_bytes + 1
]u8 = undefined;
const encoded = try request.encode(
self.terminal,
response[0 .. response.len - 1],
);
response[encoded.len] = 0;
self.writePty(response[0..encoded.len :0]);
},
.tmux,
.xtgettcap,
=> {},
}
}
fn bell(self: *Handler) void {
const func = self.effects.bell orelse return;
func(self);
@@ -1314,6 +1357,94 @@ test "attributes" {
try testing.expectEqualStrings("Bold", str);
}
test "DECRQSS responses" {
var t: Terminal = try .init(
testing.io,
testing.allocator,
.{ .cols = 80, .rows = 24 },
);
defer t.deinit(testing.allocator);
const S = struct {
var response: [dcs.Command.DECRQSS.max_response_bytes]u8 = undefined;
var response_len: usize = 0;
var calls: usize = 0;
fn reset() void {
response_len = 0;
calls = 0;
}
fn writePty(_: *Handler, data: [:0]const u8) void {
@memcpy(response[0..data.len], data);
response_len = data.len;
calls += 1;
}
fn expectResponse(expected: []const u8) !void {
try testing.expectEqual(@as(usize, 1), calls);
try testing.expectEqualStrings(
expected,
response[0..response_len],
);
reset();
}
};
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// SGR
s.nextSlice("\x1B[1m\x1BP$qm\x1B\\");
try S.expectResponse("\x1BP1$r0;1m\x1B\\");
// Requests larger than the parser's fixed request buffer are ignored,
// and the next DCS command must still be processed normally.
s.nextSlice("\x1BP$qfoo\x1B\\");
try testing.expectEqual(@as(usize, 0), S.calls);
try testing.expect(!s.handler.semantic_failure);
s.nextSlice("\x1BP$qm\x1B\\");
try S.expectResponse("\x1BP1$r0;1m\x1B\\");
}
test "DECRQSS without write effect is ignored" {
var t: Terminal = try .init(
testing.io,
testing.allocator,
.{ .cols = 80, .rows = 24 },
);
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
s.nextSlice("\x1BP$qm\x1B\\");
try testing.expect(!s.handler.semantic_failure);
}
test "DCS command memory is released" {
var t: Terminal = try .init(
testing.io,
testing.allocator,
.{ .cols = 80, .rows = 24 },
);
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
// A completed, unsupported command transfers its allocation to Command;
// dcsCommand must release it even though stream_terminal ignores it.
s.nextSlice("\x1BP+q536D756C78\x1B\\");
// An incomplete command remains owned by the handler and must be released
// when the stream is deinitialized. testing.allocator detects either leak.
s.nextSlice("\x1BP+q536D756C78");
s.deinit();
}
test "DECALN screen alignment" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 3 });
defer t.deinit(testing.allocator);

View File

@@ -464,72 +464,12 @@ pub const StreamHandler = struct {
},
.decrqss => |decrqss| {
var response: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&response);
// Offset the stream position to just past the response prefix.
// We will write the "payload" (if any) below. If no payload is
// written then we send an invalid DECRPSS response.
const prefix_fmt = "\x1bP{d}$r";
const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len;
writer.end = prefix_len;
switch (decrqss) {
// Invalid or unhandled request
.none => {},
.sgr => {
const buf = try self.terminal.printAttributes(writer.buffer[writer.end..]);
// printAttributes wrote into our buffer, so adjust the stream
// position
writer.end += buf.len;
try writer.writeByte('m');
},
.decscusr => {
const blink = self.terminal.modes.get(.cursor_blinking);
const style: u8 = switch (self.terminal.screens.active.cursor.cursor_style) {
.block => if (blink) 1 else 2,
.underline => if (blink) 3 else 4,
.bar => if (blink) 5 else 6,
// Below here, the cursor styles aren't represented by
// DECSCUSR so we map it to some other style.
.block_hollow => if (blink) 1 else 2,
};
try writer.print("{d} q", .{style});
},
.decstbm => {
try writer.print("{d};{d}r", .{
self.terminal.scrolling_region.top + 1,
self.terminal.scrolling_region.bottom + 1,
});
},
.decslrm => {
// We only send a valid response when left and right
// margin mode (DECLRMM) is enabled.
if (self.terminal.modes.get(.enable_left_and_right_margin)) {
try writer.print("{d};{d}s", .{
self.terminal.scrolling_region.left + 1,
self.terminal.scrolling_region.right + 1,
});
}
},
}
// Our response is valid if we have a response payload
const valid = writer.end > prefix_len;
// Write the terminator
try writer.writeAll("\x1b\\");
// Write the response prefix into the buffer
_ = try std.fmt.bufPrint(response[0..prefix_len], prefix_fmt, .{@intFromBool(valid)});
const msg = try termio.Message.writeReq(self.alloc, response[0..writer.end]);
var response: [terminal.dcs.Command.DECRQSS.max_response_bytes]u8 = undefined;
const encoded = try decrqss.encode(self.terminal, &response);
const msg = try termio.Message.writeReq(
self.alloc,
encoded,
);
self.messageWriter(msg);
},
}