//! Local PTY wrapper with OSC 52 clipboard interception and host image paste. //! //! Spawns a command inside a local pseudo-terminal, intercepts the OSC 52 //! clipboard escape sequences it emits, and writes their payload to the local //! system clipboard. This is the engine behind `kigi wrap` (see //! [`crate::wrap_cmd`]); it makes clipboard "copy" work for programs running //! somewhere that cannot reach the user's clipboard (containers, SSH) even when //! the outer terminal does not handle OSC 52 itself (for example Apple //! Terminal). //! //! Also consumes a private remote request OSC for host clipboard images and //! injects a bracketed-paste response on PTY stdin (see //! [`crate::wrap_clipboard_image`]). Trust model for auto-answering that OSC //! (any PTY emitter can solicit the host pasteboard) is documented there. use anyhow::Result; use base64::Engine as _; use std::io::Write; /// Maximum size for a buffered escape sequence candidate (1 MiB). /// /// This bounds the memory used while accumulating a candidate OSC 52 or DCS /// sequence. Must be large enough to hold the base64-encoded form of /// `MAX_CLIPBOARD_PAYLOAD` (~1.33x expansion) plus the escape envelope. const MAX_ESC_BUFFER: usize = 1024 * 1024; /// Maximum decoded clipboard payload size (768 KiB). /// /// Aligned with `MAX_ESC_BUFFER`: a 768 KiB payload encodes to ~1 MiB of /// base64, fitting within the buffer limit. Payloads larger than this are /// unrealistic for clipboard content over SSH. const MAX_CLIPBOARD_PAYLOAD: usize = 768 * 1024; /// The prefix that identifies an OSC 52 sequence after the `ESC ]`. const OSC52_PREFIX: &[u8] = b"52;"; /// The tmux DCS passthrough prefix after `ESC P`: `tmux;\x1b\x1b]`. const TMUX_DCS_PREFIX: &[u8] = b"tmux;\x1b\x1b]"; /// Base64 engine that accepts both padded and unpadded input. /// /// OSC 52 emitters in the wild (including some Go-based tools and terminals) /// may omit `=` padding. Using `Indifferent` mode avoids silent decode /// failures from legitimate clipboard sequences. const BASE64_STANDARD_INDIFFERENT: base64::engine::GeneralPurpose = base64::engine::GeneralPurpose::new( &base64::alphabet::STANDARD, base64::engine::GeneralPurposeConfig::new() .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent), ); /// Run an arbitrary command inside a local PTY with OSC 52 output filtering. /// /// This is the engine behind `kigi wrap`: it spawns /// `program` (with `args`) attached to a local pseudo-terminal, forwards the /// outer terminal's size changes to it, and filters its output through /// [`Osc52Filter`], which intercepts OSC 52 clipboard sequences and writes /// their payload to the local system clipboard. All other output passes /// through unchanged. /// /// Returns the child exit code on success. pub(crate) fn run_wrapped_command(program: &str, args: &[String]) -> Result { use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use std::io::Read; let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); let pty_system = native_pty_system(); let pair = pty_system.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, })?; let mut cmd = CommandBuilder::new(program); args.iter().for_each(|arg| cmd.arg(arg)); // Advertise to the wrapped program — and anything it spawns, e.g. a remote // `kigi` reached over SSH — that its OSC 52 clipboard writes are being // intercepted here and copied to the real local clipboard. The inner kigi // reads this (see `kigi_pager_render::clipboard::osc52_sink_active`) to // *trust* OSC 52 even when it can't detect an OSC-52-capable terminal, // which is the usual SSH case (only `TERM` propagates, so Apple Terminal / // unknown brands look incapable and the inner kigi would otherwise report // "Copy failed" despite the copy actually working). // // `CommandBuilder::new` inherits the full parent environment; `env` overlays // these two without clearing it. The canonical `KIGI_OSC52_SINK` is // inherited by local children; the `LC_`-prefixed alias rides the default // OpenSSH env forwarding (`SendEnv LANG LC_*` on the client, // `AcceptEnv LANG LC_*` on the server) so the signal survives the SSH hop. cmd.env("KIGI_OSC52_SINK", "1"); cmd.env("LC_KIGI_OSC52_SINK", "1"); let mut child = pair.slave.spawn_command(cmd)?; // Drop the slave so we get EOF when child exits. drop(pair.slave); // Obtain reader from the master PTY. All master *writes* (keystrokes + // host-image inject frames) go through one dedicated writer thread via a // channel. Cross-thread use of portable-pty's `Write` impl observed EIO // (errno 5) on macOS even for small inject payloads; confining `write_all` // to a single owner thread avoids that. Handles are intentionally // detached — `kigi wrap` is short-lived and exits with the child. let mut pty_reader = pair.master.try_clone_reader()?; // NOTE: we intentionally do NOT block SIGWINCH here. The resize handler // (`sigwinch_loop`) installs a real signal handler via `signal-hook`, // which must be free to run when the signal is delivered. Blocking it and // waiting via `sigwait` looks correct but silently fails on macOS (see // `sigwinch_loop`). // Switch to raw mode so keystrokes pass through unchanged. crossterm::terminal::enable_raw_mode()?; let _raw_guard = RawModeGuard; let (write_tx, write_rx) = std::sync::mpsc::channel::>(); { let mut writer = pair.master.take_writer()?; std::thread::spawn(move || { while let Ok(bytes) = write_rx.recv() { // Single-thread write_all: no libc chunking. EIO here almost // always means the slave has closed (child exited); stop. if writer .write_all(&bytes) .and_then(|_| writer.flush()) .is_err() { break; } } }); } let stdin_tx = write_tx.clone(); let _stdin_handle = std::thread::spawn(move || { let mut stdin = std::io::stdin().lock(); let mut buf = [0u8; 4096]; loop { match stdin.read(&mut buf) { Ok(0) | Err(_) => break, Ok(n) => { if stdin_tx.send(buf[..n].to_vec()).is_err() { break; } } } } }); // We move `pair.master` here since we've already cloned the reader and // taken the writer above. // // Unix only: there is no SIGWINCH on Windows. On Windows `pair.master` is // instead kept alive inside `pair` until this function returns (after // `child.wait`), so the ConPTY stays open for the read loop — the OSC 52 // clipboard bridge works identically, only live resize is unavailable. #[cfg(unix)] { let master = pair.master; std::thread::spawn(move || { sigwinch_loop(master); }); } // Host-image requests: spawn a short-lived worker for clipboard I/O (can be // hundreds of ms / osascript) then enqueue the bracketed-paste frame on the // writer thread (+ NL so ICANON slaves deliver without another key). Paste // mashing can spawn multiple workers; fine for a short-lived wrap process. { let mut stdout = std::io::stdout().lock(); let mut filter = Osc52Filter::new().with_wrap_image_handler(move || { let tx = write_tx.clone(); std::thread::spawn(move || { let mut bytes = host_clipboard_image_frame(); bytes.push(b'\n'); let _ = tx.send(bytes); }); }); let mut buf = [0u8; 8192]; loop { match pty_reader.read(&mut buf) { Ok(0) | Err(_) => break, Ok(n) => { let filtered = filter.feed(&buf[..n]); if !filtered.is_empty() { if stdout.write_all(&filtered).is_err() { break; } let _ = stdout.flush(); } } } } } let status = child.wait()?; let code = status.exit_code() as i32; Ok(code) } /// Wait for `SIGWINCH` and resize the PTY master to match the outer terminal. /// /// Uses `signal-hook` to install a real signal handler (self-pipe based). /// /// A previous implementation blocked SIGWINCH and dequeued it with `nix`'s /// `sigwait`. That pattern is POSIX-correct but **silently fails on macOS**: /// SIGWINCH's default disposition is "ignore", and macOS discards a blocked /// default-ignore signal rather than leaving it pending for `sigwait`. The /// handler never woke, so the inner PTY was never resized and the remote TUI /// kept rendering at the original size — producing overlapping/stale frames /// after the user resized their terminal. Installing an actual handler /// overrides the default-ignore disposition, which is what makes it work. #[cfg(unix)] fn sigwinch_loop(master: Box) { use portable_pty::PtySize; let mut signals = match signal_hook::iterator::Signals::new([signal_hook::consts::SIGWINCH]) { Ok(signals) => signals, Err(e) => { tracing::debug!("failed to install SIGWINCH handler: {e}"); return; } }; for _ in signals.forever() { if let Ok((cols, rows)) = crossterm::terminal::size() { let _ = master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }); } } } /// Guard that restores terminal state when dropped (including on panic). struct RawModeGuard; impl Drop for RawModeGuard { fn drop(&mut self) { let _ = crossterm::terminal::disable_raw_mode(); } } /// State machine states for the OSC 52 streaming parser. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FilterState { /// Normal output passthrough. Normal, /// Saw ESC (0x1b), waiting for next byte to determine sequence type. Esc, /// Inside OSC: saw `ESC ]` -- accumulating until BEL or ST. Osc, /// Inside DCS: saw `ESC P` -- checking for tmux passthrough prefix. Dcs, /// Inside DCS tmux passthrough, accumulating inner OSC 52. DcsTmuxOsc, /// Saw ESC inside an OSC, could be ST terminator (`ESC \`). OscEsc, /// Saw ESC inside a DCS tmux OSC, could be inner ST or DCS ST. DcsTmuxOscEsc, } /// Clipboard sink type: a boxed closure that receives decoded clipboard data. type ClipboardSink = Box; type WrapImageRequestHandler = Box; /// Streaming filter that intercepts OSC 52 clipboard sequences from PTY /// output and sends their decoded payload to the local clipboard. /// /// All non-OSC-52 bytes pass through unchanged. The parser handles sequences /// split across arbitrary byte boundaries. struct Osc52Filter { state: FilterState, buf: Vec, clipboard_sink: ClipboardSink, wrap_image_handler: Option, } impl Osc52Filter { /// Create a new filter that sends clipboard data to the system clipboard. fn new() -> Self { Self { state: FilterState::Normal, buf: Vec::new(), clipboard_sink: Box::new(set_local_clipboard), wrap_image_handler: None, } } fn with_wrap_image_handler(mut self, handler: impl FnMut() + 'static) -> Self { self.wrap_image_handler = Some(Box::new(handler)); self } /// Create a filter with a custom clipboard sink (for testing). #[cfg(test)] fn with_sink(sink: impl FnMut(&[u8]) + 'static) -> Self { Self { state: FilterState::Normal, buf: Vec::new(), clipboard_sink: Box::new(sink), wrap_image_handler: None, } } /// Process a chunk of bytes from PTY output. /// /// Returns bytes that should be written to stdout. OSC 52 clipboard /// sequences are consumed (not included in the output) and their decoded /// payload is sent to the clipboard sink. fn feed(&mut self, data: &[u8]) -> Vec { let mut output = Vec::with_capacity(data.len()); for &byte in data { match self.state { FilterState::Normal => { if byte == 0x1b { self.state = FilterState::Esc; self.buf.clear(); self.buf.push(byte); } else { output.push(byte); } } FilterState::Esc => { self.buf.push(byte); match byte { b']' => self.state = FilterState::Osc, b'P' => self.state = FilterState::Dcs, _ => { // Not an OSC or DCS -- flush buffer and continue. output.extend_from_slice(&self.buf); self.buf.clear(); self.state = FilterState::Normal; } } } FilterState::Osc => { self.buf.push(byte); match byte { // BEL terminates the OSC sequence. 0x07 => { if !self.try_handle_consumed_osc() { output.extend_from_slice(&self.buf); } self.buf.clear(); self.state = FilterState::Normal; } // ESC could be the start of ST (ESC \). 0x1b => { self.state = FilterState::OscEsc; } _ => {} } } FilterState::OscEsc => { self.buf.push(byte); if byte == b'\\' { // ST terminator: ESC \. if !self.try_handle_consumed_osc() { output.extend_from_slice(&self.buf); } self.buf.clear(); self.state = FilterState::Normal; } else { // Not ST -- continue accumulating in Osc state. // The ESC we saw might be part of the payload in some // broken sequence; just keep buffering. self.state = FilterState::Osc; } } FilterState::Dcs => { self.buf.push(byte); // buf starts with \x1bP so tmux prefix bytes start at offset 2. let prefix_pos = self.buf.len() - 2; if prefix_pos <= TMUX_DCS_PREFIX.len() { if TMUX_DCS_PREFIX[prefix_pos - 1] == byte { if prefix_pos == TMUX_DCS_PREFIX.len() { // Full tmux prefix matched: \x1bPtmux;\x1b\x1b] self.state = FilterState::DcsTmuxOsc; } // else keep matching prefix } else { // Prefix mismatch: not a tmux passthrough, flush. output.extend_from_slice(&self.buf); self.buf.clear(); self.state = FilterState::Normal; } } else { // Exceeded prefix length without matching; flush. output.extend_from_slice(&self.buf); self.buf.clear(); self.state = FilterState::Normal; } } FilterState::DcsTmuxOsc => { self.buf.push(byte); match byte { // BEL terminates the inner OSC. 0x07 => { // Inner OSC is done but we still need DCS ST // (ESC \) to close the tmux wrapper. // Remain in this state to catch the ESC. } 0x1b => { self.state = FilterState::DcsTmuxOscEsc; } _ => {} } } FilterState::DcsTmuxOscEsc => { self.buf.push(byte); if byte == b'\\' { // DCS ST: ESC \. The full tmux-wrapped sequence is done. if !self.try_handle_tmux_osc52() { output.extend_from_slice(&self.buf); } self.buf.clear(); self.state = FilterState::Normal; } else { // Not ST. Continue accumulating in DcsTmuxOsc. self.state = FilterState::DcsTmuxOsc; } } } // Guard: if the buffer grows beyond the limit, flush and reset. if self.buf.len() > MAX_ESC_BUFFER { output.extend_from_slice(&self.buf); self.buf.clear(); self.state = FilterState::Normal; } } output } /// Handle OSC 52 clipboard or wrap image request; `true` if consumed. fn try_handle_consumed_osc(&mut self) -> bool { let body = self.buf[2..].to_vec(); let body = strip_osc_terminator(&body); if self.try_handle_wrap_image_request(body) { return true; } self.extract_and_set_clipboard(body) } fn try_handle_wrap_image_request(&mut self, body: &[u8]) -> bool { if body != crate::wrap_clipboard_image::REQUEST_BODY { return false; } if let Some(handler) = self.wrap_image_handler.as_mut() { handler(); } true } /// Try to handle the buffered bytes as a tmux-wrapped OSC 52 sequence. /// /// Expected buffer format: /// `\x1bPtmux;\x1b\x1b]52;;\x07\x1b\\` /// /// Returns `true` if the sequence was a valid OSC 52 and was consumed. fn try_handle_tmux_osc52(&mut self) -> bool { // Strip the DCS tmux prefix: \x1bPtmux;\x1b\x1b] (total 9 bytes) // and the DCS ST terminator: \x1b\ (2 bytes at the end). // Copy the body to avoid borrowing self.buf while calling &mut self. let prefix_len = 2 + TMUX_DCS_PREFIX.len(); if self.buf.len() < prefix_len + 2 { return false; } let body = self.buf[prefix_len..self.buf.len() - 2].to_vec(); // Strip inner BEL if present. let body = strip_osc_terminator(&body); self.extract_and_set_clipboard(body) } /// Parse OSC 52 body (`52;;`), decode, and set clipboard. /// /// Returns `true` if successfully handled. fn extract_and_set_clipboard(&mut self, body: &[u8]) -> bool { if !body.starts_with(OSC52_PREFIX) { return false; } let after_52 = &body[OSC52_PREFIX.len()..]; // Find the selection parameter separator (next ';'). let payload_start = match after_52.iter().position(|&b| b == b';') { Some(pos) => pos + 1, None => return false, }; let b64_payload = &after_52[payload_start..]; let decoded = match BASE64_STANDARD_INDIFFERENT.decode(b64_payload) { Ok(data) => data, Err(_) => return false, }; if decoded.len() > MAX_CLIPBOARD_PAYLOAD { tracing::warn!( "OSC 52 payload too large ({} bytes), ignoring", decoded.len() ); return false; } (self.clipboard_sink)(&decoded); true } } /// Strip the OSC terminator from the end of a body slice. /// /// Removes trailing BEL (`\x07`) or ST (`\x1b\x5c`) if present. fn strip_osc_terminator(body: &[u8]) -> &[u8] { if body.ends_with(&[0x1b, b'\\']) { &body[..body.len() - 2] } else if body.ends_with(&[0x07]) { &body[..body.len() - 1] } else { body } } /// Write decoded clipboard payload to the local system clipboard. /// /// Delegates to [`kigi_shell::util::clipboard::set_text`] which uses /// `pbcopy` on macOS and `arboard` elsewhere. Failures are logged but do /// not propagate -- clipboard access is best-effort. fn set_local_clipboard(data: &[u8]) { let text = match std::str::from_utf8(data) { Ok(s) => s, Err(e) => { tracing::warn!("OSC 52 payload is not valid UTF-8: {e}"); return; } }; if let Err(e) = kigi_shell::util::clipboard::set_text(text) { tracing::warn!("clipboard copy failed: {e}"); } } /// Encode a host clipboard image (or NONE) as a bracketed-paste frame. fn host_clipboard_image_frame() -> Vec { let image = kigi_pager_render::clipboard::system_clipboard_get_image(); crate::wrap_clipboard_image::encode_wrap_image_response(image.as_ref()) } #[cfg(test)] mod tests { use super::*; use std::cell::RefCell; use std::rc::Rc; /// Helper: run data through the filter with a capturing clipboard sink. /// Returns (stdout_output, captured_clipboard_payloads). fn filter_output(input: &[u8]) -> (Vec, Vec>) { let clips = Rc::new(RefCell::new(Vec::new())); let clips_clone = Rc::clone(&clips); let mut filter = Osc52Filter::with_sink(move |data: &[u8]| { clips_clone.borrow_mut().push(data.to_vec()); }); let output = filter.feed(input); let captured = clips.borrow().clone(); (output, captured) } /// Helper: run data through the filter in multiple small chunks. fn filter_output_chunked(input: &[u8], chunk_size: usize) -> (Vec, Vec>) { let clips = Rc::new(RefCell::new(Vec::new())); let clips_clone = Rc::clone(&clips); let mut filter = Osc52Filter::with_sink(move |data: &[u8]| { clips_clone.borrow_mut().push(data.to_vec()); }); let mut output = Vec::new(); for chunk in input.chunks(chunk_size) { output.extend_from_slice(&filter.feed(chunk)); } let captured = clips.borrow().clone(); (output, captured) } /// Encode text as a plain OSC 52 sequence with BEL terminator. fn make_osc52_bel(text: &str) -> Vec { let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); format!("\x1b]52;c;{b64}\x07").into_bytes() } /// Encode text as a plain OSC 52 sequence with ST terminator. fn make_osc52_st(text: &str) -> Vec { let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); format!("\x1b]52;c;{b64}\x1b\\").into_bytes() } /// Encode text as a tmux-wrapped OSC 52 sequence. fn make_osc52_tmux(text: &str) -> Vec { let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); format!("\x1bPtmux;\x1b\x1b]52;c;{b64}\x07\x1b\\").into_bytes() } #[test] fn osc52_normal_text_unchanged() { let input = b"Hello, world!\r\n"; let (output, clips) = filter_output(input); assert_eq!(output, input); assert!(clips.is_empty()); } #[test] fn osc52_ansi_escapes_pass_through() { // SGR color: ESC [ 31 m let input = b"\x1b[31mred text\x1b[0m"; let (output, clips) = filter_output(input); assert_eq!(output, input.as_slice()); assert!(clips.is_empty()); } #[test] fn osc52_plain_bel_terminated() { let seq = make_osc52_bel("hello"); let (output, clips) = filter_output(&seq); assert!( output.is_empty(), "OSC 52 should be consumed, got: {output:?}" ); assert_eq!(clips.len(), 1); assert_eq!(clips[0], b"hello"); } #[test] fn osc52_plain_st_terminated() { let seq = make_osc52_st("hello"); let (output, clips) = filter_output(&seq); assert!( output.is_empty(), "OSC 52 should be consumed, got: {output:?}" ); assert_eq!(clips.len(), 1); assert_eq!(clips[0], b"hello"); } #[test] fn osc52_with_s0_selection() { // Selection parameter "s0" instead of "c". let b64 = base64::engine::general_purpose::STANDARD.encode(b"clipboard data"); let seq = format!("\x1b]52;s0;{b64}\x07").into_bytes(); let (output, clips) = filter_output(&seq); assert!(output.is_empty()); assert_eq!(clips.len(), 1); assert_eq!(clips[0], b"clipboard data"); } #[test] fn osc52_tmux_wrapped() { let seq = make_osc52_tmux("hello from tmux"); let (output, clips) = filter_output(&seq); assert!( output.is_empty(), "tmux OSC 52 should be consumed, got: {output:?}" ); assert_eq!(clips.len(), 1); assert_eq!(clips[0], b"hello from tmux"); } #[test] fn osc52_surrounded_by_text() { let mut input = b"before ".to_vec(); input.extend_from_slice(&make_osc52_bel("copied")); input.extend_from_slice(b" after"); let (output, clips) = filter_output(&input); assert_eq!(output, b"before after"); assert_eq!(clips.len(), 1); assert_eq!(clips[0], b"copied"); } #[test] fn osc52_multiple_sequences() { let mut input = make_osc52_bel("first"); input.extend_from_slice(b"gap"); input.extend_from_slice(&make_osc52_st("second")); let (output, clips) = filter_output(&input); assert_eq!(output, b"gap"); assert_eq!(clips.len(), 2); assert_eq!(clips[0], b"first"); assert_eq!(clips[1], b"second"); } #[test] fn osc52_split_across_chunks() { let seq = make_osc52_bel("split test"); // Feed one byte at a time. let (output, clips) = filter_output_chunked(&seq, 1); assert!(output.is_empty(), "should be consumed even byte-by-byte"); assert_eq!(clips.len(), 1); assert_eq!(clips[0], b"split test"); } #[test] fn osc52_split_at_various_sizes() { let seq = make_osc52_st("chunk test"); for chunk_size in 2..=seq.len() { let (output, clips) = filter_output_chunked(&seq, chunk_size); assert!( output.is_empty(), "chunk_size={chunk_size}: should be consumed" ); assert_eq!(clips.len(), 1, "chunk_size={chunk_size}: expected 1 clip"); assert_eq!(clips[0], b"chunk test"); } } #[test] fn osc52_tmux_split_across_chunks() { let seq = make_osc52_tmux("tmux split"); let (output, clips) = filter_output_chunked(&seq, 3); assert!(output.is_empty()); assert_eq!(clips.len(), 1); assert_eq!(clips[0], b"tmux split"); } #[test] fn osc52_invalid_base64_passes_through() { // Invalid base64 payload: "!!!" is not valid base64. let seq = b"\x1b]52;c;!!!\x07"; let (output, clips) = filter_output(seq); assert_eq!(output, seq.as_slice(), "invalid base64 should pass through"); assert!(clips.is_empty()); } #[test] fn osc52_non_52_osc_passes_through() { // OSC 0 (window title) should pass through. let seq = b"\x1b]0;my title\x07"; let (output, clips) = filter_output(seq); assert_eq!(output, seq.as_slice()); assert!(clips.is_empty()); } #[test] fn osc52_non_52_osc_st_passes_through() { // OSC 0 with ST terminator. let seq = b"\x1b]0;my title\x1b\\"; let (output, clips) = filter_output(seq); assert_eq!(output, seq.as_slice()); assert!(clips.is_empty()); } #[test] fn osc52_oversized_buffer_flushes() { // Build a sequence that exceeds MAX_ESC_BUFFER. let mut seq = b"\x1b]52;c;".to_vec(); // Fill with valid base64 chars until we exceed the limit. seq.resize(MAX_ESC_BUFFER + 100, b'A'); seq.push(0x07); let (output, clips) = filter_output(&seq); assert!( !output.is_empty(), "oversized sequence should flush through" ); assert!( clips.is_empty(), "oversized sequence should not set clipboard" ); } #[test] fn osc52_empty_payload() { // Empty base64 payload should still work (copies empty string). let seq = b"\x1b]52;c;\x07"; let (output, clips) = filter_output(seq); assert!(output.is_empty()); assert_eq!(clips.len(), 1); assert_eq!(clips[0], b""); } #[test] fn osc52_non_tmux_dcs_passes_through() { // A DCS that doesn't start with the tmux prefix should flush. let seq = b"\x1bPother;stuff\x1b\\"; let (output, clips) = filter_output(seq); // The flush happens when the prefix mismatch is detected. assert!(!output.is_empty(), "non-tmux DCS should pass through"); assert!(clips.is_empty()); } #[test] fn osc52_missing_selection_separator() { // No second ';' after "52;" -- missing selection param separator. let b64 = base64::engine::general_purpose::STANDARD.encode(b"data"); let seq = format!("\x1b]52;{b64}\x07").into_bytes(); // This has "52;" followed by base64 with no second ';'. The parser // will treat everything after "52;" up to the next ';' as the // selection param. If there's no ';', it returns false. let (output, clips) = filter_output(&seq); assert_eq!(output, seq, "should pass through without second ';'"); assert!(clips.is_empty()); } #[test] fn wrap_image_request_consumed_and_handler_runs() { let calls = Rc::new(RefCell::new(0usize)); let calls_clone = Rc::clone(&calls); let clips = Rc::new(RefCell::new(Vec::new())); let clips_clone = Rc::clone(&clips); let mut filter = Osc52Filter::with_sink(move |data: &[u8]| { clips_clone.borrow_mut().push(data.to_vec()); }) .with_wrap_image_handler(move || { *calls_clone.borrow_mut() += 1; }); let mut input = b"before".to_vec(); input.extend_from_slice(&crate::wrap_clipboard_image::request_osc_bytes()); input.extend_from_slice(b"after"); let output = filter.feed(&input); assert_eq!(output, b"beforeafter"); assert_eq!(*calls.borrow(), 1); assert!(clips.borrow().is_empty()); } #[test] fn wrap_image_request_split_across_chunks() { let calls = Rc::new(RefCell::new(0usize)); let calls_clone = Rc::clone(&calls); let mut filter = Osc52Filter::with_sink(|_| {}).with_wrap_image_handler(move || { *calls_clone.borrow_mut() += 1; }); let seq = crate::wrap_clipboard_image::request_osc_bytes(); let mut output = Vec::new(); for chunk in seq.chunks(3) { output.extend_from_slice(&filter.feed(chunk)); } assert!(output.is_empty(), "request OSC must be fully consumed"); assert_eq!(*calls.borrow(), 1); } }