terminal: expose size_report via stream_terminal effects

Add a `size` callback to the stream_terminal Effects struct that
returns a size_report.Size geometry snapshot for XTWINOPS size
queries (CSI 14/16/18 t). The handler owns all protocol encoding
using the existing size_report.encode, keeping VT knowledge out
of effect consumers. This follows the same pattern as the xtversion
effect: the callback supplies data, the handler formats the reply
and calls write_pty.

CSI 21 t (title report) is handled internally from terminal state
since the title is already available via terminal.getTitle() and
does not require an external callback.
This commit is contained in:
Mitchell Hashimoto
2026-03-22 20:55:02 -07:00
parent 26c81b4b0e
commit 6083e9f80b

View File

@@ -1,11 +1,13 @@
const std = @import("std");
const testing = std.testing;
const csi = @import("csi.zig");
const stream = @import("stream.zig");
const Action = stream.Action;
const Screen = @import("Screen.zig");
const modes = @import("modes.zig");
const osc_color = @import("osc/parsers/color.zig");
const kitty_color = @import("kitty/color.zig");
const size_report = @import("size_report.zig");
const Terminal = @import("Terminal.zig");
const log = std.log.scoped(.stream_terminal);
@@ -51,6 +53,11 @@ 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,
/// No effects means that the stream effectively becomes readonly
/// that only affects pure terminal state and ignores all side
/// effects beyond that.
@@ -59,6 +66,7 @@ pub const Handler = struct {
.write_pty = null,
.title_changed = null,
.xtversion = null,
.size = null,
};
};
@@ -206,6 +214,7 @@ pub const Handler = struct {
.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(),
@@ -225,7 +234,6 @@ pub const Handler = struct {
// Have no terminal-modifying effect
.enquiry,
.size_report,
.device_attributes,
.device_status,
.report_pwd,
@@ -259,6 +267,51 @@ pub const Handler = struct {
self.writePty(resp);
}
fn reportSize(self: *Handler, style: csi.SizeReportStyle) void {
// Almost all size reports will fit in 256 bytes so try that
// on the stack before falling back to a heap allocation.
var stack = std.heap.stackFallback(
256,
self.terminal.gpa(),
);
const alloc = stack.get();
// Allocating writing to accumulate the response.
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
// Build the response.
switch (style) {
.csi_21_t => {
const title = self.terminal.getTitle() orelse "";
aw.writer.print("\x1b]l{s}\x1b\\", .{title}) catch return;
},
.csi_14_t, .csi_16_t, .csi_18_t => {
const get_size = self.effects.size orelse return;
const s = get_size(self) orelse return;
const report_style: size_report.Style = switch (style) {
.csi_14_t => .csi_14_t,
.csi_16_t => .csi_16_t,
.csi_18_t => .csi_18_t,
.csi_21_t => unreachable,
};
size_report.encode(
&aw.writer,
report_style,
s,
) catch |err| {
log.warn("error encoding size report err={}", .{err});
return;
};
},
}
const resp = aw.toOwnedSliceSentinel(0) catch return;
defer alloc.free(resp);
self.writePty(resp);
}
fn windowTitle(self: *Handler, title_raw: []const u8) void {
// Prevent DoS attacks by limiting title length.
const max_title_len = 1024;
@@ -1389,3 +1442,137 @@ test "xtversion with empty string effect" {
s.nextSlice("\x1b[>0q");
try testing.expectEqualStrings("\x1bP>|libghostty\x1b\\", S.written.?);
}
test "size report csi_14_t with effect" {
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 {
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
}
fn getSize(_: *Handler) ?size_report.Size {
return .{ .rows = 24, .columns = 80, .cell_width = 9, .cell_height = 18 };
}
};
S.written = null;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.size = &S.getSize;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// CSI 14 t - report text area size in pixels
s.nextSlice("\x1b[14t");
defer testing.allocator.free(S.written.?);
try testing.expectEqualStrings("\x1b[4;432;720t", S.written.?);
}
test "size report csi_16_t with effect" {
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 {
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
}
fn getSize(_: *Handler) ?size_report.Size {
return .{ .rows = 24, .columns = 80, .cell_width = 9, .cell_height = 18 };
}
};
S.written = null;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.size = &S.getSize;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// CSI 16 t - report cell size in pixels
s.nextSlice("\x1b[16t");
defer testing.allocator.free(S.written.?);
try testing.expectEqualStrings("\x1b[6;18;9t", S.written.?);
}
test "size report csi_18_t with effect" {
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 {
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
}
fn getSize(_: *Handler) ?size_report.Size {
return .{ .rows = 24, .columns = 80, .cell_width = 9, .cell_height = 18 };
}
};
S.written = null;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.size = &S.getSize;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// CSI 18 t - report text area size in characters
s.nextSlice("\x1b[18t");
defer testing.allocator.free(S.written.?);
try testing.expectEqualStrings("\x1b[8;24;80t", S.written.?);
}
test "size report no effect 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 {
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
}
};
S.written = null;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Without size effect, size reports should be silently ignored
s.nextSlice("\x1b[14t");
try testing.expect(S.written == null);
}
test "size report csi_21_t title" {
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 {
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
}
};
S.written = null;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Set a title first
s.nextSlice("\x1b]2;My Title\x1b\\");
// CSI 21 t - report title (no size effect needed)
s.nextSlice("\x1b[21t");
defer testing.allocator.free(S.written.?);
try testing.expectEqualStrings("\x1b]lMy Title\x1b\\", S.written.?);
}