From 39ae85f040dd922990e58b8a830414b471ddaf97 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 26 Jul 2026 15:01:38 -0700 Subject: [PATCH] lib-vt: handle DECRQSS 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. --- src/terminal/c/terminal.zig | 35 ++++++++ src/terminal/dcs.zig | 140 ++++++++++++++++++++++++++++++ src/terminal/stream_terminal.zig | 143 +++++++++++++++++++++++++++++-- src/termio/stream_handler.zig | 72 ++-------------- 4 files changed, 318 insertions(+), 72 deletions(-) diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 195dd94dd..e76db9490 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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( diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 425325d4a..bc183dcf5 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -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; diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index bbbe5f83e..61e402a4f 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -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); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 58b7fcab4..b9b18f63a 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -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); }, }