aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs3
-rw-r--r--src/app/mod.rs7
-rw-r--r--src/events.rs3
-rw-r--r--src/pane.rs287
4 files changed, 299 insertions, 1 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index da5c8698..fcdeff92 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -518,6 +518,9 @@ impl AppState {
.update_pane_state(pane_id, |pane| pane.release_agent(&source, agent))
.into_iter()
.collect(),
+ // Intercepted in App::handle_internal_event before reaching this
+ // dispatch; never touches AppState.
+ AppEvent::ClipboardWrite { .. } => Vec::new(),
}
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 3e79ce05..266baa59 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -784,6 +784,13 @@ impl App {
}
fn handle_internal_event(&mut self, ev: AppEvent) {
+ if let AppEvent::ClipboardWrite { bytes } = ev {
+ use std::io::Write;
+ let _ = std::io::stdout().write_all(&bytes);
+ let _ = std::io::stdout().flush();
+ return;
+ }
+
if let AppEvent::PaneDied { pane_id } = &ev {
if let Some((ws_idx, _)) = self.find_pane(*pane_id) {
if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) {
diff --git a/src/events.rs b/src/events.rs
index c1c45a0e..f43001b2 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -38,4 +38,7 @@ pub enum AppEvent {
},
/// A new version was downloaded and installed. Restart to use it.
UpdateReady { version: String },
+ /// A pane child emitted an OSC 52 clipboard-write sequence that the
+ /// main loop should re-emit on herdr's stdout.
+ ClipboardWrite { bytes: Vec<u8> },
}
diff --git a/src/pane.rs b/src/pane.rs
index 1438d65e..f0dc74c5 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -281,9 +281,10 @@ impl InputState {
}
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq)]
struct ProcessBytesResult {
request_render: bool,
+ clipboard_writes: Vec<Vec<u8>>,
}
struct GhosttyPaneTerminal {
@@ -297,6 +298,7 @@ struct GhosttyPaneCore {
host_terminal_theme: crate::terminal_theme::TerminalTheme,
transient_default_color_owner_pgid: Option<u32>,
default_color_tracker: DefaultColorOscTracker,
+ osc52_forwarder: Osc52Forwarder,
}
#[derive(Debug, Default)]
@@ -376,6 +378,120 @@ fn is_default_color_set_osc(body: &[u8]) -> bool {
matches!(command, b"10" | b"11") && !value.is_empty() && value != b"?"
}
+/// 256 KiB of base64 ≈ 192 KiB of text — enough for real source-file copies
+/// while still bounding memory against stream garbage.
+const OSC52_MAX_PAYLOAD_BYTES: usize = 256 * 1024;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Osc52Terminator {
+ Bel,
+ St,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+enum Osc52ForwarderState {
+ #[default]
+ Ground,
+ Escape,
+ OscBody,
+ OscEscape,
+}
+
+/// Reconstructs OSC 52 clipboard-write sequences from raw PTY bytes so the
+/// main loop can re-emit them. `libghostty-vt`'s readonly stream handler
+/// drops `.clipboard_contents` actions, so without this forwarder clipboard
+/// writes from child processes never reach the host terminal.
+#[derive(Debug, Default)]
+struct Osc52Forwarder {
+ state: Osc52ForwarderState,
+ body: Vec<u8>,
+ pending: Vec<Vec<u8>>,
+}
+
+impl Osc52Forwarder {
+ fn observe(&mut self, bytes: &[u8]) {
+ for &byte in bytes {
+ match self.state {
+ Osc52ForwarderState::Ground => {
+ if byte == 0x1b {
+ self.state = Osc52ForwarderState::Escape;
+ }
+ }
+ Osc52ForwarderState::Escape => {
+ if byte == b']' {
+ self.body.clear();
+ self.state = Osc52ForwarderState::OscBody;
+ } else if byte == 0x1b {
+ self.state = Osc52ForwarderState::Escape;
+ } else {
+ self.state = Osc52ForwarderState::Ground;
+ }
+ }
+ Osc52ForwarderState::OscBody => match byte {
+ 0x07 => {
+ self.finalize(Osc52Terminator::Bel);
+ self.state = Osc52ForwarderState::Ground;
+ }
+ 0x1b => self.state = Osc52ForwarderState::OscEscape,
+ _ => self.body.push(byte),
+ },
+ Osc52ForwarderState::OscEscape => {
+ if byte == b'\\' {
+ self.finalize(Osc52Terminator::St);
+ self.state = Osc52ForwarderState::Ground;
+ } else {
+ self.body.push(0x1b);
+ self.body.push(byte);
+ self.state = Osc52ForwarderState::OscBody;
+ }
+ }
+ }
+
+ if self.body.len() > OSC52_MAX_PAYLOAD_BYTES {
+ self.body.clear();
+ self.state = Osc52ForwarderState::Ground;
+ }
+ }
+ }
+
+ fn finalize(&mut self, terminator: Osc52Terminator) {
+ if is_osc52_clipboard_write(&self.body) {
+ let mut sequence = Vec::with_capacity(self.body.len() + 4);
+ sequence.extend_from_slice(b"\x1b]");
+ sequence.extend_from_slice(&self.body);
+ match terminator {
+ Osc52Terminator::Bel => sequence.push(0x07),
+ Osc52Terminator::St => sequence.extend_from_slice(b"\x1b\\"),
+ }
+ self.pending.push(sequence);
+ }
+ self.body.clear();
+ }
+
+ fn drain_pending(&mut self) -> Vec<Vec<u8>> {
+ std::mem::take(&mut self.pending)
+ }
+}
+
+/// Accepts `52;c;<base64>` and `52;;<base64>` (ghostty treats the empty
+/// selector as the standard clipboard). Queries (`?`) are rejected because
+/// herdr has no path to reply to the child with clipboard contents.
+fn is_osc52_clipboard_write(body: &[u8]) -> bool {
+ let Some(rest) = body.strip_prefix(b"52;") else {
+ return false;
+ };
+ let Some(sep) = rest.iter().position(|b| *b == b';') else {
+ return false;
+ };
+ let selector = &rest[..sep];
+ let data = &rest[sep + 1..];
+ let is_standard_clipboard = selector.is_empty() || selector == b"c";
+ if !is_standard_clipboard {
+ return false;
+ }
+ !data.is_empty() && data != b"?"
+}
+
fn foreground_job_is_shell(job: &crate::platform::ForegroundJob, shell_pid: u32) -> bool {
job.processes.iter().any(|process| process.pid == shell_pid)
}
@@ -612,6 +728,7 @@ impl GhosttyPaneTerminal {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
transient_default_color_owner_pgid: None,
default_color_tracker: DefaultColorOscTracker::default(),
+ osc52_forwarder: Osc52Forwarder::default(),
}),
key_encoder: Mutex::new(key_encoder),
})
@@ -663,6 +780,7 @@ impl GhosttyPaneTerminal {
error!(pane = pane_id.raw(), "ghostty core lock poisoned in reader");
return ProcessBytesResult {
request_render: false,
+ clipboard_writes: Vec::new(),
};
};
@@ -676,6 +794,9 @@ impl GhosttyPaneTerminal {
}
}
+ core.osc52_forwarder.observe(bytes);
+ let clipboard_writes = core.osc52_forwarder.drain_pending();
+
core.terminal.write(bytes);
if let Ok(mut key_encoder) = self.key_encoder.lock() {
key_encoder.set_from_terminal(&core.terminal);
@@ -687,6 +808,7 @@ impl GhosttyPaneTerminal {
let _ = response_writer;
ProcessBytesResult {
request_render: !synchronized_output,
+ clipboard_writes,
}
}
@@ -1693,6 +1815,7 @@ impl PaneRuntime {
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
let child_pid = child_pid.clone();
+ let events = events.clone();
tokio::task::spawn_blocking(move || {
let mut buf = [0u8; 8192];
loop {
@@ -1713,6 +1836,20 @@ impl PaneRuntime {
if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) {
render_notify.notify_one();
}
+ for bytes in result.clipboard_writes {
+ // Non-blocking: stalling the PTY reader on a
+ // saturated main loop would be worse than
+ // dropping a clipboard write.
+ if let Err(err) =
+ events.try_send(AppEvent::ClipboardWrite { bytes })
+ {
+ warn!(
+ pane = pane_id.raw(),
+ err = %err,
+ "dropped OSC 52 clipboard write"
+ );
+ }
+ }
}
}
}
@@ -2508,6 +2645,154 @@ mod tests {
}
#[test]
+ fn osc52_forwarder_detects_write_with_bel() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGVsbG8=\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0], b"\x1b]52;c;aGVsbG8=\x07");
+ }
+
+ #[test]
+ fn osc52_forwarder_detects_write_with_st() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGVsbG8=\x1b\\");
+ let pending = fw.drain_pending();
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0], b"\x1b]52;c;aGVsbG8=\x1b\\");
+ }
+
+ #[test]
+ fn osc52_forwarder_detects_empty_selector_form() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;;aGVsbG8=\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0], b"\x1b]52;;aGVsbG8=\x07");
+ }
+
+ #[test]
+ fn osc52_forwarder_ignores_query() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;?\x07");
+ assert!(fw.drain_pending().is_empty());
+ }
+
+ #[test]
+ fn osc52_forwarder_ignores_empty_selector_query() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;;?\x07");
+ assert!(fw.drain_pending().is_empty());
+ }
+
+ #[test]
+ fn osc52_forwarder_ignores_other_kinds() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;p;aGk=\x07");
+ fw.observe(b"\x1b]52;s;aGk=\x07");
+ fw.observe(b"\x1b]52;q;aGk=\x07");
+ fw.observe(b"\x1b]52;0;aGk=\x07");
+ fw.observe(b"\x1b]52;7;aGk=\x07");
+ assert!(fw.drain_pending().is_empty());
+ }
+
+ #[test]
+ fn osc52_forwarder_ignores_non_osc52() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]11;?\x07");
+ fw.observe(b"\x1b]0;title\x07");
+ fw.observe(b"\x1b]8;;https://example.com\x1b\\");
+ assert!(fw.drain_pending().is_empty());
+ }
+
+ #[test]
+ fn osc52_forwarder_handles_split_sequence_mid_payload() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGVs");
+ assert!(fw.drain_pending().is_empty());
+ fw.observe(b"bG8gd29y");
+ assert!(fw.drain_pending().is_empty());
+ fw.observe(b"bGQ=\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0], b"\x1b]52;c;aGVsbG8gd29ybGQ=\x07");
+ }
+
+ #[test]
+ fn osc52_forwarder_handles_split_before_bel() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGk=");
+ assert!(fw.drain_pending().is_empty());
+ fw.observe(b"\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0], b"\x1b]52;c;aGk=\x07");
+ }
+
+ #[test]
+ fn osc52_forwarder_handles_split_between_esc_and_backslash() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGk=\x1b");
+ assert!(fw.drain_pending().is_empty());
+ fw.observe(b"\\");
+ let pending = fw.drain_pending();
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0], b"\x1b]52;c;aGk=\x1b\\");
+ }
+
+ #[test]
+ fn osc52_forwarder_payload_size_limit() {
+ let mut fw = Osc52Forwarder::default();
+ let mut huge = Vec::with_capacity(OSC52_MAX_PAYLOAD_BYTES + 32);
+ huge.extend_from_slice(b"\x1b]52;c;");
+ huge.extend(std::iter::repeat_n(b'A', OSC52_MAX_PAYLOAD_BYTES + 16));
+ huge.push(0x07);
+ fw.observe(&huge);
+ assert!(fw.drain_pending().is_empty());
+
+ fw.observe(b"\x1b]52;c;aGk=\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0], b"\x1b]52;c;aGk=\x07");
+ }
+
+ #[test]
+ fn osc52_forwarder_recovers_after_garbage() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x01\x02random\x7fbytes\x1b]52;c;aGk=\x07tail");
+ let pending = fw.drain_pending();
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0], b"\x1b]52;c;aGk=\x07");
+ }
+
+ #[test]
+ fn osc52_forwarder_esc_in_body_not_st() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGVs\x1b[bG8=\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0], b"\x1b]52;c;aGVs\x1b[bG8=\x07");
+ }
+
+ #[test]
+ fn osc52_forwarder_multiple_in_one_chunk() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGk=\x07\x1b]52;c;Ynll\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending.len(), 2);
+ assert_eq!(pending[0], b"\x1b]52;c;aGk=\x07");
+ assert_eq!(pending[1], b"\x1b]52;c;Ynll\x07");
+ }
+
+ #[test]
+ fn osc52_forwarder_drain_clears_pending() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGk=\x07");
+ assert_eq!(fw.drain_pending().len(), 1);
+ assert!(fw.drain_pending().is_empty());
+ }
+
+ #[test]
fn host_theme_restore_waits_for_shell_and_non_alternate_screen() {
assert!(!should_restore_host_terminal_theme(
42,