Skip to content

Commit 79f04a1

Browse files
dfergDavid Ferguson
andauthored
feat: add clipboard functionality with terminal multiplexer support (#123)
- Add OSC 52 clipboard integration for copying text to system clipboard - Support for tmux, GNU Screen, and Zellij terminal multiplexers - Implement proper DCS passthrough wrapping for each multiplexer type - Add base64 dependency for OSC 52 sequence encoding Co-authored-by: David Ferguson <david.ferguson@broadcom.com>
1 parent a02bdf4 commit 79f04a1

File tree

3 files changed

+104
-3
lines changed

3 files changed

+104
-3
lines changed

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ description = "A command line tool to view objects (json/yaml/toml) in TUI tree
1212

1313
[dependencies]
1414
anyhow = "^1"
15+
base64 = "^0"
1516
clap = { version = "^4", features = ["derive"] }
1617
console = "^0"
1718
crossterm = { version = "^0", features = ["use-dev-tty"] }

src/clipboard.rs

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,95 @@
11
use std::env;
2+
use std::fs::OpenOptions;
23
use std::io::{self, Write};
34
use std::process::{Command, Stdio};
45

56
use anyhow::{bail, Context, Result};
67

8+
/// The terminal multiplexer environment we are running inside, if any.
9+
enum Muxer {
10+
/// tmux — requires DCS passthrough wrapping with doubled ESC bytes.
11+
Tmux,
12+
/// GNU Screen — requires DCS passthrough wrapping.
13+
Screen,
14+
/// Zellij — supports OSC 52 natively, no wrapping needed.
15+
Zellij,
16+
/// No known multiplexer detected.
17+
None,
18+
}
19+
20+
fn detect_muxer() -> Muxer {
21+
// Order matters: it is possible to nest muxers (e.g. tmux inside Zellij).
22+
// Check the innermost (most specific) first.
23+
if env::var("ZELLIJ").is_ok() {
24+
Muxer::Zellij
25+
} else if env::var("TMUX").is_ok() {
26+
Muxer::Tmux
27+
} else if env::var("STY").is_ok() {
28+
Muxer::Screen
29+
} else {
30+
Muxer::None
31+
}
32+
}
33+
34+
/// Build the OSC 52 clipboard write sequence, wrapped appropriately for the
35+
/// current terminal multiplexer.
36+
///
37+
/// The raw OSC 52 sequence is:
38+
/// ESC ] 52 ; c ; <base64> BEL
39+
///
40+
/// Multiplexer wrapping:
41+
/// tmux — `ESC P tmux; ESC <osc52_with_doubled_escs> ESC \`
42+
/// screen — `ESC P <osc52> ESC \`
43+
/// zellij / bare terminal — no wrapping needed.
44+
fn build_osc52_sequence(text: &str) -> String {
45+
use base64::Engine;
46+
let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
47+
48+
let osc52 = format!("\x1b]52;c;{encoded}\x07");
49+
50+
match detect_muxer() {
51+
Muxer::Tmux => {
52+
// Inside tmux the ESC bytes in the inner sequence must be doubled
53+
// and the whole thing wrapped in a DCS passthrough.
54+
let inner = osc52.replace('\x1b', "\x1b\x1b");
55+
format!("\x1bPtmux;{inner}\x1b\\")
56+
}
57+
Muxer::Screen => {
58+
// GNU Screen DCS passthrough.
59+
format!("\x1bP{osc52}\x1b\\")
60+
}
61+
Muxer::Zellij | Muxer::None => osc52,
62+
}
63+
}
64+
65+
/// Write to the clipboard using the OSC 52 escape sequence.
66+
///
67+
/// The sequence is written directly to the controlling TTY (`/dev/tty`) so it
68+
/// reaches the outer terminal emulator even when stdout is owned by a TUI
69+
/// framework like ratatui / crossterm.
70+
fn write_osc52(text: &str) -> Result<()> {
71+
let seq = build_osc52_sequence(text);
72+
73+
let mut tty = OpenOptions::new()
74+
.write(true)
75+
.open("/dev/tty")
76+
.context("open /dev/tty for OSC 52 clipboard write")?;
77+
78+
tty.write_all(seq.as_bytes())
79+
.context("write OSC 52 sequence to /dev/tty")?;
80+
tty.flush().context("flush /dev/tty after OSC 52 write")?;
81+
82+
Ok(())
83+
}
84+
85+
/// Return `true` when we should prefer OSC 52 over a system clipboard command.
86+
///
87+
/// This is the case when we are inside any known terminal multiplexer, or when
88+
/// no suitable clipboard command can be found on the system.
89+
fn should_use_osc52() -> bool {
90+
!matches!(detect_muxer(), Muxer::None)
91+
}
92+
793
fn get_cmd() -> Result<Command> {
894
let cmd = match env::consts::OS {
995
"macos" => Command::new("pbcopy"),
@@ -25,15 +111,15 @@ fn get_cmd() -> Result<Command> {
25111
Ok(cmd)
26112
}
27113

28-
pub fn write_clipboard(text: &str) -> Result<()> {
114+
fn write_clipboard_cmd(text: &str) -> Result<()> {
29115
let mut cmd = get_cmd()?;
30116
cmd.stdin(Stdio::piped());
31117

32118
let mut child = match cmd.spawn() {
33119
Ok(child) => child,
34120
Err(err) if err.kind() == io::ErrorKind::NotFound => {
35-
let program = cmd.get_program().to_string_lossy();
36-
bail!("cannot find clipboard program '{program}' in your system, please install it first to support clipboard")
121+
// No system clipboard tool — fall back to OSC 52 as a last resort.
122+
return write_osc52(text);
37123
}
38124
Err(err) => return Err(err).context("launch clipboard program failed"),
39125
};
@@ -54,3 +140,10 @@ pub fn write_clipboard(text: &str) -> Result<()> {
54140

55141
Ok(())
56142
}
143+
144+
pub fn write_clipboard(text: &str) -> Result<()> {
145+
if should_use_osc52() {
146+
return write_osc52(text);
147+
}
148+
write_clipboard_cmd(text)
149+
}

0 commit comments

Comments
 (0)