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.
This commit is contained in:
Jack Pearkes
2026-07-03 16:12:47 -04:00
parent d560c64548
commit 0a410f18e5
3 changed files with 217 additions and 1 deletions

View File

@@ -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;

View File

@@ -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(

View File

@@ -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);