From 2e7aa047af3692ff6c6fa003f078d772c6db697d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Mar 2026 14:31:12 -0700 Subject: [PATCH] terminal: port device_status to stream_terminal Effects Previously device_status was in the ignored "no terminal-modifying effect" group in stream_terminal.zig. This ports it to use the Effects pattern, handling all three DSR request types. Operating status and cursor position are handled entirely within stream_terminal since they only need terminal state and write_pty. Cursor position respects origin mode and scrolling region offsets. Color scheme adds a new color_scheme effect callback that returns a ColorScheme enum (light/dark). The handler encodes the response internally, keeping protocol knowledge in the terminal layer. A new ColorScheme type is added to device_status.zig so the terminal layer does not depend on apprt. --- src/terminal/device_status.zig | 6 + src/terminal/stream_terminal.zig | 261 +++++++++++++++++++++++++++++-- 2 files changed, 252 insertions(+), 15 deletions(-) diff --git a/src/terminal/device_status.zig b/src/terminal/device_status.zig index 1be7bf719..3d0106e42 100644 --- a/src/terminal/device_status.zig +++ b/src/terminal/device_status.zig @@ -1,5 +1,11 @@ const std = @import("std"); +/// The color scheme reported in response to a CSI ? 996 n query. +pub const ColorScheme = enum { + light, + dark, +}; + /// An enum(u16) of the available device status requests. pub const Request = dsr_enum: { const EnumField = std.builtin.Type.EnumField; diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 8dbb3f2ae..ad721e24e 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -1,6 +1,7 @@ const std = @import("std"); const testing = std.testing; const csi = @import("csi.zig"); +const device_status = @import("device_status.zig"); const stream = @import("stream.zig"); const Action = stream.Action; const Screen = @import("Screen.zig"); @@ -34,15 +35,30 @@ pub const Handler = struct { effects: Effects = .readonly, pub const Effects = 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 so callers must copy it /// if it needs to be stored or used after the call returns. write_pty: ?*const fn (*Handler, [:0]const u8) void, + /// Called when the bell is rung (BEL). + bell: ?*const fn (*Handler) void, + + /// Called in response to a color scheme DSR query (CSI ? 996 n). + /// Returns the current color scheme. Return null to silently + /// ignore the query. + color_scheme: ?*const fn (*Handler) ?device_status.ColorScheme, + + /// Called in response to ENQ (0x05). Returns the raw response + /// bytes to write back to the pty. The returned memory must be + /// valid for the lifetime of the call. + enquiry: ?*const fn (*Handler) []const u8, + + /// Called in response to XTWINOPS size queries (CSI 14/16/18 t). + /// Returns the current terminal geometry used for encoding. + /// Return null to silently ignore the query. + size: ?*const fn (*Handler) ?size_report.Size, + /// Called when the terminal title changes via escape sequences /// (e.g. OSC 0/2). The new title can be queried via /// handler.terminal.getTitle(). @@ -54,16 +70,6 @@ pub const Handler = struct { /// is 256 bytes; longer strings will be silently ignored. xtversion: ?*const fn (*Handler) []const u8, - /// Called in response to XTWINOPS size queries (CSI 14/16/18 t). - /// Returns the current terminal geometry used for encoding. - /// Return null to silently ignore the query. - size: ?*const fn (*Handler) ?size_report.Size, - - /// Called in response to ENQ (0x05). Returns the raw response - /// bytes to write back to the pty. The returned memory must be - /// valid for the lifetime of the call. - enquiry: ?*const fn (*Handler) []const u8, - /// No effects means that the stream effectively becomes readonly /// that only affects pure terminal state and ignores all side /// effects beyond that. @@ -74,6 +80,7 @@ pub const Handler = struct { .xtversion = null, .size = null, .enquiry = null, + .color_scheme = null, }; }; @@ -218,13 +225,14 @@ pub const Handler = struct { // Effect-based handlers .bell => self.bell(), + .device_status => self.deviceStatus(value.request), + .enquiry => self.reportEnquiry(), .kitty_keyboard_query => self.queryKittyKeyboard(), .request_mode => self.requestMode(value.mode), .request_mode_unknown => self.requestModeUnknown(value.mode, value.ansi), .size_report => self.reportSize(value), .window_title => self.windowTitle(value.title), .xtversion => self.reportXtversion(), - .enquiry => self.reportEnquiry(), // No supported DCS commands have any terminal-modifying effects, // but they may in the future. For now we just ignore it. @@ -242,7 +250,6 @@ pub const Handler = struct { // Have no terminal-modifying effect .device_attributes, - .device_status, .report_pwd, .show_desktop_notification, .progress_report, @@ -263,6 +270,41 @@ pub const Handler = struct { func(self); } + fn deviceStatus(self: *Handler, req: device_status.Request) void { + switch (req) { + .operating_status => self.writePty("\x1B[0n"), + + .cursor_position => { + const pos: struct { + x: usize, + y: usize, + } = if (self.terminal.modes.get(.origin)) .{ + .x = self.terminal.screens.active.cursor.x -| self.terminal.scrolling_region.left, + .y = self.terminal.screens.active.cursor.y -| self.terminal.scrolling_region.top, + } else .{ + .x = self.terminal.screens.active.cursor.x, + .y = self.terminal.screens.active.cursor.y, + }; + + var buf: [64]u8 = undefined; + const resp = std.fmt.bufPrintZ(&buf, "\x1B[{};{}R", .{ + pos.y + 1, + pos.x + 1, + }) catch return; + self.writePty(resp); + }, + + .color_scheme => { + const func = self.effects.color_scheme orelse return; + const scheme = func(self) orelse return; + self.writePty(switch (scheme) { + .dark => "\x1B[?997;1n", + .light => "\x1B[?997;2n", + }); + }, + } + } + fn reportEnquiry(self: *Handler) void { const func = self.effects.enquiry orelse return; const response = func(self); @@ -1671,3 +1713,192 @@ test "enquiry with empty response" { s.nextSlice("\x05"); try testing.expect(S.written == null); } + +test "device status: operating status" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + }; + S.written = null; + defer if (S.written) |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(); + + // CSI 5 n — operating status report + s.nextSlice("\x1B[5n"); + try testing.expectEqualStrings("\x1B[0n", S.written.?); +} + +test "device status: cursor position" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + }; + S.written = null; + defer if (S.written) |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(); + + // Default position is 0,0 — reported as 1,1 + s.nextSlice("\x1B[6n"); + try testing.expectEqualStrings("\x1B[1;1R", S.written.?); + + // Move cursor to row 5, col 10 + s.nextSlice("\x1B[5;10H"); + s.nextSlice("\x1B[6n"); + try testing.expectEqualStrings("\x1B[5;10R", S.written.?); +} + +test "device status: cursor position with origin mode" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + }; + S.written = null; + defer if (S.written) |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(); + + // Set scroll region rows 5-20 + s.nextSlice("\x1B[5;20r"); + // Enable origin mode + s.nextSlice("\x1B[?6h"); + // Move to row 3, col 5 within the region + s.nextSlice("\x1B[3;5H"); + // Query cursor position + s.nextSlice("\x1B[6n"); + // Should report position relative to the scroll region + try testing.expectEqualStrings("\x1B[3;5R", S.written.?); +} + +test "device status: color scheme dark" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + fn colorScheme(_: *Handler) ?device_status.ColorScheme { + return .dark; + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.color_scheme = &S.colorScheme; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // CSI ? 996 n — color scheme query + s.nextSlice("\x1B[?996n"); + try testing.expectEqualStrings("\x1B[?997;1n", S.written.?); +} + +test "device status: color scheme light" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + fn colorScheme(_: *Handler) ?device_status.ColorScheme { + return .light; + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.color_scheme = &S.colorScheme; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // CSI ? 996 n — color scheme query + s.nextSlice("\x1B[?996n"); + try testing.expectEqualStrings("\x1B[?997;2n", S.written.?); +} + +test "device status: color scheme without callback" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + }; + S.written = null; + defer if (S.written) |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(); + + // Without color_scheme effect, query should be silently ignored + s.nextSlice("\x1B[?996n"); + try testing.expect(S.written == null); +} + +test "device status: readonly ignores all" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // All device status queries should be silently ignored without effects + s.nextSlice("\x1B[5n"); + s.nextSlice("\x1B[6n"); + s.nextSlice("\x1B[?996n"); + + // Terminal should still be functional + s.nextSlice("Test"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Test", str); +}