Files
ghostty/src/terminal/stream_terminal.zig
2026-07-27 12:16:28 -04:00

3152 lines
113 KiB
Zig

const std = @import("std");
const build_options = @import("terminal_options");
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");
const Action = stream.Action;
const Screen = @import("Screen.zig");
const color = @import("color.zig");
const modes = @import("modes.zig");
const osc = @import("osc.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);
/// This is a Stream implementation that processes actions against
/// a Terminal and updates the Terminal state.
pub const Stream = stream.Stream(Handler);
/// A stream handler that updates terminal state. By default, it is
/// readonly in the sense that it only updates terminal state and ignores
/// all other sequences that require a response or otherwise have side
/// effects (e.g. clipboards).
///
/// You can manually set various effects callbacks in the `effects` field
/// to implement certain effects such as bells, titles, clipboard, etc.
pub const Handler = struct {
/// The terminal state to modify.
terminal: *Terminal,
/// True after an error prevented a terminal-owned semantic update.
///
/// When an error happens during terminal processing, streams continue
/// forward and remain best-effort. A terminal can't really stop in
/// the middle it must go on. But this is flagged to true to let
/// consumers know some sort of unhandle-able error state happened
/// (e.g. an allocation failure).
///
/// Only non-handled outcomes set this. Gracefully handled outcomes
/// that don't meaningfully negatively impact the terminal state
/// such as hitting Kitty image limits, failure to write a response,
/// do not flag this.
semantic_failure: bool = false,
/// Callbacks for certain effects that handlers may have. These
/// may or may not fully replace internal handling of certain effects,
/// but they allow for the handler to trigger or query external
/// effects.
effects: Effects = .readonly,
/// The APC command handler maintains the APC state. APC is like
/// CSI or OSC, but it is a private escape sequence that is used
/// to send commands to the terminal emulator. This is used by
/// 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
/// 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 when the running program requests a desktop notification
/// via OSC 9 or OSC 777. The title and body are borrowed and only
/// valid for the duration of the callback.
desktop_notification: ?*const fn (*Handler, Action.ShowDesktopNotification) 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 a device attributes query (CSI c,
/// CSI > c, CSI = c). Returns the response to encode and
/// write back to the pty.
device_attributes: ?*const fn (*Handler) device_attributes.Attributes,
/// 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().
title_changed: ?*const fn (*Handler) void,
/// Called when the terminal pwd changes via escape sequences
/// (e.g. OSC 7). The new pwd can be queried via
/// handler.terminal.getPwd().
pwd_changed: ?*const fn (*Handler) void,
/// Called when the running program reports progress via OSC 9;4.
progress_report: ?*const fn (*Handler, osc.Command.ProgressReport) void,
/// Called when the running program writes to a clipboard. The write
/// has a normalized destination and one or more decoded MIME
/// representations. All request, MIME, and data memory is borrowed
/// and only valid for the duration of the callback.
///
/// A write with no contents clears the destination. A content entry
/// with empty data is a distinct empty representation.
///
/// Clipboard read requests (OSC 52 with a "?" payload) are never
/// forwarded: answering one would let any program running in the
/// terminal silently read the user's clipboard, and a VT state
/// library has no way to mediate that with user consent.
clipboard_write: ?*const fn (*Handler, clipboard.Write) clipboard.WriteResult,
/// Called in response to an XTVERSION query. Returns the version
/// string to report (e.g. "ghostty 1.2.3"). The returned memory
/// must be valid for the lifetime of the call. The maximum length
/// is 256 bytes; longer strings will be silently ignored.
xtversion: ?*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.
pub const readonly: Effects = .{
.bell = null,
.clipboard_write = null,
.color_scheme = null,
.desktop_notification = null,
.device_attributes = null,
.enquiry = null,
.progress_report = null,
.size = null,
.title_changed = null,
.pwd_changed = null,
.write_pty = null,
.xtversion = null,
};
};
pub fn init(terminal: *Terminal) Handler {
return .{
.terminal = terminal,
};
}
pub fn deinit(self: *Handler) void {
self.apc_handler.deinit();
self.dcs_handler.deinit();
}
/// Resize the terminal and apply any side effects (if supported)
/// as a result of that.
///
/// This is different than a direct `Terminal.resize` operation
/// because it also handles the side effects like mode 2048 in-band
/// size reports if write_pty is set.
pub fn resize(self: *Handler, value: Terminal.Resize) !void {
try self.terminal.resize(self.terminal.gpa(), value);
// Mode 2048 reports require complete, current cell pixel geometry.
const cell_size = value.cell_size_px orelse return;
// If we have no in-band size reports enabled then do nothing.
if (!self.terminal.modes.get(.in_band_size_reports)) return;
// If we have no write_pty effect, do nothing.
const write_pty = self.effects.write_pty orelse return;
// The maximum mode-2048 response is covered by size_report's maximum
// value test. Reserve the final byte for the callback's sentinel.
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(buf[0 .. buf.len - 1]);
size_report.encode(&writer, .mode_2048, .{
.rows = value.rows,
.columns = value.cols,
.cell_width = cell_size.width,
.cell_height = cell_size.height,
}) catch unreachable;
buf[writer.end] = 0;
write_pty(self, buf[0..writer.end :0]);
}
pub fn vt(
self: *Handler,
comptime action: Action.Tag,
value: Action.Value(action),
) void {
self.vtFallible(action, value) catch |err| {
self.semantic_failure = true;
log.warn("error handling VT action action={} err={}", .{ action, err });
};
}
inline fn vtFallible(
self: *Handler,
comptime action: Action.Tag,
value: Action.Value(action),
) !void {
switch (action) {
.print => try self.terminal.print(value.cp),
.print_slice => try self.terminal.printSlice(value.cps),
.print_repeat => try self.terminal.printRepeat(value),
.backspace => self.terminal.backspace(),
.carriage_return => self.terminal.carriageReturn(),
.linefeed => try self.terminal.linefeed(),
.index => try self.terminal.index(),
.next_line => {
try self.terminal.index();
self.terminal.carriageReturn();
},
.reverse_index => self.terminal.reverseIndex(),
.cursor_up => self.terminal.cursorUp(value.value),
.cursor_down => self.terminal.cursorDown(value.value),
.cursor_left => self.terminal.cursorLeft(value.value),
.cursor_right => self.terminal.cursorRight(value.value),
.cursor_pos => self.terminal.setCursorPos(value.row, value.col),
.cursor_col => self.terminal.setCursorPos(self.terminal.screens.active.cursor.y + 1, value.value),
.cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screens.active.cursor.x + 1),
.cursor_col_relative => self.terminal.setCursorPos(
self.terminal.screens.active.cursor.y + 1,
self.terminal.screens.active.cursor.x + 1 +| value.value,
),
.cursor_row_relative => self.terminal.setCursorPos(
self.terminal.screens.active.cursor.y + 1 +| value.value,
self.terminal.screens.active.cursor.x + 1,
),
.cursor_style => self.terminal.setCursorStyle(value),
.erase_display_below => self.terminal.eraseDisplay(.below, value),
.erase_display_above => self.terminal.eraseDisplay(.above, value),
.erase_display_complete => self.terminal.eraseDisplay(.complete, value),
.erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value),
.erase_display_scroll_complete => self.terminal.eraseDisplay(.scroll_complete, value),
.erase_line_right => self.terminal.eraseLine(.right, value),
.erase_line_left => self.terminal.eraseLine(.left, value),
.erase_line_complete => self.terminal.eraseLine(.complete, value),
.erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value),
.delete_chars => self.terminal.deleteChars(value),
.erase_chars => self.terminal.eraseChars(value),
.insert_lines => self.terminal.insertLines(value),
.insert_blanks => self.terminal.insertBlanks(value),
.delete_lines => self.terminal.deleteLines(value),
.scroll_up => try self.terminal.scrollUp(value),
.scroll_down => self.terminal.scrollDown(value),
.horizontal_tab => self.horizontalTab(value),
.horizontal_tab_back => self.horizontalTabBack(value),
.tab_clear_current => self.terminal.tabClear(.current),
.tab_clear_all => self.terminal.tabClear(.all),
.tab_set => self.terminal.tabSet(),
.tab_reset => self.terminal.tabReset(),
.set_mode => try self.setMode(value.mode, true),
.reset_mode => try self.setMode(value.mode, false),
.save_mode => self.terminal.modes.save(value.mode),
.restore_mode => {
const v = self.terminal.modes.restore(value.mode);
try self.setMode(value.mode, v);
},
.top_and_bottom_margin => self.terminal.setTopAndBottomMargin(value.top_left, value.bottom_right),
.left_and_right_margin => self.terminal.setLeftAndRightMargin(value.top_left, value.bottom_right),
.left_and_right_margin_ambiguous => {
if (self.terminal.modes.get(.enable_left_and_right_margin)) {
self.terminal.setLeftAndRightMargin(0, 0);
} else {
self.terminal.saveCursor();
}
},
.save_cursor => self.terminal.saveCursor(),
.restore_cursor => self.terminal.restoreCursor(),
.invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking),
.configure_charset => self.terminal.configureCharset(value.slot, value.charset),
.set_attribute => switch (value) {
.unknown => {},
else => try self.terminal.setAttribute(value),
},
.protected_mode_off => self.terminal.setProtectedMode(.off),
.protected_mode_iso => self.terminal.setProtectedMode(.iso),
.protected_mode_dec => self.terminal.setProtectedMode(.dec),
.mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false,
.kitty_keyboard_push => self.terminal.screens.active.kitty_keyboard.push(value.flags),
.kitty_keyboard_pop => self.terminal.screens.active.kitty_keyboard.pop(@intCast(value)),
.kitty_keyboard_set => self.terminal.screens.active.kitty_keyboard.set(.set, value.flags),
.kitty_keyboard_set_or => self.terminal.screens.active.kitty_keyboard.set(.@"or", value.flags),
.kitty_keyboard_set_not => self.terminal.screens.active.kitty_keyboard.set(.not, value.flags),
.modify_key_format => {
self.terminal.flags.modify_other_keys_2 = false;
switch (value) {
.other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true,
else => {},
}
},
.active_status_display => self.terminal.status_display = value,
.decaln => try self.terminal.decaln(),
.full_reset => self.terminal.fullReset(),
.start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id),
.end_hyperlink => self.terminal.screens.active.endHyperlink(),
.semantic_prompt => try self.terminal.semanticPrompt(value),
.mouse_shape => self.terminal.mouse_shape = value,
.color_operation => self.colorOperation(
&value.requests,
value.terminator,
) catch |err| log.warn("error reporting OSC color err={}", .{err}),
.kitty_color_report => self.kittyColorOperation(value) catch |err| {
log.warn("error reporting Kitty colors err={}", .{err});
},
// APC
.apc_start => self.apc_handler.start(),
.apc_put => self.apc_handler.feed(self.terminal.gpa(), value),
.apc_put_slice => self.apc_handler.feedSlice(self.terminal.gpa(), value.bytes),
.apc_end => self.apcEnd(),
// Effect-based handlers
.bell => self.bell(),
.show_desktop_notification => self.desktopNotification(value),
.device_attributes => self.reportDeviceAttributes(value),
.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 => try self.windowTitle(value.title),
.report_pwd => try self.reportPwd(value.url),
.progress_report => self.progressReport(value),
.xtversion => self.reportXtversion(),
.clipboard_contents => self.clipboardContents(
value.kind,
value.data,
) catch |err| {
// Clipboard writes are external effects, not terminal state.
log.warn("error handling clipboard write err={}", .{err});
},
.dcs_hook => try self.dcsHook(value),
.dcs_put => try self.dcsPut(value),
.dcs_unhook => try self.dcsUnhook(),
// Have no terminal-modifying effect
.title_push,
.title_pop,
=> {},
}
}
inline fn writePty(self: *Handler, data: [:0]const u8) void {
const func = self.effects.write_pty orelse return;
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);
}
fn desktopNotification(
self: *Handler,
notification: Action.ShowDesktopNotification,
) void {
const func = self.effects.desktop_notification orelse return;
func(self, notification);
}
fn progressReport(self: *Handler, report: osc.Command.ProgressReport) void {
const func = self.effects.progress_report orelse return;
func(self, report);
}
fn clipboardContents(self: *Handler, kind: u8, data: []const u8) !void {
const func = self.effects.clipboard_write orelse return;
// Read requests are deliberately not forwarded; see the effect docs.
if (data.len == 1 and data[0] == '?') return;
const location: clipboard.Location = switch (kind) {
's' => .selection,
'p' => .primary,
else => .standard,
};
// OSC 52 uses an empty payload to clear the selected clipboard.
if (data.len == 0) {
_ = func(self, .{
.location = location,
.contents = &.{},
});
return;
}
const decoder = std.base64.standard.Decoder;
const decoded_len = try decoder.calcSizeForSlice(data);
const alloc = self.terminal.gpa();
const decoded = try alloc.alloc(u8, decoded_len);
defer alloc.free(decoded);
try decoder.decode(decoded, data);
const contents = [_]clipboard.Content{.{
.mime = "text/plain",
.data = decoded,
}};
_ = func(self, .{
.location = location,
.contents = &contents,
});
}
fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void {
const func = self.effects.device_attributes orelse return;
const attrs = func(self);
var stack = std.heap.stackFallback(128, self.terminal.gpa());
const alloc = stack.get();
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
attrs.encode(req, &aw.writer) catch return;
const written = aw.toOwnedSliceSentinel(0) catch return;
defer alloc.free(written);
self.writePty(written);
}
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;
var buf: [device_status.max_color_scheme_report_encode_size + 1]u8 = undefined;
var writer: std.Io.Writer = .fixed(buf[0..device_status.max_color_scheme_report_encode_size]);
device_status.encodeColorSchemeReport(&writer, scheme) catch return;
buf[writer.end] = 0;
self.writePty(buf[0..writer.end :0]);
},
}
}
fn reportEnquiry(self: *Handler) void {
const func = self.effects.enquiry orelse return;
const response = func(self);
if (response.len == 0) return;
var buf: [256]u8 = undefined;
if (response.len >= buf.len) return;
@memcpy(buf[0..response.len], response);
buf[response.len] = 0;
self.writePty(buf[0..response.len :0]);
}
fn reportXtversion(self: *Handler) void {
const version = if (self.effects.xtversion) |func| func(self) else "";
var buf: [288]u8 = undefined;
const resp = std.fmt.bufPrintZ(
&buf,
"\x1BP>|{s}\x1B\\",
.{if (version.len > 0) version else "libghostty"},
) catch return;
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;
const title = if (title_raw.len > max_title_len) title: {
log.warn("title length {d} exceeds max length {d}, truncating", .{
title_raw.len,
max_title_len,
});
break :title title_raw[0..max_title_len];
} else title_raw;
try self.terminal.setTitle(title);
const func = self.effects.title_changed orelse return;
func(self);
}
fn reportPwd(self: *Handler, url_raw: []const u8) !void {
// Prevent DoS attacks by limiting url length. Headroom for
// Linux PATH_MAX (4096) plus URI scheme/host and percent-encoding.
const max_url_len = 4096;
const url = if (url_raw.len > max_url_len) url: {
log.warn("pwd url length {d} exceeds max length {d}, truncating", .{
url_raw.len,
max_url_len,
});
break :url url_raw[0..max_url_len];
} else url_raw;
// We store the raw payload unparsed. Embedders read it via
// getPwd() and are responsible for decoding any URI scheme.
try self.terminal.setPwd(url);
const func = self.effects.pwd_changed orelse return;
func(self);
}
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]);
}
fn queryKittyKeyboard(self: *Handler) void {
// Max response is "\x1b[?31u\x00" (7 bytes): the flags are a u5 (max 31).
var buf: [32]u8 = undefined;
const resp = std.fmt.bufPrintZ(&buf, "\x1b[?{}u", .{
self.terminal.screens.active.kitty_keyboard.current().int(),
}) catch return;
self.writePty(resp);
}
inline fn horizontalTab(self: *Handler, count: u16) void {
for (0..count) |_| {
const x = self.terminal.screens.active.cursor.x;
self.terminal.horizontalTab();
if (x == self.terminal.screens.active.cursor.x) break;
}
}
inline fn horizontalTabBack(self: *Handler, count: u16) void {
for (0..count) |_| {
const x = self.terminal.screens.active.cursor.x;
self.terminal.horizontalTabBack();
if (x == self.terminal.screens.active.cursor.x) break;
}
}
fn setMode(self: *Handler, mode: modes.Mode, enabled: bool) !void {
// Set the mode on the terminal
self.terminal.modes.set(mode, enabled);
// Some modes require additional processing
switch (mode) {
.autorepeat,
.reverse_colors,
=> {},
.origin => self.terminal.setCursorPos(1, 1),
.enable_left_and_right_margin => if (!enabled) {
self.terminal.scrolling_region.left = 0;
self.terminal.scrolling_region.right = self.terminal.cols - 1;
},
.alt_screen_legacy => try self.terminal.switchScreenMode(.@"47", enabled),
.alt_screen => try self.terminal.switchScreenMode(.@"1047", enabled),
.alt_screen_save_cursor_clear_enter => try self.terminal.switchScreenMode(.@"1049", enabled),
.save_cursor => if (enabled) {
self.terminal.saveCursor();
} else {
self.terminal.restoreCursor();
},
.enable_mode_3 => {},
.@"132_column" => try self.terminal.deccolm(
self.terminal.screens.active.alloc,
if (enabled) .@"132_cols" else .@"80_cols",
),
.synchronized_output,
.linefeed,
.in_band_size_reports,
.focus_event,
=> {},
.mouse_event_x10 => {
if (enabled) {
self.terminal.flags.mouse_event = .x10;
} else {
self.terminal.flags.mouse_event = .none;
}
},
.mouse_event_normal => {
if (enabled) {
self.terminal.flags.mouse_event = .normal;
} else {
self.terminal.flags.mouse_event = .none;
}
},
.mouse_event_button => {
if (enabled) {
self.terminal.flags.mouse_event = .button;
} else {
self.terminal.flags.mouse_event = .none;
}
},
.mouse_event_any => {
if (enabled) {
self.terminal.flags.mouse_event = .any;
} else {
self.terminal.flags.mouse_event = .none;
}
},
.mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10,
.mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10,
.mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10,
.mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10,
else => {},
}
}
fn colorOperation(
self: *Handler,
requests: *const osc_color.List,
terminator: osc.Terminator,
) !void {
if (requests.count() == 0) return;
var stack = std.heap.stackFallback(1024, self.terminal.gpa());
const alloc = stack.get();
var response: std.Io.Writer.Allocating = .init(alloc);
defer response.deinit();
const writer = &response.writer;
var it = requests.constIterator(0);
while (it.next()) |req| {
switch (req.*) {
.set => |set| {
switch (set.target) {
.palette => |i| {
self.terminal.flags.dirty.palette = true;
self.terminal.colors.palette.set(i, set.color);
},
.dynamic => |dynamic| switch (dynamic) {
.foreground => self.terminal.colors.foreground.set(set.color),
.background => self.terminal.colors.background.set(set.color),
.cursor => self.terminal.colors.cursor.set(set.color),
.pointer_foreground,
.pointer_background,
.tektronix_foreground,
.tektronix_background,
.highlight_background,
.tektronix_cursor,
.highlight_foreground,
=> {},
},
.special => {},
}
},
.reset => |target| switch (target) {
.palette => |i| {
self.terminal.flags.dirty.palette = true;
self.terminal.colors.palette.reset(i);
},
.dynamic => |dynamic| switch (dynamic) {
.foreground => self.terminal.colors.foreground.reset(),
.background => self.terminal.colors.background.reset(),
.cursor => self.terminal.colors.cursor.reset(),
.pointer_foreground,
.pointer_background,
.tektronix_foreground,
.tektronix_background,
.highlight_background,
.tektronix_cursor,
.highlight_foreground,
=> {},
},
.special => {},
},
.reset_palette => {
const mask = &self.terminal.colors.palette.mask;
var mask_it = mask.iterator(.{});
while (mask_it.next()) |i| {
self.terminal.flags.dirty.palette = true;
self.terminal.colors.palette.reset(@intCast(i));
}
mask.* = .initEmpty();
},
.query => |target| {
if (self.effects.write_pty == null) continue;
const c = self.terminal.colorForXterm(target) orelse continue;
try writeXtermColorReport(writer, target, c, terminator);
},
.reset_special => {},
}
}
if (response.written().len > 0) {
const resp = try response.toOwnedSliceSentinel(0);
defer alloc.free(resp);
self.writePty(resp);
}
}
fn writeXtermColorReport(
writer: *std.Io.Writer,
target: osc_color.Target,
c: color.RGB,
terminator: osc.Terminator,
) !void {
switch (target) {
.palette => |i| {
try writer.print("\x1b]4;{d};", .{i});
try c.encodeRgb16(writer);
try writer.writeAll(terminator.string());
},
.dynamic => |dynamic| switch (dynamic) {
.foreground,
.background,
.cursor,
=> {
try writer.print("\x1b]{d};", .{@intFromEnum(dynamic)});
try c.encodeRgb16(writer);
try writer.writeAll(terminator.string());
},
.pointer_foreground,
.pointer_background,
.tektronix_foreground,
.tektronix_background,
.highlight_background,
.tektronix_cursor,
.highlight_foreground,
=> {},
},
.special => {},
}
}
fn kittyColorOperation(
self: *Handler,
request: kitty_color.OSC,
) !void {
var stack = std.heap.stackFallback(1024, self.terminal.gpa());
const alloc = stack.get();
var response: std.Io.Writer.Allocating = .init(alloc);
defer response.deinit();
const writer = &response.writer;
for (request.list.items) |item| {
switch (item) {
.set => |v| switch (v.key) {
.palette => |palette| {
self.terminal.flags.dirty.palette = true;
self.terminal.colors.palette.set(palette, v.color);
},
.special => |special| switch (special) {
.foreground => self.terminal.colors.foreground.set(v.color),
.background => self.terminal.colors.background.set(v.color),
.cursor => self.terminal.colors.cursor.set(v.color),
else => {},
},
},
.reset => |key| switch (key) {
.palette => |palette| {
self.terminal.flags.dirty.palette = true;
self.terminal.colors.palette.reset(palette);
},
.special => |special| switch (special) {
.foreground => self.terminal.colors.foreground.reset(),
.background => self.terminal.colors.background.reset(),
.cursor => self.terminal.colors.cursor.reset(),
else => {},
},
},
.query => |key| {
if (self.effects.write_pty == null) continue;
const c = self.terminal.colorForKitty(key) orelse {
if (!key.hasTerminalQueryColor()) continue;
if (response.written().len == 0) try writer.writeAll("\x1b]21");
try writer.print(";{f}=", .{key});
continue;
};
if (response.written().len == 0) try writer.writeAll("\x1b]21");
try writer.print(";{f}=", .{key});
try c.encodeRgb8(writer);
},
}
}
if (response.written().len > 0) {
try writer.writeAll(request.terminator.string());
const resp = try response.toOwnedSliceSentinel(0);
defer alloc.free(resp);
self.writePty(resp);
}
}
fn apcEnd(self: *Handler) void {
const io = self.terminal.io();
const alloc = self.terminal.gpa();
var cmd = self.apc_handler.end() orelse return;
defer cmd.deinit(alloc);
switch (cmd) {
.kitty => |*kitty_cmd| if (comptime build_options.kitty_graphics) {
if (self.terminal.kittyGraphics(
io,
alloc,
kitty_cmd,
)) |resp| resp: {
// Don't waste time encoding if we can't write responses
// anyways.
if (self.effects.write_pty == null) break :resp;
// Encode and write the response if we have one.
var buf: [1024]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
resp.encode(&writer) catch return;
writer.writeByte(0) catch return;
const final = writer.buffered();
if (final.len > 3) self.writePty(final[0 .. final.len - 1 :0]);
}
},
.glyph => |*glyph_req| {
const resp = self.terminal.glyphProtocol(alloc, glyph_req);
if (resp) |r| resp_block: {
// Don't waste time encoding if we can't write responses
// anyways.
if (self.effects.write_pty == null) break :resp_block;
// Glyph responses are short and bounded by the protocol
// fields we emit, so this matches the Kitty response
// buffer size above with ample headroom.
var buf: [apc.glyph.Response.max_wire_bytes]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
r.formatWire(&writer) catch return;
writer.writeByte(0) catch return;
const final = writer.buffered();
self.writePty(final[0 .. final.len - 1 :0]);
}
},
}
}
};
test "resize clears synchronized output on unchanged cell dimensions" {
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();
t.modes.set(.synchronized_output, true);
try s.handler.resize(.{
.cols = 80,
.rows = 24,
.cell_size_px = .{ .width = 9, .height = 18 },
});
try testing.expect(!t.modes.get(.synchronized_output));
try testing.expectEqual(@as(u32, 720), t.width_px);
try testing.expectEqual(@as(u32, 432), t.height_px);
}
test "resize reports mode 2048 geometry" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var response: [128]u8 = undefined;
var response_len: usize = 0;
fn writePty(_: *Handler, data: [:0]const u8) void {
@memcpy(response[0..data.len], data);
response_len = data.len;
}
};
S.response_len = 0;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
t.modes.set(.in_band_size_reports, true);
try s.handler.resize(.{
.cols = 100,
.rows = 40,
.cell_size_px = .{ .width = 9, .height = 18 },
});
try testing.expectEqualStrings(
"\x1B[48;40;100;720;900t",
S.response[0..S.response_len],
);
}
test "resize suppresses mode 2048 reports" {
const S = struct {
var calls: usize = 0;
fn writePty(_: *Handler, _: [:0]const u8) void {
calls += 1;
}
};
S.calls = 0;
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Disabled mode suppresses a report even with pixels and a callback.
try s.handler.resize(.{
.cols = 80,
.rows = 24,
.cell_size_px = .{ .width = 9, .height = 18 },
});
try testing.expectEqual(@as(usize, 0), S.calls);
// Missing pixel geometry suppresses a report even with the mode enabled.
t.modes.set(.in_band_size_reports, true);
try s.handler.resize(.{ .cols = 80, .rows = 24 });
try testing.expectEqual(@as(usize, 0), S.calls);
// A read-only stream has no write effect and remains successful.
var readonly_terminal: Terminal = try .init(
testing.io,
testing.allocator,
.{ .cols = 80, .rows = 24 },
);
defer readonly_terminal.deinit(testing.allocator);
readonly_terminal.modes.set(.in_band_size_reports, true);
var readonly_stream: Stream = .initAlloc(
testing.allocator,
.init(&readonly_terminal),
);
defer readonly_stream.deinit();
try readonly_stream.handler.resize(.{
.cols = 80,
.rows = 24,
.cell_size_px = .{ .width = 9, .height = 18 },
});
try testing.expectEqual(@as(usize, 0), S.calls);
}
test "resize failure preserves terminal state and does not write" {
var failing = testing.FailingAllocator.init(testing.allocator, .{});
const alloc = failing.allocator();
var t: Terminal = try .init(testing.io, alloc, .{ .cols = 10, .rows = 1 });
defer t.deinit(alloc);
const S = struct {
var called: bool = false;
fn writePty(_: *Handler, _: [:0]const u8) void {
called = true;
}
};
S.called = false;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(alloc, handler);
defer s.deinit();
t.modes.set(.synchronized_output, true);
t.modes.set(.in_band_size_reports, true);
failing.fail_index = failing.alloc_index;
try testing.expectError(error.OutOfMemory, s.handler.resize(.{
.cols = 513,
.rows = 1,
.cell_size_px = .{ .width = 9, .height = 18 },
}));
try testing.expect(t.modes.get(.synchronized_output));
try testing.expect(!S.called);
try testing.expectEqual(@as(@TypeOf(t.cols), 10), t.cols);
try testing.expectEqual(@as(u32, 0), t.width_px);
try testing.expectEqual(@as(u32, 0), t.height_px);
}
test "resize effects do not change canonical terminal state" {
var authoritative: Terminal = try .init(
testing.io,
testing.allocator,
.{ .cols = 10, .rows = 5 },
);
defer authoritative.deinit(testing.allocator);
var readonly: Terminal = try .init(
testing.io,
testing.allocator,
.{ .cols = 10, .rows = 5 },
);
defer readonly.deinit(testing.allocator);
const S = struct {
fn writePty(_: *Handler, _: [:0]const u8) void {}
};
var authoritative_handler: Handler = .init(&authoritative);
authoritative_handler.effects.write_pty = &S.writePty;
var authoritative_stream: Stream = .initAlloc(
testing.allocator,
authoritative_handler,
);
defer authoritative_stream.deinit();
var readonly_stream: Stream = .initAlloc(
testing.allocator,
.init(&readonly),
);
defer readonly_stream.deinit();
authoritative.modes.set(.in_band_size_reports, true);
readonly.modes.set(.in_band_size_reports, true);
const value: Terminal.Resize = .{
.cols = 20,
.rows = 10,
.cell_size_px = .{ .width = 9, .height = 18 },
};
try authoritative_stream.handler.resize(value);
try readonly_stream.handler.resize(value);
try testing.expectEqual(authoritative.cols, readonly.cols);
try testing.expectEqual(authoritative.rows, readonly.rows);
try testing.expectEqual(authoritative.width_px, readonly.width_px);
try testing.expectEqual(authoritative.height_px, readonly.height_px);
try testing.expect(std.meta.eql(authoritative.modes, readonly.modes));
try testing.expect(std.meta.eql(
authoritative.scrolling_region,
readonly.scrolling_region,
));
}
test "basic print" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
s.nextSlice("Hello");
try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("Hello", str);
}
test "semantic failure is sticky while processing continues" {
var failing = testing.FailingAllocator.init(testing.allocator, .{});
const alloc = failing.allocator();
var t: Terminal = try .init(testing.io, alloc, .{ .cols = 10, .rows = 2 });
defer t.deinit(alloc);
var s: Stream = .initAlloc(alloc, .init(&t));
defer s.deinit();
try testing.expect(!s.handler.semantic_failure);
// Setting the title is a terminal-owned semantic update. Force its
// allocation to fail at the central vtFallible boundary.
failing.fail_index = failing.alloc_index;
s.nextSlice("\x1B]2;unavailable\x1B\\");
try testing.expect(s.handler.semantic_failure);
// Later input and RIS remain best-effort and never clear the diagnostic.
failing.fail_index = std.math.maxInt(usize);
s.nextSlice("ignored");
s.nextSlice("\x1Bc");
s.nextSlice("OK");
try testing.expect(s.handler.semantic_failure);
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("OK", str);
// A new execution root starts without inheriting the diagnostic.
var fresh = Handler.init(&t);
defer fresh.deinit();
try testing.expect(!fresh.semantic_failure);
}
test "cursor movement" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Move cursor using escape sequences
s.nextSlice("Hello\x1B[1;1H");
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
// Move to position 2,3
s.nextSlice("\x1B[2;3H");
try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y);
}
test "erase operations" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 20, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Print some text
s.nextSlice("Hello World");
try testing.expectEqual(@as(usize, 11), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
// Move cursor to position 1,6 and erase from cursor to end of line
s.nextSlice("\x1B[1;6H");
s.nextSlice("\x1B[K");
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("Hello", str);
}
test "tabs" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
s.nextSlice("A\tB");
try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.x);
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("A B", str);
}
test "modes" {
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();
// Test wraparound mode
try testing.expect(t.modes.get(.wraparound));
s.nextSlice("\x1B[?7l"); // Disable wraparound
try testing.expect(!t.modes.get(.wraparound));
s.nextSlice("\x1B[?7h"); // Enable wraparound
try testing.expect(t.modes.get(.wraparound));
}
test "scrolling regions" {
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();
// Set scrolling region from line 5 to 20
s.nextSlice("\x1B[5;20r");
try testing.expectEqual(@as(usize, 4), t.scrolling_region.top);
try testing.expectEqual(@as(usize, 19), t.scrolling_region.bottom);
try testing.expectEqual(@as(usize, 0), t.scrolling_region.left);
try testing.expectEqual(@as(usize, 79), t.scrolling_region.right);
}
test "charsets" {
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();
// Configure G0 as DEC special graphics
s.nextSlice("\x1B(0");
s.nextSlice("`"); // Should print diamond character
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("", str);
}
test "alt screen" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 5 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Write to primary screen
s.nextSlice("Primary");
try testing.expectEqual(.primary, t.screens.active_key);
// Switch to alt screen
s.nextSlice("\x1B[?1049h");
try testing.expectEqual(.alternate, t.screens.active_key);
// Write to alt screen
s.nextSlice("Alt");
// Switch back to primary
s.nextSlice("\x1B[?1049l");
try testing.expectEqual(.primary, t.screens.active_key);
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("Primary", str);
}
test "cursor save and restore" {
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();
// Move cursor to 10,15
s.nextSlice("\x1B[10;15H");
try testing.expectEqual(@as(usize, 14), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y);
// Save cursor
s.nextSlice("\x1B7");
// Move cursor elsewhere
s.nextSlice("\x1B[1;1H");
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
// Restore cursor
s.nextSlice("\x1B8");
try testing.expectEqual(@as(usize, 14), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y);
}
test "attributes" {
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();
// Set bold and write text
s.nextSlice("\x1B[1mBold\x1B[0m");
// Verify we can write attributes - just check the string was written
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
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);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Run DECALN
s.nextSlice("\x1B#8");
// Verify entire screen is filled with 'E'
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("EEEEEEEEEE\nEEEEEEEEEE\nEEEEEEEEEE", str);
// Cursor should be at 1,1
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
}
test "full reset" {
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();
// Make some changes
s.nextSlice("Hello");
s.nextSlice("\x1B[10;20H");
s.nextSlice("\x1B[5;20r"); // Set scroll region
s.nextSlice("\x1B[?7l"); // Disable wraparound
s.nextSlice("\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\");
try testing.expect(t.glyph_glossary.contains(0xE0A0));
// Full reset
s.nextSlice("\x1Bc");
// Verify reset state
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
try testing.expectEqual(@as(usize, 0), t.scrolling_region.top);
try testing.expectEqual(@as(usize, 23), t.scrolling_region.bottom);
try testing.expect(t.modes.get(.wraparound));
try testing.expect(!t.glyph_glossary.contains(0xE0A0));
}
test "glyph protocol APC with write_pty callback" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
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();
s.nextSlice("\x1B_25a1;s\x1B\\");
try testing.expectEqualStrings("\x1B_25a1;s;fmt=glyf\x1B\\", S.last_response.?);
s.nextSlice("\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\");
try testing.expectEqualStrings("\x1B_25a1;r;cp=e0a0;status=0\x1B\\", S.last_response.?);
try testing.expect(t.glyph_glossary.contains(0xE0A0));
}
test "ignores query actions" {
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();
// These should be ignored without error
s.nextSlice("\x1B[c"); // Device attributes
s.nextSlice("\x1B[5n"); // Device status report
s.nextSlice("\x1B[6n"); // Cursor position report
s.nextSlice("\x1B]4;0;?\x1B\\"); // OSC color query
s.nextSlice("\x1B]21;foreground=?\x1B\\"); // Kitty color query
s.nextSlice("\x1B]52;c;%%%invalid-base64%%%\x1B\\");
s.nextSlice("\x1B_Ga=p,i=999\x1B\\"); // Missing Kitty image
s.nextSlice("\x1B_25a1;r;cp=41;%%%invalid%%%\x1B\\"); // Rejected glyph
// Query, malformed input, protocol failure responses, and external-effect
// failures do not imply that terminal-owned semantic state diverged.
try testing.expect(!s.handler.semantic_failure);
// 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);
}
test "OSC 4 set and reset palette" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Save default color
const default_color_0 = t.colors.palette.original[0];
// Set color 0 to red
s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\");
try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[0].r);
try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[0].g);
try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[0].b);
try testing.expect(t.colors.palette.mask.isSet(0));
// Reset color 0
s.nextSlice("\x1b]104;0\x1b\\");
try testing.expectEqual(default_color_0, t.colors.palette.current[0]);
try testing.expect(!t.colors.palette.mask.isSet(0));
}
test "OSC 104 reset all palette colors" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Set multiple colors
s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\");
s.nextSlice("\x1b]4;1;rgb:00/ff/00\x1b\\");
s.nextSlice("\x1b]4;2;rgb:00/00/ff\x1b\\");
try testing.expect(t.colors.palette.mask.isSet(0));
try testing.expect(t.colors.palette.mask.isSet(1));
try testing.expect(t.colors.palette.mask.isSet(2));
// Reset all palette colors
s.nextSlice("\x1b]104\x1b\\");
try testing.expectEqual(t.colors.palette.original[0], t.colors.palette.current[0]);
try testing.expectEqual(t.colors.palette.original[1], t.colors.palette.current[1]);
try testing.expectEqual(t.colors.palette.original[2], t.colors.palette.current[2]);
try testing.expect(!t.colors.palette.mask.isSet(0));
try testing.expect(!t.colors.palette.mask.isSet(1));
try testing.expect(!t.colors.palette.mask.isSet(2));
}
test "OSC 10 set and reset foreground color" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Initially unset
try testing.expect(t.colors.foreground.get() == null);
// Set foreground to red
s.nextSlice("\x1b]10;rgb:ff/00/00\x1b\\");
const fg = t.colors.foreground.get().?;
try testing.expectEqual(@as(u8, 0xff), fg.r);
try testing.expectEqual(@as(u8, 0x00), fg.g);
try testing.expectEqual(@as(u8, 0x00), fg.b);
// Reset foreground
s.nextSlice("\x1b]110\x1b\\");
try testing.expect(t.colors.foreground.get() == null);
}
test "OSC 11 set and reset background color" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Set background to green
s.nextSlice("\x1b]11;rgb:00/ff/00\x1b\\");
const bg = t.colors.background.get().?;
try testing.expectEqual(@as(u8, 0x00), bg.r);
try testing.expectEqual(@as(u8, 0xff), bg.g);
try testing.expectEqual(@as(u8, 0x00), bg.b);
// Reset background
s.nextSlice("\x1b]111\x1b\\");
try testing.expect(t.colors.background.get() == null);
}
test "OSC 12 set and reset cursor color" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Set cursor to blue
s.nextSlice("\x1b]12;rgb:00/00/ff\x1b\\");
const cursor = t.colors.cursor.get().?;
try testing.expectEqual(@as(u8, 0x00), cursor.r);
try testing.expectEqual(@as(u8, 0x00), cursor.g);
try testing.expectEqual(@as(u8, 0xff), cursor.b);
// Reset cursor
s.nextSlice("\x1b]112\x1b\\");
// After reset, cursor might be null (using default)
}
test "OSC color query responses" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
const S = struct {
var last_response: ?[:0]const u8 = null;
fn reset() void {
if (last_response) |old| testing.allocator.free(old);
last_response = null;
}
fn writePty(_: *Handler, data: [:0]const u8) void {
reset();
last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
}
};
S.last_response = null;
defer S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1b]10;?\x1b\\");
try testing.expect(S.last_response == null);
s.nextSlice("\x1b]11;?\x1b\\");
try testing.expect(S.last_response == null);
s.nextSlice("\x1b]4;2;rgb:12/34/56;2;?\x1b\\");
try testing.expectEqualStrings(
"\x1b]4;2;rgb:1212/3434/5656\x1b\\",
S.last_response.?,
);
s.nextSlice("\x1b]10;rgb:01/02/03\x1b\\");
s.nextSlice("\x1b]11;rgb:04/05/06\x1b\\");
s.nextSlice("\x1b]12;rgb:07/08/09\x1b\\");
s.nextSlice("\x1b]10;?;?;?\x1b\\");
try testing.expectEqualStrings(
"\x1b]10;rgb:0101/0202/0303\x1b\\" ++
"\x1b]11;rgb:0404/0505/0606\x1b\\" ++
"\x1b]12;rgb:0707/0808/0909\x1b\\",
S.last_response.?,
);
s.nextSlice("\x1b]112\x1b\\");
s.nextSlice("\x1b]12;?\x07");
try testing.expectEqualStrings(
"\x1b]12;rgb:0101/0202/0303\x07",
S.last_response.?,
);
}
test "kitty color protocol set palette" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Set palette color 5 to magenta using kitty protocol
s.nextSlice("\x1b]21;5=rgb:ff/00/ff\x1b\\");
try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[5].r);
try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[5].g);
try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[5].b);
try testing.expect(t.colors.palette.mask.isSet(5));
try testing.expect(t.flags.dirty.palette);
}
test "kitty color protocol reset palette" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Set and then reset palette color
const original = t.colors.palette.original[7];
s.nextSlice("\x1b]21;7=rgb:aa/bb/cc\x1b\\");
try testing.expect(t.colors.palette.mask.isSet(7));
s.nextSlice("\x1b]21;7=\x1b\\");
try testing.expectEqual(original, t.colors.palette.current[7]);
try testing.expect(!t.colors.palette.mask.isSet(7));
}
test "kitty color protocol set foreground" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Set foreground using kitty protocol
s.nextSlice("\x1b]21;foreground=rgb:12/34/56\x1b\\");
const fg = t.colors.foreground.get().?;
try testing.expectEqual(@as(u8, 0x12), fg.r);
try testing.expectEqual(@as(u8, 0x34), fg.g);
try testing.expectEqual(@as(u8, 0x56), fg.b);
}
test "kitty color protocol set background" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Set background using kitty protocol
s.nextSlice("\x1b]21;background=rgb:78/9a/bc\x1b\\");
const bg = t.colors.background.get().?;
try testing.expectEqual(@as(u8, 0x78), bg.r);
try testing.expectEqual(@as(u8, 0x9a), bg.g);
try testing.expectEqual(@as(u8, 0xbc), bg.b);
}
test "kitty color protocol set cursor" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Set cursor using kitty protocol
s.nextSlice("\x1b]21;cursor=rgb:de/f0/12\x1b\\");
const cursor = t.colors.cursor.get().?;
try testing.expectEqual(@as(u8, 0xde), cursor.r);
try testing.expectEqual(@as(u8, 0xf0), cursor.g);
try testing.expectEqual(@as(u8, 0x12), cursor.b);
}
test "kitty color protocol reset foreground" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Set and reset foreground
s.nextSlice("\x1b]21;foreground=rgb:11/22/33\x1b\\");
try testing.expect(t.colors.foreground.get() != null);
s.nextSlice("\x1b]21;foreground=\x1b\\");
// After reset, should be unset
try testing.expect(t.colors.foreground.get() == null);
}
test "kitty color protocol query responses" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
const S = struct {
var last_response: ?[:0]const u8 = null;
fn reset() void {
if (last_response) |old| testing.allocator.free(old);
last_response = null;
}
fn writePty(_: *Handler, data: [:0]const u8) void {
reset();
last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
}
};
S.last_response = null;
defer S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1b]21;background=?\x1b\\");
try testing.expectEqualStrings(
"\x1b]21;background=\x1b\\",
S.last_response.?,
);
s.nextSlice("\x1b]21;foreground=rgb:12/34/56;2=rgb:aa/bb/cc\x1b\\");
s.nextSlice("\x1b]21;foreground=?;background=?;2=?\x1b\\");
try testing.expectEqualStrings(
"\x1b]21;foreground=rgb:12/34/56;background=;2=rgb:aa/bb/cc\x1b\\",
S.last_response.?,
);
}
test "palette dirty flag set on color change" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Clear dirty flag
t.flags.dirty.palette = false;
// Setting palette color should set dirty flag
s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\");
try testing.expect(t.flags.dirty.palette);
// Clear and test reset
t.flags.dirty.palette = false;
s.nextSlice("\x1b]104;0\x1b\\");
try testing.expect(t.flags.dirty.palette);
// Clear and test kitty protocol
t.flags.dirty.palette = false;
s.nextSlice("\x1b]21;1=rgb:00/ff/00\x1b\\");
try testing.expect(t.flags.dirty.palette);
}
test "semantic prompt fresh line" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
s.nextSlice("Hello");
s.nextSlice("\x1b]133;L\x07");
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y);
}
test "semantic prompt fresh line new prompt" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Write some text and then send OSC 133;A (fresh_line_new_prompt)
s.nextSlice("Hello");
s.nextSlice("\x1b]133;A\x07");
// Should do a fresh line (carriage return + index)
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y);
// Should set cursor semantic_content to prompt
try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content);
// Test with redraw option
s.nextSlice("prompt$ ");
s.nextSlice("\x1b]133;A;redraw=1\x07");
try testing.expect(t.flags.shell_redraws_prompt == .true);
}
test "semantic prompt end of input, then start output" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Write some text and then send OSC 133;A (fresh_line_new_prompt)
s.nextSlice("Hello");
s.nextSlice("\x1b]133;A\x07");
s.nextSlice("prompt$ ");
s.nextSlice("\x1b]133;B\x07");
try testing.expectEqual(.input, t.screens.active.cursor.semantic_content);
s.nextSlice("\x1b]133;C\x07");
try testing.expectEqual(.output, t.screens.active.cursor.semantic_content);
}
test "semantic prompt prompt_start" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Write some text
s.nextSlice("Hello");
// OSC 133;P marks the start of a prompt (without fresh line behavior)
s.nextSlice("\x1b]133;P\x07");
try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content);
try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
}
test "semantic prompt new_command" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Write some text
s.nextSlice("Hello");
s.nextSlice("\x1b]133;N\x07");
// Should behave like fresh_line_new_prompt - cursor moves to column 0
// on next line since we had content
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y);
try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content);
}
test "semantic prompt new_command at column zero" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// OSC 133;N when already at column 0 should stay on same line
s.nextSlice("\x1b]133;N\x07");
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x);
try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content);
}
test "semantic prompt end_prompt_start_input_terminate_eol clears on linefeed" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
// Set input terminated by EOL
s.nextSlice("\x1b]133;I\x07");
try testing.expectEqual(.input, t.screens.active.cursor.semantic_content);
// Linefeed should reset semantic content to output
s.nextSlice("\n");
try testing.expectEqual(.output, t.screens.active.cursor.semantic_content);
}
test "bell effect callback" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
// Test bell with null callback (default readonly effects) doesn't crash
{
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
s.nextSlice("\x07");
// Terminal should still be functional after bell
s.nextSlice("AfterBell");
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("AfterBell", str);
}
t.fullReset();
// Test bell with a callback
{
const S = struct {
var bell_count: usize = 0;
fn bell(_: *Handler) void {
bell_count += 1;
}
};
S.bell_count = 0;
var handler: Handler = .init(&t);
handler.effects.bell = &S.bell;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x07");
try testing.expectEqual(@as(usize, 1), S.bell_count);
s.nextSlice("\x07\x07");
try testing.expectEqual(@as(usize, 3), S.bell_count);
}
}
test "desktop_notification effect callback" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
// A null callback (the default readonly effects) silently ignores
// notifications and leaves the terminal usable.
{
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
s.nextSlice("\x1B]9;Ignored\x1B\\AfterNotification");
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("AfterNotification", str);
}
t.fullReset();
const S = struct {
var count: usize = 0;
var last_title: []const u8 = "";
var last_body: []const u8 = "";
fn desktopNotification(
_: *Handler,
notification: Action.ShowDesktopNotification,
) void {
count += 1;
last_title = notification.title;
last_body = notification.body;
}
};
S.count = 0;
S.last_title = "";
S.last_body = "";
var handler: Handler = .init(&t);
handler.effects.desktop_notification = &S.desktopNotification;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// OSC 9 is split across writes and carries only a body.
s.nextSlice("\x1B]9;Build ");
try testing.expectEqual(@as(usize, 0), S.count);
s.nextSlice("complete\x1B\\");
try testing.expectEqual(@as(usize, 1), S.count);
try testing.expectEqualStrings("", S.last_title);
try testing.expectEqualStrings("Build complete", S.last_body);
// OSC 777 preserves its separate title and body fields.
s.nextSlice("\x1B]777;notify;Codex;Needs attention\x07");
try testing.expectEqual(@as(usize, 2), S.count);
try testing.expectEqualStrings("Codex", S.last_title);
try testing.expectEqualStrings("Needs attention", S.last_body);
}
test "progress_report effect callback" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
// A null callback (the default readonly effects) silently ignores reports.
{
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
s.nextSlice("\x1B]9;4;1;25\x1B\\");
}
const S = struct {
var count: usize = 0;
var last_state: osc.Command.ProgressReport.State = .remove;
var last_progress: ?u8 = null;
fn progressReport(_: *Handler, report: osc.Command.ProgressReport) void {
count += 1;
last_state = report.state;
last_progress = report.progress;
}
};
S.count = 0;
S.last_state = .remove;
S.last_progress = null;
var handler: Handler = .init(&t);
handler.effects.progress_report = &S.progressReport;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
const cases = [_]struct {
sequence: []const u8,
state: osc.Command.ProgressReport.State,
progress: ?u8,
}{
.{ .sequence = "\x1B]9;4;0;\x1B\\", .state = .remove, .progress = null },
.{ .sequence = "\x1B]9;4;1;42\x07", .state = .set, .progress = 42 },
.{ .sequence = "\x1B]9;4;2;7\x1B\\", .state = .@"error", .progress = 7 },
.{ .sequence = "\x1B]9;4;3\x1B\\", .state = .indeterminate, .progress = null },
.{ .sequence = "\x1B]9;4;4;75\x1B\\", .state = .pause, .progress = 75 },
};
for (cases, 1..) |case, expected_count| {
// Split each sequence to verify parsing survives PTY read boundaries.
const midpoint = case.sequence.len / 2;
s.nextSlice(case.sequence[0..midpoint]);
try testing.expectEqual(expected_count - 1, S.count);
s.nextSlice(case.sequence[midpoint..]);
try testing.expectEqual(expected_count, S.count);
try testing.expectEqual(case.state, S.last_state);
try testing.expectEqual(case.progress, S.last_progress);
}
}
test "clipboard_write effect callback" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
// A null callback (the default readonly effects) silently ignores writes.
{
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\");
// Terminal should still be functional after the ignored sequence
s.nextSlice("AfterClipboard");
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("AfterClipboard", str);
}
t.fullReset();
const S = struct {
var count: usize = 0;
var result: clipboard.WriteResult = .success;
var last_location: clipboard.Location = .standard;
var last_contents_len: usize = 0;
var last_mime: ?[]u8 = null;
var last_data: ?[]u8 = null;
fn clearCapture() void {
if (last_mime) |value| testing.allocator.free(value);
if (last_data) |value| testing.allocator.free(value);
last_mime = null;
last_data = null;
last_contents_len = 0;
}
fn clipboardWrite(_: *Handler, write: clipboard.Write) clipboard.WriteResult {
clearCapture();
count += 1;
last_location = write.location;
last_contents_len = write.contents.len;
if (write.contents.len > 0) {
last_mime = testing.allocator.dupe(u8, write.contents[0].mime) catch
@panic("failed to capture clipboard MIME type");
last_data = testing.allocator.dupe(u8, write.contents[0].data) catch
@panic("failed to capture clipboard data");
}
return result;
}
};
S.count = 0;
S.result = .denied;
S.clearCapture();
defer S.clearCapture();
var handler: Handler = .init(&t);
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Selectors are normalized and payloads are decoded before the callback.
const cases = [_]struct {
sequence: []const u8,
location: clipboard.Location,
data: []const u8,
}{
.{ .sequence = "\x1B]52;c;aGVsbG8=\x1B\\", .location = .standard, .data = "hello" },
.{ .sequence = "\x1B]52;s;d29ybGQ=\x07", .location = .selection, .data = "world" },
.{ .sequence = "\x1B]52;p;cHJpbWFyeQ==\x1B\\", .location = .primary, .data = "primary" },
.{ .sequence = "\x1B]52;0;Y3V0\x1B\\", .location = .standard, .data = "cut" },
.{ .sequence = "\x1B]52;x;ZmFsbGJhY2s=\x1B\\", .location = .standard, .data = "fallback" },
.{ .sequence = "\x1B]52;c;YQBi\x1B\\", .location = .standard, .data = "a\x00b" },
};
for (cases, 1..) |case, expected_count| {
s.nextSlice(case.sequence);
try testing.expectEqual(expected_count, S.count);
try testing.expectEqual(case.location, S.last_location);
try testing.expectEqual(@as(usize, 1), S.last_contents_len);
try testing.expectEqualStrings("text/plain", S.last_mime.?);
try testing.expectEqualSlices(u8, case.data, S.last_data.?);
}
// Empty data is a clear, represented by an empty contents slice.
s.nextSlice("\x1B]52;s;\x1B\\");
try testing.expectEqual(@as(usize, cases.len + 1), S.count);
try testing.expectEqual(clipboard.Location.selection, S.last_location);
try testing.expectEqual(@as(usize, 0), S.last_contents_len);
try testing.expect(S.last_mime == null);
try testing.expect(S.last_data == null);
// Reads and malformed base64 are ignored.
s.nextSlice("\x1B]52;c;?\x1B\\");
s.nextSlice("\x1B]52;c;***\x1B\\");
try testing.expectEqual(@as(usize, cases.len + 1), S.count);
// OSC 1337 Copy shares the normalized clipboard write path.
s.nextSlice("\x1B]1337;Copy=:aVRlcm0y\x1B\\");
try testing.expectEqual(@as(usize, cases.len + 2), S.count);
try testing.expectEqual(clipboard.Location.standard, S.last_location);
try testing.expectEqualStrings("text/plain", S.last_mime.?);
try testing.expectEqualStrings("iTerm2", S.last_data.?);
// Parsing across write boundaries still invokes exactly one atomic write.
s.nextSlice("\x1B]52;p;ZnJh");
s.nextSlice("Z21lbnRlZA==\x1B");
s.nextSlice("\\");
try testing.expectEqual(@as(usize, cases.len + 3), S.count);
try testing.expectEqual(clipboard.Location.primary, S.last_location);
try testing.expectEqualStrings("text/plain", S.last_mime.?);
try testing.expectEqualStrings("fragmented", S.last_data.?);
// Callback results are intentionally ignored for protocols without a
// write acknowledgement. The denied result above did not stop later writes.
try testing.expectEqual(clipboard.WriteResult.denied, S.result);
}
test "clipboard_write allocation failure is ignored" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var count: usize = 0;
fn clipboardWrite(_: *Handler, _: clipboard.Write) clipboard.WriteResult {
count += 1;
return .success;
}
};
S.count = 0;
var handler: Handler = .init(&t);
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Only the decoded scratch data uses the terminal allocator here. Swap in
// an allocator that always fails, then restore it before terminal teardown.
{
const alloc = t.screens.active.alloc;
t.screens.active.alloc = testing.failing_allocator;
defer t.screens.active.alloc = alloc;
s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\");
}
try testing.expectEqual(@as(usize, 0), S.count);
try testing.expect(!s.handler.semantic_failure);
}
test "request mode DECRQM with write_pty callback" {
var t: Terminal = try .init(testing.io, 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].
var t: Terminal = try .init(testing.io, testing.allocator, .{
.cols = 80,
.rows = 24,
.max_scrollback_bytes = 100,
});
defer t.deinit(testing.allocator);
var s: Stream = .initAlloc(testing.allocator, .init(&t));
defer s.deinit();
s.nextSlice("\x1b[?W");
}
test "window_title effect is called" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var title_changed_count: usize = 0;
fn titleChanged(_: *Handler) void {
title_changed_count += 1;
}
};
S.title_changed_count = 0;
var handler: Handler = .init(&t);
handler.effects.title_changed = &S.titleChanged;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Set window title via OSC 2
s.nextSlice("\x1b]2;Hello World\x1b\\");
try testing.expectEqualStrings("Hello World", t.getTitle().?);
try testing.expectEqual(@as(usize, 1), S.title_changed_count);
}
test "window_title effect not called without callback" {
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();
// Should not crash when no callback is set
s.nextSlice("\x1b]2;Hello World\x1b\\");
// Title should still be set on terminal state
try testing.expectEqualStrings("Hello World", t.getTitle().?);
// 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);
}
test "window_title effect with empty title" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var title_changed_count: usize = 0;
fn titleChanged(_: *Handler) void {
title_changed_count += 1;
}
};
S.title_changed_count = 0;
var handler: Handler = .init(&t);
handler.effects.title_changed = &S.titleChanged;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Set empty window title
s.nextSlice("\x1b]2;\x1b\\");
try testing.expect(t.getTitle() == null);
try testing.expectEqual(@as(usize, 1), S.title_changed_count);
}
test "kitty_keyboard_query" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var written: ?[:0]const u8 = null;
fn writePty(_: *Handler, data: [:0]const u8) void {
written = data;
}
};
S.written = null;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Default kitty keyboard flags should be 0
s.nextSlice("\x1b[?u");
try testing.expectEqualStrings("\x1b[?0u", S.written.?);
// Push kitty keyboard mode with flags and query again
S.written = null;
s.nextSlice("\x1b[>1u"); // push with disambiguate flag
s.nextSlice("\x1b[?u");
try testing.expectEqualStrings("\x1b[?1u", S.written.?);
}
test "xtversion default" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var written: ?[:0]const u8 = null;
fn writePty(_: *Handler, data: [:0]const u8) void {
written = data;
}
};
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 xtversion effect set, should report "libghostty"
s.nextSlice("\x1b[>0q");
try testing.expectEqualStrings("\x1bP>|libghostty\x1b\\", S.written.?);
}
test "xtversion with effect" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var written: ?[:0]const u8 = null;
fn writePty(_: *Handler, data: [:0]const u8) void {
written = data;
}
fn xtversion(_: *Handler) []const u8 {
return "ghostty 1.2.3";
}
};
S.written = null;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.xtversion = &S.xtversion;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1b[>0q");
try testing.expectEqualStrings("\x1bP>|ghostty 1.2.3\x1b\\", S.written.?);
}
test "xtversion with empty string effect" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var written: ?[:0]const u8 = null;
fn writePty(_: *Handler, data: [:0]const u8) void {
written = data;
}
fn xtversion(_: *Handler) []const u8 {
return "";
}
};
S.written = null;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.xtversion = &S.xtversion;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Empty string from effect should fall back to "libghostty"
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.io, 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.io, 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.io, 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.io, 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.io, 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.?);
}
test "enquiry no effect" {
var t: Terminal = try .init(testing.io, 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();
// ENQ without enquiry effect should not write anything
s.nextSlice("\x05");
try testing.expect(S.written == null);
}
test "enquiry with effect" {
var t: Terminal = try .init(testing.io, 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 enquiry(_: *Handler) []const u8 {
return "ghostty";
}
};
S.written = null;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.enquiry = &S.enquiry;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x05");
defer testing.allocator.free(S.written.?);
try testing.expectEqualStrings("ghostty", S.written.?);
}
test "enquiry with empty response" {
var t: Terminal = try .init(testing.io, 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 enquiry(_: *Handler) []const u8 {
return "";
}
};
S.written = null;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.enquiry = &S.enquiry;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Empty enquiry response should not write anything
s.nextSlice("\x05");
try testing.expect(S.written == null);
}
test "device status: operating status" {
var t: Terminal = try .init(testing.io, 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.io, 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.io, 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.io, 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.io, 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.io, 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.io, 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);
}
test "device attributes: primary DA" {
var t: Terminal = try .init(testing.io, 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 da(_: *Handler) device_attributes.Attributes {
return .{};
}
};
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.device_attributes = &S.da;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1B[c");
try testing.expectEqualStrings("\x1b[?62;22c", S.written.?);
}
test "device attributes: secondary DA" {
var t: Terminal = try .init(testing.io, 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 da(_: *Handler) device_attributes.Attributes {
return .{};
}
};
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.device_attributes = &S.da;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1B[>c");
try testing.expectEqualStrings("\x1b[>1;0;0c", S.written.?);
}
test "device attributes: tertiary DA" {
var t: Terminal = try .init(testing.io, 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 da(_: *Handler) device_attributes.Attributes {
return .{};
}
};
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.device_attributes = &S.da;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1B[=c");
try testing.expectEqualStrings("\x1bP!|00000000\x1b\\", S.written.?);
}
test "device attributes: readonly ignores" {
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();
// All DA queries should be silently ignored without effects
s.nextSlice("\x1B[c");
s.nextSlice("\x1B[>c");
s.nextSlice("\x1B[=c");
// 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);
}
test "device attributes: custom response" {
var t: Terminal = try .init(testing.io, 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 da(_: *Handler) device_attributes.Attributes {
return .{
.primary = .{
.conformance_level = .vt420,
.features = &.{ .ansi_color, .clipboard },
},
.secondary = .{
.device_type = .vt420,
.firmware_version = 100,
},
};
}
};
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.device_attributes = &S.da;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
s.nextSlice("\x1B[c");
try testing.expectEqualStrings("\x1b[?64;22;52c", S.written.?);
s.nextSlice("\x1B[>c");
try testing.expectEqualStrings("\x1b[>41;100;0c", S.written.?);
}
test "kitty graphics APC response" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
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();
// Send a kitty graphics transmit command with image id 1
s.nextSlice("\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;////////\x1b\\");
// Should have written a response back
try testing.expectEqualStrings("\x1b_Gi=1;OK\x1b\\", S.written.?);
}
test "kitty graphics via APC" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
const handler: Handler = .init(&t);
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Send a kitty graphics transmit command via APC:
// ESC _ G <payload> ESC \
// a=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;//////// (1x2 RGB direct)
s.nextSlice("\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;////////\x1b\\");
const storage = &t.screens.active.kitty_images;
const img = storage.imageById(1).?;
try testing.expectEqual(.rgb, img.format);
}