libghostty: clipboard_read effect, enables OSC52 reads

This adds a `clipboard_read` effect to the stream terminal handler and a
matching `GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ` callback to the
libghostty-vt C API so that embedders can answer OSC 52 read requests
(the `?` payload). 

This is a _blocking_ effect: if the embedder needs to ask the user for
permission, the entire VT processing pipeline is _blocked_ during the
callback. This is a purposeful simplification choice compared to how
Ghostty GUI works with async requests. I think its reasonable, it
eliminates a TON of complexity.

If the effect isn't set, then any clipboard reads are denied.

This can be expanded easily to support Kitty clipboard protocol later.
This commit is contained in:
Mitchell Hashimoto
2026-08-21 20:34:38 -07:00
parent ca9e5b1301
commit e03475c0cc
11 changed files with 852 additions and 22 deletions

View File

@@ -70,6 +70,46 @@ GhosttyClipboardWriteResult on_clipboard_write(
}
//! [effects-clipboard-write]
//! [effects-clipboard-read]
void on_clipboard_read(
GhosttyTerminal terminal,
void* userdata,
const GhosttyClipboardRead* read) {
(void)terminal;
(void)userdata;
// The read is synchronous: a real embedder would ask the user for
// permission here (unless read->granted) and the VT stream waits until
// this callback returns. The reply is sent to the program through the
// write_pty callback.
printf(" clipboard read (location=%d, mimes=%zu)\n",
(int)read->location, read->mimes_len);
for (size_t i = 0; i < read->mimes_len; i++) {
printf(" ");
fwrite(read->mimes[i].ptr, 1, read->mimes[i].len, stdout);
printf("\n");
}
// Reply with every requested representation we have. This example only
// has text.
const char* text = "Hello from the clipboard";
GhosttyClipboardContent content = {
.mime = {.ptr = (const uint8_t*)"text/plain", .len = 10},
.data = {.ptr = (const uint8_t*)text, .len = strlen(text)},
};
GhosttyClipboardReadReply reply = {
.size = sizeof(reply),
.result = GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS,
.contents = &content,
.contents_len = 1,
.available = NULL,
.available_len = 0,
.remember = false,
};
read->reply(read, &reply);
}
//! [effects-clipboard-read]
//! [effects-unknown-sequence]
void on_unknown_sequence(
GhosttyTerminal terminal,
@@ -118,6 +158,8 @@ int main() {
(const void *)on_title_changed);
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE,
(const void *)on_clipboard_write);
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ,
(const void *)on_clipboard_read);
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE,
(const void *)on_unknown_sequence);
@@ -154,13 +196,19 @@ int main() {
ghostty_terminal_vt_write(terminal, (const uint8_t*)clipboard_seq,
strlen(clipboard_seq));
// 5. Unsupported APC sequence
// 5. Clipboard read (OSC 52 ; c ; ? ST)
printf("Sending clipboard read:\n");
const char* clipboard_read_seq = "\x1B]52;c;?\x1B\\";
ghostty_terminal_vt_write(terminal, (const uint8_t*)clipboard_read_seq,
strlen(clipboard_read_seq));
// 6. Unsupported APC sequence
printf("Sending unknown APC:\n");
const char* unknown_apc = "\x1B_private-command;payload\x1B\\";
ghostty_terminal_vt_write(terminal, (const uint8_t*)unknown_apc,
strlen(unknown_apc));
// 6. Another bell to show the counter increments
// 7. Another bell to show the counter increments
printf("Sending another BEL:\n");
ghostty_terminal_vt_write(terminal, &bel, 1);

View File

@@ -96,6 +96,7 @@ extern "C" {
* | `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_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 |
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ` | `GhosttyTerminalClipboardReadFn` | Clipboard read via OSC 52 "?" |
* | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 |
* | `GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT` | `GhosttyTerminalProgressReportFn` | Progress report via OSC 9;4 |
* | `GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE` | `GhosttyTerminalUnknownSequenceFn` | Unsupported sequence identifier |
@@ -112,6 +113,9 @@ extern "C" {
* ### Defining a clipboard_write callback
* @snippet c-vt-effects/src/main.c effects-clipboard-write
*
* ### Defining a clipboard_read callback
* @snippet c-vt-effects/src/main.c effects-clipboard-read
*
* ### Defining an unknown_sequence callback
* @snippet c-vt-effects/src/main.c effects-unknown-sequence
*
@@ -522,8 +526,8 @@ typedef enum GHOSTTY_ENUM_TYPED {
* details such as OSC 52 selectors, base64 encoding, multipart chunks,
* aliases, and terminators are normalized before this callback is invoked.
* OSC 52 and iTerm2 OSC 1337 Copy writes therefore use the same callback
* shape. OSC 52 clipboard read requests ("?") are always ignored and never
* forwarded to this callback.
* shape. OSC 52 clipboard read requests ("?") are delivered to
* GhosttyTerminalClipboardReadFn instead.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
@@ -537,6 +541,180 @@ typedef GhosttyClipboardWriteResult (*GhosttyTerminalClipboardWriteFn)(
void* userdata,
const GhosttyClipboardWrite* write);
/**
* Result of a clipboard read reply.
*
* @ingroup terminal
*/
typedef enum {
/** The clipboard was read; the reply carries its contents. */
GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS = 0,
/** The clipboard read was denied by policy or the user. */
GHOSTTY_CLIPBOARD_READ_RESULT_DENIED = 1,
/** The embedder cannot read this clipboard. */
GHOSTTY_CLIPBOARD_READ_RESULT_UNSUPPORTED = 2,
/** The clipboard is temporarily unavailable. */
GHOSTTY_CLIPBOARD_READ_RESULT_BUSY = 3,
/** Reading the clipboard failed due to an I/O error. */
GHOSTTY_CLIPBOARD_READ_RESULT_IO_ERROR = 4,
GHOSTTY_CLIPBOARD_READ_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyClipboardReadResult;
/**
* The reply to a clipboard read request.
*
* This is a sized struct; set `size` to `sizeof(GhosttyClipboardReadReply)`.
* All arrays and the strings they point to are borrowed only for the
* duration of the reply call and may be freed as soon as it returns.
*
* Any result other than GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS answers the
* program with an empty clipboard; the other fields are ignored in that
* case. On success, `contents` should carry one representation per
* requested MIME type (GhosttyClipboardRead::mimes) that the clipboard
* has; unrequested representations are ignored. Protocols that carry a
* single text value (OSC 52) use the first entry with a text MIME type
* such as "text/plain".
*
* @ingroup terminal
*/
typedef struct {
/** Size of this struct in bytes. */
size_t size;
/** Outcome of the read. */
GhosttyClipboardReadResult result;
/** Borrowed array of MIME representations of the clipboard contents. */
const GhosttyClipboardContent* contents;
/** Number of entries in contents. */
size_t contents_len;
/**
* Borrowed array of all MIME types available on the clipboard. Only
* used when GhosttyClipboardRead::list is set; may be NULL otherwise.
*/
const GhosttyString* available;
/** Number of entries in available. */
size_t available_len;
/**
* Record a session grant so future requests from the same program skip
* the permission prompt. Only honored on success when
* GhosttyClipboardRead::can_remember is set.
*/
bool remember;
} GhosttyClipboardReadReply;
typedef struct GhosttyClipboardRead GhosttyClipboardRead;
/**
* Function type used to answer a clipboard read request. Obtained from
* GhosttyClipboardRead::reply; see that struct for the contract.
*
* @param read The request being answered
* @param reply The reply, borrowed only for the duration of this call
*
* @ingroup terminal
*/
typedef void (*GhosttyClipboardReadReplyFn)(
const GhosttyClipboardRead* read,
const GhosttyClipboardReadReply* reply);
/**
* A synchronous request to read clipboard contents.
*
* This is a sized struct. The callback must only access fields present in the
* size reported by `size`. The request is borrowed and valid only for the
* callback duration.
*
* The read is answered by calling `reply` with this request and a
* GhosttyClipboardReadReply. This must happen before the callback returns;
* the request is invalid afterwards. Calling `reply` more than once is
* ignored. Returning without replying answers the program with an empty
* clipboard.
*
* @ingroup terminal
*/
struct GhosttyClipboardRead {
/** Size of this struct in bytes. */
size_t size;
/** Clipboard to read. */
GhosttyClipboardLocation location;
/**
* Borrowed array of the MIME types the program wants, in order of
* preference. Protocols that only carry text (OSC 52) request
* "text/plain". NULL when mimes_len is zero.
*/
const GhosttyString* mimes;
/** Number of entries in mimes. */
size_t mimes_len;
/**
* True if the program also wants the list of MIME types available on the
* clipboard, delivered through GhosttyClipboardReadReply::available.
*/
bool list;
/**
* Name of the requesting program for permission prompts, if the protocol
* carries one. Empty otherwise.
*/
GhosttyString name;
/**
* True if the terminal already holds a session grant for this request
* (kitty clipboard protocol passwords). The embedder should skip any
* permission prompt and serve the read.
*/
bool granted;
/**
* True if the program supplied a session password, so the embedder may
* offer to remember the user's decision through
* GhosttyClipboardReadReply::remember. When false, remember is ignored.
*/
bool can_remember;
/** Terminal-owned reply state. Do not access. */
const void* ctx;
/** Answer the read; see the struct documentation. */
GhosttyClipboardReadReplyFn reply;
};
/**
* Callback function type for clipboard_read.
*
* Called synchronously when the running program requests clipboard contents
* via OSC 52 with a "?" payload. Answering lets the program read the user's
* clipboard, so the embedder is expected to mediate consent. Because the
* read is synchronous, an embedder that needs to ask the user must block
* (for example by running a modal prompt) until it has an answer; the VT
* stream waits until the callback returns.
*
* Answer by calling `read->reply(read, &reply)` before returning. See
* GhosttyClipboardRead for the full contract.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
* @param read Borrowed clipboard read request
*
* @ingroup terminal
*/
typedef void (*GhosttyTerminalClipboardReadFn)(
GhosttyTerminal terminal,
void* userdata,
const GhosttyClipboardRead* read);
/**
* A request to show a desktop notification.
*
@@ -1067,8 +1245,8 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Callback invoked when the running program performs a clipboard write.
* OSC 52 and iTerm2 OSC 1337 Copy writes are normalized to an atomic set
* of decoded MIME representations. Set to NULL to ignore clipboard writes.
* Clipboard read requests are always ignored; see
* GhosttyTerminalClipboardWriteFn.
* Clipboard read requests are delivered to
* GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ instead.
*
* Input type: GhosttyTerminalClipboardWriteFn
*/
@@ -1230,6 +1408,16 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: GhosttyString*
*/
GHOSTTY_TERMINAL_OPT_TERMINFO_NAME = 37,
/**
* Callback invoked when the running program requests clipboard contents
* via OSC 52 with a "?" payload. The read is synchronous and must be
* answered before the callback returns. Set to NULL to ignore clipboard
* read requests (the default).
*
* Input type: GhosttyTerminalClipboardReadFn
*/
GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ = 38,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;

View File

@@ -8,6 +8,7 @@ pub const String = extern struct {
.ptr = zig.ptr,
.len = zig.len,
},
else => @compileError("unsupported String.init type: " ++ @typeName(@TypeOf(zig))),
};
}
};

View File

@@ -53,6 +53,7 @@ pub const apc = terminal.apc;
pub const dcs = terminal.dcs;
pub const osc = terminal.osc;
pub const point = terminal.point;
pub const clipboard = terminal.clipboard;
pub const color = terminal.color;
pub const device_status = terminal.device_status;
pub const formatter = terminal.formatter;

View File

@@ -138,6 +138,42 @@ pub const ClipboardWrite = extern struct {
contents_len: usize,
};
/// The reply to a clipboard read request.
///
/// C: GhosttyClipboardReadReply
pub const ClipboardReadReply = extern struct {
size: usize,
result: clipboard.Read.Status,
contents: ?[*]const ClipboardContent,
contents_len: usize,
available: ?[*]const lib.String,
available_len: usize,
remember: bool,
};
/// A synchronous request to read clipboard contents. The embedder answers
/// by calling `reply` with the request before the callback returns.
///
/// C: GhosttyClipboardRead
pub const ClipboardRead = extern struct {
size: usize,
location: clipboard.Location,
mimes: ?[*]const lib.String,
mimes_len: usize,
list: bool,
name: lib.String,
granted: bool,
can_remember: bool,
/// Terminal-owned reply state; opaque to the embedder.
ctx: *const anyopaque,
reply: ClipboardReadReplyFn,
};
/// C function pointer type for replying to a clipboard read.
///
/// C: GhosttyClipboardReadReplyFn
pub const ClipboardReadReplyFn = *const fn (*const ClipboardRead, *const ClipboardReadReply) callconv(lib.calling_conv) void;
/// A request to show a desktop notification.
///
/// C: GhosttyTerminalDesktopNotification
@@ -220,6 +256,7 @@ const Effects = struct {
progress_report: ?ProgressReportFn = null,
size_cb: ?SizeFn = null,
clipboard_write: ?ClipboardWriteFn = null,
clipboard_read: ?ClipboardReadFn = null,
unknown_sequence: ?UnknownSequenceFn = null,
/// Scratch buffer for DA1 feature codes. The device attributes
@@ -255,6 +292,11 @@ const Effects = struct {
/// and its contents are borrowed and only valid for the callback duration.
pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) clipboard.WriteResult;
/// C function pointer type for the clipboard_read callback. The request
/// is borrowed for the callback duration and must be answered through
/// its reply function before the callback returns.
pub const ClipboardReadFn = *const fn (Terminal, ?*anyopaque, *const ClipboardRead) callconv(lib.calling_conv) void;
/// C function pointer type for the desktop_notification callback. The
/// request and its strings are borrowed for the callback duration.
pub const DesktopNotificationFn = *const fn (Terminal, ?*anyopaque, *const DesktopNotification) callconv(lib.calling_conv) void;
@@ -356,6 +398,100 @@ const Effects = struct {
return func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
}
/// Opaque context behind ClipboardRead.ctx for the reply trampoline.
const ClipboardReadCtx = struct {
read: clipboard.Read,
wrapper: *TerminalWrapper,
};
fn clipboardReadTrampoline(handler: *Handler, read: clipboard.Read) void {
const wrapper = TerminalWrapper.fromHandler(handler);
const func = wrapper.effects.clipboard_read orelse return;
// Requests carry a handful of MIME types, so keep the common case
// allocation-free. On OOM the request goes unanswered and the
// handler replies with an empty clipboard.
var sfa = std.heap.stackFallback(128, wrapper.terminal.gpa());
const alloc = sfa.get();
const mimes = alloc.alloc(lib.String, read.mimes.len) catch {
log.warn("out of memory converting clipboard read request", .{});
return;
};
defer alloc.free(mimes);
for (mimes, read.mimes) |*c_mime, mime| c_mime.* = .init(mime);
const ctx: ClipboardReadCtx = .{ .read = read, .wrapper = wrapper };
const request: ClipboardRead = .{
.size = @sizeOf(ClipboardRead),
.location = read.location,
.mimes = if (mimes.len > 0) mimes.ptr else null,
.mimes_len = mimes.len,
.list = read.list,
.name = .init(read.name),
.granted = read.granted,
.can_remember = read.can_remember,
.ctx = &ctx,
.reply = &clipboardReadReplyTrampoline,
};
func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
}
fn clipboardReadReplyTrampoline(
request: *const ClipboardRead,
reply: *const ClipboardReadReply,
) callconv(lib.calling_conv) void {
const ctx: *const ClipboardReadCtx = @ptrCast(@alignCast(request.ctx));
const read = ctx.read;
switch (reply.result) {
.success => {},
.denied => return read.reply(.denied),
.busy => return read.reply(.busy),
.io_error => return read.reply(.io_error),
.unsupported, _ => return read.reply(.unsupported),
}
const c_contents: []const ClipboardContent = if (reply.contents) |ptr|
ptr[0..reply.contents_len]
else
&.{};
const c_available: []const lib.String = if (reply.available) |ptr|
ptr[0..reply.available_len]
else
&.{};
// Most replies carry one representation, so keep that path
// allocation-free while supporting arbitrary multi-MIME replies.
// On OOM we don't reply and the handler answers with an empty
// clipboard.
var sfa = std.heap.stackFallback(256, ctx.wrapper.terminal.gpa());
const alloc = sfa.get();
const contents = alloc.alloc(clipboard.Content, c_contents.len) catch {
log.warn("out of memory converting clipboard read reply", .{});
return;
};
defer alloc.free(contents);
for (contents, c_contents) |*content, c_content| {
content.* = .{
.mime = c_content.mime.ptr[0..c_content.mime.len],
.data = c_content.data.ptr[0..c_content.data.len],
};
}
const available = alloc.alloc([]const u8, c_available.len) catch {
log.warn("out of memory converting clipboard read reply", .{});
return;
};
defer alloc.free(available);
for (available, c_available) |*mime, c_mime| {
mime.* = c_mime.ptr[0..c_mime.len];
}
read.reply(.{ .success = .{
.contents = contents,
.available = available,
.remember = reply.remember,
} });
}
fn desktopNotificationTrampoline(
handler: *Handler,
notification: Action.ShowDesktopNotification,
@@ -525,6 +661,7 @@ fn wrap(
.progress_report = &Effects.progressReportTrampoline,
.size = &Effects.sizeTrampoline,
.clipboard_write = &Effects.clipboardWriteTrampoline,
.clipboard_read = null,
};
wrapper.* = .{
@@ -1021,6 +1158,7 @@ pub const Option = enum(c_int) {
unknown_sequence = 35,
unknown_max_bytes = 36,
terminfo_name = 37,
clipboard_read = 38,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -1038,6 +1176,7 @@ pub const Option = enum(c_int) {
.progress_report => ?Effects.ProgressReportFn,
.size_cb => ?Effects.SizeFn,
.clipboard_write => ?Effects.ClipboardWriteFn,
.clipboard_read => ?Effects.ClipboardReadFn,
.unknown_sequence => ?Effects.UnknownSequenceFn,
.title, .pwd, .terminfo_name => ?*const lib.String,
.color_foreground, .color_background, .color_cursor => ?*const color.RGB.C,
@@ -1106,6 +1245,13 @@ fn setTyped(
.progress_report => wrapper.effects.progress_report = value,
.size_cb => wrapper.effects.size_cb = value,
.clipboard_write => wrapper.effects.clipboard_write = value,
.clipboard_read => {
wrapper.effects.clipboard_read = value;
wrapper.stream.handler.effects.clipboard_read = if (value != null)
&Effects.clipboardReadTrampoline
else
null;
},
.unknown_sequence => {
wrapper.effects.unknown_sequence = value;
wrapper.stream.handler.unknown_sequence = if (value != null)
@@ -4600,6 +4746,112 @@ test "clipboard_write without callback is unsupported and silent" {
try testing.expectEqual(clipboard.WriteResult.unsupported, result);
}
test "set clipboard_read callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var last_data: ?[]u8 = null;
var count: usize = 0;
var last_size: usize = 0;
var last_location: clipboard.Location = .standard;
var last_mimes_len: usize = 0;
var last_mime_is_text: bool = false;
var last_list: bool = true;
var last_name_len: usize = 1;
var last_granted: bool = true;
var last_can_remember: bool = true;
var result: clipboard.Read.Status = .success;
fn deinit() void {
if (last_data) |d| testing.allocator.free(d);
last_data = null;
}
fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
if (last_data) |d| testing.allocator.free(d);
last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
}
fn clipboardRead(
_: Terminal,
_: ?*anyopaque,
request: *const ClipboardRead,
) callconv(lib.calling_conv) void {
count += 1;
last_size = request.size;
last_location = request.location;
last_mimes_len = request.mimes_len;
last_mime_is_text = request.mimes_len > 0 and std.mem.eql(
u8,
request.mimes.?[0].ptr[0..request.mimes.?[0].len],
"text/plain",
);
last_list = request.list;
last_name_len = request.name.len;
last_granted = request.granted;
last_can_remember = request.can_remember;
const mime: []const u8 = "text/plain";
const data: []const u8 = "hello";
const contents = [_]ClipboardContent{.{
.mime = .init(mime),
.data = .init(data),
}};
request.reply(request, &.{
.size = @sizeOf(ClipboardReadReply),
.result = result,
.contents = &contents,
.contents_len = contents.len,
.available = null,
.available_len = 0,
.remember = false,
});
}
};
defer S.deinit();
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
// Without a callback the handler effect is unset and reads are silent.
try testing.expect(t.?.stream.handler.effects.clipboard_read == null);
const read_st = "\x1B]52;c;?\x1B\\";
vt_write(t, read_st, read_st.len);
try testing.expect(S.last_data == null);
try testing.expectEqual(Result.success, set(t, .clipboard_read, @ptrCast(&S.clipboardRead)));
try testing.expect(t.?.stream.handler.effects.clipboard_read != null);
const read_bel = "\x1B]52;p;?\x07";
vt_write(t, read_bel, read_bel.len);
try testing.expectEqual(1, S.count);
try testing.expectEqual(@sizeOf(ClipboardRead), S.last_size);
try testing.expectEqual(clipboard.Location.primary, S.last_location);
try testing.expectEqual(1, S.last_mimes_len);
try testing.expect(S.last_mime_is_text);
try testing.expect(!S.last_list);
try testing.expectEqual(0, S.last_name_len);
try testing.expect(!S.last_granted);
try testing.expect(!S.last_can_remember);
try testing.expectEqualStrings("\x1B]52;p;aGVsbG8=\x07", S.last_data.?);
// Denied replies with an empty clipboard.
S.result = .denied;
vt_write(t, read_st, read_st.len);
try testing.expectEqual(2, S.count);
try testing.expectEqualStrings("\x1B]52;c;\x1B\\", S.last_data.?);
// Clearing the callback uninstalls the handler effect.
try testing.expectEqual(Result.success, set(t, .clipboard_read, null));
try testing.expect(t.?.stream.handler.effects.clipboard_read == null);
}
test "pwd_changed without callback is silent" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(

View File

@@ -174,6 +174,8 @@ const type_decls = [_]TypeDecl{
.initStruct("GhosttyBuffer", lib.Buffer),
.initStruct("GhosttyCellsView", cell.CellsView),
.initStruct("GhosttyClipboardContent", terminal.ClipboardContent),
.initStruct("GhosttyClipboardRead", terminal.ClipboardRead),
.initStruct("GhosttyClipboardReadReply", terminal.ClipboardReadReply),
.initStruct("GhosttyClipboardWrite", terminal.ClipboardWrite),
.initStruct("GhosttyCodepoints", Codepoints),
.initStruct("GhosttyColorPaletteMask", color_c.PaletteMask),
@@ -241,6 +243,7 @@ const type_decls = [_]TypeDecl{
.initEnum("GhosttyCellSemanticContent", cell.SemanticContent, "GHOSTTY_CELL_SEMANTIC_"),
.initEnum("GhosttyCellWide", cell.Wide, "GHOSTTY_CELL_WIDE_"),
.initEnum("GhosttyClipboardLocation", clipboard.Location, "GHOSTTY_CLIPBOARD_LOCATION_"),
.initEnum("GhosttyClipboardReadResult", clipboard.Read.Status, "GHOSTTY_CLIPBOARD_READ_RESULT_"),
.initEnum("GhosttyClipboardWriteResult", clipboard.WriteResult, "GHOSTTY_CLIPBOARD_WRITE_RESULT_"),
.initEnum("GhosttyColorScheme", device_status.ColorScheme, "GHOSTTY_COLOR_SCHEME_"),
.initEnum("GhosttyFocusEvent", focus_pkg.Event, "GHOSTTY_FOCUS_"),

View File

@@ -52,6 +52,101 @@ pub const WriteResult = enum(c_int) {
_,
};
/// A request from the running program to read a clipboard.
///
/// Reads are synchronous: the effect callback must answer through `reply`
/// before it returns, and the request (including its reply context) is
/// invalid afterwards. An embedder that needs user consent must block until
/// it has an answer; the VT stream waits with it.
pub const Read = struct {
location: Location,
/// The MIME types the program wants, in order of preference. The
/// reply should carry every requested representation the clipboard
/// has; unrequested ones are ignored. Protocols that only carry text
/// (OSC 52) request "text/plain".
mimes: []const []const u8,
/// The program also wants the list of MIME types available on the
/// clipboard, delivered as Result.Success.available.
list: bool,
/// Name of the requesting program for permission prompts, if the
/// protocol carries one. Empty otherwise.
name: []const u8,
/// True if the terminal already holds a session grant for this
/// request (kitty clipboard protocol passwords). The embedder should
/// skip any permission prompt and serve the read.
granted: bool,
/// True if the program supplied a session password, so the embedder
/// may offer to remember the user's decision via
/// Result.Success.remember. When false, remember is ignored.
can_remember: bool,
/// Terminal-owned reply state, only valid during the callback.
///
/// The result is delivered through a call rather than returned so
/// the terminal consumes it while the embedder's memory is still
/// alive; a returned slice would have to outlive the callback.
reply_ctx: *anyopaque,
reply_fn: *const fn (*anyopaque, Result) void,
/// Answer the read. May be called at most once; later calls are
/// ignored. Result memory is borrowed only for the duration of this
/// call.
pub fn reply(self: Read, result: Result) void {
self.reply_fn(self.reply_ctx, result);
}
/// The status of a clipboard read reply.
pub const Status = enum(c_int) {
success = 0,
denied = 1,
unsupported = 2,
busy = 3,
io_error = 4,
_,
};
/// The reply to a clipboard read.
pub const Result = union(enum) {
/// The read was denied by policy or the user.
denied,
/// The embedder cannot read this clipboard.
unsupported,
/// The clipboard is temporarily unavailable.
busy,
/// Reading the clipboard failed.
io_error,
/// The read succeeded. All memory is borrowed for the duration
/// of the reply call.
success: Success,
pub const Success = struct {
/// Representations of the clipboard contents, one per
/// requested MIME type the clipboard has. Protocols that
/// carry a single text value use the first entry with a
/// text MIME type (see isTextMime).
contents: []const Content = &.{},
/// All MIME types available on the clipboard. Only used when
/// the request set `list`.
available: []const []const u8 = &.{},
/// Record a session grant so future requests from the same
/// program skip the permission prompt. Only honored when the
/// request set `can_remember`.
remember: bool = false,
};
};
};
test isTextMime {
const testing = std.testing;
try testing.expect(isTextMime("text/plain"));

View File

@@ -46,12 +46,13 @@ pub const Command = union(Key) {
/// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
semantic_prompt: SemanticPrompt,
/// Set or get clipboard contents. If data is null, then the current
/// clipboard contents are sent to the pty. If data is set, this
/// contents is set on the clipboard.
/// Set or get clipboard contents. If data is "?", then the current
/// clipboard contents are sent to the pty. Otherwise, the contents
/// are set on the clipboard.
clipboard_contents: struct {
kind: u8,
data: [:0]const u8,
terminator: Terminator = .st,
},
/// OSC 7. Reports the current working directory of the shell. This is

View File

@@ -6,7 +6,7 @@ const Parser = @import("../../osc.zig").Parser;
const Command = @import("../../osc.zig").Command;
/// Parse OSC 52
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
assert(parser.state == .@"52");
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
@@ -26,6 +26,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
.clipboard_contents = .{
.kind = 'c',
.data = data[1 .. data.len - 1 :0],
.terminator = .init(terminator_ch),
},
};
} else {
@@ -41,6 +42,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
.clipboard_contents = .{
.kind = data[0],
.data = data[2 .. data.len - 1 :0],
.terminator = .init(terminator_ch),
},
};
}
@@ -59,6 +61,20 @@ test "OSC 52: get/set clipboard" {
try testing.expect(cmd == .clipboard_contents);
try testing.expect(cmd.clipboard_contents.kind == 's');
try testing.expectEqualStrings("?", cmd.clipboard_contents.data);
try testing.expectEqual(.st, cmd.clipboard_contents.terminator);
}
test "OSC 52: get clipboard with BEL terminator" {
const testing = std.testing;
var p: Parser = .init(null);
const input = "52;c;?";
for (input) |ch| p.next(ch);
const cmd = p.end(0x07).?.*;
try testing.expect(cmd == .clipboard_contents);
try testing.expectEqual(.bel, cmd.clipboard_contents.terminator);
}
test "OSC 52: get/set clipboard (optional parameter)" {

View File

@@ -414,6 +414,7 @@ pub const Action = union(Key) {
pub const ClipboardContents = struct {
kind: u8,
data: []const u8,
terminator: osc.Terminator,
pub const C = extern struct {
kind: u8,
@@ -2500,6 +2501,7 @@ pub fn Stream(comptime H: type) type {
self.handler.vt(.clipboard_contents, .{
.kind = clip.kind,
.data = clip.data,
.terminator = clip.terminator,
});
},

View File

@@ -145,12 +145,23 @@ pub const Handler = struct {
/// A write with no contents clears the destination. A content entry
/// with empty data is a distinct empty representation.
///
/// 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 read requests (OSC 52 with a "?" payload) are
/// delivered to clipboard_read instead.
clipboard_write: ?*const fn (*Handler, clipboard.Write) clipboard.WriteResult,
/// Called when the running program requests clipboard contents
/// (OSC 52 with a "?" payload). Answering one lets the program
/// read the user's clipboard, so the embedder is expected to
/// mediate consent.
///
/// Reads are synchronous: the callback must answer through
/// `read.reply` before it returns, so an embedder that needs to
/// ask the user must block (e.g. run a modal prompt) while the
/// stream waits. Returning without a reply, or replying denied or
/// unsupported, answers the program with an empty clipboard so it
/// doesn't hang. If this is null, read requests are ignored.
clipboard_read: ?*const fn (*Handler, clipboard.Read) void,
/// Called in response to an XTVERSION query. Returns the version
/// string to report (e.g. "ghostty 1.2.3"). The returned memory
/// must be valid for the lifetime of the call. The maximum length
@@ -162,6 +173,7 @@ pub const Handler = struct {
/// effects beyond that.
pub const readonly: Effects = .{
.bell = null,
.clipboard_read = null,
.clipboard_write = null,
.color_scheme = null,
.desktop_notification = null,
@@ -386,9 +398,10 @@ pub const Handler = struct {
.clipboard_contents => self.clipboardContents(
value.kind,
value.data,
value.terminator,
) catch |err| {
// Clipboard writes are external effects, not terminal state.
log.warn("error handling clipboard write err={}", .{err});
// Clipboard operations are external effects, not terminal state.
log.warn("error handling clipboard operation err={}", .{err});
},
.dcs_hook => try self.dcsHook(value),
@@ -507,18 +520,26 @@ pub const Handler = struct {
func(self, report);
}
fn clipboardContents(self: *Handler, kind: u8, data: []const u8) !void {
const func = self.effects.clipboard_write orelse return;
// Read requests are deliberately not forwarded; see the effect docs.
if (data.len == 1 and data[0] == '?') return;
fn clipboardContents(
self: *Handler,
kind: u8,
data: []const u8,
terminator: osc.Terminator,
) !void {
const location: clipboard.Location = switch (kind) {
's' => .selection,
'p' => .primary,
else => .standard,
};
// OSC 52 uses a "?" payload to request the clipboard contents.
if (data.len == 1 and data[0] == '?') {
self.clipboardRead(location, terminator);
return;
}
const func = self.effects.clipboard_write orelse return;
// OSC 52 uses an empty payload to clear the selected clipboard.
if (data.len == 0) {
_ = func(self, .{
@@ -546,6 +567,94 @@ pub const Handler = struct {
});
}
fn clipboardRead(
self: *Handler,
location: clipboard.Location,
terminator: osc.Terminator,
) void {
const func = self.effects.clipboard_read orelse return;
var state: ClipboardReadState = .{
.handler = self,
.location = location,
.terminator = terminator,
};
func(self, .{
.location = location,
.mimes = &.{"text/plain"},
.list = false,
.name = "",
.granted = false,
.can_remember = false,
.reply_ctx = &state,
.reply_fn = &ClipboardReadState.reply,
});
// The program is waiting on us, so a callback that returned
// without a (successful) reply gets an empty clipboard rather
// than silence.
if (!state.replied) state.respond("") catch |err| {
log.warn("error replying to clipboard read err={}", .{err});
};
}
/// Reply state for one synchronous clipboard read. This lives on the
/// clipboardRead stack frame, so it is only valid during the callback.
const ClipboardReadState = struct {
handler: *Handler,
location: clipboard.Location,
terminator: osc.Terminator,
replied: bool = false,
fn reply(ctx: *anyopaque, result: clipboard.Read.Result) void {
const self: *ClipboardReadState = @ptrCast(@alignCast(ctx));
if (self.replied) {
log.warn("clipboard read replied more than once, ignoring", .{});
return;
}
// OSC 52 carries a single text value.
const data: []const u8 = switch (result) {
.denied, .unsupported, .busy, .io_error => "",
.success => |s| for (s.contents) |c| {
if (clipboard.isTextMime(c.mime)) break c.data;
} else "",
};
self.respond(data) catch |err| {
// Leave replied unset so clipboardRead falls back to the
// empty reply.
log.warn("error replying to clipboard read err={}", .{err});
return;
};
self.replied = true;
}
fn respond(
self: *ClipboardReadState,
data: []const u8,
) error{ OutOfMemory, WriteFailed }!void {
const handler = self.handler;
var stack = std.heap.stackFallback(256, handler.terminal.gpa());
const alloc = stack.get();
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
const kind: u8 = switch (self.location) {
.selection => 's',
.primary => 'p',
.standard, _ => 'c',
};
try aw.writer.print("\x1b]52;{c};", .{kind});
try std.base64.standard.Encoder.encodeWriter(&aw.writer, data);
try aw.writer.writeAll(self.terminator.string());
const written = try aw.toOwnedSliceSentinel(0);
defer alloc.free(written);
handler.writePty(written);
}
};
fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void {
const func = self.effects.device_attributes orelse return;
const attrs = func(self);
@@ -2640,6 +2749,120 @@ test "clipboard_write effect callback" {
try testing.expectEqual(clipboard.WriteResult.denied, S.result);
}
test "clipboard_read effect callback" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var written: std.ArrayList(u8) = .empty;
var count: usize = 0;
var last_location: clipboard.Location = .standard;
var last_mimes: []const []const u8 = &.{};
var last_list: bool = true;
var last_name: []const u8 = "unset";
var last_granted: bool = true;
var last_can_remember: bool = true;
var result: ?clipboard.Read.Result = .{ .success = .{ .contents = &.{.{
.mime = "text/plain",
.data = "hello",
}} } };
var reply_twice: bool = false;
fn writePty(_: *Handler, data: [:0]const u8) void {
written.appendSlice(testing.allocator, data) catch @panic("OOM");
}
fn clipboardRead(_: *Handler, read: clipboard.Read) void {
count += 1;
last_location = read.location;
last_mimes = read.mimes;
last_list = read.list;
last_name = read.name;
last_granted = read.granted;
last_can_remember = read.can_remember;
if (result) |r| read.reply(r);
if (reply_twice) read.reply(.{ .success = .{ .contents = &.{.{
.mime = "text/plain",
.data = "again",
}} } });
}
};
defer S.written.deinit(testing.allocator);
// A null callback (the default readonly effects) silently ignores reads.
{
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
s.nextSlice("\x1B]52;c;?\x1B\\");
try testing.expectEqual(0, S.written.items.len);
}
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
handler.effects.clipboard_read = &S.clipboardRead;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Success echoes the normalized selector and request terminator.
s.nextSlice("\x1B]52;c;?\x1B\\");
try testing.expectEqual(1, S.count);
try testing.expectEqual(clipboard.Location.standard, S.last_location);
try testing.expectEqual(1, S.last_mimes.len);
try testing.expectEqualStrings("text/plain", S.last_mimes[0]);
try testing.expect(!S.last_list);
try testing.expectEqualStrings("", S.last_name);
try testing.expect(!S.last_granted);
try testing.expect(!S.last_can_remember);
try testing.expectEqualStrings("\x1B]52;c;aGVsbG8=\x1B\\", S.written.items);
S.written.clearRetainingCapacity();
s.nextSlice("\x1B]52;p;?\x07");
try testing.expectEqual(clipboard.Location.primary, S.last_location);
try testing.expectEqualStrings("\x1B]52;p;aGVsbG8=\x07", S.written.items);
// Only the first text representation is used.
S.written.clearRetainingCapacity();
S.result = .{
.success = .{
.contents = &.{
.{ .mime = "image/png", .data = "\x89PNG" },
.{ .mime = "UTF8_STRING", .data = "hi" },
},
// OSC 52 has no session passwords, so remember is ignored.
.remember = true,
},
};
s.nextSlice("\x1B]52;s;?\x1B\\");
try testing.expectEqual(clipboard.Location.selection, S.last_location);
try testing.expectEqualStrings("\x1B]52;s;aGk=\x1B\\", S.written.items);
// Every failure, no text, and no reply all answer with an empty
// clipboard.
for ([_]?clipboard.Read.Result{
.denied,
.unsupported,
.busy,
.io_error,
.{ .success = .{} },
null,
}) |result| {
S.written.clearRetainingCapacity();
S.result = result;
s.nextSlice("\x1B]52;c;?\x1B\\");
try testing.expectEqualStrings("\x1B]52;c;\x1B\\", S.written.items);
}
// A second reply is ignored.
S.written.clearRetainingCapacity();
S.result = .{ .success = .{ .contents = &.{.{ .mime = "text/plain", .data = "hello" }} } };
S.reply_twice = true;
s.nextSlice("\x1B]52;c;?\x1B\\");
try testing.expectEqualStrings("\x1B]52;c;aGVsbG8=\x1B\\", S.written.items);
}
test "clipboard_write allocation failure is ignored" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);