Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ description = "A command line tool to view objects (json/yaml/toml) in TUI tree

[dependencies]
anyhow = "^1"
base64 = "^0"
clap = { version = "^4", features = ["derive"] }
console = "^0"
crossterm = { version = "^0", features = ["use-dev-tty"] }
Expand Down
99 changes: 96 additions & 3 deletions src/clipboard.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,95 @@
use std::env;
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::process::{Command, Stdio};

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

/// The terminal multiplexer environment we are running inside, if any.
enum Muxer {
/// tmux — requires DCS passthrough wrapping with doubled ESC bytes.
Tmux,
/// GNU Screen — requires DCS passthrough wrapping.
Screen,
/// Zellij — supports OSC 52 natively, no wrapping needed.
Zellij,
/// No known multiplexer detected.
None,
}

fn detect_muxer() -> Muxer {
// Order matters: it is possible to nest muxers (e.g. tmux inside Zellij).
// Check the innermost (most specific) first.
if env::var("ZELLIJ").is_ok() {
Muxer::Zellij
} else if env::var("TMUX").is_ok() {
Muxer::Tmux
} else if env::var("STY").is_ok() {
Muxer::Screen
} else {
Muxer::None
}
}

/// Build the OSC 52 clipboard write sequence, wrapped appropriately for the
/// current terminal multiplexer.
///
/// The raw OSC 52 sequence is:
/// ESC ] 52 ; c ; <base64> BEL
///
/// Multiplexer wrapping:
/// tmux — `ESC P tmux; ESC <osc52_with_doubled_escs> ESC \`
/// screen — `ESC P <osc52> ESC \`
/// zellij / bare terminal — no wrapping needed.
fn build_osc52_sequence(text: &str) -> String {
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());

let osc52 = format!("\x1b]52;c;{encoded}\x07");

match detect_muxer() {
Muxer::Tmux => {
// Inside tmux the ESC bytes in the inner sequence must be doubled
// and the whole thing wrapped in a DCS passthrough.
let inner = osc52.replace('\x1b', "\x1b\x1b");
format!("\x1bPtmux;{inner}\x1b\\")
}
Muxer::Screen => {
// GNU Screen DCS passthrough.
format!("\x1bP{osc52}\x1b\\")
}
Muxer::Zellij | Muxer::None => osc52,
}
}

/// Write to the clipboard using the OSC 52 escape sequence.
///
/// The sequence is written directly to the controlling TTY (`/dev/tty`) so it
/// reaches the outer terminal emulator even when stdout is owned by a TUI
/// framework like ratatui / crossterm.
fn write_osc52(text: &str) -> Result<()> {
let seq = build_osc52_sequence(text);

let mut tty = OpenOptions::new()
.write(true)
.open("/dev/tty")
.context("open /dev/tty for OSC 52 clipboard write")?;

tty.write_all(seq.as_bytes())
.context("write OSC 52 sequence to /dev/tty")?;
tty.flush().context("flush /dev/tty after OSC 52 write")?;

Ok(())
}

/// Return `true` when we should prefer OSC 52 over a system clipboard command.
///
/// This is the case when we are inside any known terminal multiplexer, or when
/// no suitable clipboard command can be found on the system.
fn should_use_osc52() -> bool {
!matches!(detect_muxer(), Muxer::None)
}

fn get_cmd() -> Result<Command> {
let cmd = match env::consts::OS {
"macos" => Command::new("pbcopy"),
Expand All @@ -25,15 +111,15 @@ fn get_cmd() -> Result<Command> {
Ok(cmd)
}

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

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

Ok(())
}

pub fn write_clipboard(text: &str) -> Result<()> {
if should_use_osc52() {
return write_osc52(text);
}
write_clipboard_cmd(text)
}