From 0c3a5d925d8c9bea64f2625a1990e17a0461e352 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 26 Jun 2026 19:31:34 +0000 Subject: [PATCH 1/2] feat: forward application OSC 52 clipboard writes through boo ui boo ui renders each session from a client-side libghostty terminal instead of passing output through raw, and libghostty-vt exposes no clipboard effect, so an application's own OSC 52 clipboard write is parsed and dropped. A copy made inside a mouse-driven TUI never reaches the user's clipboard, even though boo ui's own selection already copies via OSC 52. Add a split-safe OSC 52 scanner (src/osc52.zig) that the focused view runs over the session output stream, forwarding each clipboard write verbatim to the real terminal, the same path copySelection uses. Read requests (a "?" payload) are not forwarded, so a remote session cannot read the user's local clipboard. Plain boo attach already forwards OSC 52 as raw passthrough and is unchanged. --- src/osc52.zig | 266 +++++++++++++++++++++++++++++++++++++++++++ src/ui.zig | 21 ++++ test/integration.zig | 28 +++++ 3 files changed, 315 insertions(+) create mode 100644 src/osc52.zig diff --git a/src/osc52.zig b/src/osc52.zig new file mode 100644 index 0000000..18f4eeb --- /dev/null +++ b/src/osc52.zig @@ -0,0 +1,266 @@ +//! Forwarding of application OSC 52 clipboard *writes* to the real +//! terminal for `boo ui`. +//! +//! A `boo ui` view never passes session output through to the terminal +//! raw. The focused session feeds a client-side libghostty terminal and +//! the UI repaints the viewport from that terminal's state (see the +//! header of `ui.zig`). libghostty-vt parses OSC 52 but exposes no +//! clipboard effect, so an application that copies text itself (an +//! editor's yank, a pager's mouse selection, anything emitting +//! `ESC ] 52 ; c ; BEL`) has its clipboard write silently +//! dropped instead of reaching the user's terminal. A plain `boo attach` +//! does not have this problem: its output is written to the real +//! terminal byte for byte, OSC 52 included. +//! +//! This scanner recovers the `boo ui` case. It watches the same +//! passthrough bytes the view-canvas consumes and, on a complete OSC 52 +//! clipboard write, hands the verbatim sequence to a sink that writes it +//! to the real terminal, exactly as the ui's own selection copy does +//! (`copySelection` in `ui.zig`). The clipboard write then works over +//! SSH and through nested multiplexers like any other OSC 52. +//! +//! Only writes are forwarded. A read request (`ESC ] 52 ; c ; ? ST`) +//! asks the terminal to send the clipboard *back* to the application; a +//! remote session must not be able to read the user's local clipboard, +//! so a `?` payload is recognized and ignored. Sequences split across +//! feeds are carried over to the next call. + +const std = @import("std"); + +/// `ESC ] 5 2 ;`: the exact prefix of an OSC 52 sequence. The trailing +/// `;` keeps neighbours such as OSC 520 from matching. +const prefix = "\x1b]52;"; + +/// Cap on a single buffered clipboard write. The payload is base64, so +/// this still allows roughly 1.5 MiB of copied text. A larger write is +/// dropped rather than buffered without bound: its tail is base64 plus a +/// BEL or ST terminator, none of which can be mistaken for a new OSC 52 +/// prefix, so abandoning it to the ground state never produces a false +/// match. +const max_seq = 2 * 1024 * 1024; + +/// Incremental scanner over a session's output. It emits nothing of its +/// own; it only recognizes OSC 52 clipboard writes and forwards them. +/// Held candidate bytes persist across feeds so a write split over reads +/// is still recognized. +pub const Filter = struct { + /// Bytes of an in-progress candidate, starting at ESC. Empty in the + /// ground state. + buf: std.ArrayList(u8) = .empty, + /// The previous body byte was an ESC that may begin an ST terminator + /// (`ESC \`). + esc_pending: bool = false, + + pub fn deinit(self: *Filter, alloc: std.mem.Allocator) void { + self.buf.deinit(alloc); + } + + /// Scan `input`. On each complete OSC 52 clipboard write, call + /// `sink.clipboard(seq)` with the verbatim sequence bytes, terminator + /// included. `sink` is any value (or pointer) exposing that method. + pub fn feed( + self: *Filter, + alloc: std.mem.Allocator, + input: []const u8, + sink: anytype, + ) std.mem.Allocator.Error!void { + var i: usize = 0; + while (i < input.len) { + const byte = input[i]; + var advance = true; + + if (self.buf.items.len == 0) { + // Ground: only ESC can begin a candidate. + if (byte == 0x1b) try self.buf.append(alloc, byte); + } else if (self.buf.items.len < prefix.len) { + // Matching the fixed prefix one byte at a time. + if (byte == prefix[self.buf.items.len]) { + try self.buf.append(alloc, byte); + } else { + // Not OSC 52 after all. Drop the candidate and + // reconsider this byte from the ground state, so an + // ESC that starts a fresh candidate is not lost. + self.reset(); + advance = false; + } + } else if (self.esc_pending) { + self.esc_pending = false; + if (byte == '\\') { + // ST terminator (`ESC \`) completes the sequence. + try self.buf.append(alloc, byte); + self.emit(sink); + self.reset(); + } else { + // The ESC did not form ST: a malformed OSC. Abandon + // it and reconsider this byte from the ground state. + self.reset(); + advance = false; + } + } else if (byte == 0x07) { + // BEL terminator completes the sequence. + try self.buf.append(alloc, byte); + self.emit(sink); + self.reset(); + } else if (self.buf.items.len >= max_seq) { + // Oversized: drop it (see max_seq). + self.reset(); + advance = false; + } else { + try self.buf.append(alloc, byte); + if (byte == 0x1b) self.esc_pending = true; + } + + if (advance) i += 1; + } + } + + fn reset(self: *Filter) void { + self.buf.clearRetainingCapacity(); + self.esc_pending = false; + } + + /// Forward a completed candidate, unless it is a read request. + fn emit(self: *Filter, sink: anytype) void { + if (isWrite(self.buf.items)) sink.clipboard(self.buf.items); + } +}; + +/// Whether a complete OSC 52 sequence is a clipboard *write* (data to +/// store) rather than a read request (`?`). The sequence is +/// `ESC ] 52 ; ; `; a read request's `` is `?`. +fn isWrite(seq: []const u8) bool { + if (seq.len < prefix.len) return false; + // After the prefix: ` ; `. + const rest = seq[prefix.len..]; + const semi = std.mem.indexOfScalar(u8, rest, ';') orelse return false; + var pd = rest[semi + 1 ..]; + // Strip the terminator (BEL, or the two-byte ST `ESC \`). + if (pd.len >= 1 and pd[pd.len - 1] == 0x07) { + pd = pd[0 .. pd.len - 1]; + } else if (pd.len >= 2 and pd[pd.len - 2] == 0x1b and pd[pd.len - 1] == '\\') { + pd = pd[0 .. pd.len - 2]; + } + // A read request is a literal `?`. Anything else (base64, or empty + // to clear the clipboard) is a write. + return !(pd.len > 0 and pd[0] == '?'); +} + +const Collector = struct { + alloc: std.mem.Allocator, + seqs: std.ArrayList([]u8) = .empty, + + fn clipboard(self: *Collector, seq: []const u8) void { + const dup = self.alloc.dupe(u8, seq) catch return; + self.seqs.append(self.alloc, dup) catch self.alloc.free(dup); + } + + fn deinit(self: *Collector) void { + for (self.seqs.items) |s| self.alloc.free(s); + self.seqs.deinit(self.alloc); + } +}; + +fn expectForwards(input: []const u8, expected: []const []const u8) !void { + const alloc = std.testing.allocator; + var f: Filter = .{}; + defer f.deinit(alloc); + var c: Collector = .{ .alloc = alloc }; + defer c.deinit(); + try f.feed(alloc, input, &c); + try std.testing.expectEqual(expected.len, c.seqs.items.len); + for (expected, c.seqs.items) |want, got| { + try std.testing.expectEqualStrings(want, got); + } +} + +test "clipboard write with BEL terminator is forwarded verbatim" { + try expectForwards( + "before\x1b]52;c;SGVsbG8=\x07after", + &.{"\x1b]52;c;SGVsbG8=\x07"}, + ); +} + +test "clipboard write with ST terminator is forwarded verbatim" { + try expectForwards( + "\x1b]52;c;SGVsbG8=\x1b\\", + &.{"\x1b]52;c;SGVsbG8=\x1b\\"}, + ); +} + +test "a read request is not forwarded" { + try expectForwards("\x1b]52;c;?\x07", &.{}); +} + +test "an empty payload (clear) is forwarded" { + try expectForwards("\x1b]52;c;\x07", &.{"\x1b]52;c;\x07"}); +} + +test "an empty Pc field is forwarded" { + try expectForwards("\x1b]52;;QQ==\x07", &.{"\x1b]52;;QQ==\x07"}); +} + +test "a write split across feeds is recognized" { + const alloc = std.testing.allocator; + var f: Filter = .{}; + defer f.deinit(alloc); + var c: Collector = .{ .alloc = alloc }; + defer c.deinit(); + try f.feed(alloc, "x\x1b]52;c;SGVs", &c); + try std.testing.expectEqual(@as(usize, 0), c.seqs.items.len); + try f.feed(alloc, "bG8=\x07y", &c); + try std.testing.expectEqual(@as(usize, 1), c.seqs.items.len); + try std.testing.expectEqualStrings("\x1b]52;c;SGVsbG8=\x07", c.seqs.items[0]); +} + +test "a read request split across feeds is still ignored" { + const alloc = std.testing.allocator; + var f: Filter = .{}; + defer f.deinit(alloc); + var c: Collector = .{ .alloc = alloc }; + defer c.deinit(); + try f.feed(alloc, "\x1b]52;c;", &c); + try f.feed(alloc, "?\x07", &c); + try std.testing.expectEqual(@as(usize, 0), c.seqs.items.len); +} + +test "back-to-back writes are both forwarded" { + try expectForwards( + "\x1b]52;c;QQ==\x07\x1b]52;p;Qg==\x1b\\", + &.{ "\x1b]52;c;QQ==\x07", "\x1b]52;p;Qg==\x1b\\" }, + ); +} + +test "OSC 520 and other OSC sequences are not matched" { + try expectForwards("\x1b]520;c;QQ==\x07", &.{}); + try expectForwards("\x1b]2;a title\x07", &.{}); + try expectForwards("\x1b]11;rgb:1234/5678/9abc\x07", &.{}); +} + +test "plain text and CSI sequences pass without a match" { + try expectForwards("hello \x1b[2J\x1b[1;5H\x1b[31mworld", &.{}); +} + +test "a near-miss prefix then a real write still forwards the write" { + // ESC ] 5 3 aborts the candidate; the following real write matches. + try expectForwards( + "\x1b]53;c;QQ==\x07\x1b]52;c;Qg==\x07", + &.{"\x1b]52;c;Qg==\x07"}, + ); +} + +test "a long under-cap payload is buffered and forwarded" { + const alloc = std.testing.allocator; + var input: std.ArrayList(u8) = .empty; + defer input.deinit(alloc); + try input.appendSlice(alloc, "\x1b]52;c;"); + try input.appendSlice(alloc, "QQ==" ** 4096); // ~16 KiB of base64 + try input.append(alloc, 0x07); + + var f: Filter = .{}; + defer f.deinit(alloc); + var c: Collector = .{ .alloc = alloc }; + defer c.deinit(); + try f.feed(alloc, input.items, &c); + try std.testing.expectEqual(@as(usize, 1), c.seqs.items.len); + try std.testing.expectEqualStrings(input.items, c.seqs.items[0]); +} diff --git a/src/ui.zig b/src/ui.zig index ee9be23..56f76b9 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -25,6 +25,7 @@ const vt = @import("ghostty-vt"); const client = @import("client.zig"); const keys = @import("keys.zig"); +const osc52 = @import("osc52.zig"); const paths = @import("paths.zig"); const protocol = @import("protocol.zig"); const ptypkg = @import("pty.zig"); @@ -668,6 +669,15 @@ pub const InputParser = struct { // -- Focused session view ---------------------------------------------------- +/// Sink for `osc52.Filter`: writes a forwarded clipboard sequence to +/// the real terminal (fd 1), the same destination as the ui's own +/// selection copy. +const ClipboardSink = struct { + pub fn clipboard(_: ClipboardSink, seq: []const u8) void { + protocol.writeAll(1, seq) catch {}; + } +}; + /// The attach connection and local terminal state of the focused /// session. Heap-allocated and pinned: the stream handler keeps a /// pointer to `term`, and effects callbacks recover the View with @@ -688,6 +698,10 @@ pub const View = struct { /// `.screen` messages. Decides whether a wheel over the viewport /// pages local scrollback or sends arrow keys. app_alt: bool = false, + /// Recovers application clipboard writes (OSC 52) from the output + /// stream so a copy inside the session reaches the real terminal + /// (see osc52.zig and feedOutput). + clip: osc52.Filter = .{}, pub const State = enum { live, ended, stolen, lost }; pub const Stream = vt.TerminalStream; @@ -767,6 +781,7 @@ pub const View = struct { self.stream.deinit(); self.term.deinit(self.alloc); self.decoder.deinit(); + self.clip.deinit(self.alloc); self.alloc.destroy(self); } @@ -826,6 +841,12 @@ pub const View = struct { } pub fn feedOutput(self: *View, bytes: []const u8) void { + // The view-canvas stream below parses OSC 52 but has no + // clipboard effect, so an application's own clipboard write + // would be dropped. Forward it to the real terminal first, the + // same path the ui's selection copy uses, so a copy inside a + // session reaches the user's clipboard over SSH. + self.clip.feed(self.alloc, bytes, ClipboardSink{}) catch {}; self.stream.nextSlice(bytes); } diff --git a/test/integration.zig b/test/integration.zig index 113a56b..3f7dbe1 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1624,6 +1624,34 @@ test "ui: dragging in the viewport selects text and copies it via osc 52" { try ui.waitFor("copied"); } +test "ui: an application's own OSC 52 clipboard write reaches the real terminal" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // boo ui renders sessions from terminal state rather than passing + // bytes through, and the view-canvas has no clipboard effect, so an + // application's own OSC 52 clipboard write is parsed and dropped + // unless the ui forwards it to the real terminal. + try h.startDetached("clip", &.{"sh"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("clip"); + + // Confirm the session is focused and rendering before the copy. + try h.sendLine("clip", "printf 'CLIPREADY\\n'"); + try ui.waitFor("CLIPREADY"); + ui.clearOutput(); + + // The application copies "HELLO" to the clipboard via OSC 52. The + // sequence must reach the ui's real terminal verbatim. The echoed + // command line carries the literal text "\\033]52..." (a backslash, + // not a real ESC), so only the forwarded sequence matches the wait. + try h.sendLine("clip", "printf '\\033]52;c;SEVMTE8=\\007'"); + try ui.waitFor("\x1b]52;c;SEVMTE8=\x07"); +} + test "ui: mouse events forward natively when the application asks for them" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); From 55827ad9f7931c81a4a00235e0523524ade7828e Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 26 Jun 2026 20:08:04 +0000 Subject: [PATCH 2/2] perf(src): scan session output in runs, not byte by byte In the OSC 52 forwarder, skip non-ESC bytes to the next ESC with indexOfScalar in the ground state, and accumulate a clipboard body up to its BEL/ST terminator with a single appendSlice instead of a per-byte append. Both replace per-byte work on the output hot path the view-canvas already walks once; behavior is unchanged. Also force-import osc52.zig in main.zig's test block, so its unit tests run by the same convention as the sibling filters rather than only because ui.zig happens to reference the module. --- src/main.zig | 1 + src/osc52.zig | 36 ++++++++++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/main.zig b/src/main.zig index 989e7c4..3043652 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1130,6 +1130,7 @@ test { _ = @import("pty.zig"); _ = @import("altscreen.zig"); _ = @import("oscquery.zig"); + _ = @import("osc52.zig"); _ = @import("window.zig"); _ = @import("daemon.zig"); _ = @import("client.zig"); diff --git a/src/osc52.zig b/src/osc52.zig index 18f4eeb..7b4b291 100644 --- a/src/osc52.zig +++ b/src/osc52.zig @@ -70,8 +70,14 @@ pub const Filter = struct { var advance = true; if (self.buf.items.len == 0) { - // Ground: only ESC can begin a candidate. - if (byte == 0x1b) try self.buf.append(alloc, byte); + // Ground: only ESC can begin a candidate. Skip the run + // of non-ESC bytes to the next ESC in one vectorized pass. + if (byte != 0x1b) { + const rel = std.mem.indexOfScalar(u8, input[i..], 0x1b) orelse input.len - i; + i += rel; + continue; + } + try self.buf.append(alloc, byte); } else if (self.buf.items.len < prefix.len) { // Matching the fixed prefix one byte at a time. if (byte == prefix[self.buf.items.len]) { @@ -101,13 +107,27 @@ pub const Filter = struct { try self.buf.append(alloc, byte); self.emit(sink); self.reset(); - } else if (self.buf.items.len >= max_seq) { - // Oversized: drop it (see max_seq). - self.reset(); - advance = false; } else { - try self.buf.append(alloc, byte); - if (byte == 0x1b) self.esc_pending = true; + // Body: base64 data up to a BEL or ST terminator. Neither + // terminator byte occurs in base64, so accumulate the + // whole run in one copy instead of byte by byte. + const rest = input[i..]; + const run = std.mem.indexOfAny(u8, rest, &[_]u8{ 0x07, 0x1b }) orelse rest.len; + if (run == 0) { + // The byte is ESC (BEL is handled above); it may open ST. + try self.buf.append(alloc, byte); + self.esc_pending = true; + } else if (self.buf.items.len + run > max_seq) { + // Oversized: drop it. The skipped tail is base64 plus + // a terminator, never a new OSC 52 prefix (see max_seq). + self.reset(); + i += run; + continue; + } else { + try self.buf.appendSlice(alloc, rest[0..run]); + i += run; + continue; + } } if (advance) i += 1;