terminal: add write_pty effect and implement DECRQM

Add a generic write_pty effect callback to the stream terminal
handler, allowing callers to receive pty response data. Use it to
implement request_mode and request_mode_unknown (DECRQM), which
encode the mode state as a DECRPM response and write it back
through the callback. Previously these were silently ignored.

The write_pty data is stack-allocated and only valid for the
duration of the call.
This commit is contained in:
Mitchell Hashimoto
2026-03-22 20:18:47 -07:00
parent 67d8d86efd
commit e24cc1b53b

View File

@@ -35,11 +35,17 @@ pub const Handler = struct {
/// Called when the bell is rung (BEL).
bell: ?*const fn (*Handler) void,
/// 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
/// during the lifetime of the call.
write_pty: ?*const fn (*Handler, [:0]const u8) void,
/// No effects means that the stream effectively becomes readonly
/// that only affects pure terminal state and ignores all side
/// effects beyond that.
pub const readonly: Effects = .{
.bell = null,
.write_pty = null,
};
};
@@ -184,6 +190,8 @@ pub const Handler = struct {
// Effect-based handlers
.bell => self.bell(),
.request_mode => self.requestMode(value.mode),
.request_mode_unknown => self.requestModeUnknown(value.mode, value.ansi),
// No supported DCS commands have any terminal-modifying effects,
// but they may in the future. For now we just ignore it.
@@ -201,8 +209,6 @@ pub const Handler = struct {
// Have no terminal-modifying effect
.enquiry,
.request_mode,
.request_mode_unknown,
.size_report,
.xtversion,
.device_attributes,
@@ -224,6 +230,36 @@ pub const Handler = struct {
func(self);
}
inline fn writePty(self: *Handler, data: [:0]const u8) void {
const func = self.effects.write_pty orelse return;
func(self, data);
}
fn requestMode(self: *Handler, mode: modes.Mode) void {
const report = self.terminal.modes.getReport(.fromMode(mode));
self.sendModeReport(report);
}
fn requestModeUnknown(self: *Handler, mode_raw: u16, ansi: bool) void {
const report = self.terminal.modes.getReport(.{
.value = @truncate(mode_raw),
.ansi = ansi,
});
self.sendModeReport(report);
}
fn sendModeReport(self: *Handler, report: modes.Report) void {
var buf: [modes.Report.max_size + 1]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
report.encode(&writer) catch |err| {
log.warn("error encoding mode report err={}", .{err});
return;
};
const len = writer.buffered().len;
buf[len] = 0;
self.writePty(buf[0..len :0]);
}
inline fn horizontalTab(self: *Handler, count: u16) void {
for (0..count) |_| {
const x = self.terminal.screens.active.cursor.x;
@@ -1064,6 +1100,54 @@ test "bell effect callback" {
}
}
test "request mode DECRQM with write_pty callback" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
// Without callback, DECRQM should not crash
{
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// DECRQM for mode 7 (wraparound) — should be silently ignored
s.nextSlice("\x1B[?7$p");
}
t.fullReset();
// With callback, DECRQM should produce a response
{
const S = struct {
var last_response: ?[:0]const u8 = null;
fn writePty(_: *Handler, data: [:0]const u8) void {
if (last_response) |old| testing.allocator.free(old);
last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
}
};
S.last_response = null;
defer if (S.last_response) |old| testing.allocator.free(old);
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Wraparound mode (7) is set by default
s.nextSlice("\x1B[?7$p");
try testing.expectEqualStrings("\x1B[?7;1$y", S.last_response.?);
// Disable wraparound and query again
s.nextSlice("\x1B[?7l");
s.nextSlice("\x1B[?7$p");
try testing.expectEqualStrings("\x1B[?7;2$y", S.last_response.?);
// Query an unknown mode
s.nextSlice("\x1B[?9999$p");
try testing.expectEqualStrings("\x1B[?9999;0$y", S.last_response.?);
}
}
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].