Skip to content

Commit c06b6ec

Browse files
sei40krclaude
andcommitted
feat: add configurable OSC 52 clipboard support
Add OSC 52 terminal escape sequence support for clipboard operations as an opt-in feature via configuration. Implementation: - Created clipboard trait with two backends: - ArboardClipboard: System clipboard (default) - Osc52Clipboard: Terminal escape sequences - Added use_osc52_clipboard config option (default: false) - When enabled, uses OSC 52; when disabled, uses arboard - Returns None if arboard initialization fails and OSC 52 is disabled Benefits: - Enables clipboard in SSH sessions - Works in headless/remote environments - No external clipboard tools required when using OSC 52 Testing: - Added instructions in config.toml to test terminal OSC 52 support - Users can verify with: printf '\033]52;c;%s\007' "$(echo -n 'test' | base64)" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
1 parent 3580daa commit c06b6ec

File tree

8 files changed

+127
-5
lines changed

8 files changed

+127
-5
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
@@ -30,6 +30,7 @@ strip = true
3030
arboard = { version = "3.6.1", default-features = false, features = [
3131
"windows-sys",
3232
] }
33+
base64 = "0.22.1"
3334
chrono = "0.4.42"
3435
clap = { version = "4.5.53", features = ["derive"] }
3536
crossterm = "0.28.1"

src/app.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ use std::sync::Arc;
1010
use std::sync::RwLock;
1111
use std::time::Duration;
1212

13-
use arboard::Clipboard;
1413
use crossterm::event;
1514
use crossterm::event::Event;
1615
use crossterm::event::KeyCode;
@@ -23,6 +22,7 @@ use ratatui::layout::Size;
2322
use tui_prompts::State as _;
2423

2524
use crate::cli;
25+
use crate::clipboard::Clipboard;
2626
use crate::cmd_log::CmdLog;
2727
use crate::cmd_log::CmdLogEntry;
2828
use crate::config::Config;
@@ -87,9 +87,7 @@ impl App {
8787

8888
let pending_menu = root_menu(&config).map(PendingMenu::init);
8989

90-
let clipboard = Clipboard::new()
91-
.inspect_err(|e| log::warn!("Couldn't initialize clipboard: {e}"))
92-
.ok();
90+
let clipboard = Clipboard::new(config.general.use_osc52_clipboard);
9391

9492
let mut app = Self {
9593
state: State {

src/clipboard.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
use base64::{engine::general_purpose::STANDARD, Engine};
2+
use std::io::{self, Write};
3+
4+
/// Trait for clipboard implementations
5+
trait ClipboardBackend {
6+
fn set_text(&mut self, text: &str) -> Result<(), ClipboardError>;
7+
}
8+
9+
/// Arboard-based clipboard implementation (system clipboard)
10+
struct ArboardClipboard {
11+
clipboard: arboard::Clipboard,
12+
}
13+
14+
impl ArboardClipboard {
15+
fn new() -> Result<Self, arboard::Error> {
16+
Ok(Self {
17+
clipboard: arboard::Clipboard::new()?,
18+
})
19+
}
20+
}
21+
22+
impl ClipboardBackend for ArboardClipboard {
23+
fn set_text(&mut self, text: &str) -> Result<(), ClipboardError> {
24+
self.clipboard
25+
.set_text(text)
26+
.map_err(|e| ClipboardError::Arboard(e))
27+
}
28+
}
29+
30+
/// OSC 52-based clipboard implementation (terminal escape sequences)
31+
struct Osc52Clipboard;
32+
33+
impl Osc52Clipboard {
34+
fn new() -> Self {
35+
Self
36+
}
37+
}
38+
39+
impl ClipboardBackend for Osc52Clipboard {
40+
fn set_text(&mut self, text: &str) -> Result<(), ClipboardError> {
41+
let encoded = STANDARD.encode(text);
42+
let osc52 = format!("\x1b]52;c;{}\x07", encoded);
43+
44+
// Write directly to stderr to avoid interfering with UI
45+
io::stderr().write_all(osc52.as_bytes())?;
46+
io::stderr().flush()?;
47+
48+
Ok(())
49+
}
50+
}
51+
52+
/// Main clipboard wrapper that manages different backend implementations
53+
pub(crate) struct Clipboard {
54+
backend: Box<dyn ClipboardBackend>,
55+
}
56+
57+
impl Clipboard {
58+
/// Creates a new clipboard instance with the preferred backend.
59+
/// If use_osc52 is true, uses OSC 52 with arboard as fallback.
60+
/// Otherwise, uses only arboard.
61+
pub fn new(use_osc52: bool) -> Option<Self> {
62+
if use_osc52 {
63+
// Prefer OSC 52, fallback to arboard
64+
Some(Self {
65+
backend: Box::new(Osc52Clipboard::new()),
66+
})
67+
} else {
68+
// Try arboard only
69+
ArboardClipboard::new()
70+
.inspect_err(|e| log::warn!("Couldn't initialize arboard clipboard: {e}"))
71+
.ok()
72+
.map(|cb| Self {
73+
backend: Box::new(cb),
74+
})
75+
}
76+
}
77+
78+
/// Sets text to clipboard using the configured backend.
79+
pub fn set_text(&mut self, text: String) -> Result<(), ClipboardError> {
80+
self.backend.set_text(&text)
81+
}
82+
}
83+
84+
#[derive(Debug)]
85+
pub enum ClipboardError {
86+
Io(io::Error),
87+
Arboard(arboard::Error),
88+
}
89+
90+
impl From<io::Error> for ClipboardError {
91+
fn from(err: io::Error) -> Self {
92+
ClipboardError::Io(err)
93+
}
94+
}
95+
96+
impl std::fmt::Display for ClipboardError {
97+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98+
match self {
99+
ClipboardError::Io(err) => write!(f, "IO error: {}", err),
100+
ClipboardError::Arboard(err) => write!(f, "Clipboard error: {}", err),
101+
}
102+
}
103+
}
104+
105+
impl std::error::Error for ClipboardError {}

src/config.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub struct GeneralConfig {
3737
pub recent_commits_limit: usize,
3838
pub mouse_support: bool,
3939
pub mouse_scroll_lines: usize,
40+
pub use_osc52_clipboard: bool,
4041
}
4142

4243
#[derive(Default, Debug, Deserialize)]

src/default_config.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ mouse_scroll_lines = 3
2121
# "never" - never prompt when discarding.
2222
confirm_discard = "line"
2323

24+
# Enable OSC 52 clipboard support for terminals that support it.
25+
# This allows clipboard operations over SSH and in environments without
26+
# system clipboard access. Set to true to enable.
27+
#
28+
# To test if your terminal supports OSC 52, run this command:
29+
# printf '\033]52;c;%s\007' "$(echo -n 'test' | base64)"
30+
# Then try pasting - if you see 'test', OSC 52 is supported.
31+
use_osc52_clipboard = false
32+
2433
[style]
2534
# fg / bg can be either of:
2635
# - a hex value: "#707070"

src/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ pub enum Error {
3737
ReadOid(git2::Error),
3838
ArgMustBePositiveNumber,
3939
ArgInvalidRegex(regex::Error),
40-
Clipboard(arboard::Error),
40+
Clipboard(crate::clipboard::ClipboardError),
4141
FindGitRev(git2::Error),
4242
NoEditorSet,
4343
GitStatus(git2::Error),

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod app;
22
mod bindings;
33
pub mod cli;
4+
mod clipboard;
45
mod cmd_log;
56
pub mod config;
67
pub mod error;

0 commit comments

Comments
 (0)