diff --git a/src/app.rs b/src/app.rs index 0a8d0bc..d95c4b4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,7 @@ //! here through `Command`s and `Event` snapshots over a `Transport`. use std::{ - io::{self, Stdout}, + io::{self, Stdout, Write}, path::{Path, PathBuf}, sync::{ Arc, @@ -10,9 +10,10 @@ use std::{ mpsc::{Receiver, Sender, channel}, }, thread, - time::Duration, + time::{Duration, Instant}, }; +use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; use crossterm::{ cursor::MoveTo, event::{ @@ -27,8 +28,8 @@ use crate::{ editbuf::EditBuffer, path, protocol::{ - Command, Event, Key, Lifecycle, Mods, MouseBtn, MouseKind, RecoveryEntry, ScreenView, - ScrollAction, TaskView, + ClipboardKind, Command, Event, Key, Lifecycle, Mods, MouseBtn, MouseKind, RecoveryEntry, + ScreenView, ScrollAction, TaskView, }, transport::{ExitIntent, SocketTransport, ThreadTransport, Transport}, ui, @@ -44,6 +45,16 @@ const _: () = assert!( "MAX_PASTE must base64-encode to under frame::MAX_FRAME" ); +/// How long an ephemeral notice remains visible. +const NOTICE_TTL: Duration = Duration::from_secs(5); + +/// Priority of an ephemeral notice. Active warnings take precedence over info. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NoticeLevel { + Warning, + Info, +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum Mode { Dashboard, @@ -140,8 +151,8 @@ pub struct App { pub views: Vec, /// The watched task's screen (attach/peek), from `Event::Screen`. focused_screen: Option, - /// Last `Watch` target sent to the core, so we don't resend it every tick. - watched: Option, + /// Last `(target, attached)` watch state sent to the core. + watched: Option<(u64, bool)>, /// Whether this client talks to a daemon (vs. an in-process `--foreground` /// core). Only a daemon client can meaningfully reconnect after a drop. pub daemon_backed: bool, @@ -194,6 +205,10 @@ pub struct App { pub recovery_sel: usize, /// Transient one-line notice (save/load result), dismissed on the next key. pub status: Option, + /// Ephemeral notice text, priority, and creation time. + notice: Option<(String, NoticeLevel, Instant)>, + /// Clipboard stores awaiting emission to the host terminal. + pending_clipboard: Vec<(ClipboardKind, String)>, /// Parsed terminal events from the stdin reader thread. crossterm owns the /// tty, so a dedicated thread blocks on `event::read()` and forwards here; the /// run loop drains this instead of polling stdin itself. @@ -371,6 +386,8 @@ impl App { session_page: SessionPage::Saved, recovery_sel: 0, status: None, + notice: None, + pending_clipboard: Vec::new(), input_rx, input_tx: Some(input_tx), wait_rx, @@ -586,9 +603,8 @@ impl App { self.selected_id = Some(self.views[sections[prev].1[0]].id); } - /// Tell the core which task's screen we need (attach/peek), sending `Watch` - /// only when the target actually changes. - fn set_watch(&mut self, want: Option) { + /// Send the desired watch state when its target or attachment mode changes. + fn set_watch(&mut self, want: Option<(u64, bool)>) { if want != self.watched { self.watched = want; // Drop the now-irrelevant screen so a stale one can't flash before @@ -596,7 +612,10 @@ impl App { if want.is_none() { self.focused_screen = None; } - self.transport.send(Command::Watch { id: want }); + self.transport.send(Command::Watch { + id: want.map(|(id, _)| id), + attached: want.is_some_and(|(_, attached)| attached), + }); } } @@ -616,7 +635,14 @@ impl App { } self.focused_screen = Some(s); } - Event::Status(s) => self.status = Some(s), + Event::Status(s) => { + // Mirror attached-mode status messages into the visible notice bar. + if self.mode == Mode::Attached { + self.set_notice(s.clone(), NoticeLevel::Warning); + } + self.status = Some(s); + } + Event::ClipboardCopy { id, kind, text } => self.on_clipboard_copy(id, kind, text), Event::Sessions { names, recovery } => { // Clamp both page selections to the refreshed lists. self.session_sel = self.session_sel.min(names.len().saturating_sub(1)); @@ -631,6 +657,51 @@ impl App { } } + /// Queue a clipboard store only when it comes from the attached task. + fn on_clipboard_copy(&mut self, id: u64, kind: ClipboardKind, text: String) { + if self.mode == Mode::Attached && self.focused_id == Some(id) { + self.pending_clipboard.push((kind, text)); + } + } + + /// Set an ephemeral notice without replacing an active warning with info. + fn set_notice(&mut self, msg: String, level: NoticeLevel) { + if level == NoticeLevel::Info + && let Some((_, NoticeLevel::Warning, set_at)) = self.notice.as_ref() + && set_at.elapsed() < NOTICE_TTL + { + return; + } + self.notice = Some((msg, level, Instant::now())); + } + + /// Return the staged notice while it is younger than `NOTICE_TTL`. + pub fn notice(&self) -> Option<&str> { + let (msg, _, set_at) = self.notice.as_ref()?; + (set_at.elapsed() < NOTICE_TTL).then_some(msg.as_str()) + } + + /// Emit queued clipboard stores as base64-encoded OSC 52 sequences in order. + fn flush_clipboard(&mut self, out: &mut impl Write) -> io::Result<()> { + if self.pending_clipboard.is_empty() { + return Ok(()); + } + let mut last = 0; + for (kind, text) in self.pending_clipboard.drain(..) { + let k = match kind { + ClipboardKind::Clipboard => 'c', + ClipboardKind::Primary => 'p', + ClipboardKind::Selection => 's', + }; + write!(out, "\x1b]52;{k};{}\x07", B64.encode(&text))?; + last = copied_chars(&text); + } + out.flush()?; + // Show the confirmation in the attached-mode notice bar. + self.set_notice(format!("copied {last} chars"), NoticeLevel::Info); + Ok(()) + } + pub fn run(&mut self, out: &mut Stdout) -> io::Result<()> { // Read terminal events on a dedicated thread and wake the UI loop. if let Some(input_tx) = self.input_tx.take() { @@ -650,8 +721,8 @@ impl App { // Synchronize before checking for exit so teardown still runs if the // terminal has gone away. let watch = match self.mode { - Mode::Peek => self.selected_id, - Mode::Attached => self.focused_id, + Mode::Peek => self.selected_id.map(|id| (id, false)), + Mode::Attached => self.focused_id.map(|id| (id, true)), _ => None, }; self.set_watch(watch); @@ -680,6 +751,8 @@ impl App { self.focused_id = None; } + // Emit accepted clipboard stores before painting the next frame. + self.flush_clipboard(out)?; self.sync_input_modes(out)?; ui::render(out, self)?; @@ -1492,6 +1565,11 @@ fn on_key_edit(buf: &mut EditBuffer, k: KeyEvent) -> Option { } } +/// Count the characters reported by the clipboard-copy notice. +fn copied_chars(text: &str) -> usize { + text.chars().count() +} + /// Insert pasted text at the caret after removing control characters. fn paste_into(buf: &mut EditBuffer, s: &str) { for c in s.chars().filter(|c| !c.is_control()) { diff --git a/src/app_tests.rs b/src/app_tests.rs index f239252..0bf15f1 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -1242,7 +1242,7 @@ fn attached_wheel_honors_the_childs_1007_veto() { app.resolve_selection(); app.attach(); let id = app.focused_id.expect("attached"); - app.set_watch(Some(id)); + app.set_watch(Some((id, true))); // Wait for the child's terminal modes to reach the client. assert!( wait_until(Duration::from_secs(5), || { @@ -1882,3 +1882,271 @@ fn state_and_dir_mode_spawns_stay_unassigned() { let v = app.views.iter().find(|v| v.id == 2).unwrap(); assert_eq!(v.group, None); } + +// --- OSC 52 clipboard emission ------------------------------------------ + +/// An attached clipboard store emits one BEL-terminated OSC 52 sequence. +#[test] +fn attached_clipboard_store_emits_the_osc52_envelope() { + let mut app = App::new_local(30, 100); + app.mode = Mode::Attached; + app.focused_id = Some(1); + app.on_clipboard_copy(1, ClipboardKind::Clipboard, "hello".to_string()); + let mut out = Vec::new(); + app.flush_clipboard(&mut out).unwrap(); + assert_eq!(out, b"\x1b]52;c;aGVsbG8=\x07"); + assert_eq!(app.notice(), Some("copied 5 chars")); + assert!( + app.status.is_none(), + "the copy confirmation is ephemeral; it must not occupy the status" + ); +} + +/// A selection store emits the `s` selector. +#[test] +fn selection_store_emits_its_own_kind_byte() { + let mut app = App::new_local(30, 100); + app.mode = Mode::Attached; + app.focused_id = Some(1); + app.on_clipboard_copy(1, ClipboardKind::Selection, "hello".to_string()); + let mut out = Vec::new(); + app.flush_clipboard(&mut out).unwrap(); + assert_eq!(out, b"\x1b]52;s;aGVsbG8=\x07"); +} + +/// A primary-selection store emits the `p` selector. +#[test] +fn primary_store_emits_its_own_kind_byte() { + let mut app = App::new_local(30, 100); + app.mode = Mode::Attached; + app.focused_id = Some(1); + app.on_clipboard_copy(1, ClipboardKind::Primary, "hello".to_string()); + let mut out = Vec::new(); + app.flush_clipboard(&mut out).unwrap(); + assert_eq!(out, b"\x1b]52;p;aGVsbG8=\x07"); +} + +/// Clipboard payloads are base64-encoded before reaching the host terminal. +#[test] +fn clipboard_payload_bytes_never_reach_the_terminal_raw() { + let payload = "line1\nline2\x1b[31mred\x1b]52;c;evil\x07"; + let mut app = App::new_local(30, 100); + app.mode = Mode::Attached; + app.focused_id = Some(1); + app.on_clipboard_copy(1, ClipboardKind::Clipboard, payload.to_string()); + let mut out = Vec::new(); + app.flush_clipboard(&mut out).unwrap(); + + assert!(out.starts_with(b"\x1b]52;c;")); + assert!(out.ends_with(b"\x07")); + let body = &out[b"\x1b]52;c;".len()..out.len() - 1]; + assert!( + body.iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=')), + "only base64 may sit between the prefix and the BEL" + ); + assert_eq!(B64.decode(body).unwrap(), payload.as_bytes()); + assert!( + !out.windows(payload.len()).any(|w| w == payload.as_bytes()), + "the raw payload must not appear in the output" + ); +} + +/// Clipboard stores are ignored outside attached mode. +#[test] +fn clipboard_stores_outside_attached_mode_are_dropped() { + for mode in [Mode::Peek, Mode::Dashboard] { + let mut app = App::new_local(30, 100); + app.mode = mode; + app.focused_id = Some(1); + app.on_clipboard_copy(1, ClipboardKind::Clipboard, "hello".to_string()); + assert!(app.pending_clipboard.is_empty(), "nothing may buffer"); + let mut out = Vec::new(); + app.flush_clipboard(&mut out).unwrap(); + assert!(out.is_empty(), "nothing may emit"); + assert!(app.notice().is_none(), "no notice without an emission"); + } +} + +/// Stores from tasks other than the attached task are ignored. +#[test] +fn mismatched_id_clipboard_store_drops_at_receipt() { + let mut app = App::new_local(30, 100); + app.mode = Mode::Attached; + app.focused_id = Some(7); + app.on_clipboard_copy(3, ClipboardKind::Clipboard, "stale".to_string()); + assert!( + app.pending_clipboard.is_empty(), + "an in-flight copy from another task must not buffer" + ); + app.on_clipboard_copy(7, ClipboardKind::Clipboard, "fresh".to_string()); + assert_eq!( + app.pending_clipboard, + vec![(ClipboardKind::Clipboard, "fresh".to_string())] + ); +} + +/// Changing from peek to attach sends a new watch for the same task. +#[test] +fn set_watch_resends_on_kind_change_with_the_same_id() { + let dir = temp("app_watch_kind"); + let flag = dir.join("flag"); + let mut app = App::new_local(30, 100); + let cwd = app.invocation_dir.clone(); + let cmd = format!( + "until [ -e {f} ]; do sleep 0.05; done; printf '\\033]52;c;cG9zdA==\\007'; sleep 30", + f = flag.display() + ); + app.spawn_in(&cmd, cwd); + app.pump(); + let id = app.views[0].id; + + // Change only the attachment mode. + app.set_watch(Some((id, false))); + app.set_watch(Some((id, true))); + app.mode = Mode::Attached; + app.focused_id = Some(id); + + std::fs::write(&flag, b"").unwrap(); + let ok = wait_until(Duration::from_secs(5), || { + app.pump(); + !app.pending_clipboard.is_empty() + }); + assert!( + ok, + "the post-attach store never forwarded: the kind change never reached the core" + ); + assert_eq!( + app.pending_clipboard, + vec![(ClipboardKind::Clipboard, "post".to_string())] + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Pending stores emit in order, and the notice counts the last store's characters. +#[test] +fn pending_stores_emit_in_order_and_notice_counts_last_entry_chars() { + let mut app = App::new_local(30, 100); + app.mode = Mode::Attached; + app.focused_id = Some(1); + app.on_clipboard_copy(1, ClipboardKind::Clipboard, "first".to_string()); + app.on_clipboard_copy(1, ClipboardKind::Primary, "second".to_string()); + app.on_clipboard_copy(1, ClipboardKind::Selection, "héllo日".to_string()); + let mut out = Vec::new(); + app.flush_clipboard(&mut out).unwrap(); + + let expected = format!( + "\x1b]52;c;{}\x07\x1b]52;p;{}\x07\x1b]52;s;{}\x07", + B64.encode("first"), + B64.encode("second"), + B64.encode("héllo日") + ); + assert_eq!(out, expected.as_bytes()); + // The final payload contains six characters and nine bytes. + assert_eq!(app.notice(), Some("copied 6 chars")); + assert!( + app.pending_clipboard.is_empty(), + "the flush drains the buffer" + ); +} + +/// Flushing an empty clipboard buffer writes nothing. +#[test] +fn empty_clipboard_flush_writes_nothing() { + let mut app = App::new_local(30, 100); + app.mode = Mode::Attached; + let mut out = Vec::new(); + app.flush_clipboard(&mut out).unwrap(); + assert!(out.is_empty()); + assert!(app.notice().is_none()); +} + +/// Notices are hidden after `NOTICE_TTL`. +#[test] +fn notice_expires_lazily_after_the_ttl() { + let mut app = App::new_local(30, 100); + app.set_notice("copied 5 chars".to_string(), NoticeLevel::Info); + assert_eq!(app.notice(), Some("copied 5 chars")); + + let past = Instant::now() + .checked_sub(NOTICE_TTL) + .expect("system uptime exceeds NOTICE_TTL"); + app.notice = Some(("copied 5 chars".to_string(), NoticeLevel::Info, past)); + assert_eq!(app.notice(), None, "an aged-out notice must not render"); +} + +/// A copy confirmation does not replace an active warning. +#[test] +fn warning_notice_survives_the_copy_confirmation() { + let mut app = App::new_local(30, 100); + app.mode = Mode::Attached; + app.focused_id = Some(1); + app.set_notice("clipboard copy dropped".to_string(), NoticeLevel::Warning); + app.on_clipboard_copy(1, ClipboardKind::Clipboard, "hello".to_string()); + let mut out = Vec::new(); + app.flush_clipboard(&mut out).unwrap(); + assert_eq!(out, b"\x1b]52;c;aGVsbG8=\x07", "the copy must still emit"); + assert_eq!(app.notice(), Some("clipboard copy dropped")); +} + +/// A new info notice replaces the current info notice. +#[test] +fn info_notice_replaces_info() { + let mut app = App::new_local(30, 100); + app.set_notice("copied 5 chars".to_string(), NoticeLevel::Info); + app.set_notice("copied 2 chars".to_string(), NoticeLevel::Info); + assert_eq!(app.notice(), Some("copied 2 chars")); +} + +/// A warning replaces any current notice. +#[test] +fn warning_notice_replaces_info() { + let mut app = App::new_local(30, 100); + app.set_notice("copied 5 chars".to_string(), NoticeLevel::Info); + app.set_notice("spawn failed".to_string(), NoticeLevel::Warning); + assert_eq!(app.notice(), Some("spawn failed")); + + app.set_notice("recovery failed".to_string(), NoticeLevel::Warning); + assert_eq!( + app.notice(), + Some("recovery failed"), + "warning over warning" + ); +} + +/// An info notice replaces an expired warning. +#[test] +fn expired_warning_yields_to_info() { + let mut app = App::new_local(30, 100); + let past = Instant::now() + .checked_sub(NOTICE_TTL) + .expect("system uptime exceeds NOTICE_TTL"); + app.notice = Some(("old warning".to_string(), NoticeLevel::Warning, past)); + app.set_notice("copied 5 chars".to_string(), NoticeLevel::Info); + assert_eq!(app.notice(), Some("copied 5 chars")); +} + +/// Attached-mode status events update both the notice and persistent status. +#[test] +fn attached_status_event_mirrors_into_the_notice() { + let dir = session_scratch("status_mirror", &[]); + let mut app = app_with_config_dir(&dir); + app.mode = Mode::Attached; + app.save_session("mirror"); + app.pump(); + assert_eq!(app.status.as_deref(), Some("saved 'mirror': 0 command(s)")); + assert_eq!(app.notice(), Some("saved 'mirror': 0 command(s)")); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Dashboard status events do not create an ephemeral notice. +#[test] +fn dashboard_status_event_sets_only_the_status() { + let dir = session_scratch("status_dash", &[]); + let mut app = app_with_config_dir(&dir); + app.save_session("dash"); + app.pump(); + assert_eq!(app.status.as_deref(), Some("saved 'dash': 0 command(s)")); + assert!(app.notice().is_none(), "no mirror outside attached mode"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/src/core.rs b/src/core.rs index 8a11db6..81f989a 100644 --- a/src/core.rs +++ b/src/core.rs @@ -224,7 +224,10 @@ mod tests { })) .unwrap(); wake_tx - .send(Wake::Cmd(Command::Watch { id: Some(1) })) + .send(Wake::Cmd(Command::Watch { + id: Some(1), + attached: true, + })) .unwrap(); // Wait for the task to come up and emit its first (blank) screen. diff --git a/src/protocol.rs b/src/protocol.rs index e2e01c9..5ee19d0 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -12,7 +12,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; use crate::frame::{KIND_CONTROL, KIND_HELLO, KIND_SCREEN}; /// Wire-protocol version; the handshake rejects mismatched peers. -pub const PROTOCOL_VERSION: u32 = 9; +pub const PROTOCOL_VERSION: u32 = 10; /// Environment and working directory supplied by the launching client. #[derive(Debug, Clone, PartialEq)] @@ -71,7 +71,8 @@ pub enum Command { /// client has already subtracted the row it reserves for its status bar. Resize { rows: u16, cols: u16 }, /// Stream this task's screen (attach or peek), or `None` to stop. - Watch { id: Option }, + /// `attached` permits clipboard forwarding from the watched task. + Watch { id: Option, attached: bool }, /// Forward raw keystroke bytes to a task's PTY. Input { id: u64, bytes: Vec }, /// Clipboard paste for a task. Kept distinct from `Input` because the @@ -196,6 +197,23 @@ pub enum Event { names: Vec, recovery: Vec, }, + /// A decoded OSC 52 clipboard store from the attached task. + ClipboardCopy { + id: u64, + kind: ClipboardKind, + text: String, + }, +} + +/// Clipboard target carried by an OSC 52 event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClipboardKind { + /// The system clipboard (OSC 52 kind byte `c`). + Clipboard, + /// The primary selection (OSC 52 kind byte `p`). + Primary, + /// The select buffer (OSC 52 kind byte `s`). + Selection, } /// Recovery-snapshot metadata sent to the session picker. @@ -557,7 +575,7 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { let _ = o.insert("rows", *rows as u64); let _ = o.insert("cols", *cols as u64); } - Command::Watch { id } => { + Command::Watch { id, attached } => { let _ = o.insert("t", "watch"); match id { Some(n) => { @@ -567,6 +585,7 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { let _ = o.insert("id", jzon::JsonValue::Null); } } + let _ = o.insert("attached", *attached); } // Encode both byte-carrying commands as base64. The paste-size bound in // `app` accounts for base64 expansion and the frame limit. @@ -722,6 +741,8 @@ pub fn decode_command(kind: u8, payload: &[u8]) -> Option { } else { Some(v["id"].as_u64()?) }, + // Reject watch frames that do not specify an attachment mode. + attached: v["attached"].as_bool()?, }, "input" => Command::Input { id: v["id"].as_u64()?, @@ -884,6 +905,22 @@ pub fn encode_event(ev: &Event) -> (u8, Vec) { let _ = o.insert("recovery", rec); (KIND_CONTROL, o.dump().into_bytes()) } + // Base64 preserves arbitrary clipboard text in the JSON frame. + Event::ClipboardCopy { id, kind, text } => { + let mut o = jzon::JsonValue::new_object(); + let _ = o.insert("t", "clip"); + let _ = o.insert("id", *id); + let _ = o.insert( + "k", + match kind { + ClipboardKind::Clipboard => "c", + ClipboardKind::Primary => "p", + ClipboardKind::Selection => "s", + }, + ); + let _ = o.insert("text", B64.encode(text.as_bytes())); + (KIND_CONTROL, o.dump().into_bytes()) + } Event::Screen(sv) => { let mut header = jzon::JsonValue::new_object(); let _ = header.insert("id", sv.id); @@ -968,6 +1005,18 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { names: str_vec(&v["names"])?, recovery: recovery_vec(&v["recovery"]), }), + "clip" => { + let id = v["id"].as_u64()?; + let kind = match v["k"].as_str()? { + "c" => ClipboardKind::Clipboard, + "p" => ClipboardKind::Primary, + "s" => ClipboardKind::Selection, + _ => return None, + }; + // Reject clipboard payloads that are not valid UTF-8. + let text = String::from_utf8(B64.decode(v["text"].as_str()?).ok()?).ok()?; + Some(Event::ClipboardCopy { id, kind, text }) + } _ => None, } } @@ -1038,8 +1087,18 @@ mod tests { rows: 30, cols: 100, }, - Command::Watch { id: Some(5) }, - Command::Watch { id: None }, + Command::Watch { + id: Some(5), + attached: true, + }, + Command::Watch { + id: Some(5), + attached: false, + }, + Command::Watch { + id: None, + attached: false, + }, Command::Input { id: 1, bytes: vec![0, 27, 91, 255], @@ -1677,6 +1736,40 @@ mod tests { ); } + /// Watch frames require a Boolean `attached` field. + #[test] + fn watch_wire_form_requires_the_attached_flag() { + let (k, p) = encode_command(&Command::Watch { + id: Some(5), + attached: true, + }); + assert_eq!(k, KIND_CONTROL); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"watch","id":5,"attached":true}"# + ); + let (_, p) = encode_command(&Command::Watch { + id: None, + attached: false, + }); + assert_eq!( + std::str::from_utf8(&p).unwrap(), + r#"{"t":"watch","id":null,"attached":false}"# + ); + for json in [ + r#"{"t":"watch","id":5}"#, // missing flag + r#"{"t":"watch","id":null}"#, // missing flag on unwatch + r#"{"t":"watch","id":5,"attached":null}"#, // null is not a kind + r#"{"t":"watch","id":5,"attached":1}"#, // flag must be a boolean + ] { + assert_eq!( + decode_command(KIND_CONTROL, json.as_bytes()), + None, + "should reject {json}" + ); + } + } + /// `LoadRecovery` requires a string stem in its wire representation. #[test] fn load_recovery_wire_form() { @@ -1721,4 +1814,84 @@ mod tests { assert_eq!(k, KIND_SCREEN); assert_eq!(decode_event(k, &p), Some(screen)); } + + /// Clipboard events preserve their source, target, and text. + #[test] + fn clipboard_copy_round_trips() { + for ev in [ + Event::ClipboardCopy { + id: 1, + kind: ClipboardKind::Clipboard, + text: "hello".into(), + }, + Event::ClipboardCopy { + id: 1 << 40, + kind: ClipboardKind::Selection, + text: "sélection λ 🦀".into(), + }, + Event::ClipboardCopy { + id: 2, + kind: ClipboardKind::Primary, + text: "primary".into(), + }, + Event::ClipboardCopy { + id: 7, + kind: ClipboardKind::Clipboard, + // Exercise control characters and paste-marker-shaped text. + text: "line1\nline2\tcol\u{0}\u{1b}[201~end".into(), + }, + ] { + let (k, p) = encode_event(&ev); + assert_eq!(k, KIND_CONTROL); + assert_eq!(decode_event(k, &p), Some(ev)); + } + } + + /// Clipboard events encode every target with its OSC 52 selector. + #[test] + fn clipboard_copy_wire_form() { + for (kind, wire) in [ + ( + ClipboardKind::Clipboard, + r#"{"t":"clip","id":5,"k":"c","text":"aGk="}"#, + ), + ( + ClipboardKind::Primary, + r#"{"t":"clip","id":5,"k":"p","text":"aGk="}"#, + ), + ( + ClipboardKind::Selection, + r#"{"t":"clip","id":5,"k":"s","text":"aGk="}"#, + ), + ] { + let (k, p) = encode_event(&Event::ClipboardCopy { + id: 5, + kind, + text: "hi".into(), + }); + assert_eq!(k, KIND_CONTROL); + assert_eq!(std::str::from_utf8(&p).unwrap(), wire); + } + } + + /// Malformed clipboard event frames are rejected. + #[test] + fn malformed_clipboard_copy_is_rejected() { + for json in [ + r#"{"t":"clip","id":1,"text":"aGk="}"#, // missing kind + r#"{"t":"clip","id":1,"k":"c"}"#, // missing text + r#"{"t":"clip","k":"c","text":"aGk="}"#, // missing id + r#"{"t":"clip","id":"1","k":"c","text":"aGk="}"#, // id must be a number + r#"{"t":"clip","id":1,"k":"x","text":"aGk="}"#, // unknown kind string + r#"{"t":"clip","id":1,"k":"c","text":"!!!"}"#, // invalid base64 + r#"{"t":"clip","id":1,"k":"c","text":"/w=="}"#, // 0xFF: not UTF-8 + r#"{"t":"clip","id":1,"k":"c","text":["aGk="]}"#, // text must be a string + ] { + assert_eq!( + decode_event(KIND_CONTROL, json.as_bytes()), + None, + "should reject {json}" + ); + } + } } diff --git a/src/supervisor.rs b/src/supervisor.rs index a5e2126..7938f24 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -15,9 +15,12 @@ use std::{ use crate::{ core::{Wake, Waker}, + emulator::ClipboardSelector, harness::{self, assets}, path, - protocol::{Command, Event, LaunchContext, ScreenView, ScrollAction, TaskView, env_get}, + protocol::{ + ClipboardKind, Command, Event, LaunchContext, ScreenView, ScrollAction, TaskView, env_get, + }, session::{self, SessionConfig, SessionEntry}, task::Task, }; @@ -119,6 +122,15 @@ fn normalize_group(name: Option) -> Option { normalize_label(name).filter(|g| g != "Unassigned") } +/// Map an emulator clipboard selector to its protocol representation. +fn clipboard_kind(kind: ClipboardSelector) -> ClipboardKind { + match kind { + ClipboardSelector::Clipboard => ClipboardKind::Clipboard, + ClipboardSelector::Primary => ClipboardKind::Primary, + ClipboardSelector::Select => ClipboardKind::Selection, + } +} + /// Return the 64-bit FNV-1a hash used to separate fallback capture roots. The /// fixed-width hexadecimal result is a single path-safe component. fn fnv1a_hex(bytes: &[u8]) -> String { @@ -223,6 +235,8 @@ pub struct Supervisor { scrollback: usize, /// The task whose screen the client is watching (attach/peek), or `None`. watched: Option, + /// Whether the current watch permits clipboard forwarding. + watch_attached: bool, /// The last emitted screen fingerprint. `lines` stays empty because only /// emitted copies carry them. Cleared when `watched` changes to force a /// fresh screen after attachment. @@ -256,6 +270,7 @@ impl Supervisor { cols, scrollback, watched: None, + watch_attached: false, last_screen: None, launch: None, events: Vec::new(), @@ -311,6 +326,7 @@ impl Supervisor { t.scroll_view(ScrollAction::Live); } self.watched = None; + self.watch_attached = false; self.last_screen = None; } @@ -387,17 +403,25 @@ impl Supervisor { let _ = t.resize(self.rows, self.cols); } } - Command::Watch { id } => { - // Reset the previous task's viewport before changing targets. - if id != self.watched { - if let Some(old) = self.watched - && let Some(t) = self.by_id_mut(old) + Command::Watch { id, attached } => { + // Preserve the viewport when only the attachment mode changes. + if id != self.watched + && let Some(old) = self.watched + && let Some(t) = self.by_id_mut(old) + { + t.scroll_view(ScrollAction::Live); + } + if id != self.watched || attached != self.watch_attached { + // Discard stores captured before this watch state took effect. + if let Some(new) = id + && let Some(t) = self.by_id_mut(new) { - t.scroll_view(ScrollAction::Live); + let _ = t.drain_clipboard(); } self.last_screen = None; } self.watched = id; + self.watch_attached = attached; } Command::Input { id, bytes } => { let refused = self.by_id_mut(id).and_then(|t| t.send_input(&bytes).err()); @@ -519,11 +543,23 @@ impl Supervisor { // least every 200 ms), so an expired sync flushes here, before the // preview resolution reads the grid, letting the same tick ship it. // Resolution mutates per-task hold state; all tasks use one timestamp. + // Only the attached watch may forward clipboard stores. + let forwarding = if self.watch_attached { + self.watched + } else { + None + }; + let mut clipboard = None; let views = self .tasks .iter_mut() .map(|t| { t.flush_expired_sync(); + // Drain all tasks so stores from inactive tasks cannot be forwarded later. + let stores = t.drain_clipboard(); + if forwarding == Some(t.id) { + clipboard = Some((t.id, stores)); + } TaskView { id: t.id, command: t.command.clone(), @@ -542,6 +578,23 @@ impl Supervisor { .collect(); self.events.push(Event::Tasks(views)); + if let Some((id, stores)) = clipboard { + for (kind, text) in stores.stores { + self.events.push(Event::ClipboardCopy { + id, + kind: clipboard_kind(kind), + text, + }); + } + if let Some(len) = stores.oversized_len { + self.status(format!( + "clipboard copy dropped: {} exceeds the {} limit", + crate::format::bytes(len), + crate::format::bytes(crate::emulator::CLIPBOARD_STORE_MAX_BYTES) + )); + } + } + if let Some(id) = self.watched && let Some(t) = self.tasks.iter().find(|t| t.id == id) { diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index 71a720f..111d9eb 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -100,7 +100,10 @@ fn tick_emits_snapshot_and_watched_screen() { _ => panic!("expected a Tasks snapshot"), }; - s.apply(Command::Watch { id: Some(id) }); + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); s.tick(); let evs = s.drain(); assert!(evs.iter().any(|e| matches!(e, Event::Tasks(_)))); @@ -133,7 +136,10 @@ fn watched_screen_not_resent_when_unchanged() { } assert!(id != 0, "task never appeared"); - s.apply(Command::Watch { id: Some(id) }); + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); s.tick(); assert!( s.drain().iter().any(|e| matches!(e, Event::Screen(_))), @@ -163,7 +169,10 @@ fn decset_1007_flip_resends_watched_screen() { f = flag.display() ); let id = spawn_ready(&mut s, cmd, here(), &ready); - s.apply(Command::Watch { id: Some(id) }); + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); // Wait for the initial alternate-scroll state. let open = wait_until(Duration::from_secs(5), || { @@ -213,6 +222,283 @@ fn tick_flushes_a_stalled_sync_update() { ); } +/// Stores from the attached task are forwarded in arrival order. +#[test] +fn watched_task_clipboard_stores_are_forwarded() { + let dir = scratch("clip_fwd"); + let ready = dir.join("ready"); + let flag = dir.join("flag"); + let mut s = sup(24, 80); + let cmd = format!( + "touch {r}; until [ -e {f} ]; do sleep 0.05; done; \ + printf '\\033]52;c;aGVsbG8=\\007\\033]52;p;cHJp\\007\\033]52;s;d29ybGQ=\\007'; sleep 30", + r = ready.display(), + f = flag.display() + ); + let id = spawn_ready(&mut s, cmd, here(), &ready); + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); + std::fs::write(&flag, b"").unwrap(); + + let mut copies = Vec::new(); + let ok = wait_until(Duration::from_secs(5), || { + s.tick(); + copies.extend(s.drain().into_iter().filter_map(|e| match e { + Event::ClipboardCopy { id, kind, text } => Some((id, kind, text)), + _ => None, + })); + copies.len() >= 3 + }); + assert!(ok, "the clipboard stores never arrived; got {copies:?}"); + // Each selector is forwarded as its matching protocol kind. + assert_eq!( + copies, + vec![ + (id, ClipboardKind::Clipboard, "hello".to_string()), + (id, ClipboardKind::Primary, "pri".to_string()), + (id, ClipboardKind::Selection, "world".to_string()), + ] + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Starting a watch discards stores captured before the watch. +#[test] +fn watch_purges_stores_captured_before_the_watch() { + let mut s = sup(24, 80); + // The marker follows the store and confirms that both were parsed. + spawn( + &mut s, + "printf '\\033]52;c;c3RhbGU=\\007MARKER'; sleep 30", + here(), + ); + let parsed = wait_until(Duration::from_secs(5), || { + s.tasks.first().is_some_and(|t| { + let (formatted, _, _) = t.formatted(); + String::from_utf8_lossy(&formatted).contains("MARKER") + }) + }); + assert!(parsed, "the marker never reached the grid"); + + let id = s.tasks[0].id; + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); + let mut saw_screen = false; + for _ in 0..3 { + s.tick(); + for e in s.drain() { + match e { + Event::ClipboardCopy { .. } => { + panic!("a store captured before the watch must not fire after it") + } + Event::Screen(_) => saw_screen = true, + _ => {} + } + } + } + assert!(saw_screen, "watching the task should stream its screen"); +} + +/// Stores from an unwatched task are discarded instead of deferred. +#[test] +fn backgrounded_clipboard_store_is_discarded_not_deferred() { + let mut s = sup(24, 80); + // The marker follows the store and confirms that both were parsed. + spawn( + &mut s, + "printf '\\033]52;c;c3RhbGU=\\007COPIED'; sleep 30", + here(), + ); + let mut id = 0; + let parsed = wait_until(Duration::from_secs(5), || { + s.tick(); + let mut seen = false; + for e in s.drain() { + match e { + Event::Tasks(v) => { + if let Some(t) = v.first() { + id = t.id; + seen = t.preview.text.contains("COPIED"); + } + } + Event::ClipboardCopy { .. } => { + panic!("an unwatched task's store must not be forwarded") + } + _ => {} + } + } + seen + }); + assert!(parsed, "the marker never reached the grid"); + // Run one more tick to drain a store parsed after the preceding drain. + s.tick(); + let _ = s.drain(); + + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); + let mut saw_screen = false; + for _ in 0..3 { + s.tick(); + for e in s.drain() { + match e { + Event::ClipboardCopy { .. } => { + panic!("a store buffered while backgrounded must never fire on watch") + } + Event::Screen(_) => saw_screen = true, + _ => {} + } + } + } + assert!(saw_screen, "watching the task should stream its screen"); +} + +/// Peeked stores are discarded, while stores captured after attachment forward. +#[test] +fn peeked_stores_never_forward_and_die_at_the_attach_transition() { + let dir = scratch("clip_peek"); + let ready = dir.join("ready"); + let flag1 = dir.join("flag1"); + let flag2 = dir.join("flag2"); + let flag3 = dir.join("flag3"); + let mut s = sup(24, 80); + // Each marker follows its store and confirms that both were parsed. + let cmd = format!( + "touch {r}; until [ -e {f1} ]; do sleep 0.05; done; \ + printf '\\033]52;c;cGVlazE=\\007M1'; \ + until [ -e {f2} ]; do sleep 0.05; done; \ + printf '\\033]52;c;cGVlazI=\\007M2'; \ + until [ -e {f3} ]; do sleep 0.05; done; \ + printf '\\033]52;c;cG9zdA==\\007M3'; sleep 30", + r = ready.display(), + f1 = flag1.display(), + f2 = flag2.display(), + f3 = flag3.display() + ); + let id = spawn_ready(&mut s, cmd, here(), &ready); + s.apply(Command::Watch { + id: Some(id), + attached: false, + }); + + // A store drained during peek is not forwarded. + std::fs::write(&flag1, b"").unwrap(); + let parsed = wait_until(Duration::from_secs(5), || { + s.tasks.first().is_some_and(|t| { + let (formatted, _, _) = t.formatted(); + String::from_utf8_lossy(&formatted).contains("M1") + }) + }); + assert!(parsed, "the first marker never reached the grid"); + let mut saw_screen = false; + for _ in 0..3 { + s.tick(); + for e in s.drain() { + match e { + Event::ClipboardCopy { .. } => { + panic!("a peeked task's store must never forward") + } + Event::Screen(_) => saw_screen = true, + _ => {} + } + } + } + assert!( + saw_screen, + "peeking the task should still stream its screen" + ); + + // A buffered peek store is discarded when the same task becomes attached. + std::fs::write(&flag2, b"").unwrap(); + let parsed = wait_until(Duration::from_secs(5), || { + s.tasks.first().is_some_and(|t| { + let (formatted, _, _) = t.formatted(); + String::from_utf8_lossy(&formatted).contains("M2") + }) + }); + assert!(parsed, "the second marker never reached the grid"); + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); + for _ in 0..3 { + s.tick(); + for e in s.drain() { + if let Event::ClipboardCopy { .. } = e { + panic!("a store captured during peek must not fire after attach") + } + } + } + + // A store captured after attachment is forwarded. + std::fs::write(&flag3, b"").unwrap(); + let mut copies = Vec::new(); + let ok = wait_until(Duration::from_secs(5), || { + s.tick(); + copies.extend(s.drain().into_iter().filter_map(|e| match e { + Event::ClipboardCopy { id, kind, text } => Some((id, kind, text)), + _ => None, + })); + !copies.is_empty() + }); + assert!(ok, "the post-attach store never arrived"); + assert_eq!( + copies, + vec![(id, ClipboardKind::Clipboard, "post".to_string())] + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// An oversized store produces a status notice instead of a clipboard event. +#[test] +fn oversized_watched_store_yields_notice_and_no_copy() { + let dir = scratch("clip_oversize"); + let ready = dir.join("ready"); + let flag = dir.join("flag"); + let mut s = sup(24, 80); + // Generate a 3 MiB decoded payload without placing it in the command string. + let cmd = format!( + "touch {r}; until [ -e {f} ]; do sleep 0.05; done; \ + printf '\\033]52;c;'; \ + dd if=/dev/zero bs=1024 count=3072 2>/dev/null | base64 | tr -d '\\n'; \ + printf '\\007'; sleep 30", + r = ready.display(), + f = flag.display() + ); + let id = spawn_ready(&mut s, cmd, here(), &ready); + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); + std::fs::write(&flag, b"").unwrap(); + + let mut notice = None; + let ok = wait_until(Duration::from_secs(10), || { + s.tick(); + for e in s.drain() { + match e { + Event::ClipboardCopy { .. } => { + panic!("an over-cap store must be dropped, not forwarded") + } + Event::Status(msg) => notice = Some(msg), + _ => {} + } + } + notice.is_some() + }); + assert!(ok, "the oversized-store notice never arrived"); + assert_eq!( + notice.as_deref(), + Some("clipboard copy dropped: 3 MiB exceeds the 1 MiB limit") + ); + let _ = std::fs::remove_dir_all(&dir); +} + /// Scratch dir for tests that sync through marker files. fn scratch(tag: &str) -> PathBuf { crate::testutil::temp(&format!("sup_{tag}")) @@ -428,7 +714,10 @@ fn clear_watch_stops_screen_stream_and_resets_dedup() { spawn(&mut s, "sleep 30", here()); let id = first_id(&mut s); - s.apply(Command::Watch { id: Some(id) }); + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); s.tick(); assert!( s.drain().iter().any(|e| matches!(e, Event::Screen(_))), @@ -445,7 +734,10 @@ fn clear_watch_stops_screen_stream_and_resets_dedup() { // A new client watching the same task gets a full screen at once, even // though the screen bytes haven't changed since the last send. - s.apply(Command::Watch { id: Some(id) }); + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); s.tick(); assert!( s.drain().iter().any(|e| matches!(e, Event::Screen(_))), @@ -460,7 +752,10 @@ fn clear_watch_snaps_the_watched_task_live() { let mut s = sup(6, 80); spawn(&mut s, "seq 1 200; sleep 30", here()); let id = first_id(&mut s); - s.apply(Command::Watch { id: Some(id) }); + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); // Retry until output has produced retained history. let scrolled = wait_until(Duration::from_secs(5), || { @@ -738,7 +1033,10 @@ fn rerun_watched_task_resends_screen() { let id = first_id(&mut s); wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); - s.apply(Command::Watch { id: Some(id) }); + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); s.tick(); assert!( s.drain().iter().any(|e| matches!(e, Event::Screen(_))), diff --git a/src/task.rs b/src/task.rs index 677f4df..adb3f39 100644 --- a/src/task.rs +++ b/src/task.rs @@ -23,7 +23,7 @@ use rustix::process::{WaitId, WaitIdOptions, waitid}; use crate::{ core::{Wake, Waker}, - emulator::Emulator, + emulator::{ClipboardStores, Emulator}, input, preview::PreviewState, protocol::{Key, Lifecycle, Mods, MouseKind, Preview, ScrollAction, env_get}, @@ -485,6 +485,11 @@ impl Task { } } + /// Drain OSC 52 clipboard stores captured by this task's emulator. + pub fn drain_clipboard(&self) -> ClipboardStores { + grid(&self.parser).drain_clipboard() + } + /// The dashboard preview, resolved through the provenance cascade under /// the grid lock (see [`crate::preview`]). `now` is the caller's tick /// instant so every task in one snapshot resolves against the same clock. diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index c280627..07cd07d 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -17,6 +17,7 @@ use alacritty_terminal::{ }, vte::ansi::{self as vt, Handler, Processor}, }; +use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; /// Mouse event classes requested by the child through DECSET 1000/1002/1003. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -35,9 +36,39 @@ pub enum MouseProtocolEncoding { Sgr, } +/// Maximum decoded size of one buffered OSC 52 clipboard payload. +pub(crate) const CLIPBOARD_STORE_MAX_BYTES: usize = 1024 * 1024; + +// A maximum-size store expands to this base64 bound when re-encoded for +// forwarding. Reserve 64 KiB for the command envelope and keep the result +// within one frame. +const _: () = assert!( + CLIPBOARD_STORE_MAX_BYTES.div_ceil(3) * 4 + 64 * 1024 <= crate::frame::MAX_FRAME as usize, + "CLIPBOARD_STORE_MAX_BYTES must base64-encode to under frame::MAX_FRAME" +); + +/// Supported OSC 52 clipboard targets. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClipboardSelector { + /// The system clipboard (selector byte `c`). + Clipboard, + /// The primary selection (selector byte `p`). + Primary, + /// The select buffer (selector byte `s`). + Select, +} + +/// OSC 52 clipboard stores captured since the last drain. +#[derive(Debug, Default)] +pub struct ClipboardStores { + /// Latest store per selector, ordered by arrival. + pub stores: Vec<(ClipboardSelector, String)>, + /// Byte length of the most recent oversized store. + pub oversized_len: Option, +} + /// Buffers backend-generated PTY responses while the parser advances. Other -/// backend events are discarded; [`ObservedTerm`] captures titles directly -/// from parser events. +/// events are discarded; [`ObservedTerm`] captures titles and clipboard stores. pub struct ProbeSink { responses: Arc>>, } @@ -177,6 +208,8 @@ pub struct Emulator { term: Term, parser: Processor, responses: Arc>>, + /// OSC 52 stores captured since the last drain. + clipboard: ClipboardStores, /// Alt-screen and title facts, advanced at parser-event granularity by /// [`ObservedTerm`] during the parse itself. alt: AltScreen, @@ -201,6 +234,11 @@ impl Emulator { .collect() } + /// Drain captured OSC 52 stores and the latest oversized-store length. + pub fn drain_clipboard(&mut self) -> ClipboardStores { + std::mem::take(&mut self.clipboard) + } + /// Truncate zero-width characters in each active-grid cell to /// `MAX_ZEROWIDTH`. The scan covers the viewport and all scrollback rows. /// @@ -262,6 +300,7 @@ impl Emulator { term, parser: Processor::new(), responses, + clipboard: ClipboardStores::default(), alt: AltScreen::default(), revision: 0, bytes_since_sweep: 0, @@ -280,6 +319,7 @@ impl Emulator { let mut observed = ObservedTerm { term: &mut self.term, alt: &mut self.alt, + clipboard: &mut self.clipboard, }; self.parser.advance(&mut observed, bytes); self.observe_advance(); @@ -310,6 +350,7 @@ impl Emulator { let mut observed = ObservedTerm { term: &mut self.term, alt: &mut self.alt, + clipboard: &mut self.clipboard, }; self.parser.stop_sync(&mut observed); self.observe_advance(); @@ -329,6 +370,7 @@ impl Emulator { let mut observed = ObservedTerm { term: &mut self.term, alt: &mut self.alt, + clipboard: &mut self.clipboard, }; self.parser.stop_sync(&mut observed); self.observe_advance(); @@ -628,6 +670,7 @@ const TITLE_STACK_SHADOW_MAX: usize = 4096; /// `Handler` methods default to no-ops, so every method must delegate to /// `Term`. `golden::emulator_wrapper_matches_the_raw_backend_on_every_fixture` /// compares wrapper and raw-backend replays to detect missing delegation. +/// `clipboard_store` is captured by this wrapper instead of delegated. /// /// # Synchronized updates /// @@ -639,6 +682,7 @@ const TITLE_STACK_SHADOW_MAX: usize = 4096; struct ObservedTerm<'a> { term: &'a mut Term, alt: &'a mut AltScreen, + clipboard: &'a mut ClipboardStores, } impl ObservedTerm<'_> { @@ -874,10 +918,30 @@ impl Handler for ObservedTerm<'_> { fn reset_color(&mut self, a0: usize) { self.term.reset_color(a0); } + /// Capture supported OSC 52 stores while preserving their selector. fn clipboard_store(&mut self, a0: u8, a1: &[u8]) { - self.term.clipboard_store(a0, a1); + // Ignore selectors without a forwarding target. + let selector = match a0 { + b'c' => ClipboardSelector::Clipboard, + b'p' => ClipboardSelector::Primary, + b's' => ClipboardSelector::Select, + _ => return, + }; + // Accept padded standard base64 containing UTF-8 text. + let Ok(bytes) = B64.decode(a1) else { return }; + let Ok(text) = String::from_utf8(bytes) else { + return; + }; + // Retain only the most recent value for this selector, including on overflow. + self.clipboard.stores.retain(|(k, _)| *k != selector); + if text.len() > CLIPBOARD_STORE_MAX_BYTES { + self.clipboard.oversized_len = Some(text.len()); + return; + } + self.clipboard.stores.push((selector, text)); } fn clipboard_load(&mut self, a0: u8, a1: &str) { + // The configured terminal policy denies clipboard loads. self.term.clipboard_load(a0, a1); } fn decaln(&mut self) { @@ -1014,6 +1078,144 @@ mod tests { assert!(emu.process(b"\x1b[?u").is_empty()); } + /// An OSC 52 store is captured without a probe reply and drains once. + #[test] + fn osc52_store_is_captured_and_drains_once() { + let mut emu = Emulator::new(4, 20, 0); + assert!( + emu.process(b"\x1b]52;c;aGVsbG8=\x07").is_empty(), + "a store is not a probe reply" + ); + let drained = emu.drain_clipboard(); + assert_eq!( + drained.stores, + vec![(ClipboardSelector::Clipboard, "hello".to_string())] + ); + assert_eq!(drained.oversized_len, None); + let again = emu.drain_clipboard(); + assert!(again.stores.is_empty(), "a drain empties the buffer"); + assert_eq!(again.oversized_len, None); + } + + /// Primary and selection stores remain distinct. + #[test] + fn osc52_p_and_s_selectors_stay_distinct() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b]52;p;YQ==\x07\x1b]52;s;Yg==\x07"); + assert_eq!( + emu.drain_clipboard().stores, + vec![ + (ClipboardSelector::Primary, "a".to_string()), + (ClipboardSelector::Select, "b".to_string()), + ] + ); + } + + /// An empty OSC 52 selector targets the system clipboard. + #[test] + fn osc52_empty_selector_defaults_to_clipboard() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b]52;;aGk=\x07"); + assert_eq!( + emu.drain_clipboard().stores, + vec![(ClipboardSelector::Clipboard, "hi".to_string())] + ); + } + + /// Unsupported OSC 52 selectors are ignored. + #[test] + fn osc52_unknown_selectors_drop() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b]52;q;aGk=\x07"); + emu.process(b"\x1b]52;0;aGk=\x07"); + let drained = emu.drain_clipboard(); + assert!(drained.stores.is_empty()); + assert_eq!(drained.oversized_len, None); + } + + /// Only the latest store for each selector is retained. + #[test] + fn osc52_last_store_wins_per_kind() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b]52;c;Zmlyc3Q=\x07\x1b]52;c;c2Vjb25k\x07"); + assert_eq!( + emu.drain_clipboard().stores, + vec![(ClipboardSelector::Clipboard, "second".to_string())] + ); + } + + /// Different selectors buffer independently and drain in arrival order. + #[test] + fn osc52_kinds_buffer_independently() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b]52;s;c2Vs\x07\x1b]52;p;cHJp\x07\x1b]52;c;Y2xpcA==\x07"); + assert_eq!( + emu.drain_clipboard().stores, + vec![ + (ClipboardSelector::Select, "sel".to_string()), + (ClipboardSelector::Primary, "pri".to_string()), + (ClipboardSelector::Clipboard, "clip".to_string()), + ] + ); + } + + /// Invalid base64, clear requests, and non-UTF-8 payloads are ignored. + #[test] + fn osc52_invalid_base64_and_clear_buffer_nothing() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b]52;c;%%%\x07"); + emu.process(b"\x1b]52;c;!\x07"); + // "/w==" decodes to 0xFF: valid base64, invalid UTF-8. + emu.process(b"\x1b]52;c;/w==\x07"); + let drained = emu.drain_clipboard(); + assert!(drained.stores.is_empty()); + assert_eq!(drained.oversized_len, None); + } + + /// OSC 52 clipboard queries are denied without a reply. + #[test] + fn osc52_query_is_denied_without_a_reply() { + let mut emu = Emulator::new(4, 20, 0); + assert!(emu.process(b"\x1b]52;c;?\x07").is_empty()); + let drained = emu.drain_clipboard(); + assert!(drained.stores.is_empty()); + assert_eq!(drained.oversized_len, None); + } + + /// ST-terminated OSC 52 stores are captured. + #[test] + fn osc52_st_terminated_store_is_captured() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b]52;c;aGVsbG8=\x1b\\"); + assert_eq!( + emu.drain_clipboard().stores, + vec![(ClipboardSelector::Clipboard, "hello".to_string())] + ); + } + + /// An oversized store clears its selector and records its length. + #[test] + fn osc52_oversized_store_supersedes_its_kind_and_records_length() { + let mut emu = Emulator::new(4, 20, 0); + emu.process(b"\x1b]52;c;aGVsbG8=\x07"); + emu.process(b"\x1b]52;s;c2Vs\x07"); + emu.process(b"\x1b]52;p;cHJp\x07"); + // This payload decodes to two bytes over the cap. + let reps = CLIPBOARD_STORE_MAX_BYTES / 3 + 1; + let payload = "YWFh".repeat(reps); + emu.process(format!("\x1b]52;c;{payload}\x07").as_bytes()); + let drained = emu.drain_clipboard(); + assert_eq!( + drained.stores, + vec![ + (ClipboardSelector::Select, "sel".to_string()), + (ClipboardSelector::Primary, "pri".to_string()), + ], + "the drop clears its own selector's slot and no other" + ); + assert_eq!(drained.oversized_len, Some(reps * 3)); + } + /// The stall the flush hook exists for: BSU plus a partial frame, then /// silence. The buffered frame must stay invisible while the timeout is /// pending (the hook must not tear an in-flight frame) and flush once the diff --git a/src/transport.rs b/src/transport.rs index 7e1c058..5833acd 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -290,7 +290,10 @@ mod tests { assert!(t.connected()); drop(theirs); // the daemon is gone - t.send(Command::Watch { id: None }); + t.send(Command::Watch { + id: None, + attached: false, + }); assert!(!t.connected(), "a failed send must mark the transport dead"); // The stream was shut down with it, so the reader thread saw EOF and // exited: joining it cannot hang. diff --git a/src/ui.rs b/src/ui.rs index 2b72b0e..0cb78b0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -181,13 +181,12 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { y += 1; } - // Command line: input modes show a prompt (with cursor); otherwise a - // transient save/load notice, else the key hint. + // Input modes show a prompt; otherwise show a notice, status, or key hint. let cmd_y = rows.saturating_sub(2); match cmdline(app) { Some((line, _)) => put(out, cmd_y, &line, cols)?, - None => match &app.status { - Some(s) => put(out, cmd_y, &format!(" {s}"), cols)?, + None => match transient_line(app.notice(), app.status.as_deref()) { + Some(line) => put(out, cmd_y, &line, cols)?, None => dim( out, cmd_y, @@ -225,6 +224,11 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { Ok(()) } +/// Prefer an active notice over persistent status text. +fn transient_line(notice: Option<&str>, status: Option<&str>) -> Option { + notice.or(status).map(|s| format!(" {s}")) +} + /// The editable bottom line for the text-input modes (the rendered line and /// the caret's display column), or `None` when the command line should show a /// hint/status instead. @@ -704,14 +708,8 @@ fn render_attached(out: &mut impl Write, app: &App) -> io::Result<()> { } let cols = app.cols as usize; - // Display the scrollback offset when viewing history. let title = attached_title(v); - let bar = match screen.map_or(0, |s| s.scrollback) { - 0 => format!(" [attached] {title} Ctrl-\\ background"), - n => { - format!(" [scroll ↑{n}] {title} Esc live · PgUp/PgDn move · Ctrl-\\ background") - } - }; + let bar = attached_bar(&title, screen.map_or(0, |s| s.scrollback), app.notice()); rev(out, app.rows.saturating_sub(1), &bar, cols)?; // Place the real cursor where the child's is, so typing feels native. @@ -722,6 +720,17 @@ fn render_attached(out: &mut impl Write, app: &App) -> io::Result<()> { Ok(()) } +/// Build the attached or scrollback bar, showing notices only in live view. +fn attached_bar(title: &str, scrollback: usize, notice: Option<&str>) -> String { + match (scrollback, notice) { + (0, Some(n)) => format!(" [attached] {title} {n}"), + (0, None) => format!(" [attached] {title} Ctrl-\\ background"), + (n, _) => { + format!(" [scroll ↑{n}] {title} Esc live · PgUp/PgDn move · Ctrl-\\ background") + } + } +} + /// Visible `(start, count)` window that includes the selected list item. pub fn scroll_window(sel: usize, total: usize, max: usize) -> (usize, usize) { if total == 0 || max == 0 { @@ -972,6 +981,37 @@ mod tests { ); } + /// The live bar shows notices while the scrollback bar keeps its key hints. + #[test] + fn attached_bar_swaps_the_hint_for_an_active_notice() { + assert_eq!( + attached_bar("cargo test", 0, None), + " [attached] cargo test Ctrl-\\ background" + ); + assert_eq!( + attached_bar("cargo test", 0, Some("copied 5 chars")), + " [attached] cargo test copied 5 chars" + ); + assert_eq!( + attached_bar("cargo test", 3, Some("copied 5 chars")), + " [scroll ↑3] cargo test Esc live · PgUp/PgDn move · Ctrl-\\ background" + ); + } + + /// The dashboard command row prefers an active notice over status text. + #[test] + fn transient_line_prefers_the_notice_over_the_status() { + assert_eq!( + transient_line(Some("copied 5 chars"), Some("saved 'x': 1 command(s)")), + Some(" copied 5 chars".to_string()) + ); + assert_eq!( + transient_line(None, Some("saved 'x': 1 command(s)")), + Some(" saved 'x': 1 command(s)".to_string()) + ); + assert_eq!(transient_line(None, None), None); + } + /// The strip's plain text is fixed; exactly the active mode's label is /// bold, every other strip run (labels and separators) is dim, and the /// prefix/suffix keep the header's bold. diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 9eb31b9..da75524 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -17,7 +17,7 @@ use std::{ /// The protocol version this test suite speaks; must track /// `protocol::PROTOCOL_VERSION` (drift fails the handshake, loudly). -pub const PROTOCOL_VERSION: u32 = 9; +pub const PROTOCOL_VERSION: u32 = 10; /// One frame of the given kind: `[u32 len][kind][payload]`. pub fn frame(kind: u8, payload: &[u8]) -> Vec {