const std = @import("std"); const builtin = @import("builtin"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const global = @import("../global.zig"); const xev = global.xev; const apprt = @import("../apprt.zig"); const build_config = @import("../build_config.zig"); const configpkg = @import("../config.zig"); const internal_os = @import("../os/main.zig"); const renderer = @import("../renderer.zig"); const termio = @import("../termio.zig"); const terminal = @import("../terminal/main.zig"); const terminfo = @import("../terminfo/main.zig"); const posix = std.posix; const log = std.log.scoped(.io_handler); /// This is used as the handler for the terminal.Stream type. This is /// stateful and is expected to live for the entire lifetime of the terminal. /// It is NOT VALID to stop a stream handler, create a new one, and use that /// unless all of the member fields are copied. pub const StreamHandler = struct { alloc: Allocator, size: *renderer.Size, terminal: *terminal.Terminal, /// Mailbox for data to the termio thread. termio_mailbox: *termio.Mailbox, /// Mailbox for the surface. surface_mailbox: apprt.surface.Mailbox, /// The shared render state renderer_state: *renderer.State, /// The mailbox for notifying the renderer of things. renderer_mailbox: *renderer.Thread.Mailbox, /// A handle to wake up the renderer. This hints to the renderer that /// a repaint should happen. renderer_wakeup: xev.Async, /// The response to use for ENQ requests. The memory is owned by /// whoever owns StreamHandler. enquiry_response: []const u8, /// The color reporting format for OSC requests. osc_color_report_format: configpkg.Config.OSCColorReportFormat, /// The clipboard write access configuration. clipboard_write: configpkg.ClipboardAccess, /// Maximum total decoded bytes per Kitty clipboard protocol /// (OSC 5522) write transaction; exceeding it aborts with EFBIG. clipboard_write_limit: usize, //--------------------------------------------------------------- // Internal state /// 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: terminal.apc.Handler = .{}, /// The DCS handler maintains DCS state. DCS is like CSI or OSC, /// but requires more stateful parsing. This is used by functionality /// such as XTGETTCAP. dcs: terminal.dcs.Handler = .{}, /// The tmux control mode viewer state. tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {}, /// Session password grants for the Kitty clipboard protocol. /// Requests carrying a granted password skip the permission prompt. kitty_clipboard_grants: terminal.kitty.clipboard.Grants = .{}, /// The in-flight Kitty clipboard protocol (OSC 5522) write /// transaction, if any. kitty_clipboard_write: ?*terminal.kitty.clipboard.WriteState = null, /// This is set to true when a message was written to the termio /// mailbox. This can be used by callers to determine if they need /// to wake up the termio thread. termio_messaged: bool = false, /// This is set to true when we've seen a title escape sequence. We use /// this to determine if we need to default the window title. seen_title: bool = false, pub const Stream = terminal.Stream(StreamHandler); /// True if we have tmux control mode built in. pub const tmux_enabled = terminal.options.tmux_control_mode; pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); self.dcs.deinit(); self.kittyClipboardWriteAbort(); self.kitty_clipboard_grants.deinit(self.alloc); if (comptime tmux_enabled) tmux: { const viewer = self.tmux_viewer orelse break :tmux; viewer.deinit(); self.alloc.destroy(viewer); self.tmux_viewer = null; } } /// This queues a render operation with the renderer thread. The render /// isn't guaranteed to happen immediately but it will happen as soon as /// practical. pub inline fn queueRender(self: *StreamHandler) !void { try self.renderer_wakeup.notify(); } /// Change the configuration for this handler. pub fn changeConfig(self: *StreamHandler, config: *termio.DerivedConfig) void { self.osc_color_report_format = config.osc_color_report_format; self.clipboard_write = config.clipboard_write; self.clipboard_write_limit = config.clipboard_write_limit; self.enquiry_response = config.enquiry_response; self.terminal.setDefaultCursorStyle(config.cursor_style); self.terminal.setDefaultCursorBlink(config.cursor_blink); // The config could have changed any of our colors so update mode 2031 self.messageWriter(.{ .color_scheme_report = .{ .force = false } }); } inline fn surfaceMessageWriter( self: *StreamHandler, msg: apprt.surface.Message, ) void { // See messageWriter which has similar logic and explains why // we may have to do this. if (self.surface_mailbox.push(msg, .{ .instant = {} }) == 0) { self.renderer_state.mutex.unlock(global.io()); defer self.renderer_state.mutex.lockUncancelable(global.io()); _ = self.surface_mailbox.push(msg, .{ .forever = {} }); } } inline fn messageWriter(self: *StreamHandler, msg: termio.Message) void { self.termio_mailbox.send(msg, self.renderer_state.mutex); self.termio_messaged = true; } /// Send a renderer message and unlock the renderer state mutex /// if necessary to ensure we don't deadlock. /// /// This assumes the renderer state mutex is locked. inline fn rendererMessageWriter( self: *StreamHandler, msg: renderer.Message, ) void { // See termio.Mailbox.send for more details on how this works. // Try instant first. If it works then we can return. if (self.renderer_mailbox.push(msg, .{ .instant = {} }) > 0) { return; } // Instant would have blocked. Release the renderer mutex, // wake up the renderer to allow it to process the message, // and then try again. self.renderer_state.mutex.unlock(global.io()); defer self.renderer_state.mutex.lockUncancelable(global.io()); self.renderer_wakeup.notify() catch |err| { // This is an EXTREMELY unlikely case. We still don't return // and attempt to send the message because its most likely // that everything is fine, but log in case a freeze happens. log.warn( "failed to notify renderer, may deadlock err={}", .{err}, ); }; _ = self.renderer_mailbox.push(msg, .{ .forever = {} }); } pub fn vt( self: *StreamHandler, comptime action: Stream.Action.Tag, value: Stream.Action.Value(action), ) void { self.vtFallible(action, value) catch |err| { log.warn("error handling VT action action={} err={}", .{ action, err }); }; } inline fn vtFallible( self: *StreamHandler, comptime action: Stream.Action.Tag, value: Stream.Action.Value(action), ) !void { // The branch hints here are based on real world data // which indicates that the most common actions are: // // 1. print // 2. set_attribute // 3. carriage_return // 4. line_feed // 5. cursor_pos // // Together, these 5 actions make up nearly 98% of // all actions encountered in real world scenarios. // // ref: https://github.com/qwerasd205/asciinema-stats switch (action) { .print => { @branchHint(.likely); try self.terminal.print(value.cp); }, .print_slice => { @branchHint(.likely); try self.terminal.printSlice(value.cps); }, .print_repeat => try self.terminal.printRepeat(value), .bell => self.bell(), .backspace => self.terminal.backspace(), .horizontal_tab => self.horizontalTab(value), .horizontal_tab_back => self.horizontalTabBack(value), .linefeed => { @branchHint(.likely); try self.linefeed(); }, .carriage_return => { @branchHint(.likely); self.terminal.carriageReturn(); }, .enquiry => try self.enquiry(), .invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking), .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 => { @branchHint(.likely); 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.scrollViewport(.{ .bottom = {} }); 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), .tab_clear_current => self.terminal.tabClear(.current), .tab_clear_all => self.terminal.tabClear(.all), .tab_set => self.terminal.tabSet(), .tab_reset => self.terminal.tabReset(), .index => try self.index(), .next_line => try self.nextLine(), .reverse_index => try self.reverseIndex(), .full_reset => try self.fullReset(), .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 => { // For restore mode we have to restore but if we set it, we // always have to call setMode because setting some modes have // side effects and we want to make sure we process those. const v = self.terminal.modes.restore(value.mode); try self.setMode(value.mode, v); }, .request_mode => try self.requestMode(value.mode), .request_mode_unknown => try self.requestModeUnknown(value.mode, value.ansi), .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 => try self.saveCursor(), .restore_cursor => try self.restoreCursor(), .modify_key_format => try self.setModifyKeyFormat(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, .size_report => self.sendSizeReport(value), .xtversion => try self.reportXtversion(), .device_attributes => try self.deviceAttributes(value), .device_status => try self.deviceStatusReport(value.request), .kitty_keyboard_query => try self.queryKittyKeyboard(), .kitty_keyboard_push => { log.debug("pushing kitty keyboard mode: {}", .{value.flags}); self.terminal.screens.active.kitty_keyboard.push(value.flags); }, .kitty_keyboard_pop => { log.debug("popping kitty keyboard mode n={}", .{value}); self.terminal.screens.active.kitty_keyboard.pop(@intCast(value)); }, .kitty_keyboard_set => { log.debug("setting kitty keyboard mode: set {}", .{value.flags}); self.terminal.screens.active.kitty_keyboard.set(.set, value.flags); }, .kitty_keyboard_set_or => { log.debug("setting kitty keyboard mode: or {}", .{value.flags}); self.terminal.screens.active.kitty_keyboard.set(.@"or", value.flags); }, .kitty_keyboard_set_not => { log.debug("setting kitty keyboard mode: not {}", .{value.flags}); self.terminal.screens.active.kitty_keyboard.set(.not, value.flags); }, .kitty_color_report => try self.kittyColorReport(value), .color_operation => try self.colorOperation(value.op, &value.requests, value.terminator), .end_hyperlink => try self.endHyperlink(), .active_status_display => self.terminal.status_display = value, .decaln => try self.decaln(), .window_title => try self.windowTitle(value.title), .report_pwd => try self.reportPwd(value.url), .show_desktop_notification => try self.showDesktopNotification(value.title, value.body), .progress_report => self.progressReport(value), .start_hyperlink => try self.startHyperlink(value.uri, value.id), .clipboard_contents => try self.clipboardContents(value.kind, value.data), .semantic_prompt => try self.semanticPrompt(value), .mouse_shape => try self.setMouseShape(value), .configure_charset => self.configureCharset(value.slot, value.charset), .set_attribute => { @branchHint(.likely); switch (value) { .unknown => |unk| { // We optimize for the happy path scenario here, since // unknown/invalid SGRs aren't that common in the wild. @branchHint(.unlikely); log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}); }, else => { @branchHint(.likely); self.terminal.setAttribute(value) catch |err| { @branchHint(.cold); log.warn("error setting attribute {}: {}", .{ value, err }); }; }, } }, .dcs_hook => try self.dcsHook(value), .dcs_put => try self.dcsPut(value), .dcs_unhook => try self.dcsUnhook(), .apc_start => self.apc.start(), .apc_end => try self.apcEnd(), .apc_put => self.apc.feed(self.alloc, value), .apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes), .kitty_clipboard => try self.kittyClipboard(value), // Unimplemented .title_push, .title_pop, .kitty_dnd, => {}, } } pub inline fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { var cmd = self.dcs.hook(self.alloc, dcs) orelse return; defer cmd.deinit(); try self.dcsCommand(&cmd); } pub inline fn dcsPut(self: *StreamHandler, byte: u8) !void { var cmd = self.dcs.put(byte) orelse return; defer cmd.deinit(); try self.dcsCommand(&cmd); } pub inline fn dcsUnhook(self: *StreamHandler) !void { var cmd = self.dcs.unhook() orelse return; defer cmd.deinit(); try self.dcsCommand(&cmd); } fn dcsCommand(self: *StreamHandler, cmd: *terminal.dcs.Command) !void { // log.warn("DCS command: {}", .{cmd}); switch (cmd.*) { .tmux => |tmux| tmux: { // If tmux control mode is disabled at the build level, // then this whole block shouldn't be analyzed. if (comptime !tmux_enabled) break :tmux; log.info("tmux control mode event cmd={f}", .{tmux}); switch (tmux) { .enter => { // Setup our viewer state assert(self.tmux_viewer == null); const viewer = try self.alloc.create(terminal.tmux.Viewer); errdefer self.alloc.destroy(viewer); viewer.* = try .init(global.io(), self.alloc); errdefer viewer.deinit(); self.tmux_viewer = viewer; break :tmux; }, .exit => { // Free our viewer state if we have one if (self.tmux_viewer) |viewer| { viewer.deinit(); self.alloc.destroy(viewer); self.tmux_viewer = null; } // And always break since we assert below // that we're not handling an exit command. break :tmux; }, else => {}, } assert(tmux != .enter); assert(tmux != .exit); const viewer = self.tmux_viewer orelse { // This can only really happen if we failed to // initialize the viewer on enter. log.info( "received tmux control mode command without viewer: {f}", .{tmux}, ); break :tmux; }; for (viewer.next(.{ .tmux = tmux })) |action| { log.info("tmux viewer action={f}", .{action}); switch (action) { .exit => { // We ignore this because we will fully exit when // our DCS connection ends. We may want to handle // this in the future to notify our GUI we're // disconnected though. }, .command => |command| { assert(command.len > 0); assert(command[command.len - 1] == '\n'); self.messageWriter(try termio.Message.writeReq( self.alloc, command, )); }, .windows => { // TODO }, } } }, .xtgettcap => |*gettcap| { const map = comptime terminfo.ghostty.xtgettcapMap(); while (gettcap.next()) |key| { const response = map.get(key) orelse continue; self.messageWriter(.{ .write_stable = response }); } }, .decrqss => |decrqss| { var response: [terminal.dcs.Command.DECRQSS.max_response_bytes]u8 = undefined; const encoded = try decrqss.encode(self.terminal, &response); const msg = try termio.Message.writeReq( self.alloc, encoded, ); self.messageWriter(msg); }, } } pub fn apcEnd(self: *StreamHandler) !void { var result = self.apc.end() orelse return; defer result.deinit(self.alloc); // log.warn("APC command: {}", .{result}); switch (result) { .unknown => return, .kitty => |*kitty_cmd| { if (self.terminal.kittyGraphics(global.io(), self.alloc, kitty_cmd)) |resp| { var buf: [1024]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); try resp.encode(&writer); const final = writer.buffered(); if (final.len > 2) { log.debug("kitty graphics response: {x}", .{final}); self.messageWriter(try termio.Message.writeReq(self.alloc, final)); } } }, .glyph => |*glyph_req| { const resp = self.terminal.glyphProtocol(self.alloc, glyph_req); switch (glyph_req.*) { .register, .clear => try self.queueRender(), .support, .query => {}, } if (resp) |r| { var buf: [terminal.apc.glyph.Response.max_wire_bytes]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); try r.formatWire(&writer); const final = writer.buffered(); log.debug("glyph protocol response: {x}", .{final}); self.messageWriter(try termio.Message.writeReq(self.alloc, final)); } }, } } inline fn bell(self: *StreamHandler) void { self.surfaceMessageWriter(.ring_bell); } inline fn horizontalTab(self: *StreamHandler, 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: *StreamHandler, 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; } } inline fn linefeed(self: *StreamHandler) !void { // Small optimization: call index instead of linefeed because they're // identical and this avoids one layer of function call overhead. try self.terminal.index(); } pub inline fn reverseIndex(self: *StreamHandler) !void { self.terminal.reverseIndex(); } pub inline fn index(self: *StreamHandler) !void { try self.terminal.index(); } pub inline fn nextLine(self: *StreamHandler) !void { try self.terminal.index(); self.terminal.carriageReturn(); } pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void { self.terminal.flags.modify_other_keys_2 = false; switch (format) { .other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true, else => {}, } } fn requestMode(self: *StreamHandler, mode: terminal.Mode) !void { self.sendModeReport(self.terminal.modes.getReport(.fromMode(mode))); } fn requestModeUnknown(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { self.sendModeReport(self.terminal.modes.getReport(.{ .value = @truncate(mode_raw), .ansi = ansi })); } fn sendModeReport(self: *StreamHandler, report: terminal.modes.Report) void { var data: termio.Message.WriteReq.Small.Array = undefined; var writer: std.Io.Writer = .fixed(&data); report.encode(&writer) catch |err| { log.err("error encoding mode report err={}", .{err}); return; }; self.messageWriter(.{ .write_small = .{ .data = data, .len = @intCast(writer.buffered().len), } }); } pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void { // Note: this function doesn't need to grab the render state or // terminal locks because it is only called from process() which // grabs the lock. // If we are setting cursor blinking, we ignore it if we have // a default cursor blink setting set. This is a really weird // behavior so this comment will go deep into trying to explain it. // // There are two ways to set cursor blinks: DECSCUSR (CSI _ q) // and DEC mode 12. DECSCUSR is the modern approach and has a // way to revert to the "default" (as defined by the terminal) // cursor style and blink by doing "CSI 0 q". DEC mode 12 controls // blinking and is either on or off and has no way to set a // default. DEC mode 12 is also the more antiquated approach. // // The problem is that if the user specifies a desired default // cursor blink with `cursor-style-blink`, the moment a running // program uses DEC mode 12, the cursor blink can never be reset // to the default without an explicit DECSCUSR. But if a program // is using mode 12, it is by definition not using DECSCUSR. // This makes for somewhat annoying interactions where a poorly // (or legacy) behaved program will stop blinking, and it simply // never restarts. // // To get around this, we have a special case where if the user // specifies some explicit default cursor blink desire, we ignore // DEC mode 12. We allow DECSCUSR to still set the cursor blink // because programs using DECSCUSR usually are well behaved and // reset the cursor blink to the default when they exit. // // To be extra safe, users can also add a manual `CSI 0 q` to // their shell config when they render prompts to ensure the // cursor is exactly as they request. if (mode == .cursor_blinking and self.terminal.cursor.default_blink != null) { return; } // We first always set the raw mode on our mode state. self.terminal.modes.set(mode, enabled); // And then some modes require additional processing. switch (mode) { // Just noting here that autorepeat has no effect on // the terminal. xterm ignores this mode and so do we. // We know about just so that we don't log that it is // an unknown mode. .autorepeat => {}, // Schedule a render since we changed colors .reverse_colors => self.terminal.flags.dirty.reverse_colors = true, // Origin resets cursor pos. This is called whether or not // we're enabling or disabling origin mode and whether or // not the value changed. .origin => self.terminal.setCursorPos(1, 1), .enable_left_and_right_margin => if (!enabled) { // When we disable left/right margin mode we need to // reset the left/right margins. 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); }, // Mode 1048 is xterm's conditional save cursor depending // on if alt screen is enabled or not (at the terminal emulator // level). Alt screen is always enabled for us so this just // does a save/restore cursor. .save_cursor => { if (enabled) { self.terminal.saveCursor(); } else { self.terminal.restoreCursor(); } }, // Force resize back to the window size .enable_mode_3 => { const grid_size = self.size.grid(); self.terminal.resize( self.alloc, .{ .cols = grid_size.columns, .rows = grid_size.rows, }, ) catch |err| { log.err("error updating terminal size: {}", .{err}); }; }, .@"132_column" => try self.terminal.deccolm( self.alloc, if (enabled) .@"132_cols" else .@"80_cols", ), // We need to start a timer to prevent the emulator being hung // forever. .synchronized_output => { if (enabled) self.messageWriter(.{ .start_synchronized_output = {} }); }, .linefeed => { self.messageWriter(.{ .linefeed_mode = enabled }); }, .in_band_size_reports => if (enabled) self.messageWriter(.{ .size_report = .mode_2048, }), .report_visibility => if (enabled) self.messageWriter(.{ .visibility_report = .{ .visible = self.terminal.flags.visible, .force = true, }, }), .focus_event => if (enabled) self.messageWriter(.{ .focused = self.terminal.flags.focused, }), .mouse_event_x10 => { if (enabled) { self.terminal.flags.mouse_event = .x10; try self.setMouseShape(.default); } else { self.terminal.flags.mouse_event = .none; try self.setMouseShape(.text); } }, .mouse_event_normal => { if (enabled) { self.terminal.flags.mouse_event = .normal; try self.setMouseShape(.default); } else { self.terminal.flags.mouse_event = .none; try self.setMouseShape(.text); } }, .mouse_event_button => { if (enabled) { self.terminal.flags.mouse_event = .button; try self.setMouseShape(.default); } else { self.terminal.flags.mouse_event = .none; try self.setMouseShape(.text); } }, .mouse_event_any => { if (enabled) { self.terminal.flags.mouse_event = .any; try self.setMouseShape(.default); } else { self.terminal.flags.mouse_event = .none; try self.setMouseShape(.text); } }, .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 => {}, } } inline fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { try self.terminal.screens.active.startHyperlink(uri, id); } pub inline fn endHyperlink(self: *StreamHandler) !void { self.terminal.screens.active.endHyperlink(); } pub fn deviceAttributes( self: *StreamHandler, req: terminal.DeviceAttributeReq, ) !void { // For the below, we quack as a VT220. We don't quack as // a 420 because we don't support DCS sequences. switch (req) { .primary => self.messageWriter(.{ // 62 = Level 2 conformance // 22 = Color text // 52 = Clipboard access .write_stable = if (self.clipboard_write != .deny) "\x1B[?62;22;52c" else "\x1B[?62;22c", }), .secondary => self.messageWriter(.{ .write_stable = "\x1B[>1;10;0c", }), else => log.warn("unimplemented device attributes req: {}", .{req}), } } pub fn deviceStatusReport( self: *StreamHandler, req: terminal.device_status.Request, ) !void { switch (req) { .operating_status => self.messageWriter(.{ .write_stable = "\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, }; // Response always is at least 4 chars, so this leaves the // remainder for the row/column as base-10 numbers. This // will support a very large terminal. var msg: termio.Message = .{ .write_small = .{} }; const resp = try std.fmt.bufPrint(&msg.write_small.data, "\x1B[{};{}R", .{ pos.y + 1, pos.x + 1, }); msg.write_small.len = @intCast(resp.len); self.messageWriter(msg); }, .color_scheme => self.messageWriter(.{ .color_scheme_report = .{ .force = true } }), .visibility => self.messageWriter(.{ .visibility_report = .{ .visible = self.terminal.flags.visible, .force = true, } }), } } pub inline fn decaln(self: *StreamHandler) !void { try self.terminal.decaln(); } pub inline fn saveCursor(self: *StreamHandler) !void { self.terminal.saveCursor(); } pub inline fn restoreCursor(self: *StreamHandler) !void { self.terminal.restoreCursor(); } pub fn enquiry(self: *StreamHandler) !void { log.debug("sending enquiry response={s}", .{self.enquiry_response}); self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response)); } fn configureCharset( self: *StreamHandler, slot: terminal.CharsetSlot, set: terminal.Charset, ) void { self.terminal.configureCharset(slot, set); } pub fn fullReset( self: *StreamHandler, ) !void { self.terminal.fullReset(); try self.setMouseShape(.text); // Full reset clears Kitty clipboard session grants. self.kitty_clipboard_grants.deinit(self.alloc); self.kitty_clipboard_grants = .{}; // Reset resets our palette so we report it for mode 2031. self.messageWriter(.{ .color_scheme_report = .{ .force = false } }); // Clear the progress bar self.progressReport(.{ .state = .remove }); } /// Record a Kitty clipboard protocol session grant so future /// requests with this password skip the permission prompt. pub fn kittyClipboardGrant( self: *StreamHandler, pw: []const u8, dir: terminal.kitty.clipboard.Grants.Direction, ) error{OutOfMemory}!void { try self.kitty_clipboard_grants.grant(self.alloc, pw, dir, false); } pub fn queryKittyKeyboard(self: *StreamHandler) !void { log.debug("querying kitty keyboard mode", .{}); var data: termio.Message.WriteReq.Small.Array = undefined; const resp = try std.fmt.bufPrint(&data, "\x1b[?{}u", .{ self.terminal.screens.active.kitty_keyboard.current().int(), }); self.messageWriter(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), }, }); } pub fn reportXtversion( self: *StreamHandler, ) !void { log.debug("reporting XTVERSION: ghostty {s}", .{build_config.version_string}); var buf: [288]u8 = undefined; const resp = try std.fmt.bufPrint( &buf, "\x1BP>|{s} {s}\x1B\\", .{ "ghostty", build_config.version_string, }, ); const msg = try termio.Message.writeReq(self.alloc, resp); self.messageWriter(msg); } //------------------------------------------------------------------------- // OSC fn windowTitle(self: *StreamHandler, title: []const u8) !void { var buf: [256]u8 = undefined; if (title.len >= buf.len) { log.warn("change title requested larger than our buffer size, ignoring", .{}); return; } // Set the title on the terminal state. We ignore any errors since // we can continue to operate just fine without it. self.terminal.setTitle(title) catch |err| { log.warn("error setting title in terminal state: {}", .{err}); }; @memcpy(buf[0..title.len], title); buf[title.len] = 0; // Special handling for the empty title. We treat the empty title // as resetting to as if we never saw a title. Other terminals // behave this way too (e.g. iTerm2). if (title.len == 0) { // If we have a pwd then we set the title as the pwd else // we just set it to blank. if (self.terminal.getPwd()) |pwd| pwd: { if (pwd.len >= buf.len) break :pwd; @memcpy(buf[0..pwd.len], pwd); buf[pwd.len] = 0; } self.surfaceMessageWriter(.{ .set_title = buf }); self.seen_title = false; return; } self.seen_title = true; self.surfaceMessageWriter(.{ .set_title = buf }); } inline fn setMouseShape( self: *StreamHandler, shape: terminal.MouseShape, ) !void { // Avoid changing the shape if it is already set to avoid excess // cross-thread messaging. if (self.terminal.mouse_shape == shape) return; self.terminal.mouse_shape = shape; self.surfaceMessageWriter(.{ .set_mouse_shape = shape }); } fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { // Note: we ignore the "kind" field and always use the standard clipboard. // iTerm also appears to do this but other terminals seem to only allow // certain. Let's investigate more. const clipboard_type: apprt.Clipboard = switch (kind) { 'c' => .standard, 's' => .selection, 'p' => .primary, else => .standard, }; // Get clipboard contents if (data.len == 1 and data[0] == '?') { self.surfaceMessageWriter(.{ .clipboard_read = clipboard_type }); return; } // Write clipboard contents self.surfaceMessageWriter(.{ .clipboard_write = .{ .req = try apprt.surface.Message.WriteReq.init( self.alloc, data, ), .clipboard_type = clipboard_type, }, }); } /// Handle one Kitty clipboard protocol (OSC 5522) packet. fn kittyClipboard( self: *StreamHandler, v: terminal.osc.Command.KittyClipboardProtocol, ) error{ OutOfMemory, WriteFailed }!void { const kitty_clipboard = terminal.kitty.clipboard; // Decode and validate the metadata. Malformed structure drops // the packet without a response. Invalid decoded text on a write // data or alias packet aborts an in-flight transaction. var arena: std.heap.ArenaAllocator = .init(self.alloc); 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 => try self.kittyClipboardWriteFinish( state, .EINVAL, v.terminator, ), .read, .write => {}, } return; }, }) orelse return; switch (meta.op) { .read => try self.kittyClipboardRead( &meta, v.payload orelse "", v.terminator, ), .write => try self.kittyClipboardWriteBegin( &meta, v.terminator, ), .wdata => try self.kittyClipboardWriteData( &meta, v.payload orelse "", v.terminator, ), .walias => try self.kittyClipboardWriteAlias( &meta, v.payload orelse "", v.terminator, ), } } fn kittyClipboardRead( self: *StreamHandler, meta: *const terminal.kitty.clipboard.Metadata, payload: []const u8, terminator: terminal.osc.Terminator, ) !void { const kitty_clipboard = terminal.kitty.clipboard; // Everything about the request, including the request struct // itself, lives in a single arena that crosses to the surface // thread, which owns it from the moment the message is sent. var arena: std.heap.ArenaAllocator = .init(self.alloc); errdefer arena.deinit(); const alloc = arena.allocator(); // The payload is the requested MIME list. A read request with // an undecodable payload is dropped without any response, // matching kitty. const decoded = kitty_clipboard.Payload.init( alloc, payload, ) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Invalid => { arena.deinit(); return; }, }; if (!decoded.isValidUtf8()) { arena.deinit(); 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; var mimes_len: usize = 0; var list = false; var it = decoded.mimeIterator(); while (it.next()) |mime| { if (std.mem.eql(u8, mime, kitty_clipboard.targets_mime)) { list = true; continue; } if (mimes_len == mimes_buf.len) continue; mimes_buf[mimes_len] = mime; mimes_len += 1; } // Per the spec a password without a name is no password. A // stored session grant for it lets the surface skip its // permission prompt. const pw: []const u8 = if (meta.name.len > 0) meta.pw else ""; const granted = self.kittyClipboardReadGranted(pw, mimes_len); const req = try alloc.create(apprt.ClipboardRequest.KittyRead); const mimes = try alloc.alloc([:0]const u8, mimes_len); for (mimes_buf[0..mimes_len], mimes) |src, *dst| { dst.* = try alloc.dupeZ(u8, src); } const id = try alloc.dupe(u8, meta.id); const pw_owned = try alloc.dupe(u8, pw); const name_owned = try alloc.dupeZ(u8, meta.name); req.* = .{ // The arena must be copied in last so it tracks every // allocation above. .arena = arena, .location = switch (meta.loc) { .primary => .primary, else => .standard, }, .mimes = mimes, .list = list, .id = id, .pw = pw_owned, .name = name_owned, .granted = granted, .terminator = terminator, }; self.surfaceMessageWriter(.{ .kitty_clipboard_read = req }); } /// Whether a session grant covers a read request, consuming /// one-time grants. A prompt-exempt request never consults the /// grants: consuming a one-time paste password on a listing would /// burn the grant before the follow-up data read. fn kittyClipboardReadGranted( self: *StreamHandler, pw: []const u8, mimes_len: usize, ) bool { if (terminal.kitty.clipboard.readPromptExempt(mimes_len)) return false; return self.kitty_clipboard_grants.use(self.alloc, pw, .read); } /// Begin a Kitty clipboard write transaction (type=write). fn kittyClipboardWriteBegin( self: *StreamHandler, meta: *const terminal.kitty.clipboard.Metadata, terminator: terminal.osc.Terminator, ) error{ OutOfMemory, WriteFailed }!void { // A new write silently replaces any in-flight transaction. self.kittyClipboardWriteAbort(); // A write denied by policy 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.clipboard_write == .deny) { log.info("application attempted to write clipboard, but 'clipboard-write' is set to deny", .{}); try self.kittyClipboardWriteStatus( .EPERM, meta.id, terminator, ); return; } const state = try self.alloc.create(terminal.kitty.clipboard.WriteState); errdefer self.alloc.destroy(state); state.* = try .init(self.alloc, meta, .{ .max_size = self.clipboard_write_limit, }); self.kitty_clipboard_write = state; } /// Accumulate one wdata chunk, or commit the transaction when the /// chunk carries no MIME type. fn kittyClipboardWriteData( self: *StreamHandler, meta: *const terminal.kitty.clipboard.Metadata, payload: []const u8, terminator: terminal.osc.Terminator, ) error{ OutOfMemory, WriteFailed }!void { // Data without a transaction is silently ignored, matching // kitty. 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.kittyClipboardWriteCommit( state, terminator, ); state.data( self.alloc, meta, payload, ) catch |err| switch (err) { // Failing to spool matches kitty's EIO for a failed buffer write. error.OutOfMemory => { try self.kittyClipboardWriteFinish( state, .EIO, terminator, ); return error.OutOfMemory; }, // Data over the write limit aborts the transaction and is // reported to the client. error.TooLarge => try self.kittyClipboardWriteFinish( state, .EFBIG, terminator, ), // An invalid base64 payload stream aborts the transaction. error.Invalid => try self.kittyClipboardWriteFinish( state, .EINVAL, terminator, ), }; } /// Register aliases from a walias packet. fn kittyClipboardWriteAlias( self: *StreamHandler, meta: *const terminal.kitty.clipboard.Metadata, payload: []const u8, terminator: terminal.osc.Terminator, ) error{ OutOfMemory, WriteFailed }!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.kittyClipboardWriteFinish( state, .EINVAL, terminator, ); state.alias( self.alloc, meta, payload, ) catch |err| switch (err) { error.OutOfMemory => { try self.kittyClipboardWriteFinish( state, .EIO, terminator, ); return error.OutOfMemory; }, // An undecodable alias payload aborts the transaction. error.Invalid => try self.kittyClipboardWriteFinish( state, .EINVAL, terminator, ), }; } /// Commit the transaction: resolve the final contents and forward /// them to the surface thread, which owns policy, any permission /// prompt, the clipboard write itself, and the final reply. fn kittyClipboardWriteCommit( self: *StreamHandler, state: *terminal.kitty.clipboard.WriteState, terminator: terminal.osc.Terminator, ) error{ OutOfMemory, WriteFailed }!void { self.kittyClipboardWriteSend( state, terminator, ) catch |err| switch (err) { error.OutOfMemory => { try self.kittyClipboardWriteFinish( state, .EIO, terminator, ); return error.OutOfMemory; }, // The last MIME type's payload stream was not correctly // padded, which aborts the transaction. error.Invalid => return try self.kittyClipboardWriteFinish( state, .EINVAL, terminator, ), }; // The transaction is complete; the surface owns the reply. self.kittyClipboardWriteAbort(); } fn kittyClipboardWriteSend( self: *StreamHandler, state: *terminal.kitty.clipboard.WriteState, terminator: terminal.osc.Terminator, ) error{ OutOfMemory, Invalid }!void { const committed = try state.commit(self.alloc); defer committed.deinit(self.alloc); // Per the spec a password without a name is no password. A // stored session grant for it lets the surface skip its // permission prompt. const pw: []const u8 = if (committed.name.len > 0) committed.pw else ""; const granted = self.kitty_clipboard_grants.use(self.alloc, pw, .write); // Everything about the request, including the request struct // itself, lives in a single arena that crosses to the surface // thread, which owns it from the moment the message is sent. var arena: std.heap.ArenaAllocator = .init(self.alloc); errdefer arena.deinit(); const alloc = arena.allocator(); const req = try alloc.create(apprt.ClipboardRequest.KittyWrite); const contents = try alloc.alloc( apprt.ClipboardContent, committed.contents.len, ); for (committed.contents, contents) |src, *dst| dst.* = .{ .mime = try alloc.dupeZ(u8, src.mime), .data = try alloc.dupeZ(u8, src.data), }; const id = try alloc.dupe(u8, committed.id); const pw_owned = try alloc.dupe(u8, pw); const name_owned = try alloc.dupeZ(u8, committed.name); req.* = .{ // The arena must be copied in last so it tracks every // allocation above. .arena = arena, .location = switch (committed.loc) { .primary => .primary, else => .standard, }, .contents = contents, .id = id, .pw = pw_owned, .name = name_owned, .granted = granted, .terminator = terminator, }; self.surfaceMessageWriter(.{ .kitty_clipboard_write = req }); } /// Answer the 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 kittyClipboardWriteFinish( self: *StreamHandler, state: *const terminal.kitty.clipboard.WriteState, status: terminal.kitty.clipboard.Status, terminator: terminal.osc.Terminator, ) error{ OutOfMemory, WriteFailed }!void { defer self.kittyClipboardWriteAbort(); try self.kittyClipboardWriteStatus(status, state.id, terminator); } /// Reply to a write transaction with a single status packet. fn kittyClipboardWriteStatus( self: *StreamHandler, status: terminal.kitty.clipboard.Status, id: []const u8, terminator: terminal.osc.Terminator, ) error{ OutOfMemory, WriteFailed }!void { var stream: std.Io.Writer.Allocating = .init(self.alloc); defer stream.deinit(); try (terminal.kitty.clipboard.Response{ .op = .write, .status = status, .id = id, .terminator = terminator, }).encode(&stream.writer); self.messageWriter(.{ .write_alloc = .{ .alloc = self.alloc, .data = try stream.toOwnedSlice(), } }); } /// Drop any in-flight write transaction without responding. fn kittyClipboardWriteAbort(self: *StreamHandler) void { if (self.kitty_clipboard_write) |state| { state.deinit(self.alloc); self.alloc.destroy(state); self.kitty_clipboard_write = null; } } fn semanticPrompt( self: *StreamHandler, cmd: Stream.Action.SemanticPrompt, ) !void { switch (cmd.action) { .end_input_start_output => { self.surfaceMessageWriter(.start_command); }, .end_command => { // The specification seems to not specify the type but // other terminals accept 32-bits, but exit codes are really // bytes, so we just do our best here. const code: u8 = code: { const raw: i32 = cmd.readOption(.exit_code) orelse 0; break :code std.math.cast(u8, raw) orelse 1; }; self.surfaceMessageWriter(.{ .stop_command = code }); }, // Handled by Terminal, no special handling by us .end_prompt_start_input, .end_prompt_start_input_terminate_eol, .fresh_line, .fresh_line_new_prompt, .new_command, .prompt_start, => {}, } // We do this last so failures are still processed correctly // above. try self.terminal.semanticPrompt(cmd); } fn reportPwd(self: *StreamHandler, url: []const u8) !void { // Special handling for the empty URL. We treat the empty URL // as resetting the pwd as if we never saw a pwd. I can't find any // other terminal that does this but it seems like a reasonable // behavior that enables some useful features. For example, the macOS // proxy icon can be hidden when a program reports it doesn't know // the pwd rather than showing a stale pwd. if (url.len == 0) { // Blank value can never fail because no allocs happen. self.terminal.setPwd("") catch unreachable; // If we haven't seen a title, we're using the pwd as our title. // Set it to blank which will reset our title behavior. if (!self.seen_title) { try self.windowTitle(""); assert(!self.seen_title); } // Report the change. self.surfaceMessageWriter(.{ .pwd_change = .{ .stable = "" } }); return; } if (builtin.os.tag == .windows) { log.warn("reportPwd unimplemented on windows", .{}); return; } // Attempt to parse this file-style URI using options appropriate // for this OSC 7 context (e.g. kitty-shell-cwd expects the full, // unencoded path). const uri: std.Uri = internal_os.uri.parse(url, .{ .mac_address = comptime builtin.os.tag != .macos, .raw_path = std.mem.startsWith(u8, url, "kitty-shell-cwd://"), }) catch |e| { log.warn("invalid url in OSC 7: {}", .{e}); return; }; if (!std.mem.eql(u8, "file", uri.scheme) and !std.mem.eql(u8, "kitty-shell-cwd", uri.scheme)) { log.warn("OSC 7 scheme must be file or kitty-shell-cwd, got: {s}", .{uri.scheme}); return; } var host_buffer: [std.Io.net.HostName.max_len]u8 = undefined; const host = uri.getHost(&host_buffer) catch |err| switch (err) { error.UriMissingHost => { log.warn("OSC 7 uri must contain a hostname: {}", .{err}); return; }, }; // OSC 7 is a little sketchy because anyone can send any value from // any host (such an SSH session). The best practice terminals follow // is to valid the hostname to be local. const host_valid = internal_os.hostname.isLocal(host.bytes) catch |err| switch (err) { error.PermissionDenied, error.Unexpected, => { log.warn("failed to get hostname for OSC 7 validation: {}", .{err}); return; }, }; if (!host_valid) { log.warn("OSC 7 host ({s}) must be local", .{host.bytes}); return; } // We need the raw path, which might require unescaping. We try to // avoid making any heap allocations by using the stack first. var arena_alloc: std.heap.ArenaAllocator = .init(self.alloc); var stack_alloc = std.heap.stackFallback(1024, arena_alloc.allocator()); defer arena_alloc.deinit(); const path = try uri.path.toRawMaybeAlloc(stack_alloc.get()); log.debug("terminal pwd: {s}", .{path}); try self.terminal.setPwd(path); // Report it to the surface. If creating our write request fails // then we just ignore it. if (apprt.surface.Message.WriteReq.init(self.alloc, path)) |req| { self.surfaceMessageWriter(.{ .pwd_change = req }); } else |err| { log.warn("error notifying surface of pwd change err={}", .{err}); } // If we haven't seen a title, use our pwd as the title. if (!self.seen_title) { try self.windowTitle(path); self.seen_title = false; } } fn colorOperation( self: *StreamHandler, op: terminal.osc.color.Operation, requests: *const terminal.osc.color.List, terminator: terminal.osc.Terminator, ) !void { // We'll need op one day if we ever implement reporting special colors. _ = op; // return early if there is nothing to do if (requests.count() == 0) return; var buffer: [1024]u8 = undefined; var fba: std.heap.FixedBufferAllocator = .init(&buffer); const alloc = fba.allocator(); var response: std.Io.Writer.Allocating = .init(alloc); 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, => log.info("setting dynamic color {s} not implemented", .{ @tagName(dynamic), }), }, .special => log.info("setting special colors not implemented", .{}), } // Notify the surface of the color change self.surfaceMessageWriter(.{ .color_change = .{ .target = set.target, .color = set.color, } }); }, .reset => |target| switch (target) { .palette => |i| { self.terminal.flags.dirty.palette = true; self.terminal.colors.palette.reset(i); self.surfaceMessageWriter(.{ .color_change = .{ .target = target, .color = self.terminal.colors.palette.current[i], }, }); }, .dynamic => |dynamic| switch (dynamic) { .foreground => { self.terminal.colors.foreground.reset(); if (self.terminal.colors.foreground.default) |c| { self.surfaceMessageWriter(.{ .color_change = .{ .target = target, .color = c, } }); } }, .background => { self.terminal.colors.background.reset(); if (self.terminal.colors.background.default) |c| { self.surfaceMessageWriter(.{ .color_change = .{ .target = target, .color = c, } }); } }, .cursor => { self.terminal.colors.cursor.reset(); if (self.terminal.colors.cursor.default) |c| { self.surfaceMessageWriter(.{ .color_change = .{ .target = target, .color = c, } }); } }, .pointer_foreground, .pointer_background, .tektronix_foreground, .tektronix_background, .highlight_background, .tektronix_cursor, .highlight_foreground, => log.warn("resetting dynamic color {s} not implemented", .{ @tagName(dynamic), }), }, .special => log.info("resetting special colors not implemented", .{}), }, .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)); self.surfaceMessageWriter(.{ .color_change = .{ .target = .{ .palette = @intCast(i) }, .color = self.terminal.colors.palette.current[i], }, }); } mask.* = .initEmpty(); }, .reset_special => log.warn( "resetting all special colors not implemented", .{}, ), .query => |kind| report: { if (self.osc_color_report_format == .none) break :report; const color = switch (kind) { .palette => |i| self.terminal.colors.palette.current[i], .dynamic => |dynamic| switch (dynamic) { .foreground => self.terminal.colors.foreground.get().?, .background => self.terminal.colors.background.get().?, .cursor => self.terminal.colors.cursor.get() orelse self.terminal.colors.foreground.get().?, .pointer_foreground, .pointer_background, .tektronix_foreground, .tektronix_background, .highlight_background, .tektronix_cursor, .highlight_foreground, => { log.info( "reporting dynamic color {s} not implemented", .{@tagName(dynamic)}, ); break :report; }, }, .special => { log.info("reporting special colors not implemented", .{}); break :report; }, }; switch (self.osc_color_report_format) { .@"16-bit" => switch (kind) { .palette => |i| try response.writer.print( "\x1b]4;{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}", .{ i, @as(u16, color.r) * 257, @as(u16, color.g) * 257, @as(u16, color.b) * 257, }, ), .dynamic => |dynamic| try response.writer.print( "\x1b]{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}", .{ @intFromEnum(dynamic), @as(u16, color.r) * 257, @as(u16, color.g) * 257, @as(u16, color.b) * 257, }, ), .special => unreachable, }, .@"8-bit" => switch (kind) { .palette => |i| try response.writer.print( "\x1b]4;{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}", .{ i, @as(u16, color.r), @as(u16, color.g), @as(u16, color.b), }, ), .dynamic => |dynamic| try response.writer.print( "\x1b]{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}", .{ @intFromEnum(dynamic), @as(u16, color.r), @as(u16, color.g), @as(u16, color.b), }, ), .special => unreachable, }, .none => unreachable, } try response.writer.writeAll(terminator.string()); }, } } if (response.writer.end > 0) { // If any of the operations were reports, finalize the report // string and send it to the terminal. const msg = try termio.Message.writeReq(self.alloc, response.writer.buffered()); self.messageWriter(msg); } } fn showDesktopNotification( self: *StreamHandler, title: []const u8, body: []const u8, ) !void { self.surfaceMessageWriter(.{ .desktop_notification = .init(title, body), }); } /// Send a report to the pty. pub fn sendSizeReport(self: *StreamHandler, style: terminal.SizeReportStyle) void { switch (style) { .csi_14_t => self.messageWriter(.{ .size_report = .csi_14_t }), .csi_16_t => self.messageWriter(.{ .size_report = .csi_16_t }), .csi_18_t => self.messageWriter(.{ .size_report = .csi_18_t }), .csi_21_t => self.surfaceMessageWriter(.{ .report_title = .csi_21_t }), } } fn kittyColorReport( self: *StreamHandler, request: terminal.kitty.color.OSC, ) !void { var stream: std.Io.Writer.Allocating = .init(self.alloc); defer stream.deinit(); const writer = &stream.writer; for (request.list.items) |item| { switch (item) { .query => |key| { // If the writer buffer is empty, we need to write our prefix if (stream.written().len == 0) try writer.writeAll("\x1b]21"); const color: terminal.color.RGB = switch (key) { .palette => |palette| self.terminal.colors.palette.current[palette], .special => |special| switch (special) { .foreground => self.terminal.colors.foreground.get(), .background => self.terminal.colors.background.get(), .cursor => self.terminal.colors.cursor.get(), else => { log.warn("ignoring unsupported kitty color protocol key: {f}", .{key}); continue; }, }, } orelse { try writer.print(";{f}=", .{key}); continue; }; try writer.print( ";{f}=rgb:{x:0>2}/{x:0>2}/{x:0>2}", .{ key, color.r, color.g, color.b }, ); }, .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 => { log.warn( "ignoring unsupported kitty color protocol key: {f}", .{v.key}, ); continue; }, }, }, .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 => { log.warn( "ignoring unsupported kitty color protocol key: {f}", .{key}, ); continue; }, }, }, } } // If we had any writes to our buffer, we queue them now if (stream.written().len > 0) { try writer.writeAll(request.terminator.string()); self.messageWriter(.{ .write_alloc = .{ .alloc = self.alloc, .data = try stream.toOwnedSlice(), }, }); } // Note: we don't have to do a queueRender here because every // processed stream will queue a render once it is done processing // the read() syscall. } /// Display a GUI progress report. fn progressReport(self: *StreamHandler, report: terminal.osc.Command.ProgressReport) void { self.surfaceMessageWriter(.{ .progress_report = report }); } }; test "kitty clipboard read: targets-only never consumes a one-time grant" { const testing = std.testing; var handler: StreamHandler = undefined; handler.alloc = testing.allocator; handler.kitty_clipboard_grants = .{}; defer handler.kitty_clipboard_grants.deinit(testing.allocator); try handler.kitty_clipboard_grants.grant(testing.allocator, "otp", .read, true); // A listing request must not burn the one-time paste password... try testing.expect(!handler.kittyClipboardReadGranted("otp", 0)); // ...so the follow-up data read is still granted, exactly once. try testing.expect(handler.kittyClipboardReadGranted("otp", 1)); try testing.expect(!handler.kittyClipboardReadGranted("otp", 1)); } test "kitty clipboard write: oversized text replies EFBIG" { const testing = std.testing; var mailbox = try termio.Mailbox.initSPSC(testing.allocator); defer mailbox.deinit(testing.allocator); var mutex: std.Io.Mutex = .init; mutex.lockUncancelable(global.io()); defer mutex.unlock(global.io()); var renderer_state: renderer.State = .{ .mutex = &mutex, .terminal = undefined, }; var handler: StreamHandler = undefined; handler.alloc = testing.allocator; handler.termio_mailbox = &mailbox; handler.renderer_state = &renderer_state; handler.clipboard_write = .allow; handler.clipboard_write_limit = 4; handler.kitty_clipboard_write = null; defer handler.kittyClipboardWriteAbort(); const begin: terminal.kitty.clipboard.Metadata = .{ .op = .write, .id = "macos", }; try handler.kittyClipboardWriteBegin(&begin, .st); const state = handler.kitty_clipboard_write.?; try state.data( testing.allocator, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbA==", // "Hell" ); try testing.expectError(error.TooLarge, state.data( testing.allocator, &.{ .op = .wdata, .mime = "text/plain" }, "bw==", // "o" )); try handler.kittyClipboardWriteFinish(state, .EFBIG, .st); try testing.expect(handler.kitty_clipboard_write == null); const response = mailbox.spsc.queue.pop(global.io()); try testing.expect(response != null); const msg = response.?; defer msg.deinit(); switch (msg) { .write_alloc => |v| try testing.expectEqualStrings( "\x1B]5522;type=write:status=EFBIG:id=macos\x1B\\", v.data, ), else => try testing.expect(false), } // Teardown leaves no transaction that could be committed and // forwarded to the macOS clipboard path. try testing.expect(mailbox.spsc.queue.pop(global.io()) == null); }