From 0a410f18e5f83ef30308cc381de134a7cf23a718 Mon Sep 17 00:00:00 2001 From: Jack Pearkes Date: Fri, 3 Jul 2026 16:12:47 -0400 Subject: [PATCH] terminal: add clipboard_set effect for OSC 52 clipboard writes libghostty-vt already parses OSC 52 into the clipboard_contents action but the stream handler dropped it in the no-effect list, so embedders had no way to observe a program's clipboard writes. Add a clipboard_set effect following the existing bell/title_changed pattern and expose it through the C API as GHOSTTY_TERMINAL_OPT_CLIPBOARD_SET. The callback receives the OSC 52 kind byte and the base64 payload exactly as received; decoding and kind interpretation are left to the embedder, matching how ghostty itself defers decoding to the apprt layer. Clipboard read requests ("?") are never forwarded: answering one would let any program running in the terminal silently read the user's clipboard, and a VT state library cannot mediate that with user consent. Empty payloads are also ignored rather than inventing clear semantics. --- include/ghostty/vt/terminal.h | 38 ++++++++++++++ src/terminal/c/terminal.zig | 90 ++++++++++++++++++++++++++++++++ src/terminal/stream_terminal.zig | 90 +++++++++++++++++++++++++++++++- 3 files changed, 217 insertions(+), 1 deletion(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index b22e8aedc..0cd87feb5 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -82,6 +82,7 @@ extern "C" { * | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) | * | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) | * | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)| + * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_SET` | `GhosttyTerminalClipboardSetFn` | Clipboard write via OSC 52 | * * ### Defining a write_pty callback * @snippet c-vt-effects/src/main.c effects-write-pty @@ -284,6 +285,34 @@ typedef struct { typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, void* userdata); +/** + * Callback function type for clipboard_set. + * + * Called when the running program sets the clipboard via OSC 52. The + * kind byte identifies the target selection ('c' clipboard, 'p' primary, + * 's' selection, '0'-'7' cut buffers); most terminals treat everything + * as the standard clipboard. The data is the base64-encoded payload + * exactly as received; decoding is left to the embedder. The data is + * only valid for the duration of the call. + * + * OSC 52 clipboard *read* requests ("?") are never forwarded to this + * callback: answering one would let any program running in the terminal + * silently read the user's clipboard. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param kind The OSC 52 selection kind byte + * @param data Pointer to the base64-encoded payload bytes + * @param len Length of the payload in bytes + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalClipboardSetFn)(GhosttyTerminal terminal, + void* userdata, + uint8_t kind, + const uint8_t* data, + size_t len); + /** * Callback function type for color scheme queries (CSI ? 996 n). * @@ -693,6 +722,15 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: GhosttyTerminalPwdChangedFn */ GHOSTTY_TERMINAL_OPT_PWD_CHANGED = 25, + + /** + * Callback invoked when the running program sets the clipboard via + * OSC 52. Set to NULL to ignore clipboard writes. Read requests are + * always ignored; see GhosttyTerminalClipboardSetFn. + * + * Input type: GhosttyTerminalClipboardSetFn + */ + GHOSTTY_TERMINAL_OPT_CLIPBOARD_SET = 26, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 0bacbc068..81fe67f15 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -54,6 +54,7 @@ const Effects = struct { title_changed: ?TitleChangedFn = null, pwd_changed: ?PwdChangedFn = null, size_cb: ?SizeFn = null, + clipboard_set: ?ClipboardSetFn = null, /// Scratch buffer for DA1 feature codes. The device attributes /// trampoline converts C feature codes into this buffer and returns @@ -84,6 +85,11 @@ const Effects = struct { /// (len=0) causes the default "libghostty" to be reported. pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String; + /// C function pointer type for the clipboard_set callback. The kind + /// byte identifies the OSC 52 target selection and the data is the + /// base64-encoded payload exactly as received. + pub const ClipboardSetFn = *const fn (Terminal, ?*anyopaque, u8, [*]const u8, usize) callconv(lib.calling_conv) void; + /// C function pointer type for the title_changed callback. pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; @@ -138,6 +144,13 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata); } + fn clipboardSetTrampoline(handler: *Handler, kind: u8, data: []const u8) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.clipboard_set orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata, kind, data.ptr, data.len); + } + fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); @@ -296,6 +309,7 @@ fn new_( .title_changed = &Effects.titleChangedTrampoline, .pwd_changed = &Effects.pwdChangedTrampoline, .size = &Effects.sizeTrampoline, + .clipboard_set = &Effects.clipboardSetTrampoline, }; wrapper.* = .{ @@ -343,6 +357,7 @@ pub const Option = enum(c_int) { default_cursor_blink = 23, glyph_protocol = 24, pwd_changed = 25, + clipboard_set = 26, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -357,6 +372,7 @@ pub const Option = enum(c_int) { .title_changed => ?Effects.TitleChangedFn, .pwd_changed => ?Effects.PwdChangedFn, .size_cb => ?Effects.SizeFn, + .clipboard_set => ?Effects.ClipboardSetFn, .title, .pwd => ?*const lib.String, .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, .color_palette => ?*const color.PaletteC, @@ -413,6 +429,7 @@ fn setTyped( .title_changed => wrapper.effects.title_changed = value, .pwd_changed => wrapper.effects.pwd_changed = value, .size_cb => wrapper.effects.size_cb = value, + .clipboard_set => wrapper.effects.clipboard_set = value, .title => { const str = if (value) |v| v.ptr[0..v.len] else ""; wrapper.terminal.setTitle(str) catch return .out_of_memory; @@ -2421,6 +2438,79 @@ test "set pwd_changed callback" { try testing.expectEqualStrings("file:///home/user", zigTerminal(t).?.getPwd().?); } +test "set clipboard_set callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var count: usize = 0; + var last_userdata: ?*anyopaque = null; + var last_kind: u8 = 0; + var last_data: [64]u8 = undefined; + var last_len: usize = 0; + + fn clipboardSet( + _: Terminal, + ud: ?*anyopaque, + kind: u8, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) void { + count += 1; + last_userdata = ud; + last_kind = kind; + last_len = @min(len, last_data.len); + @memcpy(last_data[0..last_len], data[0..last_len]); + } + }; + S.count = 0; + S.last_userdata = null; + + var sentinel: u8 = 88; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + try testing.expectEqual(Result.success, set(t, .clipboard_set, @ptrCast(&S.clipboardSet))); + + // OSC 52 ; c ; base64("hello") ST — clipboard write + const seq1 = "\x1B]52;c;aGVsbG8=\x1B\\"; + vt_write(t, seq1, seq1.len); + try testing.expectEqual(@as(usize, 1), S.count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + try testing.expectEqual(@as(u8, 'c'), S.last_kind); + try testing.expectEqualStrings("aGVsbG8=", S.last_data[0..S.last_len]); + + // OSC 52 read requests ("?") must never reach the callback. + const seq2 = "\x1B]52;c;?\x1B\\"; + vt_write(t, seq2, seq2.len); + try testing.expectEqual(@as(usize, 1), S.count); +} + +test "clipboard_set without callback is silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // OSC 52 without a callback should not crash + const seq = "\x1B]52;c;aGVsbG8=\x1B\\"; + vt_write(t, seq, seq.len); +} + test "pwd_changed without callback is silent" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index e3d42c51e..021fac854 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -88,6 +88,19 @@ pub const Handler = struct { /// handler.terminal.getPwd(). pwd_changed: ?*const fn (*Handler) void, + /// Called when the running program sets the clipboard via OSC 52. + /// The kind byte identifies the target selection ('c' clipboard, + /// 'p' primary, 's' selection, '0'-'7' cut buffers) and data is the + /// base64-encoded payload exactly as received; decoding and kind + /// interpretation are left to the embedder. The data is only valid + /// during the lifetime of the call. + /// + /// Clipboard read requests (OSC 52 with a "?" payload) are never + /// forwarded: answering one would let any program running in the + /// terminal silently read the user's clipboard, and a VT state + /// library has no way to mediate that with user consent. + clipboard_set: ?*const fn (*Handler, u8, []const u8) 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 @@ -99,6 +112,7 @@ pub const Handler = struct { /// effects beyond that. pub const readonly: Effects = .{ .bell = null, + .clipboard_set = null, .color_scheme = null, .device_attributes = null, .enquiry = null, @@ -276,6 +290,7 @@ pub const Handler = struct { .window_title => self.windowTitle(value.title), .report_pwd => self.reportPwd(value.url), .xtversion => self.reportXtversion(), + .clipboard_contents => self.clipboardContents(value.kind, value.data), // No supported DCS commands have any terminal-modifying effects, // but they may in the future. For now we just ignore it. @@ -287,7 +302,6 @@ pub const Handler = struct { // Have no terminal-modifying effect .show_desktop_notification, .progress_report, - .clipboard_contents, .title_push, .title_pop, => {}, @@ -304,6 +318,19 @@ pub const Handler = struct { func(self); } + fn clipboardContents(self: *Handler, kind: u8, data: []const u8) void { + const func = self.effects.clipboard_set orelse return; + + // Read requests are deliberately not forwarded; see the + // clipboard_set effect docs. Empty payloads carry nothing to set + // (some emitters use them to clear the clipboard), so they are + // ignored as well rather than inventing clear semantics here. + if (data.len == 0) return; + if (data.len == 1 and data[0] == '?') return; + + func(self, kind, data); + } + fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void { const func = self.effects.device_attributes orelse return; const attrs = func(self); @@ -1417,6 +1444,67 @@ test "bell effect callback" { } } +test "clipboard_set effect callback" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + // Test OSC 52 with null callback (default readonly effects) doesn't crash + { + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\"); + + // Terminal should still be functional after the ignored sequence + s.nextSlice("AfterClipboard"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AfterClipboard", str); + } + + t.fullReset(); + + // Test OSC 52 with a callback + { + const S = struct { + var count: usize = 0; + var last_kind: u8 = 0; + var last_data: ?[:0]const u8 = null; + fn clipboardSet(_: *Handler, kind: u8, data: []const u8) void { + count += 1; + last_kind = kind; + if (last_data) |old| testing.allocator.free(old); + last_data = testing.allocator.dupeZ(u8, data) catch null; + } + }; + S.count = 0; + defer if (S.last_data) |data| testing.allocator.free(data); + + var handler: Handler = .init(&t); + handler.effects.clipboard_set = &S.clipboardSet; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // A write is forwarded with its kind and undecoded base64 payload. + s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.count); + try testing.expectEqual(@as(u8, 'c'), S.last_kind); + try testing.expectEqualStrings("aGVsbG8=", S.last_data.?); + + // BEL termination and a non-default kind work too. + s.nextSlice("\x1B]52;p;d29ybGQ=\x07"); + try testing.expectEqual(@as(usize, 2), S.count); + try testing.expectEqual(@as(u8, 'p'), S.last_kind); + try testing.expectEqualStrings("d29ybGQ=", S.last_data.?); + + // Read requests and empty payloads are never forwarded. + s.nextSlice("\x1B]52;c;?\x1B\\"); + s.nextSlice("\x1B]52;c;\x1B\\"); + try testing.expectEqual(@as(usize, 2), S.count); + } +} + test "request mode DECRQM with write_pty callback" { var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator);