Skip to content

Commit 0cc7146

Browse files
authored
feat: forward application OSC 52 clipboard writes through boo ui (#95)
Forward an application's own OSC 52 clipboard writes through boo ui to the real terminal, so a copy made inside a session reaches the user's clipboard over SSH. boo ui renders sessions from libghostty state and the view-canvas has no clipboard effect, so these writes were dropped; plain boo attach was already unaffected. Read requests are not forwarded. The scanner walks output in runs (indexOfScalar / appendSlice) and its unit tests are wired into main.zig's test block.
1 parent 75d3dea commit 0cc7146

4 files changed

Lines changed: 336 additions & 0 deletions

File tree

src/main.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,6 +1130,7 @@ test {
11301130
_ = @import("pty.zig");
11311131
_ = @import("altscreen.zig");
11321132
_ = @import("oscquery.zig");
1133+
_ = @import("osc52.zig");
11331134
_ = @import("window.zig");
11341135
_ = @import("daemon.zig");
11351136
_ = @import("client.zig");

src/osc52.zig

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
//! Forwarding of application OSC 52 clipboard *writes* to the real
2+
//! terminal for `boo ui`.
3+
//!
4+
//! A `boo ui` view never passes session output through to the terminal
5+
//! raw. The focused session feeds a client-side libghostty terminal and
6+
//! the UI repaints the viewport from that terminal's state (see the
7+
//! header of `ui.zig`). libghostty-vt parses OSC 52 but exposes no
8+
//! clipboard effect, so an application that copies text itself (an
9+
//! editor's yank, a pager's mouse selection, anything emitting
10+
//! `ESC ] 52 ; c ; <base64> BEL`) has its clipboard write silently
11+
//! dropped instead of reaching the user's terminal. A plain `boo attach`
12+
//! does not have this problem: its output is written to the real
13+
//! terminal byte for byte, OSC 52 included.
14+
//!
15+
//! This scanner recovers the `boo ui` case. It watches the same
16+
//! passthrough bytes the view-canvas consumes and, on a complete OSC 52
17+
//! clipboard write, hands the verbatim sequence to a sink that writes it
18+
//! to the real terminal, exactly as the ui's own selection copy does
19+
//! (`copySelection` in `ui.zig`). The clipboard write then works over
20+
//! SSH and through nested multiplexers like any other OSC 52.
21+
//!
22+
//! Only writes are forwarded. A read request (`ESC ] 52 ; c ; ? ST`)
23+
//! asks the terminal to send the clipboard *back* to the application; a
24+
//! remote session must not be able to read the user's local clipboard,
25+
//! so a `?` payload is recognized and ignored. Sequences split across
26+
//! feeds are carried over to the next call.
27+
28+
const std = @import("std");
29+
30+
/// `ESC ] 5 2 ;`: the exact prefix of an OSC 52 sequence. The trailing
31+
/// `;` keeps neighbours such as OSC 520 from matching.
32+
const prefix = "\x1b]52;";
33+
34+
/// Cap on a single buffered clipboard write. The payload is base64, so
35+
/// this still allows roughly 1.5 MiB of copied text. A larger write is
36+
/// dropped rather than buffered without bound: its tail is base64 plus a
37+
/// BEL or ST terminator, none of which can be mistaken for a new OSC 52
38+
/// prefix, so abandoning it to the ground state never produces a false
39+
/// match.
40+
const max_seq = 2 * 1024 * 1024;
41+
42+
/// Incremental scanner over a session's output. It emits nothing of its
43+
/// own; it only recognizes OSC 52 clipboard writes and forwards them.
44+
/// Held candidate bytes persist across feeds so a write split over reads
45+
/// is still recognized.
46+
pub const Filter = struct {
47+
/// Bytes of an in-progress candidate, starting at ESC. Empty in the
48+
/// ground state.
49+
buf: std.ArrayList(u8) = .empty,
50+
/// The previous body byte was an ESC that may begin an ST terminator
51+
/// (`ESC \`).
52+
esc_pending: bool = false,
53+
54+
pub fn deinit(self: *Filter, alloc: std.mem.Allocator) void {
55+
self.buf.deinit(alloc);
56+
}
57+
58+
/// Scan `input`. On each complete OSC 52 clipboard write, call
59+
/// `sink.clipboard(seq)` with the verbatim sequence bytes, terminator
60+
/// included. `sink` is any value (or pointer) exposing that method.
61+
pub fn feed(
62+
self: *Filter,
63+
alloc: std.mem.Allocator,
64+
input: []const u8,
65+
sink: anytype,
66+
) std.mem.Allocator.Error!void {
67+
var i: usize = 0;
68+
while (i < input.len) {
69+
const byte = input[i];
70+
var advance = true;
71+
72+
if (self.buf.items.len == 0) {
73+
// Ground: only ESC can begin a candidate. Skip the run
74+
// of non-ESC bytes to the next ESC in one vectorized pass.
75+
if (byte != 0x1b) {
76+
const rel = std.mem.indexOfScalar(u8, input[i..], 0x1b) orelse input.len - i;
77+
i += rel;
78+
continue;
79+
}
80+
try self.buf.append(alloc, byte);
81+
} else if (self.buf.items.len < prefix.len) {
82+
// Matching the fixed prefix one byte at a time.
83+
if (byte == prefix[self.buf.items.len]) {
84+
try self.buf.append(alloc, byte);
85+
} else {
86+
// Not OSC 52 after all. Drop the candidate and
87+
// reconsider this byte from the ground state, so an
88+
// ESC that starts a fresh candidate is not lost.
89+
self.reset();
90+
advance = false;
91+
}
92+
} else if (self.esc_pending) {
93+
self.esc_pending = false;
94+
if (byte == '\\') {
95+
// ST terminator (`ESC \`) completes the sequence.
96+
try self.buf.append(alloc, byte);
97+
self.emit(sink);
98+
self.reset();
99+
} else {
100+
// The ESC did not form ST: a malformed OSC. Abandon
101+
// it and reconsider this byte from the ground state.
102+
self.reset();
103+
advance = false;
104+
}
105+
} else if (byte == 0x07) {
106+
// BEL terminator completes the sequence.
107+
try self.buf.append(alloc, byte);
108+
self.emit(sink);
109+
self.reset();
110+
} else {
111+
// Body: base64 data up to a BEL or ST terminator. Neither
112+
// terminator byte occurs in base64, so accumulate the
113+
// whole run in one copy instead of byte by byte.
114+
const rest = input[i..];
115+
const run = std.mem.indexOfAny(u8, rest, &[_]u8{ 0x07, 0x1b }) orelse rest.len;
116+
if (run == 0) {
117+
// The byte is ESC (BEL is handled above); it may open ST.
118+
try self.buf.append(alloc, byte);
119+
self.esc_pending = true;
120+
} else if (self.buf.items.len + run > max_seq) {
121+
// Oversized: drop it. The skipped tail is base64 plus
122+
// a terminator, never a new OSC 52 prefix (see max_seq).
123+
self.reset();
124+
i += run;
125+
continue;
126+
} else {
127+
try self.buf.appendSlice(alloc, rest[0..run]);
128+
i += run;
129+
continue;
130+
}
131+
}
132+
133+
if (advance) i += 1;
134+
}
135+
}
136+
137+
fn reset(self: *Filter) void {
138+
self.buf.clearRetainingCapacity();
139+
self.esc_pending = false;
140+
}
141+
142+
/// Forward a completed candidate, unless it is a read request.
143+
fn emit(self: *Filter, sink: anytype) void {
144+
if (isWrite(self.buf.items)) sink.clipboard(self.buf.items);
145+
}
146+
};
147+
148+
/// Whether a complete OSC 52 sequence is a clipboard *write* (data to
149+
/// store) rather than a read request (`?`). The sequence is
150+
/// `ESC ] 52 ; <Pc> ; <Pd> <terminator>`; a read request's `<Pd>` is `?`.
151+
fn isWrite(seq: []const u8) bool {
152+
if (seq.len < prefix.len) return false;
153+
// After the prefix: `<Pc> ; <Pd> <terminator>`.
154+
const rest = seq[prefix.len..];
155+
const semi = std.mem.indexOfScalar(u8, rest, ';') orelse return false;
156+
var pd = rest[semi + 1 ..];
157+
// Strip the terminator (BEL, or the two-byte ST `ESC \`).
158+
if (pd.len >= 1 and pd[pd.len - 1] == 0x07) {
159+
pd = pd[0 .. pd.len - 1];
160+
} else if (pd.len >= 2 and pd[pd.len - 2] == 0x1b and pd[pd.len - 1] == '\\') {
161+
pd = pd[0 .. pd.len - 2];
162+
}
163+
// A read request is a literal `?`. Anything else (base64, or empty
164+
// to clear the clipboard) is a write.
165+
return !(pd.len > 0 and pd[0] == '?');
166+
}
167+
168+
const Collector = struct {
169+
alloc: std.mem.Allocator,
170+
seqs: std.ArrayList([]u8) = .empty,
171+
172+
fn clipboard(self: *Collector, seq: []const u8) void {
173+
const dup = self.alloc.dupe(u8, seq) catch return;
174+
self.seqs.append(self.alloc, dup) catch self.alloc.free(dup);
175+
}
176+
177+
fn deinit(self: *Collector) void {
178+
for (self.seqs.items) |s| self.alloc.free(s);
179+
self.seqs.deinit(self.alloc);
180+
}
181+
};
182+
183+
fn expectForwards(input: []const u8, expected: []const []const u8) !void {
184+
const alloc = std.testing.allocator;
185+
var f: Filter = .{};
186+
defer f.deinit(alloc);
187+
var c: Collector = .{ .alloc = alloc };
188+
defer c.deinit();
189+
try f.feed(alloc, input, &c);
190+
try std.testing.expectEqual(expected.len, c.seqs.items.len);
191+
for (expected, c.seqs.items) |want, got| {
192+
try std.testing.expectEqualStrings(want, got);
193+
}
194+
}
195+
196+
test "clipboard write with BEL terminator is forwarded verbatim" {
197+
try expectForwards(
198+
"before\x1b]52;c;SGVsbG8=\x07after",
199+
&.{"\x1b]52;c;SGVsbG8=\x07"},
200+
);
201+
}
202+
203+
test "clipboard write with ST terminator is forwarded verbatim" {
204+
try expectForwards(
205+
"\x1b]52;c;SGVsbG8=\x1b\\",
206+
&.{"\x1b]52;c;SGVsbG8=\x1b\\"},
207+
);
208+
}
209+
210+
test "a read request is not forwarded" {
211+
try expectForwards("\x1b]52;c;?\x07", &.{});
212+
}
213+
214+
test "an empty payload (clear) is forwarded" {
215+
try expectForwards("\x1b]52;c;\x07", &.{"\x1b]52;c;\x07"});
216+
}
217+
218+
test "an empty Pc field is forwarded" {
219+
try expectForwards("\x1b]52;;QQ==\x07", &.{"\x1b]52;;QQ==\x07"});
220+
}
221+
222+
test "a write split across feeds is recognized" {
223+
const alloc = std.testing.allocator;
224+
var f: Filter = .{};
225+
defer f.deinit(alloc);
226+
var c: Collector = .{ .alloc = alloc };
227+
defer c.deinit();
228+
try f.feed(alloc, "x\x1b]52;c;SGVs", &c);
229+
try std.testing.expectEqual(@as(usize, 0), c.seqs.items.len);
230+
try f.feed(alloc, "bG8=\x07y", &c);
231+
try std.testing.expectEqual(@as(usize, 1), c.seqs.items.len);
232+
try std.testing.expectEqualStrings("\x1b]52;c;SGVsbG8=\x07", c.seqs.items[0]);
233+
}
234+
235+
test "a read request split across feeds is still ignored" {
236+
const alloc = std.testing.allocator;
237+
var f: Filter = .{};
238+
defer f.deinit(alloc);
239+
var c: Collector = .{ .alloc = alloc };
240+
defer c.deinit();
241+
try f.feed(alloc, "\x1b]52;c;", &c);
242+
try f.feed(alloc, "?\x07", &c);
243+
try std.testing.expectEqual(@as(usize, 0), c.seqs.items.len);
244+
}
245+
246+
test "back-to-back writes are both forwarded" {
247+
try expectForwards(
248+
"\x1b]52;c;QQ==\x07\x1b]52;p;Qg==\x1b\\",
249+
&.{ "\x1b]52;c;QQ==\x07", "\x1b]52;p;Qg==\x1b\\" },
250+
);
251+
}
252+
253+
test "OSC 520 and other OSC sequences are not matched" {
254+
try expectForwards("\x1b]520;c;QQ==\x07", &.{});
255+
try expectForwards("\x1b]2;a title\x07", &.{});
256+
try expectForwards("\x1b]11;rgb:1234/5678/9abc\x07", &.{});
257+
}
258+
259+
test "plain text and CSI sequences pass without a match" {
260+
try expectForwards("hello \x1b[2J\x1b[1;5H\x1b[31mworld", &.{});
261+
}
262+
263+
test "a near-miss prefix then a real write still forwards the write" {
264+
// ESC ] 5 3 aborts the candidate; the following real write matches.
265+
try expectForwards(
266+
"\x1b]53;c;QQ==\x07\x1b]52;c;Qg==\x07",
267+
&.{"\x1b]52;c;Qg==\x07"},
268+
);
269+
}
270+
271+
test "a long under-cap payload is buffered and forwarded" {
272+
const alloc = std.testing.allocator;
273+
var input: std.ArrayList(u8) = .empty;
274+
defer input.deinit(alloc);
275+
try input.appendSlice(alloc, "\x1b]52;c;");
276+
try input.appendSlice(alloc, "QQ==" ** 4096); // ~16 KiB of base64
277+
try input.append(alloc, 0x07);
278+
279+
var f: Filter = .{};
280+
defer f.deinit(alloc);
281+
var c: Collector = .{ .alloc = alloc };
282+
defer c.deinit();
283+
try f.feed(alloc, input.items, &c);
284+
try std.testing.expectEqual(@as(usize, 1), c.seqs.items.len);
285+
try std.testing.expectEqualStrings(input.items, c.seqs.items[0]);
286+
}

src/ui.zig

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const vt = @import("ghostty-vt");
2525

2626
const client = @import("client.zig");
2727
const keys = @import("keys.zig");
28+
const osc52 = @import("osc52.zig");
2829
const paths = @import("paths.zig");
2930
const protocol = @import("protocol.zig");
3031
const ptypkg = @import("pty.zig");
@@ -668,6 +669,15 @@ pub const InputParser = struct {
668669

669670
// -- Focused session view ----------------------------------------------------
670671

672+
/// Sink for `osc52.Filter`: writes a forwarded clipboard sequence to
673+
/// the real terminal (fd 1), the same destination as the ui's own
674+
/// selection copy.
675+
const ClipboardSink = struct {
676+
pub fn clipboard(_: ClipboardSink, seq: []const u8) void {
677+
protocol.writeAll(1, seq) catch {};
678+
}
679+
};
680+
671681
/// The attach connection and local terminal state of the focused
672682
/// session. Heap-allocated and pinned: the stream handler keeps a
673683
/// pointer to `term`, and effects callbacks recover the View with
@@ -688,6 +698,10 @@ pub const View = struct {
688698
/// `.screen` messages. Decides whether a wheel over the viewport
689699
/// pages local scrollback or sends arrow keys.
690700
app_alt: bool = false,
701+
/// Recovers application clipboard writes (OSC 52) from the output
702+
/// stream so a copy inside the session reaches the real terminal
703+
/// (see osc52.zig and feedOutput).
704+
clip: osc52.Filter = .{},
691705

692706
pub const State = enum { live, ended, stolen, lost };
693707
pub const Stream = vt.TerminalStream;
@@ -767,6 +781,7 @@ pub const View = struct {
767781
self.stream.deinit();
768782
self.term.deinit(self.alloc);
769783
self.decoder.deinit();
784+
self.clip.deinit(self.alloc);
770785
self.alloc.destroy(self);
771786
}
772787

@@ -826,6 +841,12 @@ pub const View = struct {
826841
}
827842

828843
pub fn feedOutput(self: *View, bytes: []const u8) void {
844+
// The view-canvas stream below parses OSC 52 but has no
845+
// clipboard effect, so an application's own clipboard write
846+
// would be dropped. Forward it to the real terminal first, the
847+
// same path the ui's selection copy uses, so a copy inside a
848+
// session reaches the user's clipboard over SSH.
849+
self.clip.feed(self.alloc, bytes, ClipboardSink{}) catch {};
829850
self.stream.nextSlice(bytes);
830851
}
831852

test/integration.zig

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1624,6 +1624,34 @@ test "ui: dragging in the viewport selects text and copies it via osc 52" {
16241624
try ui.waitFor("copied");
16251625
}
16261626

1627+
test "ui: an application's own OSC 52 clipboard write reaches the real terminal" {
1628+
const alloc = std.testing.allocator;
1629+
var h = try Harness.init(alloc);
1630+
defer h.deinit();
1631+
1632+
// boo ui renders sessions from terminal state rather than passing
1633+
// bytes through, and the view-canvas has no clipboard effect, so an
1634+
// application's own OSC 52 clipboard write is parsed and dropped
1635+
// unless the ui forwards it to the real terminal.
1636+
try h.startDetached("clip", &.{"sh"});
1637+
1638+
var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100);
1639+
defer ui.deinit();
1640+
try ui.waitFor("clip");
1641+
1642+
// Confirm the session is focused and rendering before the copy.
1643+
try h.sendLine("clip", "printf 'CLIPREADY\\n'");
1644+
try ui.waitFor("CLIPREADY");
1645+
ui.clearOutput();
1646+
1647+
// The application copies "HELLO" to the clipboard via OSC 52. The
1648+
// sequence must reach the ui's real terminal verbatim. The echoed
1649+
// command line carries the literal text "\\033]52..." (a backslash,
1650+
// not a real ESC), so only the forwarded sequence matches the wait.
1651+
try h.sendLine("clip", "printf '\\033]52;c;SEVMTE8=\\007'");
1652+
try ui.waitFor("\x1b]52;c;SEVMTE8=\x07");
1653+
}
1654+
16271655
test "ui: mouse events forward natively when the application asks for them" {
16281656
const alloc = std.testing.allocator;
16291657
var h = try Harness.init(alloc);

0 commit comments

Comments
 (0)