terminal: answer ANSI DECRQM queries

#14225

Handle the ANSI form of DECRQM (`CSI Ps $ p`), as specified by the
VT510 reference [1] and xterm [2]. Previously, only the DEC private
form reached the mode query handler.

[1] https://vt100.net/docs/vt510-rm/DECRQM.html
[2] https://invisible-island.net/xterm/ctlseqs/ctlseqs.txt
This commit is contained in:
Mitchell Hashimoto
2026-09-14 12:23:39 -07:00
parent b38394ae34
commit 9dc0d974e6
3 changed files with 128 additions and 2 deletions

View File

@@ -3882,7 +3882,7 @@ test "point_from_grid_ref null node" {
try testing.expectEqual(Result.invalid_value, point_from_grid_ref(t, &ref, .active, null));
}
test "set write_pty callback" {
test "set write_pty callback DECRQM" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
@@ -3895,17 +3895,20 @@ test "set write_pty callback" {
const S = struct {
var last_data: ?[]u8 = null;
var last_userdata: ?*anyopaque = null;
var calls: usize = 0;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
last_userdata = null;
calls = 0;
}
fn writePty(_: Terminal, ud: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
last_userdata = ud;
calls += 1;
}
};
defer S.deinit();
@@ -3920,6 +3923,13 @@ test "set write_pty callback" {
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);
try testing.expectEqual(1, S.calls);
const query = "\x1B[4h\x1B[4$p";
vt_write(t, query, query.len);
try testing.expectEqual(2, S.calls);
try testing.expectEqualStrings("\x1B[4;1$y", S.last_data.?);
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
}
test "write_pty receives DECRQSS response" {
@@ -4003,6 +4013,7 @@ test "set write_pty without callback ignores queries" {
// Without setting a callback, DECRQM should be silently ignored (no crash)
vt_write(t, "\x1B[?7$p", 6);
vt_write(t, "\x1B[4$p", 5);
}
test "set write_pty null clears callback" {

View File

@@ -2167,7 +2167,7 @@ pub fn Stream(comptime H: type) type {
// DECRQM - Request Mode
'p' => switch (input.intermediates.len) {
2 => decrqm: {
1, 2 => decrqm: {
const ansi_mode = ansi: {
switch (input.intermediates.len) {
1 => if (input.intermediates[0] == '$') break :ansi true,
@@ -3401,6 +3401,61 @@ test "stream: ansi set mode (SM) and reset mode (RM)" {
try testing.expect(s.handler.mode == null);
}
test "stream: DECRQM dispatch" {
const H = struct {
calls: usize = 0,
mode: ?modes.Mode = null,
raw: ?Action.RawMode = null,
pub fn vt(self: *@This(), comptime action: Action.Tag, value: Action.Value(action)) void {
switch (action) {
.request_mode => {
self.calls += 1;
self.mode = value.mode;
},
.request_mode_unknown => {
self.calls += 1;
self.raw = value;
},
else => {},
}
}
};
const cases = [_]struct {
input: []const u8,
mode: ?modes.Mode = null,
raw: ?Action.RawMode = null,
}{
.{ .input = "\x1b[4$p", .mode = .insert },
.{ .input = "\x1b[?4$p", .mode = .slow_scroll },
.{ .input = "\x1b[9999$p", .raw = .{ .mode = 9999, .ansi = true } },
.{ .input = "\x1b[?9999$p", .raw = .{ .mode = 9999, .ansi = false } },
.{ .input = "\x1b[4p" },
.{ .input = "\x1b[?4p" },
.{ .input = "\x1b[4!p" },
.{ .input = "\x1b[4 p" },
.{ .input = "\x1b[>4$p" },
.{ .input = "\x1b[?4!p" },
.{ .input = "\x1b[$p" },
.{ .input = "\x1b[?$p" },
.{ .input = "\x1b[4;20$p" },
.{ .input = "\x1b[?4;7$p" },
.{ .input = "\x1b[4:20$p" },
};
for (cases) |case| {
for (0..case.input.len + 1) |split| {
var s: Stream(H) = .init(.{ .handler = .{} });
s.nextSlice(case.input[0..split]);
if (split < case.input.len) try testing.expectEqual(0, s.handler.calls);
s.nextSlice(case.input[split..]);
try testing.expectEqual(@as(usize, if (case.mode != null or case.raw != null) 1 else 0), s.handler.calls);
try testing.expectEqual(case.mode, s.handler.mode);
try testing.expectEqualDeep(case.raw, s.handler.raw);
}
}
}
test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" {
const H = struct {
mode: ?modes.Mode = null,

View File

@@ -4574,6 +4574,7 @@ test "request mode DECRQM with write_pty callback" {
// DECRQM for mode 7 (wraparound) — should be silently ignored
s.nextSlice("\x1B[?7$p");
s.nextSlice("\x1B[4$p");
}
t.fullReset();
@@ -4615,6 +4616,65 @@ test "request mode DECRQM with write_pty callback" {
}
}
test "request mode DECRQM ANSI responses" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var response: [32]u8 = undefined;
var len: usize = 0;
var calls: usize = 0;
fn writePty(_: *Handler, data: []const u8) void {
@memcpy(response[0..data.len], data);
len = data.len;
calls += 1;
}
};
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
inline for (.{
.{ "2", modes.Mode.disable_keyboard },
.{ "4", modes.Mode.insert },
.{ "12", modes.Mode.send_receive_mode },
.{ "20", modes.Mode.linefeed },
}) |mode| {
inline for (.{ false, true, false }) |enabled| {
s.nextSlice("\x1b[" ++ mode[0] ++ (if (enabled) "h" else "l"));
try testing.expectEqual(enabled, t.modes.get(mode[1]));
const query = "\x1b[" ++ mode[0] ++ "$p";
for (0..query.len + 1) |split| {
S.calls = 0;
S.len = 0;
s.nextSlice(query[0..split]);
if (split < query.len) try testing.expectEqual(0, S.calls);
s.nextSlice(query[split..]);
try testing.expectEqual(1, S.calls);
try testing.expectEqualStrings("\x1b[" ++ mode[0] ++ (if (enabled) ";1$y" else ";2$y"), S.response[0..S.len]);
try testing.expectEqual(enabled, t.modes.get(mode[1]));
}
}
}
// The two namespaces must report independent states for mode 4.
s.nextSlice("\x1b[4h\x1b[?4l");
const cases = .{
.{ "\x1b[4$p", "\x1b[4;1$y" },
.{ "\x1b[?4$p", "\x1b[?4;2$y" },
.{ "\x1b[9999$p", "\x1b[9999;0$y" },
};
inline for (cases) |case| {
S.calls = 0;
S.len = 0;
s.nextSlice(case[0]);
try testing.expectEqual(1, S.calls);
try testing.expectEqualStrings(case[1], S.response[0..S.len]);
}
}
test "stream: CSI W with intermediate but no params" {
// Regression test from AFL++ crash. CSI ? W without
// parameters caused an out-of-bounds access on input.params[0].