Compare commits

..

4 commits
0.1.0 ... main

Author SHA1 Message Date
2178c4d839
feat(OSC52): added tmux autodetect
All checks were successful
ci/crow/push/release Pipeline was successful
ci/crow/tag/build Pipeline was successful
2026-07-06 17:15:47 -06:00
130e668dd5
feat(OSC52): added tmux mode to use tmux passthrough 2026-07-06 17:15:47 -06:00
ec524f039c
fix: added os comptime check for running ForkOut 2026-07-06 17:15:47 -06:00
c228995f20
feat: added ForkOut backend and now forks out to xclip and wl-copy on linux
All checks were successful
ci/crow/push/release Pipeline was successful
ci/crow/tag/build Pipeline was successful
2026-07-03 20:40:06 -06:00
5 changed files with 112 additions and 23 deletions

View file

@ -1,6 +1,6 @@
.{
.name = .tpb,
.version = "0.1.0",
.version = "0.1.2",
.fingerprint = 0x203a601c46df0265,
.minimum_zig_version = "0.16.0",
.paths = .{

View file

@ -7,6 +7,8 @@ pub const OSC52 = @import("Clip/OSC52.zig");
pub const NSPasteboard =
if (builtin.target.os.tag == .macos) @import("Clip/NSPasteboard.zig") else void;
pub const ForkOut = @import("Clip/ForkOut.zig");
// I am not sure if this is super idiomatic Zig or if this is bad practice, but
// by ensuring that all Clip backends implement the same methods, we are able
// to use the `anytype` type and we are able to have an interface that is able

24
src/Clip/ForkOut.zig Normal file
View file

@ -0,0 +1,24 @@
/// ForkOut Clipboard backend
/// Fulfills `Clip` interface
pub const ForkOut = @This();
const std = @import("std");
extern fn sendPB(text: [*:0]const u8) void;
extern fn initPB() void;
io: std.Io,
child: std.process.Child,
pub fn init(io: std.Io, argv: []const []const u8) !ForkOut {
return .{ .child = try std.process.spawn(io, .{ .argv = argv, .stdin = .pipe }), .io = io };
}
pub fn writeCopyBuffer(self: *ForkOut, buf: []const u8) anyerror!void {
try self.child.stdin.?.writeStreamingAll(self.io, buf);
}
pub fn writePasteboard(self: *ForkOut) anyerror!void {
self.child.stdin.?.close(self.io);
}

View file

@ -12,16 +12,29 @@ base64buffer: std.ArrayList(u8),
leftover_bytes: [3]u8,
leftover_count: u8,
/// This is to wrap the OSC buffer in the tmux passthrough sequence. This lets
/// OSC52 clipboard requests go directly to the host instead of going to a tmux
/// buffer.
/// https://github.com/tmux/tmux/wiki/FAQ#what-is-the-passthrough-escape-sequence-and-how-do-i-use-it
/// Since tmux v3.7, there is a `get-clipboard` option and when it is set to
/// `request` any OSC52 query that comes to it will cause the tmux buffer to
/// update before sending the information.
/// https://raw.githubusercontent.com/tmux/tmux/3.7b/CHANGES
/// This still wraps the tmux session in an escape whenever one is detected
/// since this option is still incredibly new.
is_tmux: bool,
const base64encoder =
std.base64.Base64Encoder.init(std.base64.standard_alphabet_chars, '=');
pub fn init(alloc: std.mem.Allocator, io: std.Io, buf_size: usize) !OSC52 {
pub fn init(alloc: std.mem.Allocator, io: std.Io, buf_size: usize, is_tmux: bool) !OSC52 {
return .{
.alloc = alloc,
.io = io,
.base64buffer = try std.ArrayList(u8).initCapacity(alloc, buf_size),
.leftover_bytes = undefined,
.leftover_count = 0,
.is_tmux = is_tmux,
};
}
@ -71,7 +84,29 @@ pub fn writePasteboard(self: *OSC52) anyerror!void {
try self.encodeRemaining();
// https://ghostty.org/docs/vt/osc/52
const osc_header = &[_]u8{
// Write data to stdout
const stdout = std.Io.File.stdout();
try stdout.writeStreamingAll(self.io, self.osc_header());
try stdout.writeStreamingAll(self.io, self.base64buffer.items);
try stdout.writeStreamingAll(self.io, self.osc_footer());
}
fn osc_header(self: *OSC52) []const u8 {
if (self.is_tmux) {
return &[_]u8{
0x1b, // ESC
'P', 't', 'm', 'u', 'x', ';', // tmux escape
0x1b, 0x1b, // ESC ESC
0x5d, // ']'
0x35, 0x32, // Code 52
0x3b, // ';'
// 0x70, // 'p' Primary Clipboard
0x63, // 'c' Standard Clipboard
0x3b, // ';'
};
} else {
return &[_]u8{
0x1b, // ESC
0x5d, // ']'
0x35, 0x32, // Code 52
@ -80,20 +115,27 @@ pub fn writePasteboard(self: *OSC52) anyerror!void {
0x63, // 'c' Standard Clipboard
0x3b, // ';'
};
const osc_footer = &[_]u8{
}
}
fn osc_footer(self: *OSC52) []const u8 {
if (self.is_tmux) {
return &[_]u8{
0x1b, 0x1b, // ESC ESC
0x5c, // '\'
0x1b, // ESC
0x5c, // '\'
};
// Write data to stdout
const stdout = std.Io.File.stdout();
try stdout.writeStreamingAll(self.io, osc_header);
try stdout.writeStreamingAll(self.io, self.base64buffer.items);
try stdout.writeStreamingAll(self.io, osc_footer);
} else {
return &[_]u8{
0x1b, // ESC
0x5c, // '\'
};
}
}
test "basic base64 encode" {
var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024);
var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024, false);
defer osc.free();
try osc.writeCopyBuffer("part1");
try osc.encodeRemaining();
@ -101,7 +143,7 @@ test "basic base64 encode" {
}
test "multi part base64 encode" {
var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024);
var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024, false);
defer osc.free();
try osc.writeCopyBuffer("part1");
try osc.writeCopyBuffer("part2");
@ -110,7 +152,7 @@ test "multi part base64 encode" {
}
test "tiny buffers base64 encode" {
var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024);
var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024, false);
defer osc.free();
try osc.writeCopyBuffer("part1");
try osc.writeCopyBuffer("part2");
@ -126,7 +168,7 @@ test "tiny buffers base64 encode" {
}
test "big buffers base64 encode" {
var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024);
var osc = try OSC52.init(std.testing.allocator, std.testing.io, 1024, false);
defer osc.free();
try osc.writeCopyBuffer("part1part1part1part1part1part1part1part1part1|");
try osc.writeCopyBuffer("part2part2part2part2part2part2part2part2part2");

View file

@ -28,8 +28,11 @@ pub fn main(init: std.process.Init) !void {
init.environ_map.get("SSH_CLIENT") != null or
init.environ_map.get("SSH_TTY") != null;
const is_tmux =
init.environ_map.get("TMUX") != null;
if (is_ssh) {
var clip = try Clip.OSC52.init(alloc, io, size);
var clip = try Clip.OSC52.init(alloc, io, size, is_tmux);
defer clip.free();
try run(io, &clip);
return;
@ -42,8 +45,26 @@ pub fn main(init: std.process.Init) !void {
return;
}
// I don't like this but it will stay until I review how dispatch works
// with zig since I will probably be refactoring there
linux: {
if (comptime os == .linux) {
const xdg_type = init.environ_map.get("XDG_SESSION_TYPE") orelse "";
if (std.mem.eql(u8, xdg_type, "wayland")) {
var clip = Clip.ForkOut.init(io, &[_][]const u8{"wl-copy"}) catch break :linux;
try run(io, &clip);
return;
} else if (std.mem.eql(u8, xdg_type, "x11")) {
var clip = Clip.ForkOut.init(io, &[_][]const u8{ "xclip", "-selection", "clipboard" }) catch break :linux;
try run(io, &clip);
return;
}
}
}
// Fallback Clipboard
var clip = try Clip.OSC52.init(alloc, io, size);
var clip = try Clip.OSC52.init(alloc, io, size, is_tmux);
defer clip.free();
try run(io, &clip);
}