From d4a0b3e0578eabfc082fe7d0e0cacf8af4cc2b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Thu, 5 Mar 2026 12:41:46 +0100 Subject: [PATCH 1/2] feat: add OSC 52 clipboard output support --- README.md | 41 ++++++++++ build.zig | 1 + examples/clipboard_osc52.zig | 153 +++++++++++++++++++++++++++++++++++ src/core/context.zig | 20 +++++ src/core/program.zig | 1 + src/root.zig | 5 ++ src/terminal/ansi.zig | 118 +++++++++++++++++++++++++++ src/terminal/terminal.zig | 122 ++++++++++++++++++++++++++++ 8 files changed, 461 insertions(+) create mode 100644 examples/clipboard_osc52.zig diff --git a/README.md b/README.md index 460125a..9181393 100644 --- a/README.md +++ b/README.md @@ -640,6 +640,13 @@ var program = try zz.Program(Model).initWithOptions(gpa.allocator(), .{ .cursor = false, // Show cursor .bracketed_paste = true, // Enable bracketed paste mode .kitty_keyboard = false, // Enable Kitty keyboard protocol + .osc52 = .{ // OSC 52 clipboard defaults + .enabled = true, + .target = .clipboard, // .primary, .secondary, .select, .cut_buffer, .raw + .terminator = .bel, // .bel or .st + .passthrough = .auto, // .auto, .none, .tmux, .dcs + .max_bytes = null, // Optional payload limit + }, .unicode_width_strategy = null, // null=auto, .legacy_wcwidth, .unicode .suspend_enabled = true, // Enable Ctrl+Z suspend/resume .title = "My App", // Window title @@ -714,6 +721,39 @@ pub const Msg = union(enum) { }; ``` +### OSC 52 Clipboard (Output) + +Copy text/bytes to the system clipboard from your app: + +```zig +// Uses Program option defaults (.osc52) +_ = try ctx.setClipboard("Copied from ZigZag"); +``` + +Per-call overrides for edge cases: + +```zig +_ = try ctx.setClipboardWithOptions("Primary selection", .{ + .target = .primary, + .terminator = .st, + .passthrough = .tmux, + .max_bytes = 64 * 1024, +}); +``` + +Advanced/extension example (non-standard selector string): + +```zig +_ = try ctx.setClipboardWithOptions("Custom selector", .{ + .target = .{ .raw = "c" }, +}); +``` + +Notes: +- Returns `false` when disabled (`.osc52.enabled = false`), blocked by guardrails (TTY/size), or unavailable in current output mode. +- `.passthrough = .auto` detects tmux/screen-like environments and wraps OSC 52 in DCS passthrough when needed. +- Terminals differ in security policy and maximum accepted sequence length. Use `.max_bytes` to enforce an app-side ceiling if desired. + ### Suspend/Resume Ctrl+Z support is enabled by default. Handle resume events by adding a `resumed` field: @@ -930,6 +970,7 @@ zig build run-dashboard zig build run-showcase # Multi-tab demo of all features zig build run-focus_form # Focus management with Tab cycling zig build run-tabs # TabGroup multi-screen routing +zig build run-clipboard_osc52 # OSC 52 clipboard output demo ``` ## Building diff --git a/build.zig b/build.zig index baf37d4..a1a830b 100644 --- a/build.zig +++ b/build.zig @@ -24,6 +24,7 @@ pub fn build(b: *std.Build) void { "modal", "tooltip", "tabs", + "clipboard_osc52", }; for (examples) |example_name| { diff --git a/examples/clipboard_osc52.zig b/examples/clipboard_osc52.zig new file mode 100644 index 0000000..2a932e9 --- /dev/null +++ b/examples/clipboard_osc52.zig @@ -0,0 +1,153 @@ +//! ZigZag OSC 52 Clipboard Example +//! Demonstrates outbound clipboard writes with configurable OSC 52 options. + +const std = @import("std"); +const zz = @import("zigzag"); + +const Model = struct { + status: []const u8, + status_buf: [320]u8, + terminator: zz.OscTerminator, + passthrough: zz.Osc52Passthrough, + + pub const Msg = union(enum) { + key: zz.KeyEvent, + }; + + pub fn init(self: *Model, _: *zz.Context) zz.Cmd(Msg) { + self.* = .{ + .status = "Ready. Press 'c' to copy to clipboard.", + .status_buf = undefined, + .terminator = .bel, + .passthrough = .auto, + }; + return .none; + } + + pub fn update(self: *Model, msg: Msg, ctx: *zz.Context) zz.Cmd(Msg) { + switch (msg) { + .key => |k| switch (k.key) { + .escape => return .quit, + .char => |c| switch (c) { + 'q' => return .quit, + 't' => { + self.terminator = switch (self.terminator) { + .bel => .st, + .st => .bel, + }; + self.setStatus("Terminator set to {s}", .{self.terminatorName()}); + }, + 'p' => { + self.passthrough = switch (self.passthrough) { + .auto => .none, + .none => .tmux, + .tmux => .dcs, + .dcs => .auto, + }; + self.setStatus("Passthrough set to {s}", .{self.passthroughName()}); + }, + 'c' => self.copyWith(ctx, "Copied via default target (clipboard)", null), + '1' => self.copyWith(ctx, "Copied to target=c (clipboard)", .clipboard), + '2' => self.copyWith(ctx, "Copied to target=p (primary)", .primary), + '3' => self.copyWith(ctx, "Copied to target=q (secondary)", .secondary), + '4' => self.copyWith(ctx, "Copied to target=s (select)", .select), + else => {}, + }, + else => {}, + }, + } + return .none; + } + + fn copyWith(self: *Model, ctx: *zz.Context, text: []const u8, target: ?zz.Osc52Target) void { + const result = if (target) |t| + ctx.setClipboardWithOptions(text, .{ + .target = t, + .terminator = self.terminator, + .passthrough = self.passthrough, + }) catch false + else + ctx.setClipboardWithOptions(text, .{ + .terminator = self.terminator, + .passthrough = self.passthrough, + }) catch false; + + if (result) { + self.setStatus("Sent: \"{s}\"", .{text}); + } else { + self.setStatus("Clipboard write rejected (disabled / guardrail / terminal policy)", .{}); + } + } + + fn setStatus(self: *Model, comptime fmt: []const u8, args: anytype) void { + self.status = std.fmt.bufPrint(&self.status_buf, fmt, args) catch "status error"; + } + + fn terminatorName(self: *const Model) []const u8 { + return switch (self.terminator) { + .bel => "BEL", + .st => "ST", + }; + } + + fn passthroughName(self: *const Model) []const u8 { + return switch (self.passthrough) { + .auto => "auto", + .none => "none", + .tmux => "tmux", + .dcs => "dcs", + }; + } + + pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 { + var title_style = zz.Style{}; + title_style = title_style.bold(true); + title_style = title_style.fg(zz.Color.cyan()); + title_style = title_style.inline_style(true); + + var info_style = zz.Style{}; + info_style = info_style.fg(zz.Color.gray(16)); + info_style = info_style.inline_style(true); + + var hint_style = zz.Style{}; + hint_style = hint_style.fg(zz.Color.gray(12)); + hint_style = hint_style.inline_style(true); + + const title = title_style.render(ctx.allocator, "OSC 52 Clipboard Demo") catch "OSC 52 Clipboard Demo"; + const mode_line = std.fmt.allocPrint(ctx.allocator, "terminator={s} passthrough={s}", .{ + self.terminatorName(), + self.passthroughName(), + }) catch ""; + const mode = info_style.render(ctx.allocator, mode_line) catch mode_line; + const status = info_style.render(ctx.allocator, self.status) catch self.status; + const hints = hint_style.render(ctx.allocator, "c copy(default) 1/2/3/4 target(c/p/q/s) t terminator p passthrough q quit") catch ""; + + const content = std.fmt.allocPrint(ctx.allocator, "{s}\n\n{s}\n{s}\n\n{s}", .{ + title, + mode, + status, + hints, + }) catch "render error"; + + return zz.place.place(ctx.allocator, ctx.width, ctx.height, .center, .middle, content) catch content; + } +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + + var program = try zz.Program(Model).initWithOptions(gpa.allocator(), .{ + .title = "ZigZag OSC 52 Clipboard", + .osc52 = .{ + .enabled = true, + .target = .clipboard, + .terminator = .bel, + .passthrough = .auto, + .max_bytes = 256 * 1024, + }, + }); + defer program.deinit(); + + try program.run(); +} diff --git a/src/core/context.zig b/src/core/context.zig index 9a90d83..81e08ee 100644 --- a/src/core/context.zig +++ b/src/core/context.zig @@ -167,6 +167,23 @@ pub const Context = struct { return false; } + /// Copy bytes to the system clipboard via OSC 52 using terminal defaults. + /// Returns false when clipboard output is unavailable or disabled. + pub fn setClipboard(self: *Context, bytes: []const u8) !bool { + if (self._terminal) |term| { + return term.setClipboard(bytes); + } + return false; + } + + /// Copy bytes to the system clipboard via OSC 52 with per-call overrides. + pub fn setClipboardWithOptions(self: *Context, bytes: []const u8, options: terminal_mod.Osc52WriteOptions) !bool { + if (self._terminal) |term| { + return term.setClipboardWithOptions(bytes, options); + } + return false; + } + /// Draw a PNG image file via Kitty graphics protocol (`t=f`). /// Returns false when unsupported or path is empty. pub fn drawKittyImageFromFile(self: *Context, path: []const u8, options: Terminal.KittyImageFileOptions) !bool { @@ -309,6 +326,9 @@ pub const Options = struct { /// Enable Kitty keyboard protocol kitty_keyboard: bool = false, + /// OSC 52 clipboard configuration + osc52: terminal_mod.Osc52Config = .{}, + /// Force Unicode width strategy (`null` = auto-detect) unicode_width_strategy: ?unicode_mod.WidthStrategy = null, diff --git a/src/core/program.zig b/src/core/program.zig index 29d39b6..e658ca2 100644 --- a/src/core/program.zig +++ b/src/core/program.zig @@ -166,6 +166,7 @@ pub fn Program(comptime Model: type) type { .input = self.options.input, .output = self.options.output, .kitty_keyboard = self.options.kitty_keyboard, + .osc52 = self.options.osc52, }); // Set title if provided diff --git a/src/root.zig b/src/root.zig index 4ce92ee..22a435b 100644 --- a/src/root.zig +++ b/src/root.zig @@ -195,6 +195,11 @@ pub const CacheImage = command.CacheImage; pub const PlaceCachedImage = command.PlaceCachedImage; pub const DeleteImage = command.DeleteImage; pub const ImageCapabilities = terminal.ImageCapabilities; +pub const Osc52Target = terminal.Osc52Target; +pub const Osc52Passthrough = terminal.Osc52Passthrough; +pub const Osc52Config = terminal.Osc52Config; +pub const Osc52WriteOptions = terminal.Osc52WriteOptions; +pub const OscTerminator = terminal.ansi.OscTerminator; // Color utilities pub const ColorProfile = color.ColorProfile; diff --git a/src/terminal/ansi.zig b/src/terminal/ansi.zig index 48484b9..e264d23 100644 --- a/src/terminal/ansi.zig +++ b/src/terminal/ansi.zig @@ -11,6 +11,17 @@ pub const DCS = ESC ++ "P"; pub const APC = ESC ++ "_"; pub const ST = ESC ++ "\\"; +pub const OscTerminator = enum { + bel, + st, +}; + +pub const Osc52Passthrough = enum { + none, + tmux, + dcs, +}; + // Cursor control pub const cursor_hide = CSI ++ "?25l"; pub const cursor_show = CSI ++ "?25h"; @@ -135,6 +146,85 @@ pub fn setTitle(writer: anytype, title: []const u8) !void { try writer.print(OSC ++ "0;{s}\x07", .{title}); } +fn writeOscTerminator(writer: anytype, terminator: OscTerminator) !void { + switch (terminator) { + .bel => try writer.writeAll("\x07"), + .st => try writer.writeAll(ST), + } +} + +fn writeEscapedForDcs(writer: anytype, bytes: []const u8) !void { + var start: usize = 0; + for (bytes, 0..) |byte, idx| { + if (byte != 0x1b) continue; + + if (idx > start) { + try writer.writeAll(bytes[start..idx]); + } + try writer.writeAll(ESC ++ ESC); + start = idx + 1; + } + + if (start < bytes.len) { + try writer.writeAll(bytes[start..]); + } +} + +/// Start an OSC 52 sequence and write the fixed header: +/// `OSC 52 ; ;` +pub fn osc52Start( + writer: anytype, + target: []const u8, + passthrough: Osc52Passthrough, +) !void { + switch (passthrough) { + .none => { + try writer.writeAll(OSC ++ "52;"); + try writer.writeAll(target); + try writer.writeAll(";"); + }, + .tmux => { + try writer.writeAll(DCS ++ "tmux;"); + try writeEscapedForDcs(writer, OSC ++ "52;"); + try writeEscapedForDcs(writer, target); + try writeEscapedForDcs(writer, ";"); + }, + .dcs => { + try writer.writeAll(DCS); + try writeEscapedForDcs(writer, OSC ++ "52;"); + try writeEscapedForDcs(writer, target); + try writeEscapedForDcs(writer, ";"); + }, + } +} + +/// Finish an OSC 52 sequence started by `osc52Start`. +pub fn osc52End(writer: anytype, terminator: OscTerminator, passthrough: Osc52Passthrough) !void { + switch (passthrough) { + .none => try writeOscTerminator(writer, terminator), + .tmux, .dcs => { + switch (terminator) { + .bel => try writer.writeAll("\x07"), + .st => try writer.writeAll(ESC ++ ESC ++ "\\"), + } + try writer.writeAll(ST); + }, + } +} + +/// Write a complete OSC 52 sequence with a pre-encoded base64 payload. +pub fn osc52Encoded( + writer: anytype, + target: []const u8, + payload_b64: []const u8, + terminator: OscTerminator, + passthrough: Osc52Passthrough, +) !void { + try osc52Start(writer, target, passthrough); + try writer.writeAll(payload_b64); + try osc52End(writer, terminator, passthrough); +} + /// SGR (Select Graphic Rendition) codes pub const SGR = struct { pub const reset = 0; @@ -251,3 +341,31 @@ pub fn iterm2InlineImage(writer: anytype, params: []const u8, payload: []const u try writer.writeAll(payload); try writer.writeAll("\x07"); } + +test "osc52Encoded direct BEL" { + var buf: [128]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + try osc52Encoded(stream.writer(), "c", "YQ==", .bel, .none); + try std.testing.expectEqualStrings("\x1b]52;c;YQ==\x07", stream.getWritten()); +} + +test "osc52Encoded direct ST" { + var buf: [128]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + try osc52Encoded(stream.writer(), "c", "YQ==", .st, .none); + try std.testing.expectEqualStrings("\x1b]52;c;YQ==\x1b\\", stream.getWritten()); +} + +test "osc52Encoded tmux passthrough BEL" { + var buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + try osc52Encoded(stream.writer(), "c", "YQ==", .bel, .tmux); + try std.testing.expectEqualStrings("\x1bPtmux;\x1b\x1b]52;c;YQ==\x07\x1b\\", stream.getWritten()); +} + +test "osc52Encoded tmux passthrough ST" { + var buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + try osc52Encoded(stream.writer(), "c", "YQ==", .st, .tmux); + try std.testing.expectEqualStrings("\x1bPtmux;\x1b\x1b]52;c;YQ==\x1b\x1b\\\x1b\\", stream.getWritten()); +} diff --git a/src/terminal/terminal.zig b/src/terminal/terminal.zig index a4ed593..23fe857 100644 --- a/src/terminal/terminal.zig +++ b/src/terminal/terminal.zig @@ -156,6 +156,65 @@ pub const SixelImageFileOptions = struct { height_pixels: ?u32 = null, }; +/// OSC 52 clipboard target selector. +/// Standard values are `c`, `p`, `q`, `s`, or cut buffers `0`..`7`. +pub const Osc52Target = union(enum) { + clipboard, + primary, + secondary, + select, + cut_buffer: u3, + raw: []const u8, + + fn encode(self: Osc52Target, scratch: *[1]u8) []const u8 { + return switch (self) { + .clipboard => "c", + .primary => "p", + .secondary => "q", + .select => "s", + .cut_buffer => |n| blk: { + scratch[0] = @as(u8, '0') + @as(u8, n); + break :blk scratch[0..1]; + }, + .raw => |value| value, + }; + } +}; + +/// OSC 52 passthrough strategy. +/// `tmux` and `dcs` wrap OSC inside DCS passthrough for multiplexers. +pub const Osc52Passthrough = enum { + auto, + none, + tmux, + dcs, +}; + +/// Default OSC 52 behavior for this terminal instance. +pub const Osc52Config = struct { + /// Master switch for clipboard writes. + enabled: bool = true, + /// Require a TTY before sending OSC 52. + require_tty: bool = true, + /// Default target selection. + target: Osc52Target = .clipboard, + /// Sequence terminator (BEL is widely compatible). + terminator: ansi.OscTerminator = .bel, + /// Passthrough mode (`auto` detects tmux/screen-like environments). + passthrough: Osc52Passthrough = .auto, + /// Optional input payload limit (bytes). `null` = no library limit. + max_bytes: ?usize = null, +}; + +/// Per-call OSC 52 overrides. +pub const Osc52WriteOptions = struct { + target: ?Osc52Target = null, + terminator: ?ansi.OscTerminator = null, + passthrough: ?Osc52Passthrough = null, + require_tty: ?bool = null, + max_bytes: ?usize = null, +}; + /// Terminal configuration options pub const Config = struct { /// Use alternate screen buffer @@ -172,6 +231,8 @@ pub const Config = struct { output: ?std.fs.File = null, /// Enable Kitty keyboard protocol kitty_keyboard: bool = false, + /// OSC 52 clipboard configuration + osc52: Osc52Config = .{}, }; /// Terminal abstraction @@ -390,6 +451,37 @@ pub const Terminal = struct { try self.writeBytes("\x07"); } + /// Copy bytes to the system clipboard using OSC 52 with instance defaults. + /// Returns `false` when disabled by config, rejected by local guardrails, or not suitable for this output. + pub fn setClipboard(self: *Terminal, bytes: []const u8) !bool { + return self.setClipboardWithOptions(bytes, .{}); + } + + /// Copy bytes to the system clipboard using OSC 52 with per-call overrides. + pub fn setClipboardWithOptions(self: *Terminal, bytes: []const u8, options: Osc52WriteOptions) !bool { + if (!self.config.osc52.enabled) return false; + + const require_tty = options.require_tty orelse self.config.osc52.require_tty; + if (require_tty and !self.isTty()) return false; + + const max_bytes = options.max_bytes orelse self.config.osc52.max_bytes; + if (max_bytes) |limit| { + if (bytes.len > limit) return false; + } + + const terminator = options.terminator orelse self.config.osc52.terminator; + const passthrough_mode = options.passthrough orelse self.config.osc52.passthrough; + const passthrough = self.resolveOsc52Passthrough(passthrough_mode); + + var target_scratch: [1]u8 = undefined; + const target = (options.target orelse self.config.osc52.target).encode(&target_scratch); + + try ansi.osc52Start(self.writer(), target, passthrough); + try self.writeBase64(bytes); + try ansi.osc52End(self.writer(), terminator, passthrough); + return true; + } + /// Write a string at position pub fn writeAt(self: *Terminal, row: u16, col: u16, str: []const u8) !void { try self.moveTo(row, col); @@ -890,6 +982,36 @@ pub const Terminal = struct { }; } + fn resolveOsc52Passthrough(self: *const Terminal, mode: Osc52Passthrough) ansi.Osc52Passthrough { + _ = self; + return switch (mode) { + .none => .none, + .tmux => .tmux, + .dcs => .dcs, + .auto => blk: { + if (envVarExists("TMUX")) break :blk .tmux; + if (envVarContains("TERM", "screen")) break :blk .dcs; + break :blk .none; + }, + }; + } + + fn writeBase64(self: *Terminal, bytes: []const u8) !void { + const encoder = std.base64.standard.Encoder; + var b64_buf: [4096]u8 = undefined; + const raw_chunk_max: usize = (b64_buf.len / 4) * 3; + + var src_index: usize = 0; + while (src_index < bytes.len) { + const take = @min(bytes.len - src_index, raw_chunk_max); + const chunk = bytes[src_index .. src_index + take]; + const encoded_len = encoder.calcSize(chunk.len); + const encoded = encoder.encode(b64_buf[0..encoded_len], chunk); + try self.writeBytes(encoded); + src_index += take; + } + } + fn sendKittyGraphicsPayload(self: *Terminal, first_params: []const u8, payload: []const u8) !void { const encoder = std.base64.standard.Encoder; var b64_buf: [4096]u8 = undefined; From 61688d308e4ba9160541dba505d77cdc183d3fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Fri, 6 Mar 2026 12:04:42 +0100 Subject: [PATCH 2/2] feat: add OSC 52 clipboard query (read) support --- README.md | 27 ++++- examples/clipboard_osc52.zig | 58 ++++++++- src/core/context.zig | 18 +++ src/root.zig | 1 + src/terminal/terminal.zig | 226 ++++++++++++++++++++++++++++++++++- 5 files changed, 325 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9181393..c7d0951 100644 --- a/README.md +++ b/README.md @@ -642,10 +642,14 @@ var program = try zz.Program(Model).initWithOptions(gpa.allocator(), .{ .kitty_keyboard = false, // Enable Kitty keyboard protocol .osc52 = .{ // OSC 52 clipboard defaults .enabled = true, + .query_enabled = true, // Allow OSC 52 clipboard reads (query) .target = .clipboard, // .primary, .secondary, .select, .cut_buffer, .raw .terminator = .bel, // .bel or .st .passthrough = .auto, // .auto, .none, .tmux, .dcs - .max_bytes = null, // Optional payload limit + .max_bytes = null, // Optional write payload limit + .query_timeout_ms = 180, + .max_read_bytes = null, // Optional decoded read limit + .strict_query_target = false, }, .unicode_width_strategy = null, // null=auto, .legacy_wcwidth, .unicode .suspend_enabled = true, // Enable Ctrl+Z suspend/resume @@ -721,7 +725,7 @@ pub const Msg = union(enum) { }; ``` -### OSC 52 Clipboard (Output) +### OSC 52 Clipboard (Copy + Query) Copy text/bytes to the system clipboard from your app: @@ -730,6 +734,14 @@ Copy text/bytes to the system clipboard from your app: _ = try ctx.setClipboard("Copied from ZigZag"); ``` +Query clipboard bytes back from the terminal: + +```zig +if (try ctx.getClipboard(ctx.allocator)) |clip| { + // clip is decoded bytes from OSC 52 response +} +``` + Per-call overrides for edge cases: ```zig @@ -739,6 +751,15 @@ _ = try ctx.setClipboardWithOptions("Primary selection", .{ .passthrough = .tmux, .max_bytes = 64 * 1024, }); + +if (try ctx.getClipboardWithOptions(ctx.allocator, .{ + .target = .clipboard, + .timeout_ms = 250, + .passthrough = .auto, + .strict_target = true, +})) |clip| { + _ = clip; +} ``` Advanced/extension example (non-standard selector string): @@ -751,8 +772,10 @@ _ = try ctx.setClipboardWithOptions("Custom selector", .{ Notes: - Returns `false` when disabled (`.osc52.enabled = false`), blocked by guardrails (TTY/size), or unavailable in current output mode. +- Query returns `null` when disabled/blocked/timed out/no response/invalid payload. - `.passthrough = .auto` detects tmux/screen-like environments and wraps OSC 52 in DCS passthrough when needed. - Terminals differ in security policy and maximum accepted sequence length. Use `.max_bytes` to enforce an app-side ceiling if desired. +- The `run-clipboard_osc52` example also handles `Msg.paste` (bracketed paste input) to demonstrate inbound paste events. ### Suspend/Resume diff --git a/examples/clipboard_osc52.zig b/examples/clipboard_osc52.zig index 2a932e9..656592e 100644 --- a/examples/clipboard_osc52.zig +++ b/examples/clipboard_osc52.zig @@ -1,5 +1,5 @@ //! ZigZag OSC 52 Clipboard Example -//! Demonstrates outbound clipboard writes with configurable OSC 52 options. +//! Demonstrates clipboard writes and reads with configurable OSC 52 options. const std = @import("std"); const zz = @import("zigzag"); @@ -12,6 +12,7 @@ const Model = struct { pub const Msg = union(enum) { key: zz.KeyEvent, + paste: []const u8, }; pub fn init(self: *Model, _: *zz.Context) zz.Cmd(Msg) { @@ -51,10 +52,14 @@ const Model = struct { '2' => self.copyWith(ctx, "Copied to target=p (primary)", .primary), '3' => self.copyWith(ctx, "Copied to target=q (secondary)", .secondary), '4' => self.copyWith(ctx, "Copied to target=s (select)", .select), + 'g' => self.readClipboard(ctx), else => {}, }, else => {}, }, + .paste => |text| { + self.setStatusFromBytes("Pasted", text); + }, } return .none; } @@ -83,6 +88,52 @@ const Model = struct { self.status = std.fmt.bufPrint(&self.status_buf, fmt, args) catch "status error"; } + fn readClipboard(self: *Model, ctx: *zz.Context) void { + const result = ctx.getClipboardWithOptions(ctx.allocator, .{ + .terminator = self.terminator, + .passthrough = self.passthrough, + }) catch null; + + if (result) |bytes| { + self.setStatusFromBytes("Read", bytes); + } else { + self.setStatus("Clipboard read unavailable (blocked / timeout / terminal policy)", .{}); + } + } + + fn setStatusFromBytes(self: *Model, label: []const u8, bytes: []const u8) void { + var out: [220]u8 = undefined; + var prefix_buf: [32]u8 = undefined; + const prefix = std.fmt.bufPrint(&prefix_buf, "{s}: \"", .{label}) catch "Data: \""; + const suffix = "\""; + if (out.len <= prefix.len + suffix.len + 4) { + self.status = "Read clipboard"; + return; + } + + var pos: usize = 0; + @memcpy(out[pos .. pos + prefix.len], prefix); + pos += prefix.len; + + const max_inner = out.len - prefix.len - suffix.len; + var shown: usize = 0; + while (shown < bytes.len and shown < max_inner) : (shown += 1) { + const b = bytes[shown]; + out[pos] = if (b >= 32 and b <= 126) b else '.'; + pos += 1; + } + if (shown < bytes.len and pos + 3 <= out.len - suffix.len) { + out[pos] = '.'; + out[pos + 1] = '.'; + out[pos + 2] = '.'; + pos += 3; + } + @memcpy(out[pos .. pos + suffix.len], suffix); + pos += suffix.len; + + self.status = std.fmt.bufPrint(&self.status_buf, "{s}", .{out[0..pos]}) catch "read status error"; + } + fn terminatorName(self: *const Model) []const u8 { return switch (self.terminator) { .bel => "BEL", @@ -120,7 +171,7 @@ const Model = struct { }) catch ""; const mode = info_style.render(ctx.allocator, mode_line) catch mode_line; const status = info_style.render(ctx.allocator, self.status) catch self.status; - const hints = hint_style.render(ctx.allocator, "c copy(default) 1/2/3/4 target(c/p/q/s) t terminator p passthrough q quit") catch ""; + const hints = hint_style.render(ctx.allocator, "c copy(default) g read(query) paste shortcut sends Msg.paste 1/2/3/4 target(c/p/q/s) t terminator p passthrough q quit") catch ""; const content = std.fmt.allocPrint(ctx.allocator, "{s}\n\n{s}\n{s}\n\n{s}", .{ title, @@ -141,10 +192,13 @@ pub fn main() !void { .title = "ZigZag OSC 52 Clipboard", .osc52 = .{ .enabled = true, + .query_enabled = true, .target = .clipboard, .terminator = .bel, .passthrough = .auto, .max_bytes = 256 * 1024, + .query_timeout_ms = 250, + .max_read_bytes = 256 * 1024, }, }); defer program.deinit(); diff --git a/src/core/context.zig b/src/core/context.zig index 81e08ee..6c0c0c5 100644 --- a/src/core/context.zig +++ b/src/core/context.zig @@ -184,6 +184,24 @@ pub const Context = struct { return false; } + /// Query clipboard bytes via OSC 52 using terminal defaults. + /// Returns null when unavailable, blocked, or timed out. + pub fn getClipboard(self: *Context, allocator: std.mem.Allocator) !?[]u8 { + if (self._terminal) |term| { + return term.getClipboard(allocator); + } + return null; + } + + /// Query clipboard bytes via OSC 52 with per-call overrides. + /// Returns null when unavailable, blocked, or timed out. + pub fn getClipboardWithOptions(self: *Context, allocator: std.mem.Allocator, options: terminal_mod.Osc52ReadOptions) !?[]u8 { + if (self._terminal) |term| { + return term.getClipboardWithOptions(allocator, options); + } + return null; + } + /// Draw a PNG image file via Kitty graphics protocol (`t=f`). /// Returns false when unsupported or path is empty. pub fn drawKittyImageFromFile(self: *Context, path: []const u8, options: Terminal.KittyImageFileOptions) !bool { diff --git a/src/root.zig b/src/root.zig index 22a435b..e400941 100644 --- a/src/root.zig +++ b/src/root.zig @@ -199,6 +199,7 @@ pub const Osc52Target = terminal.Osc52Target; pub const Osc52Passthrough = terminal.Osc52Passthrough; pub const Osc52Config = terminal.Osc52Config; pub const Osc52WriteOptions = terminal.Osc52WriteOptions; +pub const Osc52ReadOptions = terminal.Osc52ReadOptions; pub const OscTerminator = terminal.ansi.OscTerminator; // Color utilities diff --git a/src/terminal/terminal.zig b/src/terminal/terminal.zig index 23fe857..477544c 100644 --- a/src/terminal/terminal.zig +++ b/src/terminal/terminal.zig @@ -194,6 +194,8 @@ pub const Osc52Passthrough = enum { pub const Osc52Config = struct { /// Master switch for clipboard writes. enabled: bool = true, + /// Allow OSC 52 clipboard queries (`?`) for reading clipboard content. + query_enabled: bool = true, /// Require a TTY before sending OSC 52. require_tty: bool = true, /// Default target selection. @@ -204,6 +206,12 @@ pub const Osc52Config = struct { passthrough: Osc52Passthrough = .auto, /// Optional input payload limit (bytes). `null` = no library limit. max_bytes: ?usize = null, + /// Query timeout for clipboard reads. + query_timeout_ms: i32 = 180, + /// Optional decoded output limit for clipboard reads. + max_read_bytes: ?usize = null, + /// Require selector match on responses (strict mode). + strict_query_target: bool = false, }; /// Per-call OSC 52 overrides. @@ -215,6 +223,17 @@ pub const Osc52WriteOptions = struct { max_bytes: ?usize = null, }; +/// Per-call OSC 52 clipboard query overrides. +pub const Osc52ReadOptions = struct { + target: ?Osc52Target = null, + terminator: ?ansi.OscTerminator = null, + passthrough: ?Osc52Passthrough = null, + require_tty: ?bool = null, + timeout_ms: ?i32 = null, + max_bytes: ?usize = null, + strict_target: ?bool = null, +}; + /// Terminal configuration options pub const Config = struct { /// Use alternate screen buffer @@ -243,6 +262,8 @@ pub const Terminal = struct { stdin: std.fs.File, write_buffer: [4096]u8 = undefined, write_pos: usize = 0, + pending_input: [8192]u8 = undefined, + pending_input_len: usize = 0, unicode_width_caps: UnicodeWidthCapabilities = .{}, image_caps: ImageCapabilities = .{}, @@ -382,7 +403,16 @@ pub const Terminal = struct { /// Read input with timeout (in milliseconds) pub fn readInput(self: *Terminal, buffer: []u8, timeout_ms: i32) !usize { - return platform.readInput(&self.state, buffer, timeout_ms); + if (self.pending_input_len > 0) { + const take = @min(buffer.len, self.pending_input_len); + @memcpy(buffer[0..take], self.pending_input[0..take]); + if (take < self.pending_input_len) { + std.mem.copyForwards(u8, self.pending_input[0 .. self.pending_input_len - take], self.pending_input[take..self.pending_input_len]); + } + self.pending_input_len -= take; + return take; + } + return self.readPlatformInput(buffer, timeout_ms); } /// Check if terminal was resized @@ -482,6 +512,57 @@ pub const Terminal = struct { return true; } + /// Query clipboard bytes via OSC 52 (`...?;?`). + /// Returns `null` when unsupported, disabled, timed out, or rejected by guardrails. + pub fn getClipboard(self: *Terminal, allocator: std.mem.Allocator) !?[]u8 { + return self.getClipboardWithOptions(allocator, .{}); + } + + /// Query clipboard bytes via OSC 52 (`...?;?`) with per-call overrides. + /// Returns `null` when unsupported, disabled, timed out, or rejected by guardrails. + pub fn getClipboardWithOptions(self: *Terminal, allocator: std.mem.Allocator, options: Osc52ReadOptions) !?[]u8 { + if (!self.config.osc52.enabled or !self.config.osc52.query_enabled) return null; + + const require_tty = options.require_tty orelse self.config.osc52.require_tty; + if (require_tty and !self.isTty()) return null; + + const timeout_ms = options.timeout_ms orelse self.config.osc52.query_timeout_ms; + const strict_target = options.strict_target orelse self.config.osc52.strict_query_target; + const max_bytes = options.max_bytes orelse self.config.osc52.max_read_bytes; + + const terminator = options.terminator orelse self.config.osc52.terminator; + const passthrough_mode = options.passthrough orelse self.config.osc52.passthrough; + const passthrough = self.resolveOsc52Passthrough(passthrough_mode); + + var target_scratch: [1]u8 = undefined; + const target = (options.target orelse self.config.osc52.target).encode(&target_scratch); + + try ansi.osc52Start(self.writer(), target, passthrough); + try self.writeBytes("?"); + try ansi.osc52End(self.writer(), terminator, passthrough); + try self.flush(); + + var collected = std.array_list.Managed(u8).init(allocator); + defer collected.deinit(); + + const deadline_ms = std.time.milliTimestamp() + timeout_ms; + while (std.time.milliTimestamp() < deadline_ms) { + var chunk: [256]u8 = undefined; + const n = self.readPlatformInput(&chunk, 30) catch 0; + if (n == 0) continue; + try collected.appendSlice(chunk[0..n]); + + if (parseOsc52Response(collected.items, target, strict_target)) |parsed| { + const decoded = decodeOsc52Payload(allocator, parsed.payload_b64, max_bytes) catch null; + self.queueInputExceptRange(collected.items, parsed.consume_start, parsed.consume_end); + return decoded; + } + } + + self.queueInput(collected.items); + return null; + } + /// Write a string at position pub fn writeAt(self: *Terminal, row: u16, col: u16, str: []const u8) !void { try self.moveTo(row, col); @@ -982,6 +1063,117 @@ pub const Terminal = struct { }; } + const Osc52ParsedResponse = struct { + payload_b64: []const u8, + consume_start: usize, + consume_end: usize, + }; + + fn parseOsc52Response(bytes: []const u8, expected_target: []const u8, strict_target: bool) ?Osc52ParsedResponse { + const prefix = "\x1b]52;"; + var search_from: usize = 0; + + while (search_from < bytes.len) { + const start = std.mem.indexOfPos(u8, bytes, search_from, prefix) orelse return null; + const selector_start = start + prefix.len; + const selector_end = std.mem.indexOfScalarPos(u8, bytes, selector_start, ';') orelse return null; + const payload_start = selector_end + 1; + const osc_end = indexOfOscTerminator(bytes, payload_start) orelse return null; + const osc_term_len: usize = if (bytes[osc_end] == 0x07) 1 else 2; + + const selector = bytes[selector_start..selector_end]; + if (strict_target and !std.mem.eql(u8, selector, expected_target)) { + search_from = selector_end + 1; + continue; + } + + var payload_end = osc_end; + if (start > 0 and bytes[start - 1] == 0x1b and bytes[osc_end] == 0x1b and osc_end + 1 < bytes.len and bytes[osc_end + 1] == '\\') { + // DCS passthrough can encode inner ST as ESC ESC \. + if (payload_end > payload_start) payload_end -= 1; + } + + var consume_start = start; + var consume_end = osc_end + osc_term_len; + + if (findOpenDcsStart(bytes, start)) |dcs_start| { + if (indexOfSt(bytes, consume_end)) |outer_st| { + consume_start = dcs_start; + consume_end = outer_st + 2; + } + } + + return .{ + .payload_b64 = bytes[payload_start..payload_end], + .consume_start = consume_start, + .consume_end = consume_end, + }; + } + + return null; + } + + fn findOpenDcsStart(bytes: []const u8, pos: usize) ?usize { + if (pos == 0) return null; + + var i: usize = 0; + var open: ?usize = null; + while (i + 1 < pos) : (i += 1) { + if (bytes[i] != 0x1b) continue; + if (bytes[i + 1] == 'P') { + open = i; + i += 1; + continue; + } + if (bytes[i + 1] == '\\') { + open = null; + i += 1; + continue; + } + } + return open; + } + + fn decodeOsc52Payload(allocator: std.mem.Allocator, payload_b64: []const u8, max_bytes: ?usize) !?[]u8 { + if (payload_b64.len == 0 or (payload_b64.len == 1 and payload_b64[0] == '?')) return null; + + const decoder = std.base64.standard.Decoder; + const out_len = decoder.calcSizeForSlice(payload_b64) catch return null; + + if (max_bytes) |limit| { + if (out_len > limit) return null; + } + + const out = try allocator.alloc(u8, out_len); + errdefer allocator.free(out); + _ = decoder.decode(out, payload_b64) catch return null; + return out; + } + + fn readPlatformInput(self: *Terminal, buffer: []u8, timeout_ms: i32) !usize { + return platform.readInput(&self.state, buffer, timeout_ms); + } + + fn queueInput(self: *Terminal, bytes: []const u8) void { + if (bytes.len == 0) return; + + const free_space = self.pending_input.len - self.pending_input_len; + const take = @min(bytes.len, free_space); + if (take == 0) return; + + @memcpy(self.pending_input[self.pending_input_len .. self.pending_input_len + take], bytes[0..take]); + self.pending_input_len += take; + } + + fn queueInputExceptRange(self: *Terminal, bytes: []const u8, start: usize, end: usize) void { + if (start > 0) { + self.queueInput(bytes[0..start]); + } + if (end < bytes.len) { + self.queueInput(bytes[end..]); + } + } + fn resolveOsc52Passthrough(self: *const Terminal, mode: Osc52Passthrough) ansi.Osc52Passthrough { _ = self; return switch (mode) { @@ -1617,3 +1809,35 @@ pub const Terminal = struct { } }; }; + +test "parseOsc52Response direct BEL" { + const bytes = "\x1b]52;c;YQ==\x07"; + const parsed = Terminal.parseOsc52Response(bytes, "c", true).?; + try std.testing.expectEqual(@as(usize, 0), parsed.consume_start); + try std.testing.expectEqual(bytes.len, parsed.consume_end); + try std.testing.expectEqualStrings("YQ==", parsed.payload_b64); +} + +test "parseOsc52Response direct ST" { + const bytes = "\x1b]52;c;YQ==\x1b\\"; + const parsed = Terminal.parseOsc52Response(bytes, "c", true).?; + try std.testing.expectEqual(@as(usize, 0), parsed.consume_start); + try std.testing.expectEqual(bytes.len, parsed.consume_end); + try std.testing.expectEqualStrings("YQ==", parsed.payload_b64); +} + +test "parseOsc52Response tmux passthrough BEL" { + const bytes = "\x1bPtmux;\x1b\x1b]52;c;YQ==\x07\x1b\\"; + const parsed = Terminal.parseOsc52Response(bytes, "c", true).?; + try std.testing.expectEqual(@as(usize, 0), parsed.consume_start); + try std.testing.expectEqual(bytes.len, parsed.consume_end); + try std.testing.expectEqualStrings("YQ==", parsed.payload_b64); +} + +test "parseOsc52Response tmux passthrough ST" { + const bytes = "\x1bPtmux;\x1b\x1b]52;c;YQ==\x1b\x1b\\\x1b\\"; + const parsed = Terminal.parseOsc52Response(bytes, "c", true).?; + try std.testing.expectEqual(@as(usize, 0), parsed.consume_start); + try std.testing.expectEqual(bytes.len, parsed.consume_end); + try std.testing.expectEqualStrings("YQ==", parsed.payload_b64); +}