From 87603231658a0e0c6a8b4be0be684b7f08778255 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Aug 2026 14:43:23 -0700 Subject: [PATCH] terminal: add stream handler paste operation and enable mode 5522 in libghostty --- src/lib_vt.zig | 4 + src/terminal/main.zig | 1 + src/terminal/modes.zig | 11 +- src/terminal/stream_terminal.zig | 510 +++++++++++++++++++++++++++++++ 4 files changed, 522 insertions(+), 4 deletions(-) diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 423cedea7..53762f775 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -98,6 +98,7 @@ pub const UnknownSequence = terminal.UnknownSequence; pub const Paste = terminal.Paste; pub const PasteSource = terminal.PasteSource; +pub const PasteError = terminal.PasteError; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = terminal.CursorStyle; @@ -132,8 +133,11 @@ pub const input = struct { // Paste-related APIs pub const PasteError = paste.Error; pub const PasteOptions = paste.Options; + pub const max_paste_frame_size = paste.max_frame_size; pub const isSafePaste = paste.isSafe; + pub const isSafePasteWith = paste.isSafeWith; pub const encodePaste = paste.encode; + pub const encodePasteWriter = paste.encodeWriter; // Key encoding pub const Key = key.Key; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 0d09e1f77..5be82c8a6 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -63,6 +63,7 @@ pub const StreamAction = stream.Action; pub const UnknownSequence = stream_terminal.Handler.UnknownSequence; pub const Paste = paste.Request; pub const PasteSource = paste.Source; +pub const PasteError = stream_terminal.Handler.PasteError; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index baacd0089..b1330db66 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -8,6 +8,7 @@ //! to ensure all our various types and logic remain in sync. const std = @import("std"); +const build_options = @import("terminal_options"); const testing = std.testing; /// A struct that maintains the state of all the settable modes. @@ -331,10 +332,12 @@ const entries: []const ModeEntry = &.{ // paste sends an unsolicited OSC 5522 targets listing with a // one-time password instead of pasting the text. // See https://sw.kovidgoyal.net/kitty/clipboard/ - // - // Forcibly disabled for now since the functionality isn't exposed - // yet to libghostty or any GUI apps. - .{ .name = "kitty_paste_events", .value = 5522, .disabled = true }, + .{ + .name = "kitty_paste_events", + .value = 5522, + // Only libghostty-vt supports this currently + .disabled = build_options.artifact != .lib, + }, }; test { diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index bcf8690bc..4805b4eb1 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -17,6 +17,7 @@ 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"); @@ -200,6 +201,11 @@ pub const Handler = struct { /// 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 @@ -286,6 +292,56 @@ pub const Handler = struct { 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, + + /// No write_pty effect is set, so nothing can be written. + NoWritePty, + }; + + /// 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. + pub fn paste(self: *Handler, req: Paste) PasteError!bool { + if (self.effects.write_pty == null) return error.NoWritePty; + + // One buffer for the whole result (frame + data + sentinel, or + // the event packets). Typical pastes stay on the stack. + const alloc = self.terminal.gpa(); + var stack = std.heap.stackFallback(4096, alloc); + const stack_alloc = stack.get(); + var aw: std.Io.Writer.Allocating = .init(stack_alloc); + defer aw.deinit(); + + const written_any = paste_pkg.paste(.{ + .terminal = self.terminal, + .grants = &self.kitty_clipboard_grants, + .io = self.terminal.io(), + .alloc = alloc, + .can_event = self.effects.clipboard_read != null, + .writer = &aw.writer, + }, req) catch |err| return switch (err) { + // An allocating writer only fails to allocate. + error.WriteFailed => error.OutOfMemory, + error.OutOfMemory, + error.UnsafePaste, + error.EntropyUnavailable, + error.Canceled, + => |e| e, + }; + if (!written_any) return false; + + const written = try aw.toOwnedSliceSentinel(0); + defer stack_alloc.free(written); + self.writePty(written); + return true; + } + pub fn vt( self: *Handler, comptime action: Action.Tag, @@ -403,6 +459,10 @@ pub const Handler = struct { .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 }); }, @@ -5394,3 +5454,453 @@ test "kitty dnd: effect reports registration, acceptance, and conclusion" { 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: [:0]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 = &.{.{ .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 = &.{.{ .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 = &.{ + .{ .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 = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }}, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); + + try testing.expect(try handler.paste(.{ + .contents = &.{.{ .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 = &.{.{ .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 = &.{.{ .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 = &.{.{ .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 = &.{.{ .mime = "image/png", .data = "\x89PNG" }}, + })); + try testing.expect(!try handler.paste(.{ + .contents = &.{}, + })); + try testing.expect(!try handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = "" }}, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); +} + +test "paste: large text falls back to the heap in one write" { + 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); + + const data = "x" ** 10_000; + try testing.expect(try handler.paste(.{ + .contents = &.{.{ .mime = "text/plain", .data = data }}, + })); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(data.len + "\x1b[200~\x1b[201~".len, 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~")); +} + +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 = &.{ + .{ .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 = &.{.{ .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(.{ + .location = location, + .contents = &.{.{ .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(.{ + .location = .standard, + .contents = &.{.{ .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 = &.{.{ .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 = &.{.{ .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 = &.{.{ .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 = &.{.{ .mime = "text/plain", .data = "hello" }}, + })); + try testing.expectEqualStrings("hello", S.written.items); +} + +test "paste: mode 5522 is settable and reported in the lib build" { + 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(); + + // Recognized and reset by default, so programs can detect support. + s.nextSlice("\x1B[?5522$p"); + try testing.expectEqualStrings("\x1B[?5522;2$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;1$y", S.written.items); + + s.nextSlice("\x1B[?5522l"); + try testing.expect(!t.modes.get(.kitty_paste_events)); +} + +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); +}