Files
Kigi-CLI/crates/codegen/kigi-tui/src/pty_wrap.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

840 lines
31 KiB
Rust

//! 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<i32> {
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::<Vec<u8>>();
{
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<dyn portable_pty::MasterPty + Send>) {
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<dyn FnMut(&[u8])>;
type WrapImageRequestHandler = Box<dyn FnMut()>;
/// 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<u8>,
clipboard_sink: ClipboardSink,
wrap_image_handler: Option<WrapImageRequestHandler>,
}
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<u8> {
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;<sel>;<base64>\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;<sel>;<base64>`), 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<u8> {
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<u8>, Vec<Vec<u8>>) {
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<u8>, Vec<Vec<u8>>) {
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<u8> {
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<u8> {
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<u8> {
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);
}
}