From 9dc0d974e62d4a98f55680a58fa574949f4bf134 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 14 Sep 2026 12:23:39 -0700 Subject: [PATCH] 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 --- src/terminal/c/terminal.zig | 13 ++++++- src/terminal/stream.zig | 57 +++++++++++++++++++++++++++++- src/terminal/stream_terminal.zig | 60 ++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 736d0ca08..ffe2ccf4f 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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" { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 29bdde7df..8e8672c9a 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -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, diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 61172423a..72db3a7e8 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -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].