diff --git a/src/main.rs b/src/main.rs index 9d7c467..ecc1d9c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -183,6 +183,10 @@ struct App { selecting: bool, sel_scroll: i32, // non-zero while dragging outside terminal bounds (auto-scroll) + // Custom presentation layer (macOS): bypasses softbuffer's NoneSkipFirst alpha + #[cfg(target_os = "macos")] + present_layer: Option, + // Cursor blink cursor_visible: bool, last_blink: Instant, @@ -209,6 +213,8 @@ impl App { selection: None, selecting: false, sel_scroll: 0, + #[cfg(target_os = "macos")] + present_layer: None, cursor_visible: true, last_blink: Instant::now(), engine: Engine::new(), @@ -423,6 +429,63 @@ impl App { result } + /// Handle Cmd+V: paste text (with bracketed-paste wrapping if enabled), + /// or save a clipboard image to a temp file and write the path. + fn do_paste(&mut self) { + #[cfg(target_os = "macos")] + { + // Image takes priority: save PNG to a temp file, write path to PTY. + if let Some(png) = platform::clipboard_png() { + let path = std::env::temp_dir().join(format!( + "term_paste_{}.png", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + )); + if std::fs::write(&path, &png).is_ok() { + let path_str = path.to_string_lossy(); + let bracketed = self.active().terminal.state.bracketed_paste; + if bracketed { + self.pty_write(b"\x1b[200~"); + } + self.pty_write(path_str.as_bytes()); + if bracketed { + self.pty_write(b"\x1b[201~"); + } + } + return; + } + if let Some(text) = platform::clipboard_text() { + if !text.is_empty() { + let bracketed = self.active().terminal.state.bracketed_paste; + if bracketed { + self.pty_write(b"\x1b[200~"); + } + self.pty_write(text.as_bytes()); + if bracketed { + self.pty_write(b"\x1b[201~"); + } + } + return; + } + } + #[cfg(not(target_os = "macos"))] + { + let text = paste_from_clipboard(); + if !text.is_empty() { + let bracketed = self.active().terminal.state.bracketed_paste; + if bracketed { + self.pty_write(b"\x1b[200~"); + } + self.pty_write(text.as_bytes()); + if bracketed { + self.pty_write(b"\x1b[201~"); + } + } + } + } + fn prev_tab(&mut self) { self.active_tab = if self.active_tab == 0 { self.tabs.len() - 1 @@ -537,10 +600,7 @@ impl App { } } "v" => { - let text = paste_from_clipboard(); - if !text.is_empty() { - self.pty_write(text.as_bytes()); - } + self.do_paste(); } "t" => { self.open_tab(); @@ -771,6 +831,19 @@ impl App { drag_preview, &url_underlines, ); + + // On macOS, bypass softbuffer's present() which uses CGImageAlphaInfo::NoneSkipFirst + // (alpha byte ignored → everything opaque). Our PresentLayer uses AlphaFirst so + // alpha=0 terminal-background pixels are genuinely transparent. + #[cfg(target_os = "macos")] + { + if let Some(pl) = &mut self.present_layer { + pl.present(buf.as_ref(), w as u32, h as u32); + // Drop buf without calling .present() — no panic, softbuffer's buffer + // just frees its Vec on drop when present() is not called. + return; + } + } buf.present().unwrap(); } @@ -834,6 +907,10 @@ impl ApplicationHandler for App { self.surface = Some(surface); self.renderer = Some(renderer); platform::setup_vibrancy(&window); + #[cfg(target_os = "macos")] + { + self.present_layer = platform::PresentLayer::new(&window); + } self.window = Some(window); } @@ -1134,6 +1211,14 @@ impl ApplicationHandler for App { let _ = tab.pty_writer.write_all(&r); let _ = tab.pty_writer.flush(); } + // OSC 52 clipboard query: respond with clipboard content. + if tab.terminal.state.osc_52_query { + tab.terminal.state.osc_52_query = false; + let payload = osc52_clipboard_payload(); + let response = format!("\x1b]52;c;{payload}\x07"); + let _ = tab.pty_writer.write_all(response.as_bytes()); + let _ = tab.pty_writer.flush(); + } } // Update ghost text only for the active tab if self.tabs.get(self.active_tab).map(|t| t.id) == Some(tab_id) { @@ -1164,6 +1249,54 @@ impl ApplicationHandler for App { // ── Clipboard ───────────────────────────────────────────────────────────────── +/// Return the base64-encoded clipboard payload for an OSC 52 response. +/// Prefers image (PNG) over text; returns empty string if clipboard is empty. +fn osc52_clipboard_payload() -> String { + #[cfg(target_os = "macos")] + { + if let Some(png) = platform::clipboard_png() { + return base64_encode(&png); + } + if let Some(text) = platform::clipboard_text() { + if !text.is_empty() { + return base64_encode(text.as_bytes()); + } + } + return String::new(); + } + #[cfg(not(target_os = "macos"))] + { + let text = paste_from_clipboard(); + if text.is_empty() { + return String::new(); + } + base64_encode(text.as_bytes()) + } +} + +fn base64_encode(data: &[u8]) -> String { + const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity((data.len() + 2) / 3 * 4); + for chunk in data.chunks(3) { + let b0 = chunk[0]; + let b1 = if chunk.len() > 1 { chunk[1] } else { 0 }; + let b2 = if chunk.len() > 2 { chunk[2] } else { 0 }; + out.push(TABLE[(b0 >> 2) as usize] as char); + out.push(TABLE[((b0 & 3) << 4 | b1 >> 4) as usize] as char); + if chunk.len() > 1 { + out.push(TABLE[((b1 & 15) << 2 | b2 >> 6) as usize] as char); + } else { + out.push('='); + } + if chunk.len() > 2 { + out.push(TABLE[(b2 & 63) as usize] as char); + } else { + out.push('='); + } + } + out +} + fn copy_to_clipboard(text: &str) { use std::io::Write; use std::process::{Command, Stdio}; @@ -1204,6 +1337,7 @@ fn strip_tcat_gutter(text: &str) -> String { .join("\n") } +#[cfg(not(target_os = "macos"))] fn paste_from_clipboard() -> String { use std::process::Command; Command::new("pbpaste") diff --git a/src/platform.rs b/src/platform.rs index 3b3bb10..0733270 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -1,24 +1,257 @@ /// Platform-specific window setup. /// -/// On macOS this wires up NSVisualEffectView (vibrancy/blur) so that terminal -/// background pixels — which are rendered with alpha=0 — show the blurred -/// desktop through them. Everything else (glyphs, tab bar, cursor) is -/// rendered with alpha=0xFF and appears fully opaque. +/// On macOS this wires up NSVisualEffectView (vibrancy/blur) and a custom +/// presentation layer that renders pixels with proper per-pixel alpha. /// -/// On other platforms this is a no-op. +/// Softbuffer's macOS backend uses `CGImageAlphaInfo::NoneSkipFirst`, which +/// means the alpha byte we write is silently ignored — every pixel ends up +/// fully opaque. We bypass softbuffer's `present()` and instead create our +/// own `CGImage` with `CGImageAlphaInfo::First` (non-premultiplied straight +/// alpha) so that alpha=0 terminal-background pixels become genuinely +/// transparent and let the NSVisualEffectView blur show through beneath them. +#[cfg(target_os = "macos")] +mod cg_sys { + use std::os::raw::c_void; + + // Opaque Core Graphics types + pub enum CGColorSpaceOpaque {} + pub type CGColorSpaceRef = *mut CGColorSpaceOpaque; + + pub enum CGDataProviderOpaque {} + pub type CGDataProviderRef = *mut CGDataProviderOpaque; + + pub enum CGImageOpaque {} + pub type CGImageRef = *mut CGImageOpaque; + + // CGImageAlphaInfo::First (4) — non-premultiplied, alpha in the most-significant + // byte of a 32-bit little-endian word (i.e. byte[3] of [B,G,R,A] in memory). + // CGBitmapByteOrder32Little (2 << 12 = 0x2000) — little-endian 32-bit pixels. + // + // Combined: our u32 layout 0xAA_RR_GG_BB = alpha=AA, R=RR, G=GG, B=BB. ✓ + pub const BITMAP_INFO_ALPHA_FIRST_32LE: u32 = 4 | (2 << 12); + + // kCGRenderingIntentDefault = 0 + pub const RENDERING_INTENT_DEFAULT: i32 = 0; + + pub type ReleaseDataCallback = + unsafe extern "C" fn(*mut c_void, *const c_void, usize); + + #[link(name = "CoreGraphics", kind = "framework")] + unsafe extern "C" { + pub fn CGColorSpaceCreateDeviceRGB() -> CGColorSpaceRef; + pub fn CGColorSpaceRelease(cs: CGColorSpaceRef); + pub fn CGDataProviderCreateWithData( + info: *mut c_void, + data: *const c_void, + size: usize, + release_data: Option, + ) -> CGDataProviderRef; + pub fn CGDataProviderRelease(provider: CGDataProviderRef); + pub fn CGImageCreate( + width: usize, + height: usize, + bits_per_component: usize, + bits_per_pixel: usize, + bytes_per_row: usize, + color_space: CGColorSpaceRef, + bitmap_info: u32, + provider: CGDataProviderRef, + decode: *const f64, + should_interpolate: bool, + intent: i32, + ) -> CGImageRef; + pub fn CGImageRelease(image: CGImageRef); + } + + /// Release callback invoked by CGDataProvider when it's done with the buffer. + /// The `data` pointer is the raw `Box<[u32]>` we leaked into the provider. + pub unsafe extern "C" fn release_pixels( + _info: *mut c_void, + data: *const c_void, + size: usize, + ) { + unsafe { + let count = size / 4; + let ptr = data as *mut u32; + drop(Box::from_raw(std::slice::from_raw_parts_mut(ptr, count))); + } + } +} + +/// A `PresentLayer` owns a `CALayer` and a `CGColorSpace` and is responsible +/// for uploading rendered `u32` framebuffer data to the screen with correct +/// per-pixel alpha transparency. +#[cfg(target_os = "macos")] +pub struct PresentLayer { + /// The CALayer we manage (a sublayer of the view's root layer). + layer: *mut objc2::runtime::AnyObject, + color_space: cg_sys::CGColorSpaceRef, +} + +// CALayer usage is confined to the main thread via winit's guarantee. +#[cfg(target_os = "macos")] +unsafe impl Send for PresentLayer {} + +#[cfg(target_os = "macos")] +impl PresentLayer { + /// Create the layer, add it to the view's layer hierarchy, and initialise + /// the color space. Must be called after softbuffer has already called + /// `setWantsLayer:YES` on the view (so `[view layer]` is non-null). + pub fn new(window: &winit::window::Window) -> Option { + use objc2::{class, msg_send, runtime::AnyObject}; + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let ns_view = match window.window_handle().ok()?.as_raw() { + RawWindowHandle::AppKit(h) => h.ns_view.as_ptr() as *mut AnyObject, + _ => return None, + }; + + // Window size in points for the initial layer frame. + let scale = window.scale_factor(); + let phys = window.inner_size(); + let w = phys.width as f64 / scale; + let h = phys.height as f64 / scale; + + unsafe { + // The view should already be layer-backed (softbuffer enables this). + let root_layer: *mut AnyObject = msg_send![ns_view, layer]; + if root_layer.is_null() { + return None; + } + + // Make the root layer non-opaque so transparent pixels propagate. + let _: () = msg_send![root_layer, setOpaque: false]; + + // Create our custom layer. + let layer: *mut AnyObject = msg_send![class!(CALayer), new]; + + // Place it at the top-left, mirroring softbuffer's geometry. + let anchor: [f64; 2] = [0.0, 0.0]; + let _: () = msg_send![layer, setAnchorPoint: anchor]; + let _: () = msg_send![layer, setGeometryFlipped: true]; + + // Non-opaque so alpha=0 pixels in our CGImage are transparent. + let _: () = msg_send![layer, setOpaque: false]; + + // kCAGravityTopLeft — contents are not scaled. + let gravity: *mut AnyObject = msg_send![ + class!(NSString), + stringWithUTF8String: b"topLeft\0".as_ptr() as *const std::os::raw::c_char + ]; + let _: () = msg_send![layer, setContentsGravity: gravity]; + + // Match the display's HiDPI scale factor so pixels map 1:1. + let _: () = msg_send![layer, setContentsScale: scale]; + + // Set an initial frame so the layer is visible immediately. + let bounds: [f64; 4] = [0.0, 0.0, w, h]; + let _: () = msg_send![layer, setFrame: bounds]; + + // Fill the window — autoresizing mask kCALayerWidthSizable(2)|HeightSizable(4). + let _: () = msg_send![layer, setAutoresizingMask: 6usize]; + + // Add on top of softbuffer's sublayer (last added = topmost). + let _: () = msg_send![root_layer, addSublayer: layer]; + + let color_space = cg_sys::CGColorSpaceCreateDeviceRGB(); + + Some(PresentLayer { layer, color_space }) + } + } + + /// Upload `pixels` (width × height u32 values in 0xAARRGGBB layout with + /// straight alpha) to the CALayer as a CGImage. Alpha=0 pixels will be + /// transparent, letting the NSVisualEffectView below show through. + pub fn present(&mut self, pixels: &[u32], width: u32, height: u32) { + use objc2::{class, msg_send}; + use std::os::raw::c_void; + + let n = (width as usize) * (height as usize); + if n == 0 || pixels.len() < n { + return; + } + + unsafe { + // Copy the slice into a heap-allocated Box whose ownership is + // transferred to the CGDataProvider via the release callback. + let boxed: Box<[u32]> = pixels[..n].to_vec().into_boxed_slice(); + let data_ptr = boxed.as_ptr() as *const c_void; + let byte_len = n * 4; + let _ = Box::into_raw(boxed); // ownership now with release_pixels callback + + let provider = cg_sys::CGDataProviderCreateWithData( + std::ptr::null_mut(), + data_ptr, + byte_len, + Some(cg_sys::release_pixels), + ); + if provider.is_null() { + // If the provider allocation failed the callback won't fire, so + // we must free manually. Reconstruct and drop the Box. + drop(Box::from_raw(std::slice::from_raw_parts_mut( + data_ptr as *mut u32, + n, + ))); + return; + } + + let image = cg_sys::CGImageCreate( + width as usize, + height as usize, + 8, // bits per component + 32, // bits per pixel + (width as usize) * 4, // bytes per row + self.color_space, + cg_sys::BITMAP_INFO_ALPHA_FIRST_32LE, + provider, + std::ptr::null(), + false, + cg_sys::RENDERING_INTENT_DEFAULT, + ); + + // The image retains the provider; we can release our reference. + cg_sys::CGDataProviderRelease(provider); + + if image.is_null() { + return; + } + + // Disable implicit CALayer animations for the contents swap. + let _: () = msg_send![class!(CATransaction), begin]; + let _: () = msg_send![class!(CATransaction), setDisableActions: true]; + let _: () = msg_send![self.layer, setContents: image as *mut objc2::runtime::AnyObject]; + let _: () = msg_send![class!(CATransaction), commit]; + + // CALayer retained the image when setContents: was called; release ours. + cg_sys::CGImageRelease(image); + } + } +} + +#[cfg(target_os = "macos")] +impl Drop for PresentLayer { + fn drop(&mut self) { + unsafe { cg_sys::CGColorSpaceRelease(self.color_space) }; + } +} + +/// Set up window vibrancy: insert an NSVisualEffectView as a sibling behind +/// the winit view so blurred desktop content shows through alpha=0 pixels. +/// +/// We deliberately do NOT call `setContentView:` because that fires +/// `viewDidMoveToWindow:nil` on WinitView which clears winit's internal state +/// and causes a `panic_cannot_unwind` in subsequent mouse events. #[cfg(target_os = "macos")] pub fn setup_vibrancy(window: &winit::window::Window) { use objc2::{class, msg_send, runtime::AnyObject}; use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; - // Get the NSView from winit's raw window handle let ns_view = match window.window_handle().unwrap().as_raw() { RawWindowHandle::AppKit(h) => h.ns_view.as_ptr() as *mut AnyObject, _ => return, }; - // Window size in points (logical coordinates — NSRect uses points, not pixels) let scale = window.scale_factor(); let phys = window.inner_size(); let w = phys.width as f64 / scale; @@ -30,36 +263,14 @@ pub fn setup_vibrancy(window: &winit::window::Window) { return; } - // Make the window itself non-opaque with a clear background so - // alpha=0 pixels show the desktop through them. + // Non-opaque window with a clear background so alpha=0 pixels reveal + // the desktop through the NSVisualEffectView blur. let _: () = msg_send![ns_window, setOpaque: false]; let clear: *mut AnyObject = msg_send![class!(NSColor), clearColor]; let _: () = msg_send![ns_window, setBackgroundColor: clear]; - // Make the content view's CALayer non-opaque so its alpha=0 pixels - // are actually transparent in compositing (not filled with black). - let _: () = msg_send![ns_view, setWantsLayer: true]; - let layer: *mut AnyObject = msg_send![ns_view, layer]; - if !layer.is_null() { - let _: () = msg_send![layer, setOpaque: false]; - let clear2: *mut AnyObject = msg_send![class!(NSColor), clearColor]; - let cg_color: *mut AnyObject = msg_send![clear2, CGColor]; - let _: () = msg_send![layer, setBackgroundColor: cg_color]; - } - - // ── Insert NSVisualEffectView as a sibling BEHIND ns_view ───────────── - // - // We deliberately do NOT call setContentView: because that would fire - // viewDidMoveToWindow: nil on WinitView, clearing winit's internal state - // and causing a panic in mouse_moved. - // - // Instead we insert VEV into ns_view's superview (NSThemeFrame) at a - // lower z-position. When ns_view's layer renders alpha=0 pixels for - // terminal background cells, those transparent holes reveal the VEV - // blur beneath via Core Animation's standard alpha compositing. - // ns_view's superview inside NSWindow is NSThemeFrame (the internal - // window chrome view that also parents the title bar). + // frame view that also parents the title bar). let frame_view: *mut AnyObject = msg_send![ns_view, superview]; if frame_view.is_null() { return; @@ -68,25 +279,122 @@ pub fn setup_vibrancy(window: &winit::window::Window) { let vev: *mut AnyObject = msg_send![class!(NSVisualEffectView), new]; // NSRect as [f64; 4] = [origin.x, origin.y, size.width, size.height]. - // [f64; 4] has the same memory layout as NSRect and implements Encode, - // so it satisfies msg_send!'s type requirements without needing CGRect. + // Same memory layout as NSRect; [f64; 4] implements Encode. let rect: [f64; 4] = [0.0, 0.0, w, h]; let _: () = msg_send![vev, setFrame: rect]; // NSViewWidthSizable(2) | NSViewHeightSizable(16) = 18 let _: () = msg_send![vev, setAutoresizingMask: 18usize]; - // NSVisualEffectMaterial.underPageBackground = 21 — dark adaptive blur + // NSVisualEffectMaterial.underPageBackground = 21 let _: () = msg_send![vev, setMaterial: 21usize]; // NSVisualEffectBlendingMode.behindWindow = 0 let _: () = msg_send![vev, setBlendingMode: 0usize]; - // NSVisualEffectState.active = 1 — always-on blur + // NSVisualEffectState.active = 1 let _: () = msg_send![vev, setState: 1usize]; - // addSubview:positioned:relativeTo: with NSWindowBelow(-1) places vev + // addSubview:positioned:relativeTo: with NSWindowBelow(-1) inserts VEV // directly beneath ns_view in the frame_view subview stack. - let _: () = msg_send![frame_view, addSubview: vev positioned: -1i64 relativeTo: ns_view]; + let _: () = msg_send![ + frame_view, + addSubview: vev + positioned: -1i64 + relativeTo: ns_view + ]; } } #[cfg(not(target_os = "macos"))] pub fn setup_vibrancy(_window: &winit::window::Window) {} + +/// Read the macOS clipboard and return PNG-encoded image bytes, or `None` if +/// the clipboard holds no image. Screenshots are stored as TIFF internally; +/// we convert them to PNG via NSBitmapImageRep so callers always get PNG. +#[cfg(target_os = "macos")] +pub fn clipboard_png() -> Option> { + use objc2::{class, msg_send, runtime::AnyObject}; + use std::os::raw::c_char; + unsafe { + let pb: *mut AnyObject = msg_send![class!(NSPasteboard), generalPasteboard]; + + // Try PNG directly. + let png_type: *mut AnyObject = msg_send![ + class!(NSString), + stringWithUTF8String: b"public.png\0".as_ptr() as *const c_char + ]; + let data: *mut AnyObject = msg_send![pb, dataForType: png_type]; + if !data.is_null() { + let len: usize = msg_send![data, length]; + if len > 0 { + let bytes: *const u8 = msg_send![data, bytes]; + return Some(std::slice::from_raw_parts(bytes, len).to_vec()); + } + } + + // Try TIFF (screenshots, most copy-as-image operations on macOS). + let tiff_type: *mut AnyObject = msg_send![ + class!(NSString), + stringWithUTF8String: b"public.tiff\0".as_ptr() as *const c_char + ]; + let tiff_data: *mut AnyObject = msg_send![pb, dataForType: tiff_type]; + if tiff_data.is_null() { + return None; + } + + // NSImage → TIFF representation → NSBitmapImageRep → PNG data. + let img: *mut AnyObject = msg_send![class!(NSImage), alloc]; + let img: *mut AnyObject = msg_send![img, initWithData: tiff_data]; + if img.is_null() { + return None; + } + let tiff_rep: *mut AnyObject = msg_send![img, TIFFRepresentation]; + let _: () = msg_send![img, release]; + if tiff_rep.is_null() { + return None; + } + + let bmp: *mut AnyObject = msg_send![class!(NSBitmapImageRep), alloc]; + let bmp: *mut AnyObject = msg_send![bmp, initWithData: tiff_rep]; + if bmp.is_null() { + return None; + } + + // NSBitmapImageFileTypePNG = 4, empty properties dict. + let props: *mut AnyObject = msg_send![class!(NSDictionary), dictionary]; + let png_data: *mut AnyObject = + msg_send![bmp, representationUsingType: 4usize properties: props]; + let _: () = msg_send![bmp, release]; + if png_data.is_null() { + return None; + } + + let len: usize = msg_send![png_data, length]; + if len == 0 { + return None; + } + let bytes: *const u8 = msg_send![png_data, bytes]; + Some(std::slice::from_raw_parts(bytes, len).to_vec()) + } +} + +/// Read plain-text content from the macOS clipboard, or `None` if empty. +#[cfg(target_os = "macos")] +pub fn clipboard_text() -> Option { + use objc2::{class, msg_send, runtime::AnyObject}; + use std::os::raw::c_char; + unsafe { + let pb: *mut AnyObject = msg_send![class!(NSPasteboard), generalPasteboard]; + let str_type: *mut AnyObject = msg_send![ + class!(NSString), + stringWithUTF8String: b"public.utf8-plain-text\0".as_ptr() as *const c_char + ]; + let ns_str: *mut AnyObject = msg_send![pb, stringForType: str_type]; + if ns_str.is_null() { + return None; + } + let c_str: *const c_char = msg_send![ns_str, UTF8String]; + if c_str.is_null() { + return None; + } + Some(std::ffi::CStr::from_ptr(c_str).to_string_lossy().into_owned()) + } +} diff --git a/src/terminal.rs b/src/terminal.rs index 4a477c5..97a219e 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -61,6 +61,10 @@ pub struct TerminalState { pub input_cursor: usize, /// Current working directory (sent via OSC 7 on each chpwd). pub current_dir: String, + /// Set by OSC 52 `?` query; cleared after the host responds. + pub osc_52_query: bool, + /// Whether the app has enabled bracketed paste mode (?2004h). + pub bracketed_paste: bool, saved_cursor: (usize, usize), saved_attrs: Attrs, wrap_next: bool, @@ -102,6 +106,8 @@ impl TerminalState { alt_screen: false, alt_grid: vec![vec![Cell::default(); cols]; rows], alt_saved_cursor: (0, 0), + osc_52_query: false, + bracketed_paste: false, } } @@ -535,6 +541,7 @@ impl Perform for TerminalState { match param { 47 | 1047 => self.enter_alt_screen(false), 1049 => self.enter_alt_screen(true), + 2004 => self.bracketed_paste = true, _ => {} } } @@ -544,6 +551,7 @@ impl Perform for TerminalState { match param { 47 | 1047 => self.leave_alt_screen(false), 1049 => self.leave_alt_screen(true), + 2004 => self.bracketed_paste = false, _ => {} } } @@ -606,6 +614,14 @@ impl Perform for TerminalState { self.current_dir = path; } } + b"52" => { + // OSC 52 clipboard access. + // params: ["52", , ] + // A "?" payload is a read query; anything else is a write. + if params.len() >= 3 && params[2] == b"?" { + self.osc_52_query = true; + } + } b"9001" => { // Shell integration: ZLE hook sends current buffer + cursor. // Format: OSC 9001 ; \x1c ST