const std = @import("std");
const Allocator = std.mem.Allocator;
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_clipboard = @import("kitty/clipboard.zig");
const kitty_color = @import("kitty/color.zig");
const paste_pkg = @import("paste.zig");
const kitty_dnd = @import("kitty/dnd.zig");
const size_report = @import("size_report.zig");
const simd = @import("../simd/main.zig");
const terminfo = @import("../terminfo/main.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,
/// Whether CSI 21 t may report the terminal title. This is disabled by
/// default because reporting an attacker-controlled title to the pty can
/// inject text into the input stream of the foreground process.
title_report: bool = false,
/// 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 = .{},
/// The in-flight Kitty clipboard protocol (OSC 5522) write
/// transaction, if any. Null means no transaction is active.
/// Heap-allocated since transactions are rare and short-lived.
kitty_clipboard_write: ?*kitty_clipboard.WriteState = null,
/// Kitty clipboard protocol (OSC 5522) session password grants,
/// recorded when a clipboard_read or clipboard_write reply asks to
/// remember the user's decision.
kitty_clipboard_grants: kitty_clipboard.Grants = .{},
/// Maximum total decoded bytes accumulated by one Kitty clipboard
/// protocol (OSC 5522) write transaction, captured when the
/// transaction begins. Data beyond the limit fails the transaction
/// with EFBIG.
kitty_clipboard_write_max_bytes: usize = kitty_clipboard.max_write_size,
/// Called for sequence identifiers not supported by this library.
/// Currently, only APC is reported. Content is borrowed and only valid
/// for the duration of the callback. Set `apc_handler.unknown_max_bytes`
/// before starting the Stream to enable APC capture.
unknown_sequence: ?*const fn (*Handler, UnknownSequence) void = null,
/// The name of the terminfo entry this terminal runs as, reported in
/// response to an XTGETTCAP query for "TN".
///
/// The memory must remain valid for the lifetime of the handler.
/// Empty names and names longer than `max_terminfo_name_bytes` are
/// silently ignored.
terminfo_name: ?[]const u8 = null,
/// Maximum byte length accepted for `terminfo_name`.
pub const max_terminfo_name_bytes = 128;
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, []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 when drag and drop protocol state changes in a way the
/// embedder may need to act on: the running program registering
/// or unregistering to accept drops, answering a drag, or
/// concluding a drop. The event says what changed; the details
/// are read from `handler.terminal.kitty_dnd` (Kitty's OSC 72 is
/// the only drag and drop protocol today). Native drag events
/// flow the other way, by calling `kitty.dnd.State` directly.
drag_and_drop: ?*const fn (*Handler, kitty_dnd.Event) 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 for XTWINOPS size queries (CSI 14/16/18 t) and when VT input
/// enables in-band size reports (mode 2048). Returns the current
/// terminal geometry used for encoding. Return null to suppress the
/// XTWINOPS response or mode 2048 report.
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.
clipboard_write: ?*const fn (*Handler, clipboard.Write) void,
/// Called when the running program requests clipboard contents
/// (OSC 52 with a "?" payload, or a Kitty clipboard (OSC 5522)
/// read). Answering one lets the program read the user's
/// clipboard, so the embedder is expected to mediate consent.
///
/// Reads are synchronous: the callback must answer through
/// `read.reply` before it returns, so an embedder that needs to
/// ask the user must block (e.g. run a modal prompt) while the
/// stream waits. Returning without a reply, or replying with any
/// failure, answers the program with an empty clipboard (OSC 52)
/// or the matching protocol status (OSC 5522) so it doesn't hang.
/// If this is null, OSC 52 reads are ignored and OSC 5522 reads
/// are refused with EPERM.
///
/// OSC 5522 requests carry the program's MIME list, name, and
/// password grant state; a reply that sets `remember` records a
/// session grant so later requests with the same password arrive
/// with `granted` set. Kitty itself serves a request for only the
/// targets listing (`list` with no `mimes`) without prompting.
///
/// Installing this also enables Kitty paste events (mode 5522):
/// `paste` sends the program an event instead of the text, and
/// the program's follow-up read arrives here with `granted` set
/// since the user already pasted. See `paste`.
clipboard_read: ?*const fn (*Handler, clipboard.Read) void,
/// 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_read = null,
.clipboard_write = null,
.color_scheme = null,
.desktop_notification = null,
.device_attributes = null,
.drag_and_drop = null,
.enquiry = null,
.progress_report = null,
.size = null,
.title_changed = null,
.pwd_changed = null,
.write_pty = null,
.xtversion = null,
};
};
/// A sequence unsupported by the active handler. Payload data is borrowed
/// only for the duration of the handler callback.
pub const UnknownSequence = union(enum) {
apc: String,
/// Content between a string sequence's introducer and terminator.
pub const String = struct {
content: []const u8,
truncated: bool,
};
};
pub fn init(terminal: *Terminal) Handler {
return .{
.terminal = terminal,
};
}
pub fn deinit(self: *Handler) void {
self.kittyClipboardAbort();
self.kitty_clipboard_grants.deinit(self.terminal.gpa());
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]);
}
/// A paste request; see `paste`.
pub const Paste = paste_pkg.Request;
pub const PasteError = Allocator.Error || std.Io.RandomSecureError || error{
/// The data could inject commands and allow_unsafe was false.
/// Nothing was written.
UnsafePaste,
/// The contents reader failed. Nothing was written.
ReadFailed,
/// No write_pty effect is set, so nothing can be written.
NoWritePty,
};
/// The size of the chunks a paste streams to write_pty in.
pub const paste_chunk_size = 4096;
/// Paste into the terminal, applying the terminal's current state
/// as necessary to owner mode 5522, bracketed paste, unsafe paste, etc.
/// Returns true if anything was written to the pty.
///
/// The output streams to write_pty in chunks of `paste_chunk_size`.
/// The contents are read at most once and only the pasted text
/// representation is ever read, buffered whole while it is checked
/// and encoded; see `terminal.paste`.
pub fn paste(self: *Handler, req: Paste) PasteError!bool {
if (self.effects.write_pty == null) return error.NoWritePty;
var buf: [paste_chunk_size]u8 = undefined;
var pty: PtyWriter = .init(self, &buf);
// Delivered on error too: a partial paste has its frame closed
// and the program must see that.
defer pty.writer.flush() catch unreachable;
return paste_pkg.paste(.{
.terminal = self.terminal,
.alloc = self.terminal.gpa(),
// Paste events need the program's follow-up Kitty
// clipboard read served.
.kitty_clipboard = if (self.effects.clipboard_read != null) .{
.grants = &self.kitty_clipboard_grants,
.io = self.terminal.io(),
} else null,
.writer = &pty.writer,
}, req) catch |err| switch (err) {
// The pty writer never fails.
error.WriteFailed => unreachable,
error.ReadFailed,
error.OutOfMemory,
error.UnsafePaste,
error.EntropyUnavailable,
error.Canceled,
=> |e| e,
};
}
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 });
};
}
fn unknownSequence(self: *Handler, value: UnknownSequence) void {
const func = self.unknown_sequence orelse return;
func(self, value);
}
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();
// Full reset clears grants
self.kitty_clipboard_grants.deinit(self.terminal.gpa());
self.kitty_clipboard_grants = .{};
// Clear the progress bar
self.progressReport(.{ .state = .remove });
},
.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});
},
.kitty_clipboard => self.kittyClipboard(value) catch |err| {
// Clipboard operations are external effects, not terminal
// state; a failed transaction was already answered.
log.warn("error handling kitty clipboard 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(value.terminated),
// 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,
value.terminator,
) catch |err| {
// Clipboard operations are external effects, not terminal state.
log.warn("error handling clipboard operation err={}", .{err});
},
.kitty_dnd => self.kittyDnd(value) catch |err| {
// Drag and drop is a self-contained subsystem: an OOM
// updating its state or a failure writing a response
// degrades it without corrupting terminal state, so we log.
log.warn("error handling kitty dnd 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: []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]);
},
.xtgettcap => |*gettcap| {
if (self.effects.write_pty == null) return;
const map = comptime terminfo.ghostty.xtgettcapMap();
while (gettcap.next()) |key| {
if (std.mem.eql(u8, key, encoded_tn_key)) {
self.writeTerminfoName();
continue;
}
self.writePty(map.get(key) orelse continue);
}
},
.tmux => {},
}
}
// Hex-encoded "TN", the XTGETTCAP key naming the terminfo entry.
// The static map also carries this key with Ghostty's own name, so
// it is intercepted before the lookup: an embedder that never
// configured a name must not be reported as Ghostty's entry.
const encoded_tn_key = &std.fmt.bytesToHex("TN", .upper);
/// Answer an XTGETTCAP "TN" query from the configured terminfo name.
/// Unset, empty, or over-long names leave the query unanswered.
fn writeTerminfoName(self: *Handler) void {
const name = self.terminfo_name orelse return;
if (name.len == 0 or name.len > max_terminfo_name_bytes) return;
// Fixed upper bound for an encoded "TN" reply calculated
// at comptime from our max terminfo size.
const max_tn_response_bytes =
comptime "\x1bP1+r".len + encoded_tn_key.len + "=".len +
(max_terminfo_name_bytes * 2) + "\x1b\\".len +
1; // null terminator
// Values are hex-encoded uppercase, matching the static map. The
// buffer fits any name allowed above, so the print cannot fail.
var buf: [max_tn_response_bytes]u8 = undefined;
self.writePty(std.fmt.bufPrintZ(
&buf,
"\x1bP1+r" ++ encoded_tn_key ++ "={X}\x1b\\",
.{name},
) catch unreachable);
}
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,
terminator: osc.Terminator,
) !void {
const location: clipboard.Location = switch (kind) {
's' => .selection,
'p' => .primary,
else => .standard,
};
// OSC 52 uses a "?" payload to request the clipboard contents.
if (data.len == 1 and data[0] == '?') {
self.clipboardRead(location, terminator);
return;
}
const func = self.effects.clipboard_write orelse return;
// OSC 52 uses an empty payload to clear the selected clipboard.
if (data.len == 0) {
func(self, .{
.location = location,
.contents = &.{},
.name = "",
.granted = false,
.can_remember = false,
.reply_ctx = self,
.reply_fn = &ignoreWriteReply,
});
return;
}
// Decode the base64 payload with the SIMD decoder (the same one
// used for Kitty clipboard payloads) rather than the scalar std
// implementation; clipboard payloads can be megabytes. The
// Kitty clipboard spec governs OSC 52 base64 handling too: a
// request with characters outside the base64 alphabet is
// discarded entirely (never partially decoded), while a
// missing-padding tail is tolerated since OSC 52 has no way
// to report errors to the client.
const alloc = self.terminal.gpa();
const buf = try alloc.alloc(u8, simd.base64.maxLen(data));
defer alloc.free(buf);
const decoded = simd.base64.decodeStrict(data, buf, .optional) catch {
log.warn("OSC 52 clipboard write is not valid base64, ignoring", .{});
return;
};
const contents = [_]clipboard.Content{.{
.mime = "text/plain",
.data = decoded,
}};
func(self, .{
.location = location,
.contents = &contents,
.name = "",
.granted = false,
.can_remember = false,
.reply_ctx = self,
.reply_fn = &ignoreWriteReply,
});
}
/// Reply target for clipboard writes on protocols without a write
/// acknowledgement (OSC 52, OSC 1337 Copy): the reply is accepted
/// and discarded.
fn ignoreWriteReply(_: *anyopaque, _: clipboard.Write.Result) void {}
fn clipboardRead(
self: *Handler,
location: clipboard.Location,
terminator: osc.Terminator,
) void {
const func = self.effects.clipboard_read orelse return;
var state: ClipboardReadState = .{
.handler = self,
.location = location,
.terminator = terminator,
};
func(self, .{
.location = location,
.mimes = &.{"text/plain"},
.list = false,
.name = "",
.granted = false,
.can_remember = false,
.reply_ctx = &state,
.reply_fn = &ClipboardReadState.reply,
});
// The program is waiting on us, so a callback that returned
// without a (successful) reply gets an empty clipboard rather
// than silence.
if (!state.replied) state.respond("") catch |err| {
log.warn("error replying to clipboard read err={}", .{err});
};
}
/// Reply state for one synchronous clipboard read. This lives on the
/// clipboardRead stack frame, so it is only valid during the callback.
const ClipboardReadState = struct {
handler: *Handler,
location: clipboard.Location,
terminator: osc.Terminator,
replied: bool = false,
fn reply(ctx: *anyopaque, result: clipboard.Read.Result) void {
const self: *ClipboardReadState = @ptrCast(@alignCast(ctx));
if (self.replied) {
log.warn("clipboard read replied more than once, ignoring", .{});
return;
}
// OSC 52 carries a single text value.
const data: []const u8 = switch (result) {
.denied, .unsupported, .busy, .io_error => "",
.success => |s| for (s.contents) |c| {
if (clipboard.isTextMime(c.mime)) break c.data;
} else "",
};
self.respond(data) catch |err| {
// Leave replied unset so clipboardRead falls back to the
// empty reply.
log.warn("error replying to clipboard read err={}", .{err});
return;
};
self.replied = true;
}
fn respond(
self: *ClipboardReadState,
data: []const u8,
) error{ OutOfMemory, WriteFailed }!void {
const handler = self.handler;
var stack = std.heap.stackFallback(256, handler.terminal.gpa());
const alloc = stack.get();
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
const kind: u8 = switch (self.location) {
.selection => 's',
.primary => 'p',
.standard, _ => 'c',
};
try aw.writer.print("\x1b]52;{c};", .{kind});
try std.base64.standard.Encoder.encodeWriter(&aw.writer, data);
try aw.writer.writeAll(self.terminator.string());
const written = try aw.toOwnedSliceSentinel(0);
defer alloc.free(written);
handler.writePty(written);
}
};
/// Handle one Kitty clipboard protocol (OSC 5522) packet.
fn kittyClipboard(
self: *Handler,
v: Action.Value(.kitty_clipboard),
) error{OutOfMemory}!void {
// Decode and validate the metadata.
var arena: std.heap.ArenaAllocator = .init(self.terminal.gpa());
defer arena.deinit();
const meta = (kitty_clipboard.Metadata.parse(
arena.allocator(),
v.metadata,
) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.InvalidValue => {
const state = self.kitty_clipboard_write orelse return;
switch (kitty_clipboard.Metadata.operation(v.metadata) orelse return) {
.wdata, .walias => self.kittyClipboardFinish(
state,
.EINVAL,
v.terminator,
),
.read, .write => {},
}
return;
},
}) orelse return;
const payload = v.payload orelse "";
switch (meta.op) {
.read => try self.kittyClipboardRead(&meta, payload, v.terminator),
.write => try self.kittyClipboardWriteBegin(&meta, v.terminator),
.wdata => try self.kittyClipboardData(&meta, payload, v.terminator),
.walias => try self.kittyClipboardAlias(&meta, payload, v.terminator),
}
}
fn kittyClipboardRead(
self: *Handler,
meta: *const kitty_clipboard.Metadata,
payload: []const u8,
terminator: osc.Terminator,
) error{OutOfMemory}!void {
// The payload is the requested MIME list. Kitty drops a read
// request with an undecodable payload without any response.
const alloc = self.terminal.gpa();
const decoded = kitty_clipboard.Payload.init(
alloc,
payload,
) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Invalid => return,
};
defer decoded.deinit(alloc);
if (!decoded.isValidUtf8()) return;
// Without a clipboard_read effect nothing can serve the read.
// EPERM is the protocol's denial so clients degrade gracefully.
const func = self.effects.clipboard_read orelse {
self.kittyClipboardRespond(&.{
.op = .read,
.status = .EPERM,
.id = meta.id,
.terminator = terminator,
});
return;
};
// The targets type ('.') asks for the listing of available
// types rather than data. Requested types beyond the cap are
// dropped and simply never served, which is how the protocol
// reports an unavailable type anyway.
var mimes_buf: [kitty_clipboard.max_read_mimes][]const u8 = undefined;
const mimes, const list = mimes: {
var targets = false;
var len: usize = 0;
var it = decoded.mimeIterator();
while (it.next()) |mime| {
if (std.mem.eql(u8, mime, kitty_clipboard.targets_mime)) {
targets = true;
continue;
}
if (len == mimes_buf.len) continue;
mimes_buf[len] = mime;
len += 1;
}
break :mimes .{ mimes_buf[0..len], targets };
};
// Per the spec a password without a name is no password. A
// stored grant for it lets the embedder skip its prompt. A
// prompt-exempt request never consults the grants: the
// embedder serves it without a prompt anyway, and consuming a
// one-time paste password on a listing would burn the grant
// before the follow-up data read.
const pw: []const u8 = if (meta.name.len > 0) meta.pw else "";
const granted = !kitty_clipboard.readPromptExempt(mimes.len) and
self.kitty_clipboard_grants.use(alloc, pw, .read);
var state: KittyClipboardReadState = .{
.handler = self,
.primary = meta.loc == .primary,
.id = meta.id,
.pw = pw,
.mimes = mimes,
.list = list,
.terminator = terminator,
};
func(self, .{
.location = meta.loc,
.mimes = mimes,
.list = list,
.name = meta.name,
.granted = granted,
.can_remember = pw.len > 0,
.reply_ctx = &state,
.reply_fn = &KittyClipboardReadState.reply,
});
// The program is waiting on us, so a callback that returned
// without a reply is answered as a denial rather than silence.
if (!state.replied) state.respondStatus(.EPERM);
}
/// Reply state for one synchronous Kitty clipboard read. This lives
/// on the kittyClipboardRead stack frame, so it is only valid during
/// the callback.
const KittyClipboardReadState = struct {
handler: *Handler,
primary: bool,
id: []const u8,
/// The effective password, empty when the request had none.
pw: []const u8,
/// The requested types; only these are served from a reply.
mimes: []const []const u8,
list: bool,
terminator: osc.Terminator,
replied: bool = false,
fn reply(ctx: *anyopaque, result: clipboard.Read.Result) void {
const self: *KittyClipboardReadState = @ptrCast(@alignCast(ctx));
if (self.replied) {
log.warn("clipboard read replied more than once, ignoring", .{});
return;
}
self.replied = true;
const success = switch (result) {
.denied => return self.respondStatus(.EPERM),
.unsupported => return self.respondStatus(.ENOSYS),
.busy => return self.respondStatus(.EBUSY),
.io_error => return self.respondStatus(.EIO),
.success => |s| s,
};
// Remembering is only offered when the request carried a
// usable password.
if (success.remember and self.pw.len > 0) {
self.handler.kitty_clipboard_grants.grant(
self.handler.terminal.gpa(),
self.pw,
.read,
false,
) catch |err| {
log.warn("error recording clipboard grant err={}", .{err});
};
}
self.respondSuccess(&success) catch |err| {
log.warn("error replying to clipboard read err={}", .{err});
self.respondStatus(.EIO);
};
}
/// Answer with a single status packet.
fn respondStatus(
self: *const KittyClipboardReadState,
status: kitty_clipboard.Status,
) void {
self.handler.kittyClipboardRespond(&.{
.op = .read,
.status = status,
.id = self.id,
.terminator = self.terminator,
});
}
/// Answer with the full success sequence (OK, listing, DATA
/// chunks, DONE), serving only the requested representations
/// in request order.
fn respondSuccess(
self: *const KittyClipboardReadState,
success: *const clipboard.Read.Result.Success,
) error{ OutOfMemory, WriteFailed }!void {
const handler = self.handler;
if (handler.effects.write_pty == null) return;
var served_buf: [kitty_clipboard.max_read_mimes]clipboard.Content = undefined;
var served_len: usize = 0;
for (self.mimes) |mime| {
for (success.contents) |content| {
if (!std.mem.eql(u8, content.mime, mime)) continue;
served_buf[served_len] = content;
served_len += 1;
break;
}
}
// Status packets fit on the stack; DATA packets carry the
// clipboard contents and fall back to the heap.
var stack = std.heap.stackFallback(1024, handler.terminal.gpa());
const alloc = stack.get();
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
try (kitty_clipboard.ReadSuccess{
.primary = self.primary,
.id = self.id,
.list = self.list,
.available = success.available,
.contents = served_buf[0..served_len],
.terminator = self.terminator,
}).encode(&aw.writer);
const written = try aw.toOwnedSliceSentinel(0);
defer alloc.free(written);
handler.writePty(written);
}
};
fn kittyClipboardWriteBegin(
self: *Handler,
meta: *const kitty_clipboard.Metadata,
terminator: osc.Terminator,
) error{OutOfMemory}!void {
// A new write silently replaces any in-flight transaction.
self.kittyClipboardAbort();
// Without a clipboard_write effect a commit can never succeed,
// so fail the transaction up front instead of spooling data
// we'd only throw away. Later wdata packets are ignored.
if (self.effects.clipboard_write == null) {
self.kittyClipboardRespond(&.{
.op = .write,
.status = .ENOSYS,
.id = meta.id,
.terminator = terminator,
});
return;
}
// Setup our write state
const alloc = self.terminal.gpa();
const state = try alloc.create(kitty_clipboard.WriteState);
errdefer alloc.destroy(state);
state.* = try .init(alloc, meta, .{
.max_size = self.kitty_clipboard_write_max_bytes,
});
self.kitty_clipboard_write = state;
}
fn kittyClipboardData(
self: *Handler,
meta: *const kitty_clipboard.Metadata,
payload: []const u8,
terminator: osc.Terminator,
) error{OutOfMemory}!void {
// Data without a transaction is silently ignored.
const state = self.kitty_clipboard_write orelse return;
// A wdata packet without a MIME type commits the transaction.
if (meta.mime.len == 0) return self.kittyClipboardCommit(
state,
terminator,
);
state.data(
self.terminal.gpa(),
meta,
payload,
) catch |err| switch (err) {
// Failing to spool matches kitty's EIO for a failed buffer
// write.
error.OutOfMemory => {
self.kittyClipboardFinish(
state,
.EIO,
terminator,
);
return error.OutOfMemory;
},
// Data over the write limit aborts the transaction and is
// reported to the client.
error.TooLarge => self.kittyClipboardFinish(
state,
.EFBIG,
terminator,
),
// An invalid base64 payload stream aborts the transaction.
error.Invalid => self.kittyClipboardFinish(
state,
.EINVAL,
terminator,
),
};
}
fn kittyClipboardAlias(
self: *Handler,
meta: *const kitty_clipboard.Metadata,
payload: []const u8,
terminator: osc.Terminator,
) error{OutOfMemory}!void {
// Aliases without a transaction are silently ignored. Once a
// transaction exists, a missing target MIME type is invalid and
// aborts the transaction.
const state = self.kitty_clipboard_write orelse return;
if (meta.mime.len == 0) return self.kittyClipboardFinish(
state,
.EINVAL,
terminator,
);
state.alias(
self.terminal.gpa(),
meta,
payload,
) catch |err| switch (err) {
error.OutOfMemory => {
self.kittyClipboardFinish(
state,
.EIO,
terminator,
);
return error.OutOfMemory;
},
// An undecodable alias payload aborts the transaction.
error.Invalid => self.kittyClipboardFinish(
state,
.EINVAL,
terminator,
),
};
}
fn kittyClipboardCommit(
self: *Handler,
state: *kitty_clipboard.WriteState,
terminator: osc.Terminator,
) error{OutOfMemory}!void {
const alloc = self.terminal.gpa();
const committed = state.commit(alloc) catch |err| switch (err) {
error.OutOfMemory => {
self.kittyClipboardFinish(state, .EIO, terminator);
return error.OutOfMemory;
},
// The last MIME type's payload stream was not correctly
// padded, which aborts the transaction.
error.Invalid => {
self.kittyClipboardFinish(state, .EINVAL, terminator);
return;
},
};
defer committed.deinit(alloc);
// The effect can't be null here (checked when the transaction
// began) but if an embedder cleared it mid-transaction that's
// ENOSYS.
const func = self.effects.clipboard_write orelse {
self.kittyClipboardFinish(state, .ENOSYS, terminator);
return;
};
// Per the spec a password without a name is no password. A
// stored grant for it lets the embedder skip its prompt.
const pw: []const u8 = if (committed.name.len > 0) committed.pw else "";
const granted = self.kitty_clipboard_grants.use(alloc, pw, .write);
var reply_state: KittyClipboardWriteReplyState = .{
.handler = self,
.pw = pw,
};
func(self, .{
.location = committed.loc,
.contents = committed.contents,
.name = committed.name,
.granted = granted,
.can_remember = pw.len > 0,
.reply_ctx = &reply_state,
.reply_fn = &KittyClipboardWriteReplyState.reply,
});
// The program is waiting on the commit status, so a callback
// that returned without a reply is answered as a denial rather
// than silence.
self.kittyClipboardFinish(
state,
reply_state.status orelse .EPERM,
terminator,
);
}
/// Reply state for one synchronous Kitty clipboard write. This lives
/// on the kittyClipboardCommit stack frame, so it is only valid
/// during the callback.
const KittyClipboardWriteReplyState = struct {
handler: *Handler,
/// The effective password, empty when the request had none.
pw: []const u8,
/// The replied commit status, mapped 1:1 from the reply result;
/// null until the callback replies.
status: ?kitty_clipboard.Status = null,
fn reply(ctx: *anyopaque, result: clipboard.Write.Result) void {
const self: *KittyClipboardWriteReplyState = @ptrCast(@alignCast(ctx));
if (self.status != null) {
log.warn("clipboard write replied more than once, ignoring", .{});
return;
}
self.status = switch (result) {
.denied => .EPERM,
.unsupported => .ENOSYS,
.busy => .EBUSY,
.invalid_data => .EINVAL,
.io_error => .EIO,
.success => |success| status: {
// Remembering is only offered when the request
// carried a usable password.
if (success.remember and self.pw.len > 0) {
self.handler.kitty_clipboard_grants.grant(
self.handler.terminal.gpa(),
self.pw,
.write,
false,
) catch |err| {
log.warn("error recording clipboard grant err={}", .{err});
};
}
break :status .DONE;
},
};
}
};
/// Answer a write transaction with its final status and drop it.
/// The id echoed is the one from the transaction's opening write
/// packet, matching kitty.
fn kittyClipboardFinish(
self: *Handler,
state: *const kitty_clipboard.WriteState,
status: kitty_clipboard.Status,
terminator: osc.Terminator,
) void {
self.kittyClipboardRespond(&.{
.op = .write,
.status = status,
.id = state.id,
.terminator = terminator,
});
self.kittyClipboardAbort();
}
/// Drop any in-flight write transaction without responding.
fn kittyClipboardAbort(self: *Handler) void {
if (self.kitty_clipboard_write) |state| {
const alloc = self.terminal.gpa();
state.deinit(alloc);
alloc.destroy(state);
self.kitty_clipboard_write = null;
}
}
/// Encode and write a single response packet. Unlike kitty, which
/// always terminates responses with ST, we echo the terminator of
/// the request being answered, matching our other OSC responses.
fn kittyClipboardRespond(
self: *Handler,
response: *const kitty_clipboard.Response,
) void {
if (self.effects.write_pty == null) return;
// Our responses carry at most a status and the echoed id so
// they virtually always fit on the stack.
var stack = std.heap.stackFallback(1024, self.terminal.gpa());
const alloc = stack.get();
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
response.encode(&aw.writer) catch return;
const resp = aw.toOwnedSliceSentinel(0) catch return;
defer alloc.free(resp);
self.writePty(resp);
}
/// Handle an OSC 72 drag and drop command.
fn kittyDnd(
self: *Handler,
v: Action.KittyDnd,
) (Allocator.Error || std.Io.Writer.Error)!void {
// Responses are usually small (queries, errors) but data
// serving can produce many chunks, so fall back to the heap.
var stack = std.heap.stackFallback(512, self.terminal.gpa());
const response_alloc = stack.get();
var aw: std.Io.Writer.Allocating = .init(response_alloc);
defer aw.deinit();
// The state is allocated on registration and owned by the
// terminal, so it uses the terminal's allocator, not the
// response's.
const event = try kitty_dnd.handleCommand(
&self.terminal.kitty_dnd,
self.terminal.gpa(),
&aw.writer,
v,
);
if (aw.written().len > 0) {
const written = aw.toOwnedSliceSentinel(0) catch return;
defer response_alloc.free(written);
self.writePty(written);
}
if (event) |ev| {
const func = self.effects.drag_and_drop orelse return;
func(self, ev);
}
}
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]);
},
.visibility => self.sendVisibilityReport(),
}
}
fn sendVisibilityReport(self: *Handler) void {
const write_pty = self.effects.write_pty orelse return;
var buf: [device_status.max_visibility_report_encode_size + 1]u8 = undefined;
var writer: std.Io.Writer = .fixed(buf[0..device_status.max_visibility_report_encode_size]);
device_status.encodeVisibilityReport(
&writer,
if (self.terminal.flags.visible) .potentially_visible else .not_visible,
) catch return;
buf[writer.end] = 0;
write_pty(self, 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 => {
if (!self.title_report) return;
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 reportMode2048(self: *Handler) void {
const get_size = self.effects.size orelse return;
const current = get_size(self) orelse return;
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(buf[0 .. buf.len - 1]);
size_report.encode(&writer, .mode_2048, current) catch return;
buf[writer.end] = 0;
self.writePty(buf[0..writer.end :0]);
}
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 {
var report = self.terminal.modes.getReport(.fromMode(mode));
// Kitty paste events (mode 5522) can't work without a clipboard
// read effect, so if that isn't set mark it as unrecognized.
if (mode == .kitty_paste_events and
self.effects.clipboard_read == null)
{
report.state = .not_recognized;
}
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,
.focus_event,
=> {},
// Enabling mode 2048 reports already-committed pixel geometry.
// Waiting for the next resize leaves late-enabling clients without
// the dimensions they need for their first image frame.
.in_band_size_reports => if (enabled) self.reportMode2048(),
.report_visibility => if (enabled) self.sendVisibilityReport(),
.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, terminated: bool) void {
const io = self.terminal.io();
const alloc = self.terminal.gpa();
var result = self.apc_handler.end() orelse return;
defer result.deinit(alloc);
switch (result) {
.unknown => |*unknown| {
if (terminated) self.unknownSequence(.{ .apc = .{
.content = unknown.content,
.truncated = unknown.truncated,
} });
},
.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| if (comptime build_options.glyph_protocol) {
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]);
}
},
}
}
};
/// A writer that delivers everything through the write_pty effect:
/// the buffer as it fills, and data that doesn't fit it directly.
/// Never fails, since the effect can't.
const PtyWriter = struct {
handler: *Handler,
writer: std.Io.Writer,
fn init(handler: *Handler, buffer: []u8) PtyWriter {
return .{
.handler = handler,
.writer = .{
.vtable = &.{ .drain = drain },
.buffer = buffer,
},
};
}
fn drain(
w: *std.Io.Writer,
data: []const []const u8,
splat: usize,
) std.Io.Writer.Error!usize {
const self: *PtyWriter = @alignCast(@fieldParentPtr("writer", w));
// Buffered bytes go first to keep the order.
if (w.end > 0) {
self.handler.writePty(w.buffer[0..w.end]);
w.end = 0;
}
var consumed: usize = 0;
for (data[0 .. data.len - 1]) |slice| {
if (slice.len > 0) self.handler.writePty(slice);
consumed += slice.len;
}
const pattern = data[data.len - 1];
for (0..splat) |_| {
if (pattern.len > 0) self.handler.writePty(pattern);
consumed += pattern.len;
}
return consumed;
}
};
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 = .init(.{ .allocator = testing.allocator, .handler = .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 "unknown APC effect callback" {
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;
var content: [16]u8 = undefined;
var content_len: usize = undefined;
var truncated: bool = undefined;
fn unknownSequence(_: *Handler, value: Handler.UnknownSequence) void {
switch (value) {
.apc => |apc_value| {
content_len = apc_value.content.len;
@memcpy(content[0..apc_value.content.len], apc_value.content);
truncated = apc_value.truncated;
},
}
count += 1;
}
};
S.count = 0;
var handler: Handler = .init(&t);
handler.unknown_sequence = &S.unknownSequence;
handler.apc_handler.unknown_max_bytes = 8;
var s: Stream = .init(.{
.allocator = testing.allocator,
.handler = handler,
});
defer s.deinit();
// Unknown OSC commands retain their legacy behavior and are ignored.
s.nextSlice("\x1B]999;abcdef\x07");
s.nextSlice("\x1B_abcd;payload\x1B\\");
try testing.expectEqual(@as(usize, 1), S.count);
try testing.expectEqualStrings("abcd;pay", S.content[0..S.content_len]);
try testing.expect(S.truncated);
// Aborted unknown APCs are suppressed.
s.nextSlice("\x1B_Xpayload\x18");
try testing.expectEqual(@as(usize, 1), S.count);
}
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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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, _: []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 = .init(.{ .allocator = testing.allocator, .handler = 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 = .init(.{
.allocator = testing.allocator,
.handler = .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, _: []const u8) void {
called = true;
}
};
S.called = false;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .init(.{ .allocator = alloc, .handler = 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, _: []const u8) void {}
};
var authoritative_handler: Handler = .init(&authoritative);
authoritative_handler.effects.write_pty = &S.writePty;
var authoritative_stream: Stream = .init(.{
.allocator = testing.allocator,
.handler = authoritative_handler,
});
defer authoritative_stream.deinit();
var readonly_stream: Stream = .init(.{
.allocator = testing.allocator,
.handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = alloc, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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: []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 = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// SGR
s.nextSlice("\x1B[1m\x1BP$qm\x1B\\");
try S.expectResponse("\x1BP1$r0;1m\x1B\\");
// Overline
s.nextSlice("\x1B[0;53m\x1BP$qm\x1B\\");
try S.expectResponse("\x1BP1$r0;53m\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;53m\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 = .init(.{ .allocator = testing.allocator, .handler = .init(&t) });
defer s.deinit();
s.nextSlice("\x1BP$qm\x1B\\");
try testing.expect(!s.handler.semantic_failure);
}
test "XTGETTCAP responses" {
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;
var calls: usize = 0;
fn reset() void {
response_len = 0;
calls = 0;
}
fn writePty(_: *Handler, data: []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 = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// The full capability table comes from the static terminfo map; this
// checks the wiring for a valued and a valueless (boolean) capability.
s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("Co", .upper) ++ "\x1B\\");
try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("Co", .upper) ++ "=" ++
std.fmt.bytesToHex("256", .upper) ++ "\x1B\\");
s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("am", .upper) ++ "\x1B\\");
try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("am", .upper) ++ "\x1B\\");
// One response per requested key; lowercase hex is normalized by the
// DCS parser. The capture holds the last ("Co") reply.
s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("am", .lower) ++ ";" ++
std.fmt.bytesToHex("Co", .lower) ++ "\x1B\\");
try testing.expectEqual(@as(usize, 2), S.calls);
try testing.expectEqualStrings(
"\x1BP1+r" ++ std.fmt.bytesToHex("Co", .upper) ++ "=" ++
std.fmt.bytesToHex("256", .upper) ++ "\x1B\\",
S.response[0..S.response_len],
);
S.reset();
// Unknown and malformed keys are skipped without an error.
s.nextSlice("\x1BP+qWHO;5;GG\x1B\\");
try testing.expectEqual(@as(usize, 0), S.calls);
try testing.expect(!s.handler.semantic_failure);
}
test "XTGETTCAP 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 = .init(.{ .allocator = testing.allocator, .handler = .init(&t) });
defer s.deinit();
s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("TN", .upper) ++ ";" ++
std.fmt.bytesToHex("am", .upper) ++ "\x1B\\");
try testing.expect(!s.handler.semantic_failure);
}
test "XTGETTCAP TN responses" {
var t: Terminal = try .init(
testing.io,
testing.allocator,
.{ .cols = 80, .rows = 24 },
);
defer t.deinit(testing.allocator);
const S = struct {
var response: [512]u8 = undefined;
var response_len: usize = 0;
var calls: usize = 0;
fn reset() void {
response_len = 0;
calls = 0;
}
fn writePty(_: *Handler, data: []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 = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
const tn_query = "\x1BP+q" ++ std.fmt.bytesToHex("TN", .upper) ++ "\x1B\\";
// While no name is configured the query goes unanswered.
s.nextSlice(tn_query);
try testing.expectEqual(@as(usize, 0), S.calls);
// A configured name is reported hex-encoded.
s.handler.terminfo_name = "xterm-256color";
s.nextSlice(tn_query);
try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("TN", .upper) ++ "=" ++
std.fmt.bytesToHex("xterm-256color", .upper) ++ "\x1B\\");
// A maximum-length name is still reported in full.
const max_name = "a" ** Handler.max_terminfo_name_bytes;
s.handler.terminfo_name = max_name;
s.nextSlice(tn_query);
try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("TN", .upper) ++ "=" ++
std.fmt.bytesToHex(max_name.*, .upper) ++ "\x1B\\");
// An empty name is silent; "Co" is still answered.
s.handler.terminfo_name = "";
s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("TN", .upper) ++ ";" ++
std.fmt.bytesToHex("Co", .upper) ++ "\x1B\\");
try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("Co", .upper) ++ "=" ++
std.fmt.bytesToHex("256", .upper) ++ "\x1B\\");
// As are names beyond the maximum length.
s.handler.terminfo_name = "a" ** (Handler.max_terminfo_name_bytes + 1);
s.nextSlice(tn_query);
try testing.expectEqual(@as(usize, 0), S.calls);
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 = .init(.{ .allocator = testing.allocator, .handler = .init(&t) });
// A completed command transfers its allocation to Command; dcsCommand
// must release it even when there is no write effect.
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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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" {
if (comptime !build_options.glyph_protocol) return error.SkipZigTest;
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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .init(&t) });
defer s.deinit();
const default: color.RGB = .{ .r = 0x10, .g = 0x20, .b = 0x30 };
t.colors.background.default = default;
// 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.expectEqual(default, t.colors.background.get().?);
try testing.expectEqual(null, t.colors.background.override);
// A reset color continues to follow later configuration changes.
const updated: color.RGB = .{ .r = 0x40, .g = 0x50, .b = 0x60 };
t.colors.background.default = updated;
try testing.expectEqual(updated, t.colors.background.get().?);
}
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 = .init(.{ .allocator = testing.allocator, .handler = .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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = 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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = 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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = 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);
}
// A full reset (RIS) removes any active progress bar.
s.nextSlice("\x1B]9;4;1;50\x1B\\");
try testing.expectEqual(@as(usize, cases.len + 1), S.count);
try testing.expectEqual(osc.Command.ProgressReport.State.set, S.last_state);
s.nextSlice("\x1Bc");
try testing.expectEqual(@as(usize, cases.len + 2), S.count);
try testing.expectEqual(osc.Command.ProgressReport.State.remove, S.last_state);
try testing.expectEqual(@as(?u8, null), 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 = .init(.{ .allocator = testing.allocator, .handler = .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.Write.Result = .{ .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) void {
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");
}
write.reply(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 = .init(.{ .allocator = testing.allocator, .handler = 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" },
// Missing padding is tolerated for OSC 52 since it has no way
// to report errors to the client, matching kitty.
.{ .sequence = "\x1B]52;c;dW5wYWRkZWQ\x1B\\", .location = .standard, .data = "unpadded" },
};
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. The whole request is
// discarded on invalid characters (including whitespace) rather
// than decoding around them, per the Kitty clipboard spec that
// governs OSC 52 base64 handling.
s.nextSlice("\x1B]52;c;?\x1B\\");
s.nextSlice("\x1B]52;c;***\x1B\\");
s.nextSlice("\x1B]52;c;SGVs!!!bG8=\x1B\\");
s.nextSlice("\x1B]52;c;aGVs bG8=\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.?);
// Reply results are intentionally ignored for protocols without a
// write acknowledgement. The denied reply above did not stop later writes.
try testing.expect(S.result == .denied);
}
test "clipboard_read 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: std.ArrayList(u8) = .empty;
var count: usize = 0;
var last_location: clipboard.Location = .standard;
var last_mimes: []const []const u8 = &.{};
var last_list: bool = true;
var last_name: []const u8 = "unset";
var last_granted: bool = true;
var last_can_remember: bool = true;
var result: ?clipboard.Read.Result = .{ .success = .{ .contents = &.{.{
.mime = "text/plain",
.data = "hello",
}} } };
var reply_twice: bool = false;
fn writePty(_: *Handler, data: []const u8) void {
written.appendSlice(testing.allocator, data) catch @panic("OOM");
}
fn clipboardRead(_: *Handler, read: clipboard.Read) void {
count += 1;
last_location = read.location;
last_mimes = read.mimes;
last_list = read.list;
last_name = read.name;
last_granted = read.granted;
last_can_remember = read.can_remember;
if (result) |r| read.reply(r);
if (reply_twice) read.reply(.{ .success = .{ .contents = &.{.{
.mime = "text/plain",
.data = "again",
}} } });
}
};
defer S.written.deinit(testing.allocator);
// A null callback (the default readonly effects) silently ignores reads.
{
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
s.nextSlice("\x1B]52;c;?\x1B\\");
try testing.expectEqual(0, S.written.items.len);
}
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Success echoes the normalized selector and request terminator.
s.nextSlice("\x1B]52;c;?\x1B\\");
try testing.expectEqual(1, S.count);
try testing.expectEqual(clipboard.Location.standard, S.last_location);
try testing.expectEqual(1, S.last_mimes.len);
try testing.expectEqualStrings("text/plain", S.last_mimes[0]);
try testing.expect(!S.last_list);
try testing.expectEqualStrings("", S.last_name);
try testing.expect(!S.last_granted);
try testing.expect(!S.last_can_remember);
try testing.expectEqualStrings("\x1B]52;c;aGVsbG8=\x1B\\", S.written.items);
S.written.clearRetainingCapacity();
s.nextSlice("\x1B]52;p;?\x07");
try testing.expectEqual(clipboard.Location.primary, S.last_location);
try testing.expectEqualStrings("\x1B]52;p;aGVsbG8=\x07", S.written.items);
// Only the first text representation is used.
S.written.clearRetainingCapacity();
S.result = .{
.success = .{
.contents = &.{
.{ .mime = "image/png", .data = "\x89PNG" },
.{ .mime = "UTF8_STRING", .data = "hi" },
},
// OSC 52 has no session passwords, so remember is ignored.
.remember = true,
},
};
s.nextSlice("\x1B]52;s;?\x1B\\");
try testing.expectEqual(clipboard.Location.selection, S.last_location);
try testing.expectEqualStrings("\x1B]52;s;aGk=\x1B\\", S.written.items);
// Every failure, no text, and no reply all answer with an empty
// clipboard.
for ([_]?clipboard.Read.Result{
.denied,
.unsupported,
.busy,
.io_error,
.{ .success = .{} },
null,
}) |result| {
S.written.clearRetainingCapacity();
S.result = result;
s.nextSlice("\x1B]52;c;?\x1B\\");
try testing.expectEqualStrings("\x1B]52;c;\x1B\\", S.written.items);
}
// A second reply is ignored.
S.written.clearRetainingCapacity();
S.result = .{ .success = .{ .contents = &.{.{ .mime = "text/plain", .data = "hello" }} } };
S.reply_twice = true;
s.nextSlice("\x1B]52;c;?\x1B\\");
try testing.expectEqualStrings("\x1B]52;c;aGVsbG8=\x1B\\", S.written.items);
}
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, write: clipboard.Write) void {
count += 1;
write.reply(.{ .success = .{} });
}
};
S.count = 0;
var handler: Handler = .init(&t);
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = 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);
}
/// Shared capture state for the Kitty clipboard (OSC 5522) tests below:
/// records every pty response and the most recent clipboard write.
const KittyClipboardCapture = struct {
var responses: [1024]u8 = undefined;
var responses_len: usize = 0;
// Write capture. A null write_result returns without replying.
var write_count: usize = 0;
var write_result: ?clipboard.Write.Result = .{ .success = .{} };
var write_reply_twice: bool = false;
var last_location: clipboard.Location = .standard;
var last_contents_len: usize = 0;
var last_mimes: [8][64]u8 = undefined;
var last_mime_lens: [8]usize = @splat(0);
var last_data: [8][256]u8 = undefined;
var last_data_lens: [8]usize = @splat(0);
var last_write_name: [64]u8 = undefined;
var last_write_name_len: usize = 0;
var last_write_granted: bool = false;
var last_write_can_remember: bool = false;
// Read capture. A null read_result returns without replying.
var read_count: usize = 0;
var read_result: ?clipboard.Read.Result = null;
var read_reply_twice: bool = false;
var last_read_location: clipboard.Location = .standard;
var last_read_mimes: [8][64]u8 = undefined;
var last_read_mime_lens: [8]usize = @splat(0);
var last_read_mimes_len: usize = 0;
var last_read_list: bool = false;
var last_read_name: [64]u8 = undefined;
var last_read_name_len: usize = 0;
var last_read_granted: bool = false;
var last_read_can_remember: bool = false;
fn reset() void {
responses_len = 0;
write_count = 0;
write_result = .{ .success = .{} };
write_reply_twice = false;
last_location = .standard;
last_contents_len = 0;
last_mime_lens = @splat(0);
last_data_lens = @splat(0);
last_write_name_len = 0;
last_write_granted = false;
last_write_can_remember = false;
read_count = 0;
read_result = null;
read_reply_twice = false;
last_read_location = .standard;
last_read_mime_lens = @splat(0);
last_read_mimes_len = 0;
last_read_list = false;
last_read_name_len = 0;
last_read_granted = false;
last_read_can_remember = false;
}
fn writePty(_: *Handler, data: []const u8) void {
@memcpy(responses[responses_len..][0..data.len], data);
responses_len += data.len;
}
fn clipboardWrite(_: *Handler, write: clipboard.Write) void {
write_count += 1;
last_location = write.location;
last_contents_len = write.contents.len;
for (write.contents[0..@min(write.contents.len, last_mimes.len)], 0..) |content, i| {
last_mime_lens[i] = content.mime.len;
@memcpy(last_mimes[i][0..content.mime.len], content.mime);
last_data_lens[i] = content.data.len;
@memcpy(last_data[i][0..content.data.len], content.data);
}
last_write_name_len = write.name.len;
@memcpy(last_write_name[0..write.name.len], write.name);
last_write_granted = write.granted;
last_write_can_remember = write.can_remember;
if (write_result) |r| write.reply(r);
if (write_reply_twice) write.reply(.io_error);
}
fn clipboardRead(_: *Handler, read: clipboard.Read) void {
read_count += 1;
last_read_location = read.location;
last_read_mimes_len = read.mimes.len;
for (read.mimes[0..@min(read.mimes.len, last_read_mimes.len)], 0..) |mime, i| {
last_read_mime_lens[i] = mime.len;
@memcpy(last_read_mimes[i][0..mime.len], mime);
}
last_read_list = read.list;
last_read_name_len = read.name.len;
@memcpy(last_read_name[0..read.name.len], read.name);
last_read_granted = read.granted;
last_read_can_remember = read.can_remember;
if (read_result) |r| read.reply(r);
if (read_reply_twice) read.reply(.denied);
}
fn responseSlice() []const u8 {
return responses[0..responses_len];
}
fn readMimeAt(i: usize) []const u8 {
return last_read_mimes[i][0..last_read_mime_lens[i]];
}
fn readName() []const u8 {
return last_read_name[0..last_read_name_len];
}
fn writeName() []const u8 {
return last_write_name[0..last_write_name_len];
}
fn mimeAt(i: usize) []const u8 {
return last_mimes[i][0..last_mime_lens[i]];
}
fn dataAt(i: usize) []const u8 {
return last_data[i][0..last_data_lens[i]];
}
};
test "kitty clipboard write transaction round trip" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Begin a write, stream two MIME types (one chunked), alias the
// plain text, and commit. Only the commit produces a response.
s.nextSlice("\x1B]5522;type=write:id=42\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); // "Ghost"
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;dHk=\x1B\\"); // "ty"
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\"); // "hi"
// Alias "TEXT UTF8_STRING" -> text/plain.
s.nextSlice("\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;VEVYVCBVVEY4X1NUUklORw==\x1B\\");
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqual(@as(usize, 0), S.responses_len);
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqual(clipboard.Location.standard, S.last_location);
try testing.expectEqual(@as(usize, 4), S.last_contents_len);
try testing.expectEqualStrings("text/plain", S.mimeAt(0));
try testing.expectEqualStrings("Ghostty", S.dataAt(0));
try testing.expectEqualStrings("text/html", S.mimeAt(1));
try testing.expectEqualStrings("hi", S.dataAt(1));
try testing.expectEqualStrings("TEXT", S.mimeAt(2));
try testing.expectEqualStrings("Ghostty", S.dataAt(2));
try testing.expectEqualStrings("UTF8_STRING", S.mimeAt(3));
try testing.expectEqualStrings("Ghostty", S.dataAt(3));
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=DONE:id=42\x1B\\",
S.responseSlice(),
);
// A commit with no transaction in flight is silently ignored.
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(@as(usize, 1), S.write_count);
}
test "kitty clipboard write result maps to response status" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
const cases = [_]struct {
result: ?clipboard.Write.Result,
response: []const u8,
}{
.{ .result = .{ .success = .{} }, .response = "\x1B]5522;type=write:status=DONE\x1B\\" },
.{ .result = .denied, .response = "\x1B]5522;type=write:status=EPERM\x1B\\" },
.{ .result = .unsupported, .response = "\x1B]5522;type=write:status=ENOSYS\x1B\\" },
.{ .result = .busy, .response = "\x1B]5522;type=write:status=EBUSY\x1B\\" },
.{ .result = .invalid_data, .response = "\x1B]5522;type=write:status=EINVAL\x1B\\" },
.{ .result = .io_error, .response = "\x1B]5522;type=write:status=EIO\x1B\\" },
// No reply at all is a denial rather than silence.
.{ .result = null, .response = "\x1B]5522;type=write:status=EPERM\x1B\\" },
};
for (cases) |case| {
S.reset();
S.write_result = case.result;
// An immediately-committed write with no data is a clear.
s.nextSlice("\x1B]5522;type=write\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqual(@as(usize, 0), S.last_contents_len);
try testing.expectEqualStrings(case.response, S.responseSlice());
}
// A second reply is ignored.
S.reset();
S.write_reply_twice = true;
s.nextSlice("\x1B]5522;type=write\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=DONE\x1B\\",
S.responseSlice(),
);
// The response echoes the request terminator, unlike kitty which
// always uses ST.
S.reset();
s.nextSlice("\x1B]5522;type=write:loc=primary\x07");
s.nextSlice("\x1B]5522;type=wdata\x07");
try testing.expectEqual(clipboard.Location.primary, S.last_location);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=DONE\x07",
S.responseSlice(),
);
// A system without a primary selection answers a loc=primary write
// with ENOSYS, echoing the id.
S.reset();
S.write_result = .unsupported;
s.nextSlice("\x1B]5522;type=write:loc=primary:id=p1\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(clipboard.Location.primary, S.last_location);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=ENOSYS:id=p1\x1B\\",
S.responseSlice(),
);
}
test "kitty clipboard write without clipboard effect responds ENOSYS" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// The transaction fails as soon as it begins; the rest of it is
// ignored without further responses.
s.nextSlice("\x1B]5522;type=write:id=x\x1B\\");
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=ENOSYS:id=x\x1B\\",
S.responseSlice(),
);
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=ENOSYS:id=x\x1B\\",
S.responseSlice(),
);
}
test "kitty clipboard read without effect is denied with EPERM" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// The denial never includes loc (only OK responses do) and echoes
// the sanitized id.
s.nextSlice("\x1B]5522;type=read:loc=primary:id=*4 2*;dGV4dC9wbGFpbg==\x1B\\");
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=EPERM:id=42\x1B\\",
S.responseSlice(),
);
// A missing payload is an empty MIME list, still answered.
S.reset();
s.nextSlice("\x1B]5522;type=read\x07");
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=EPERM\x07",
S.responseSlice(),
);
// An undecodable payload is dropped without a response.
S.reset();
s.nextSlice("\x1B]5522;type=read;!!!\x1B\\");
try testing.expectEqual(@as(usize, 0), S.responses_len);
}
test "kitty clipboard read round trip" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
S.read_result = .{
.success = .{
.contents = &.{
// Unrequested representations are never served, and the
// served ones follow request order, not reply order.
.{ .mime = "image/png", .data = "\x89PNG" },
.{ .mime = "text/html", .data = "hi" },
.{ .mime = "text/plain", .data = "Ghostty" },
},
.available = &.{ "text/plain", "text/html" },
},
};
// Request the targets listing plus two types from the primary
// selection: ". text/plain text/html".
s.nextSlice("\x1B]5522;type=read:loc=primary:id=r1;LiB0ZXh0L3BsYWluIHRleHQvaHRtbA==\x1B\\");
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expectEqual(clipboard.Location.primary, S.last_read_location);
try testing.expectEqual(@as(usize, 2), S.last_read_mimes_len);
try testing.expectEqualStrings("text/plain", S.readMimeAt(0));
try testing.expectEqualStrings("text/html", S.readMimeAt(1));
try testing.expect(S.last_read_list);
try testing.expectEqualStrings("", S.readName());
try testing.expect(!S.last_read_granted);
try testing.expect(!S.last_read_can_remember);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=OK:loc=primary:id=r1\x1B\\" ++
"\x1B]5522;type=read:status=DATA:id=r1:mime=Lg==;dGV4dC9wbGFpbiB0ZXh0L2h0bWwK\x1B\\" ++
"\x1B]5522;type=read:status=DATA:id=r1:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1B\\" ++
"\x1B]5522;type=read:status=DATA:id=r1:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\" ++
"\x1B]5522;type=read:status=DONE:id=r1\x1B\\",
S.responseSlice(),
);
// Without the listing request `available` is ignored. The response
// echoes the request terminator.
S.responses_len = 0;
s.nextSlice("\x1B]5522;type=read:id=r2;dGV4dC9wbGFpbg==\x07");
try testing.expectEqual(clipboard.Location.standard, S.last_read_location);
try testing.expectEqual(@as(usize, 1), S.last_read_mimes_len);
try testing.expect(!S.last_read_list);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=OK:id=r2\x07" ++
"\x1B]5522;type=read:status=DATA:id=r2:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x07" ++
"\x1B]5522;type=read:status=DONE:id=r2\x07",
S.responseSlice(),
);
// A listing-only request carries no types.
S.responses_len = 0;
s.nextSlice("\x1B]5522;type=read;Lg==\x1B\\");
try testing.expectEqual(@as(usize, 0), S.last_read_mimes_len);
try testing.expect(S.last_read_list);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=OK\x1B\\" ++
"\x1B]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbiB0ZXh0L2h0bWwK\x1B\\" ++
"\x1B]5522;type=read:status=DONE\x1B\\",
S.responseSlice(),
);
}
test "kitty clipboard read result maps to response status" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
const cases = [_]struct {
result: ?clipboard.Read.Result,
response: []const u8,
}{
.{ .result = .denied, .response = "\x1B]5522;type=read:status=EPERM:id=x\x1B\\" },
.{ .result = .unsupported, .response = "\x1B]5522;type=read:status=ENOSYS:id=x\x1B\\" },
.{ .result = .busy, .response = "\x1B]5522;type=read:status=EBUSY:id=x\x1B\\" },
.{ .result = .io_error, .response = "\x1B]5522;type=read:status=EIO:id=x\x1B\\" },
// No reply at all is a denial rather than silence.
.{ .result = null, .response = "\x1B]5522;type=read:status=EPERM:id=x\x1B\\" },
// A success with nothing to serve is still OK then DONE.
.{ .result = .{ .success = .{} }, .response = "\x1B]5522;type=read:status=OK:id=x\x1B\\" ++
"\x1B]5522;type=read:status=DONE:id=x\x1B\\" },
};
for (cases) |case| {
S.reset();
S.read_result = case.result;
s.nextSlice("\x1B]5522;type=read:id=x;dGV4dC9wbGFpbg==\x1B\\");
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expectEqualStrings(case.response, S.responseSlice());
}
// A second reply is ignored.
S.reset();
S.read_result = .{ .success = .{ .contents = &.{.{ .mime = "text/plain", .data = "hello" }} } };
S.read_reply_twice = true;
s.nextSlice("\x1B]5522;type=read;dGV4dC9wbGFpbg==\x1B\\");
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=OK\x1B\\" ++
"\x1B]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1B\\" ++
"\x1B]5522;type=read:status=DONE\x1B\\",
S.responseSlice(),
);
}
test "kitty clipboard read caps requested types" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// "a/0 a/1 a/2 a/3 a/4 a/5 .": extras are dropped but the listing
// request after them still counts.
S.read_result = .{ .success = .{} };
s.nextSlice("\x1B]5522;type=read;YS8wIGEvMSBhLzIgYS8zIGEvNCBhLzUgLg==\x1B\\");
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expectEqual(kitty_clipboard.max_read_mimes, S.last_read_mimes_len);
try testing.expectEqualStrings("a/0", S.readMimeAt(0));
try testing.expectEqualStrings("a/3", S.readMimeAt(kitty_clipboard.max_read_mimes - 1));
try testing.expect(S.last_read_list);
}
test "kitty clipboard read password grants" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Every read requests a data type ("text/plain"): a request with
// no data types never consults the grants at all.
//
// pw="secret", name="app": the first request isn't granted but the
// reply may ask to remember it.
S.read_result = .{ .success = .{ .remember = true } };
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expectEqualStrings("app", S.readName());
try testing.expect(!S.last_read_granted);
try testing.expect(S.last_read_can_remember);
// The same password is now granted; a different one is not.
S.read_result = .{ .success = .{} };
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(S.last_read_granted);
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(!S.last_read_granted);
try testing.expect(S.last_read_can_remember);
// A password without a name doesn't count: it is neither granted
// nor rememberable, even if the reply asks.
S.read_result = .{ .success = .{ .remember = true } };
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0;dGV4dC9wbGFpbg==\x1B\\");
try testing.expectEqualStrings("", S.readName());
try testing.expect(!S.last_read_granted);
try testing.expect(!S.last_read_can_remember);
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(!S.last_read_can_remember);
S.read_result = .{ .success = .{} };
s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(!S.last_read_granted);
// A grant is advisory: the request is still forwarded and the
// embedder may deny it.
S.responses_len = 0;
S.read_result = .denied;
s.nextSlice("\x1B]5522;type=read:id=d:pw=c2VjcmV0:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(S.last_read_granted);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=EPERM:id=d\x1B\\",
S.responseSlice(),
);
// Grants are freed with the stream (the testing allocator catches
// the leak otherwise).
}
test "kitty clipboard read targets-only never consumes a one-time grant" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// A one-time read grant, as minted for a paste event.
try s.handler.kitty_clipboard_grants.grant(testing.allocator, "otp", .read, true);
// A targets-only read (payload ".") is prompt-exempt so it never
// consults, and must not burn, the one-time password.
S.read_result = .{ .success = .{} };
s.nextSlice("\x1B]5522;type=read:pw=b3Rw:name=YXBw;Lg==\x1B\\");
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expect(S.last_read_list);
try testing.expectEqual(@as(usize, 0), S.last_read_mimes_len);
try testing.expect(!S.last_read_granted);
// The follow-up data read still consumes the grant, exactly once.
s.nextSlice("\x1B]5522;type=read:pw=b3Rw:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(S.last_read_granted);
s.nextSlice("\x1B]5522;type=read:pw=b3Rw:name=YXBw;dGV4dC9wbGFpbg==\x1B\\");
try testing.expect(!S.last_read_granted);
}
test "kitty clipboard write password grants" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_write = &S.clipboardWrite;
handler.effects.clipboard_read = &S.clipboardRead;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// pw="secret", name="app": the first commit isn't granted but the
// reply may ask to remember it.
S.write_result = .{ .success = .{ .remember = true } };
s.nextSlice("\x1B]5522;type=write:pw=c2VjcmV0:name=YXBw\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqualStrings("app", S.writeName());
try testing.expect(!S.last_write_granted);
try testing.expect(S.last_write_can_remember);
// The same password is now granted; a different one is not.
S.write_result = .{ .success = .{} };
s.nextSlice("\x1B]5522;type=write:pw=c2VjcmV0:name=YXBw\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expect(S.last_write_granted);
s.nextSlice("\x1B]5522;type=write:pw=b3RoZXI=:name=YXBw\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expect(!S.last_write_granted);
try testing.expect(S.last_write_can_remember);
// Directions are independent: a write grant doesn't satisfy reads.
S.read_result = .{ .success = .{} };
s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\");
try testing.expect(!S.last_read_granted);
// A password without a name doesn't count: it is neither granted
// nor rememberable, even if the reply asks.
S.write_result = .{ .success = .{ .remember = true } };
s.nextSlice("\x1B]5522;type=write:pw=c2VjcmV0\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqualStrings("", S.writeName());
try testing.expect(!S.last_write_granted);
try testing.expect(!S.last_write_can_remember);
// A grant is advisory: the request is still forwarded and the
// embedder may deny it.
S.responses_len = 0;
S.write_result = .denied;
s.nextSlice("\x1B]5522;type=write:id=d:pw=c2VjcmV0:name=YXBw\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expect(S.last_write_granted);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=EPERM:id=d\x1B\\",
S.responseSlice(),
);
// Grants are freed with the stream (the testing allocator catches
// the leak otherwise).
}
test "kitty clipboard malformed packets are silently dropped" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Missing type, unknown type, bare metadata record, invalid mime
// base64, and orphaned transaction packets all drop silently.
s.nextSlice("\x1B]5522;loc=primary\x1B\\");
s.nextSlice("\x1B]5522;type=bobr\x1B\\");
s.nextSlice("\x1B]5522;type=read:bare\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=!!!;R2hvc3Q=\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
s.nextSlice("\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;VEVYVA==\x1B\\");
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqual(@as(usize, 0), S.responses_len);
try testing.expect(!s.handler.semantic_failure);
// The terminal is still functional afterwards.
s.nextSlice("ok");
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("ok", str);
}
test "kitty clipboard new write replaces in-flight transaction" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
s.nextSlice("\x1B]5522;type=write:id=old\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;b2xk\x1B\\"); // "old"
s.nextSlice("\x1B]5522;type=write:id=new\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;bmV3\x1B\\"); // "new"
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqual(@as(usize, 1), S.last_contents_len);
try testing.expectEqualStrings("new", S.dataAt(0));
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=DONE:id=new\x1B\\",
S.responseSlice(),
);
}
test "kitty clipboard invalid write packets abort with EINVAL" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
const invalid_packets = [_][]const u8{
// Alias payload decodes to a non-UTF-8 byte.
"\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;/w==\x1B\\",
// Alias has no target MIME type.
"\x1B]5522;type=walias;VEVYVA==\x1B\\",
// Alias target MIME decodes to non-UTF-8 bytes.
"\x1B]5522;type=walias:mime=//4=;VEVYVA==\x1B\\",
// Write data MIME decodes to non-UTF-8 bytes.
"\x1B]5522;type=wdata:mime=//4=;R2hvc3Q=\x1B\\",
};
for (invalid_packets) |packet| {
S.responses_len = 0;
s.nextSlice("\x1B]5522;type=write:id=w\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
s.nextSlice(packet);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=EINVAL:id=w\x1B\\",
S.responseSlice(),
);
try testing.expect(!s.handler.semantic_failure);
// The transaction is gone: a commit does nothing further.
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=EINVAL:id=w\x1B\\",
S.responseSlice(),
);
}
}
test "kitty clipboard invalid read text does not abort a write" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
S.read_result = .{ .success = .{} };
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
s.nextSlice("\x1B]5522;type=write:id=w\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
// The read payload decodes to a non-UTF-8 byte. It is dropped without
// invoking the clipboard effect or disturbing the write transaction.
s.nextSlice("\x1B]5522;type=read;/w==\x1B\\");
try testing.expectEqual(@as(usize, 0), S.read_count);
try testing.expectEqual(@as(usize, 0), S.responses_len);
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=DONE:id=w\x1B\\",
S.responseSlice(),
);
}
test "kitty clipboard oversized text write aborts with EFBIG" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Shrink the limit so the test doesn't have to stream the
// default 64MiB.
s.handler.kitty_clipboard_write_max_bytes = 4;
s.nextSlice("\x1B]5522;type=write:id=w\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;SGVsbA==\x1B\\"); // "Hell"
try testing.expectEqual(@as(usize, 0), S.responses_len);
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;bw==\x1B\\"); // "o"
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=EFBIG:id=w\x1B\\",
S.responseSlice(),
);
try testing.expect(!s.handler.semantic_failure);
// The transaction is gone: later data and the commit do nothing.
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;IQ==\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=EFBIG:id=w\x1B\\",
S.responseSlice(),
);
}
test "kitty clipboard invalid wdata chunk aborts with EINVAL" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
s.nextSlice("\x1B]5522;type=write:id=w\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;SGVsbG8=\x1B\\"); // "Hello"
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;!!!bad!!!\x1B\\");
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=EINVAL:id=w\x1B\\",
S.responseSlice(),
);
try testing.expect(!s.handler.semantic_failure);
// The transaction is gone: later data and the commit do nothing.
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;V29ybGQ=\x1B\\"); // "World"
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=EINVAL:id=w\x1B\\",
S.responseSlice(),
);
}
test "kitty clipboard wdata chunks split one base64 stream" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// "some data" encoded as one stream, split at non-group
// boundaries across packets.
s.nextSlice("\x1B]5522;type=write\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;c29\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;tZSBk\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;YXRh\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqualStrings("some data", S.dataAt(0));
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=DONE\x1B\\",
S.responseSlice(),
);
}
test "kitty clipboard unpadded wdata stream aborts at commit" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// "Hello" without its final padding byte: every packet decodes,
// but the stream ends mid-group so the commit reports EINVAL.
s.nextSlice("\x1B]5522;type=write:id=w\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;SGVsbG8\x1B\\");
try testing.expectEqual(@as(usize, 0), S.responses_len);
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=EINVAL:id=w\x1B\\",
S.responseSlice(),
);
try testing.expect(!s.handler.semantic_failure);
}
test "kitty clipboard in-flight transaction is freed on deinit" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Never committed: stream deinit must free the transaction (the
// testing allocator catches the leak otherwise).
s.nextSlice("\x1B]5522;type=write\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
try testing.expectEqual(@as(usize, 0), S.write_count);
}
test "kitty clipboard allocation failure is ignored" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Only transaction state uses the terminal allocator here. Swap in
// an allocator that always fails, then restore it before teardown.
{
const alloc = t.screens.active.alloc;
t.screens.active.alloc = testing.failing_allocator;
defer t.screens.active.alloc = alloc;
s.nextSlice("\x1B]5522;type=write\x1B\\");
}
// Clipboard writes are external effects, best-effort like OSC 52;
// the failed transaction never started and is not a semantic
// failure.
try testing.expect(!s.handler.semantic_failure);
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqual(@as(usize, 0), S.responses_len);
}
test "kitty clipboard without write_pty still commits writes" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = KittyClipboardCapture;
S.reset();
var handler: Handler = .init(&t);
handler.effects.clipboard_write = &S.clipboardWrite;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
s.nextSlice("\x1B]5522;type=write\x1B\\");
s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqualStrings("Ghost", S.dataAt(0));
// Reads are dropped without a way to respond.
s.nextSlice("\x1B]5522;type=read\x1B\\");
try testing.expectEqual(@as(usize, 0), S.responses_len);
}
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 = .init(.{ .allocator = testing.allocator, .handler = .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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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.?);
// Query DECECM, which Ghostty recognizes but does not allow changing
s.nextSlice("\x1B[?117$p");
try testing.expectEqualStrings("\x1B[?117;4$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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = 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 = .init(.{ .allocator = testing.allocator, .handler = .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 = .init(.{ .allocator = testing.allocator, .handler = 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: ?[]const u8 = null;
var written_buf: [64]u8 = undefined;
fn writePty(_: *Handler, data: []const u8) void {
std.debug.assert(data.len <= written_buf.len);
@memcpy(written_buf[0..data.len], data);
written = written_buf[0..data.len];
}
};
S.written = null;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = 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: ?[]const u8 = null;
var written_buf: [64]u8 = undefined;
fn writePty(_: *Handler, data: []const u8) void {
std.debug.assert(data.len <= written_buf.len);
@memcpy(written_buf[0..data.len], data);
written = written_buf[0..data.len];
}
};
S.written = null;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = 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: ?[]const u8 = null;
var written_buf: [64]u8 = undefined;
fn writePty(_: *Handler, data: []const u8) void {
std.debug.assert(data.len <= written_buf.len);
@memcpy(written_buf[0..data.len], data);
written = written_buf[0..data.len];
}
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 = .init(.{ .allocator = testing.allocator, .handler = 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: ?[]const u8 = null;
var written_buf: [64]u8 = undefined;
fn writePty(_: *Handler, data: []const u8) void {
std.debug.assert(data.len <= written_buf.len);
@memcpy(written_buf[0..data.len], data);
written = written_buf[0..data.len];
}
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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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 "mode 2048 enable reports current geometry and disable is silent" {
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;
var calls: usize = 0;
fn writePty(_: *Handler, data: []const u8) void {
@memcpy(response[0..data.len], data);
response_len = data.len;
calls += 1;
}
fn getSize(_: *Handler) ?size_report.Size {
return .{ .rows = 24, .columns = 80, .cell_width = 8, .cell_height = 16 };
}
};
S.response_len = 0;
S.calls = 0;
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.size = &S.getSize;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
s.nextSlice("\x1b[?2048h");
s.nextSlice("\x1b[?2048h");
try testing.expectEqual(@as(usize, 2), S.calls);
try testing.expectEqualStrings("\x1b[48;24;80;384;640t", S.response[0..S.response_len]);
s.nextSlice("\x1b[?2048l");
try testing.expectEqual(@as(usize, 2), S.calls);
try testing.expect(!t.modes.get(.in_band_size_reports));
}
test "mode 2048 enable tolerates missing effects" {
const S = struct {
var calls: usize = 0;
fn writePty(_: *Handler, _: []const u8) void {
calls += 1;
}
fn getSize(_: *Handler) ?size_report.Size {
return .{ .rows = 24, .columns = 80, .cell_width = 8, .cell_height = 16 };
}
};
S.calls = 0;
var no_size_terminal: Terminal = try .init(
testing.io,
testing.allocator,
.{ .cols = 80, .rows = 24 },
);
defer no_size_terminal.deinit(testing.allocator);
var no_size_handler: Handler = .init(&no_size_terminal);
no_size_handler.effects.write_pty = &S.writePty;
var no_size_stream: Stream = .init(.{
.allocator = testing.allocator,
.handler = no_size_handler,
});
defer no_size_stream.deinit();
no_size_stream.nextSlice("\x1b[?2048h");
try testing.expect(no_size_terminal.modes.get(.in_band_size_reports));
try testing.expectEqual(@as(usize, 0), S.calls);
var no_write_terminal: Terminal = try .init(
testing.io,
testing.allocator,
.{ .cols = 80, .rows = 24 },
);
defer no_write_terminal.deinit(testing.allocator);
var no_write_handler: Handler = .init(&no_write_terminal);
no_write_handler.effects.size = &S.getSize;
var no_write_stream: Stream = .init(.{
.allocator = testing.allocator,
.handler = no_write_handler,
});
defer no_write_stream.deinit();
no_write_stream.nextSlice("\x1b[?2048h");
try testing.expect(no_write_terminal.modes.get(.in_band_size_reports));
try testing.expectEqual(@as(usize, 0), S.calls);
}
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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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 disabled by default" {
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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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");
try testing.expect(S.written == null);
}
test "size report csi_21_t title enabled" {
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: []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;
handler.title_report = true;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Without color_scheme effect, query should be silently ignored
s.nextSlice("\x1B[?996n");
try testing.expect(S.written == null);
}
test "visibility reports" {
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;
var count: usize = 0;
fn writePty(_: *Handler, data: []const u8) void {
if (written) |old| testing.allocator.free(old);
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
count += 1;
}
};
S.written = null;
S.count = 0;
defer if (S.written) |old| testing.allocator.free(old);
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Mode 2033 is supported and initially disabled.
s.nextSlice("\x1B[?2033$p");
try testing.expectEqualStrings("\x1B[?2033;2$y", S.written.?);
// A one-shot query reports the current state without enabling the mode.
s.nextSlice("\x1B[?998n");
try testing.expectEqualStrings("\x1B[?999;1n", S.written.?);
try testing.expect(!t.modes.get(.report_visibility));
// Enabling always sends an immediate report, even when already enabled.
t.flags.visible = false;
s.nextSlice("\x1B[?2033h");
try testing.expectEqualStrings("\x1B[?999;2n", S.written.?);
const count = S.count;
s.nextSlice("\x1B[?2033h");
try testing.expectEqual(count + 1, S.count);
// Disabling sends no report.
s.nextSlice("\x1B[?2033l");
try testing.expectEqual(count + 1, S.count);
// A terminal reset preserves the view's externally owned visibility.
s.nextSlice("\x1Bc");
s.nextSlice("\x1B[?998n");
try testing.expectEqualStrings("\x1B[?999;2n", S.written.?);
}
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 = .init(.{ .allocator = testing.allocator, .handler = .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");
s.nextSlice("\x1B[?998n");
// 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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 = .init(.{ .allocator = testing.allocator, .handler = .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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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: []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 = .init(.{ .allocator = testing.allocator, .handler = 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 = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Send a kitty graphics transmit command via APC:
// ESC _ G 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);
}
test "continuation reconstructs standard stream without duplicate effects" {
const S = struct {
var bell_count: usize = 0;
var title_count: usize = 0;
var write_count: usize = 0;
var notification_count: usize = 0;
var clipboard_count: usize = 0;
fn bell(_: *Handler) void {
bell_count += 1;
}
fn titleChanged(_: *Handler) void {
title_count += 1;
}
fn writePty(_: *Handler, _: []const u8) void {
write_count += 1;
}
fn desktopNotification(
_: *Handler,
_: Action.ShowDesktopNotification,
) void {
notification_count += 1;
}
fn clipboardWrite(
_: *Handler,
write: clipboard.Write,
) void {
clipboard_count += 1;
write.reply(.{ .success = .{} });
}
fn reset() void {
bell_count = 0;
title_count = 0;
write_count = 0;
notification_count = 0;
clipboard_count = 0;
}
};
S.reset();
const committed = "A\n\x07" ++
"\x1b]2;title\x1b\\" ++
"\x1b[5n" ++
"\x1b]9;body\x1b\\" ++
"\x1b]52;c;aA==\x1b\\";
var source_terminal: Terminal = try .init(
testing.io,
testing.allocator,
.{ .cols = 80, .rows = 24 },
);
defer source_terminal.deinit(testing.allocator);
var source_handler: Handler = .init(&source_terminal);
source_handler.effects.bell = &S.bell;
source_handler.effects.title_changed = &S.titleChanged;
source_handler.effects.write_pty = &S.writePty;
source_handler.effects.desktop_notification = &S.desktopNotification;
source_handler.effects.clipboard_write = &S.clipboardWrite;
var source = Stream.init(.{
.allocator = testing.allocator,
.handler = source_handler,
.continuation_max_bytes = 1024,
});
defer source.deinit();
// Terminal mutation and all callbacks have already committed. The
// unfinished CSI is the only input needed to recreate the stream state.
source.nextSlice(committed ++ "\x1b[31");
try testing.expectEqual(@as(usize, 1), S.bell_count);
try testing.expectEqual(@as(usize, 1), S.title_count);
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqual(@as(usize, 1), S.notification_count);
try testing.expectEqual(@as(usize, 1), S.clipboard_count);
var continuation_buf: [1024]u8 = undefined;
var continuation_writer: std.Io.Writer = .fixed(&continuation_buf);
try source.writeContinuation(&continuation_writer);
try testing.expectEqualStrings("\x1b[31", continuation_writer.buffered());
var restored_terminal: Terminal = try .init(
testing.io,
testing.allocator,
.{ .cols = 80, .rows = 24 },
);
defer restored_terminal.deinit(testing.allocator);
// Stand in for restoring the already-committed terminal snapshot.
{
var snapshot_stream: Stream = .init(.{
.allocator = testing.allocator,
.handler = .init(&restored_terminal),
});
defer snapshot_stream.deinit();
snapshot_stream.nextSlice(committed);
}
const before = try restored_terminal.plainString(testing.allocator);
defer testing.allocator.free(before);
const before_x = restored_terminal.screens.active.cursor.x;
const before_y = restored_terminal.screens.active.cursor.y;
const before_style = restored_terminal.screens.active.cursor.style_id;
const before_title = restored_terminal.getTitle().?;
var restored_handler: Handler = .init(&restored_terminal);
restored_handler.effects.bell = &S.bell;
restored_handler.effects.title_changed = &S.titleChanged;
restored_handler.effects.write_pty = &S.writePty;
restored_handler.effects.desktop_notification = &S.desktopNotification;
restored_handler.effects.clipboard_write = &S.clipboardWrite;
var restored = Stream.init(.{
.allocator = testing.allocator,
.handler = restored_handler,
.continuation_max_bytes = 1024,
});
defer restored.deinit();
S.reset();
restored.nextSlice(continuation_writer.buffered());
try testing.expectEqual(@as(usize, 0), S.bell_count);
try testing.expectEqual(@as(usize, 0), S.title_count);
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqual(@as(usize, 0), S.notification_count);
try testing.expectEqual(@as(usize, 0), S.clipboard_count);
const after = try restored_terminal.plainString(testing.allocator);
defer testing.allocator.free(after);
try testing.expectEqualStrings(before, after);
try testing.expectEqual(before_x, restored_terminal.screens.active.cursor.x);
try testing.expectEqual(before_y, restored_terminal.screens.active.cursor.y);
try testing.expectEqual(before_style, restored_terminal.screens.active.cursor.style_id);
try testing.expectEqualStrings(before_title, restored_terminal.getTitle().?);
source.nextSlice("mB");
restored.nextSlice("mB");
const source_text = try source_terminal.plainString(testing.allocator);
defer testing.allocator.free(source_text);
const restored_text = try restored_terminal.plainString(testing.allocator);
defer testing.allocator.free(restored_text);
try testing.expectEqualStrings(source_text, restored_text);
try testing.expectEqual(
source_terminal.screens.active.cursor.style_id,
restored_terminal.screens.active.cursor.style_id,
);
}
test "kitty dnd: query response" {
const S = struct {
var pty: std.ArrayListUnmanaged(u8) = .empty;
fn writePty(_: *Handler, data: []const u8) void {
pty.appendSlice(testing.allocator, data) catch unreachable;
}
};
S.pty = .empty;
defer S.pty.deinit(testing.allocator);
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 = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
s.nextSlice("\x1B]72;t=q:i=3\x1B\\");
try testing.expectEqualStrings("\x1b]72;t=q:i=3\x1b\\", S.pty.items);
}
test "kitty dnd: register, drop, and serve data" {
const S = struct {
var pty: std.ArrayListUnmanaged(u8) = .empty;
fn writePty(_: *Handler, data: []const u8) void {
pty.appendSlice(testing.allocator, data) catch unreachable;
}
};
S.pty = .empty;
defer S.pty.deinit(testing.allocator);
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 = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Client registers to accept drops.
s.nextSlice("\x1B]72;t=a;text/plain text/uri-list\x1B\\");
try testing.expectEqualStrings("", S.pty.items);
try testing.expect(t.kitty_dnd != null);
// A native drop arrives; the embedder feeds it to the terminal
// state and delivers the produced event bytes itself.
{
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try t.kitty_dnd.?.dragDrop(testing.allocator, &aw.writer, .{
.cell_x = 2,
.cell_y = 1,
.pixel_x = 20,
.pixel_y = 18,
.operations = .{ .copy = true },
}, &.{
.{ .mime = "text/plain", .data = "hello" },
});
try testing.expectEqualStrings(
"\x1b]72;t=M:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\",
aw.written(),
);
}
// The client requests the data and concludes.
s.nextSlice("\x1B]72;t=r:x=1\x1B\\");
try testing.expectEqualStrings(
"\x1b]72;t=r:x=1:m=0;aGVsbG8=\x1b\\" ++ "\x1b]72;t=r:x=1\x1b\\",
S.pty.items,
);
S.pty.clearRetainingCapacity();
s.nextSlice("\x1B]72;t=r\x1B\\");
try testing.expectEqualStrings("", S.pty.items);
try testing.expect(t.kitty_dnd.?.drop.items == null);
}
test "kitty dnd: state updates work without write_pty effect" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) });
defer s.deinit();
// Queries produce no output (nowhere to write) but registration
// state still updates.
s.nextSlice("\x1B]72;t=q\x1B\\");
s.nextSlice("\x1B]72;t=a\x1B\\");
try testing.expect(t.kitty_dnd != null);
// The terminal remains functional.
s.nextSlice("ok");
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("ok", str);
}
test "kitty dnd: registration survives terminal reset" {
const S = struct {
var pty: std.ArrayListUnmanaged(u8) = .empty;
fn writePty(_: *Handler, data: []const u8) void {
pty.appendSlice(testing.allocator, data) catch unreachable;
}
};
S.pty = .empty;
defer S.pty.deinit(testing.allocator);
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 = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Start a chunked command, then reset mid-chunk.
s.nextSlice("\x1B]72;t=a:i=5\x1B\\");
s.nextSlice("\x1B]72;t=m:o=1:m=1;text/pl\x1B\\");
s.nextSlice("\x1Bc");
// Registration survives (matching kitty), chunking was interrupted
// so a new command is not treated as a continuation.
try testing.expect(t.kitty_dnd != null);
try testing.expect(!t.kitty_dnd.?.chunking.active);
s.nextSlice("\x1B]72;t=q\x1B\\");
try testing.expectEqualStrings("\x1b]72;t=q\x1b\\", S.pty.items);
}
test "kitty dnd: effect reports registration, acceptance, and conclusion" {
const S = struct {
var events: std.ArrayListUnmanaged(kitty_dnd.Event) = .empty;
var mimes: std.ArrayListUnmanaged(u8) = .empty;
fn clear() void {
events.deinit(testing.allocator);
events = .empty;
mimes.deinit(testing.allocator);
mimes = .empty;
}
fn dragAndDrop(handler: *Handler, ev: kitty_dnd.Event) void {
events.append(testing.allocator, ev) catch unreachable;
// Registration details are read from the terminal state.
if (ev == .registration) {
mimes.clearRetainingCapacity();
const state = handler.terminal.kitty_dnd orelse return;
var it = state.registeredMimes();
while (it.next()) |m| {
mimes.appendSlice(testing.allocator, m) catch unreachable;
mimes.append(testing.allocator, ',') catch unreachable;
}
}
}
};
S.clear();
defer S.clear();
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.drag_and_drop = &S.dragAndDrop;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Registration with a MIME list, read back from the state.
s.nextSlice("\x1B]72;t=a;image/png text/plain\x1B\\");
try testing.expectEqual(@as(usize, 1), S.events.items.len);
try testing.expect(S.events.items[0] == .registration);
try testing.expectEqualStrings("image/png,text/plain,", S.mimes.items);
// A native drag and the client's answer.
{
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try t.kitty_dnd.?.dragDrop(testing.allocator, &aw.writer, .{
.cell_x = 0,
.cell_y = 0,
.pixel_x = 0,
.pixel_y = 0,
.operations = .{ .copy = true },
}, &.{.{ .mime = "text/plain", .data = "x" }});
}
s.nextSlice("\x1B]72;t=m:o=2;text/plain\x1B\\");
try testing.expectEqual(@as(usize, 2), S.events.items.len);
try testing.expect(S.events.items[1] == .acceptance);
// Conclusion carries the performed operation.
s.nextSlice("\x1B]72;t=r:o=2\x1B\\");
try testing.expectEqual(@as(usize, 3), S.events.items.len);
try testing.expectEqual(kitty_dnd.Event.concluded_move, S.events.items[2]);
// Unregistration reports with the state gone.
s.nextSlice("\x1B]72;t=A\x1B\\");
try testing.expectEqual(@as(usize, 4), S.events.items.len);
try testing.expect(S.events.items[3] == .registration);
try testing.expect(t.kitty_dnd == null);
try testing.expectEqualStrings("", S.mimes.items);
}
/// Capture state for the Handler.paste tests below: every pty write and
/// the clipboard reads the program makes afterwards.
const PasteCapture = struct {
var written: std.ArrayList(u8) = .empty;
var write_count: usize = 0;
var read_count: usize = 0;
var read_granted_count: usize = 0;
var last_read_granted: bool = false;
var last_read_name: [64]u8 = undefined;
var last_read_name_len: usize = 0;
fn reset() void {
written.clearRetainingCapacity();
write_count = 0;
read_count = 0;
read_granted_count = 0;
last_read_granted = false;
last_read_name_len = 0;
}
fn deinit() void {
written.deinit(testing.allocator);
written = .empty;
}
fn writePty(_: *Handler, data: []const u8) void {
written.appendSlice(testing.allocator, data) catch @panic("OOM");
write_count += 1;
}
fn clipboardRead(_: *Handler, read: clipboard.Read) void {
read_count += 1;
last_read_granted = read.granted;
if (read.granted) read_granted_count += 1;
last_read_name_len = read.name.len;
@memcpy(last_read_name[0..read.name.len], read.name);
read.reply(.{ .success = .{ .contents = &.{.{
.mime = "text/plain",
.data = "Ghostty",
}} } });
}
fn readName() []const u8 {
return last_read_name[0..last_read_name_len];
}
};
test "paste: no write_pty effect is an error" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
var handler: Handler = .init(&t);
defer handler.deinit();
try testing.expectError(error.NoWritePty, handler.paste(.{
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello" }} },
}));
}
test "paste: plain text converts newlines and strips unsafe bytes" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
// Newlines are unsafe unbracketed; the embedder confirmed.
try testing.expect(try handler.paste(.{
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hel\x1blo\nwor\x00ld" }} },
.allow_unsafe = true,
}));
try testing.expectEqualStrings("hel lo\rwor ld", S.written.items);
try testing.expectEqual(@as(usize, 1), S.write_count);
// The first text representation is used; others are ignored.
S.reset();
try testing.expect(try handler.paste(.{
.contents = .{ .memory = &.{
.{ .mime = "image/png", .data = "\x89PNG" },
.{ .mime = "UTF8_STRING", .data = "hi" },
.{ .mime = "text/plain", .data = "ignored" },
} },
}));
try testing.expectEqualStrings("hi", S.written.items);
try testing.expectEqual(@as(usize, 1), S.write_count);
}
test "paste: unsafe text is refused unless allowed" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
try testing.expectError(error.UnsafePaste, handler.paste(.{
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }} },
}));
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expect(try handler.paste(.{
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }} },
.allow_unsafe = true,
}));
try testing.expectEqualStrings("rm -rf /\r", S.written.items);
}
test "paste: bracketed paste frames the text" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
t.modes.set(.bracketed_paste, true);
// Newlines are safe inside the frame and are preserved.
try testing.expect(try handler.paste(.{
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello\nworld" }} },
}));
try testing.expectEqualStrings("\x1b[200~hello\nworld\x1b[201~", S.written.items);
try testing.expectEqual(@as(usize, 1), S.write_count);
// The frame terminator is not.
S.reset();
try testing.expectError(error.UnsafePaste, handler.paste(.{
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }} },
}));
try testing.expectEqual(@as(usize, 0), S.write_count);
// Allowed, the stripper still defuses it: ESC becomes a space.
try testing.expect(try handler.paste(.{
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }} },
.allow_unsafe = true,
}));
try testing.expectEqualStrings("\x1b[200~he [201~llo\x1b[201~", S.written.items);
}
test "paste: no text representation writes nothing" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
try testing.expect(!try handler.paste(.{
.contents = .{ .memory = &.{.{ .mime = "image/png", .data = "\x89PNG" }} },
}));
try testing.expect(!try handler.paste(.{
.contents = .{ .memory = &.{} },
}));
try testing.expect(!try handler.paste(.{
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "" }} },
}));
try testing.expectEqual(@as(usize, 0), S.write_count);
}
test "paste: large text streams to the pty in chunks" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
t.modes.set(.bracketed_paste, true);
// Two full chunks and a partial one with the frame, never the
// whole thing at once.
const data = "x" ** 10_000;
try testing.expect(try handler.paste(.{
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = data }} },
}));
const total = data.len + "\x1b[200~\x1b[201~".len;
try testing.expectEqual(
@as(usize, (total + Handler.paste_chunk_size - 1) / Handler.paste_chunk_size),
S.write_count,
);
try testing.expectEqual(total, S.written.items.len);
try testing.expect(std.mem.startsWith(u8, S.written.items, "\x1b[200~xxx"));
try testing.expect(std.mem.endsWith(u8, S.written.items, "xxx\x1b[201~"));
}
/// A paste contents reader for the tests: serves fixed data per MIME
/// type in pieces, counting the reads of each representation.
const PasteReader = struct {
mimes: []const []const u8,
data: []const []const u8,
piece: usize = 3,
reads: [4]usize = @splat(0),
/// Fail after this many bytes of a read.
fail_after: ?usize = null,
fn contents(self: *PasteReader) paste_pkg.Contents {
return .{ .reader = .{
.mimes = self.mimes,
.read = .{ .ctx = self, .read_fn = &read },
} };
}
fn read(ctx: ?*anyopaque, mime: []const u8, sink: *std.Io.Writer) clipboard.MimeReader.Error!void {
const self: *PasteReader = @ptrCast(@alignCast(ctx.?));
const index: usize = for (self.mimes, 0..) |m, i| {
if (std.mem.eql(u8, m, mime)) break i;
} else return error.ReadFailed;
self.reads[index] += 1;
const data = self.data[index];
var offset: usize = 0;
while (offset < data.len) {
if (self.fail_after) |limit| if (offset >= limit) return error.ReadFailed;
const n = @min(self.piece, data.len - offset);
try sink.writeAll(data[offset..][0..n]);
offset += n;
}
}
};
test "paste: reader contents are read on demand" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
// Unsafe text is refused from one read with nothing written; the
// image is never read.
var reader: PasteReader = .{
.mimes = &.{ "image/png", "text/plain" },
.data = &.{ "\x89PNG", "echo hi\nrm -rf /\n" },
};
try testing.expectError(error.UnsafePaste, handler.paste(.{
.contents = reader.contents(),
}));
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqual(@as(usize, 0), reader.reads[0]);
try testing.expectEqual(@as(usize, 1), reader.reads[1]);
// Allowed, the text is read once, encoded.
reader.reads = @splat(0);
try testing.expect(try handler.paste(.{
.contents = reader.contents(),
.allow_unsafe = true,
}));
try testing.expectEqualStrings("echo hi\rrm -rf /\r", S.written.items);
try testing.expectEqual(@as(usize, 0), reader.reads[0]);
try testing.expectEqual(@as(usize, 1), reader.reads[1]);
// Safe text is read once too: buffered for the check, then written.
S.reset();
reader = .{
.mimes = &.{"text/plain"},
.data = &.{"hello world"},
};
try testing.expect(try handler.paste(.{ .contents = reader.contents() }));
try testing.expectEqualStrings("hello world", S.written.items);
try testing.expectEqual(@as(usize, 1), reader.reads[0]);
// Empty text is nothing to paste, found on the one read.
S.reset();
reader = .{
.mimes = &.{"text/plain"},
.data = &.{""},
};
try testing.expect(!try handler.paste(.{ .contents = reader.contents() }));
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqual(@as(usize, 1), reader.reads[0]);
// A paste event lists the types and reads nothing at all.
S.reset();
t.modes.set(.kitty_paste_events, true);
reader = .{
.mimes = &.{ "text/plain", "image/png" },
.data = &.{ "secret", "\x89PNG" },
};
try testing.expect(try handler.paste(.{ .contents = reader.contents() }));
try testing.expect(std.mem.indexOf(u8, S.written.items, ";dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\") != null);
try testing.expect(std.mem.indexOf(u8, S.written.items, "secret") == null);
try testing.expectEqual(@as(usize, 0), reader.reads[0]);
try testing.expectEqual(@as(usize, 0), reader.reads[1]);
}
test "paste: reader failure writes nothing" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
t.modes.set(.bracketed_paste, true);
// The read is buffered whole before anything is written, so a
// mid-read failure discards the buffer, checked or not.
var reader: PasteReader = .{
.mimes = &.{"text/plain"},
.data = &.{"hello world"},
.fail_after = 6,
};
try testing.expectError(error.ReadFailed, handler.paste(.{
.contents = reader.contents(),
}));
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectError(error.ReadFailed, handler.paste(.{
.contents = reader.contents(),
.allow_unsafe = true,
}));
try testing.expectEqual(@as(usize, 0), S.write_count);
}
test "paste: mode 5522 sends an event the program can read with" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
t.modes.set(.kitty_paste_events, true);
t.modes.set(.bracketed_paste, true);
// Every representation is listed, the data is never written, and
// the one-time password rides on every packet.
try testing.expect(try s.handler.paste(.{
.contents = .{ .memory = &.{
.{ .mime = "text/plain", .data = "secret" },
.{ .mime = "image/png", .data = "" },
} },
}));
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, S.written.items, "\x1b]5522;"));
try testing.expect(std.mem.indexOf(u8, S.written.items, "secret") == null);
try testing.expect(std.mem.indexOf(u8, S.written.items, "\x1b[200~") == null);
// OK packet: parse the (base64) password out.
const ok_prefix = "\x1b]5522;type=read:status=OK:pw=";
try testing.expect(std.mem.startsWith(u8, S.written.items, ok_prefix));
const pw_end = std.mem.indexOfPos(u8, S.written.items, ok_prefix.len, "\x1b\\").?;
// Copied out since the capture buffer is reused below.
var pw_buf: [64]u8 = undefined;
const pw_b64 = pw_buf[0 .. pw_end - ok_prefix.len];
@memcpy(pw_b64, S.written.items[ok_prefix.len..pw_end]);
try testing.expectEqual(
std.base64.standard.Encoder.calcSize(kitty_clipboard.otp_len),
pw_b64.len,
);
// Listing packet: base64 of "text/plain image/png\n".
var expected_buf: [256]u8 = undefined;
const expected = try std.fmt.bufPrint(
&expected_buf,
"\x1b]5522;type=read:status=OK:pw={s}\x1b\\" ++
"\x1b]5522;type=read:status=DATA:mime=Lg==:pw={s};dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\" ++
"\x1b]5522;type=read:status=DONE:pw={s}\x1b\\",
.{ pw_b64, pw_b64, pw_b64 },
);
try testing.expectEqualStrings(expected, S.written.items);
// The program reads with the password and the name "Paste event":
// the read arrives granted, exactly once.
var read_buf: [256]u8 = undefined;
const read = try std.fmt.bufPrint(
&read_buf,
"\x1b]5522;type=read:pw={s}:name=UGFzdGUgZXZlbnQ=;dGV4dC9wbGFpbg==\x1b\\",
.{pw_b64},
);
S.reset();
s.nextSlice(read);
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expect(S.last_read_granted);
try testing.expectEqualStrings("Paste event", S.readName());
try testing.expectEqualStrings(
"\x1b]5522;type=read:status=OK\x1b\\" ++
"\x1b]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1b\\" ++
"\x1b]5522;type=read:status=DONE\x1b\\",
S.written.items,
);
// The password was one-time: a second read is not granted.
S.reset();
s.nextSlice(read);
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expect(!S.last_read_granted);
try testing.expectEqual(@as(usize, 0), S.read_granted_count);
// Every event mints a fresh password.
S.reset();
try testing.expect(try s.handler.paste(.{
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "secret" }} },
}));
try testing.expect(std.mem.indexOf(u8, S.written.items, pw_b64) == null);
}
test "paste: mode 5522 reports the selection as primary" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
t.modes.set(.kitty_paste_events, true);
for ([_]clipboard.Location{ .primary, .selection }) |location| {
S.reset();
try testing.expect(try handler.paste(.{
.source = .{ .clipboard = location },
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "x" }} },
}));
try testing.expect(std.mem.startsWith(
u8,
S.written.items,
"\x1b]5522;type=read:status=OK:loc=primary:pw=",
));
// Only on the OK packet.
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, S.written.items, "loc=primary"));
}
S.reset();
try testing.expect(try handler.paste(.{
.source = .{ .clipboard = .standard },
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "x" }} },
}));
try testing.expect(std.mem.indexOf(u8, S.written.items, "loc=") == null);
}
test "paste: mode 5522 without clipboard_read pastes text" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
t.modes.set(.kitty_paste_events, true);
try testing.expect(try handler.paste(.{
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello" }} },
}));
try testing.expectEqualStrings("hello", S.written.items);
try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len);
}
test "paste: text source never becomes an event" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
t.modes.set(.kitty_paste_events, true);
try testing.expect(try handler.paste(.{
.source = .text,
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "committed" }} },
}));
try testing.expectEqualStrings("committed", S.written.items);
try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len);
}
test "paste: mode 5522 without entropy fails and records no grant" {
var t: Terminal = try .init(std.Io.failing, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
defer handler.deinit();
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
t.modes.set(.kitty_paste_events, true);
try testing.expectError(error.EntropyUnavailable, handler.paste(.{
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "secret" }} },
}));
try testing.expectEqual(@as(usize, 0), S.write_count);
try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len);
// Text pastes need no entropy and still work.
try testing.expect(try handler.paste(.{
.source = .text,
.contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello" }} },
}));
try testing.expectEqualStrings("hello", S.written.items);
}
test "paste: mode 5522 DECRQM requires clipboard read effect" {
if (comptime build_options.artifact != .lib) return error.SkipZigTest;
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// A paste event is unusable if its follow-up read can't be served, so
// the mode isn't advertised without a clipboard read effect.
s.nextSlice("\x1B[?5522$p");
try testing.expectEqualStrings("\x1B[?5522;0$y", S.written.items);
S.reset();
s.nextSlice("\x1B[?5522h");
try testing.expect(t.modes.get(.kitty_paste_events));
s.nextSlice("\x1B[?5522$p");
try testing.expectEqualStrings("\x1B[?5522;0$y", S.written.items);
// Installing the effect makes the current mode state reportable.
S.reset();
s.handler.effects.clipboard_read = &S.clipboardRead;
s.nextSlice("\x1B[?5522$p");
try testing.expectEqualStrings("\x1B[?5522;1$y", S.written.items);
S.reset();
s.nextSlice("\x1B[?5522l");
try testing.expect(!t.modes.get(.kitty_paste_events));
s.nextSlice("\x1B[?5522$p");
try testing.expectEqualStrings("\x1B[?5522;2$y", S.written.items);
// Removing the effect stops advertising the capability again.
S.reset();
s.handler.effects.clipboard_read = null;
s.nextSlice("\x1B[?5522$p");
try testing.expectEqualStrings("\x1B[?5522;0$y", S.written.items);
}
test "full reset drops kitty clipboard grants" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = PasteCapture;
S.reset();
defer S.deinit();
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
try s.handler.kitty_clipboard_grants.grant(testing.allocator, "pw", .read, false);
try testing.expectEqual(@as(usize, 1), s.handler.kitty_clipboard_grants.entries.items.len);
s.nextSlice("\x1Bc");
try testing.expectEqual(@as(usize, 0), s.handler.kitty_clipboard_grants.entries.items.len);
// A read with the old password is no longer granted, and the
// handler keeps working (grants can be recorded again).
S.reset();
s.nextSlice("\x1b]5522;type=read:pw=cHc=:name=YXBw;dGV4dC9wbGFpbg==\x1b\\");
try testing.expectEqual(@as(usize, 1), S.read_count);
try testing.expect(!S.last_read_granted);
try s.handler.kitty_clipboard_grants.grant(testing.allocator, "pw", .read, false);
}