From 1d238bc9a365d9dd1297b4e11fe775cd656aebd0 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 22 Jul 2026 18:59:01 +0300 Subject: [PATCH] refactor: replace pane mouse and clipboard internals replaces contributions from othavi0 --- src/app/input/mod.rs | 49 ++-- src/app/input/mouse.rs | 118 +++++++-- src/app/input/terminal.rs | 115 --------- src/ghostty/mod.rs | 189 ++++++++++++++ src/pane/osc.rs | 515 ++++++++++++-------------------------- src/pane/terminal.rs | 42 +++- 6 files changed, 511 insertions(+), 517 deletions(-) diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index c17f64cd..c9056109 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -286,31 +286,13 @@ impl App { } let handled_pane_double_click = self.handle_pane_double_click(mouse); + if !handled_pane_double_click { + self.focus_pane_before_mouse_press(mouse); + } let previous_agent_panel_sort = self.state.agent_panel_sort; let previous_settings_section = self.state.settings.section; if !handled_pane_double_click { - let right_button = matches!( - mouse.kind, - MouseEventKind::Down(MouseButton::Right) - | MouseEventKind::Up(MouseButton::Right) - | MouseEventKind::Drag(MouseButton::Right) - ); - let intentional_pane_press = matches!( - mouse.kind, - MouseEventKind::Down(MouseButton::Left | MouseButton::Middle) - ); - if !right_button - && intentional_pane_press - && matches!(self.state.mode, Mode::Terminal | Mode::Resize) - { - if let (Some(ws_idx), Some(info)) = ( - self.state.active, - self.state.pane_at(mouse.column, mouse.row).cloned(), - ) { - self.focus_pane_internal_via_api(ws_idx, info.id); - } - } if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) { match action { MouseAction::NewWorkspace => { @@ -459,6 +441,31 @@ impl App { } } + fn focus_pane_before_mouse_press(&mut self, mouse: MouseEvent) { + if !matches!(self.state.mode, Mode::Terminal | Mode::Resize) + || !matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left | MouseButton::Middle) + ) + { + return; + } + + let Some(pane_id) = self + .state + .pane_at(mouse.column, mouse.row) + .map(|info| info.id) + else { + return; + }; + let Some(ws_idx) = self.state.active else { + return; + }; + + // Focus through the runtime API before an application can consume its press. + self.focus_pane_internal_via_api(ws_idx, pane_id); + } + fn handle_modified_url_click(&mut self, mouse: MouseEvent) -> bool { if self.state.mode != Mode::Terminal || !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 59dcf502..2283b754 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -627,13 +627,7 @@ impl AppState { if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; - if let Some(ws_idx) = self.active { - return Some(MouseAction::FocusPane { - ws_idx, - pane_id: info.id, - }); - } - return None; + return self.mouse_pane_focus_action(info.id); } let (row, col) = ( @@ -646,12 +640,7 @@ impl AppState { col, self.pane_scroll_metrics(terminal_runtimes, info.id), )); - if let Some(ws_idx) = self.active { - return Some(MouseAction::FocusPane { - ws_idx, - pane_id: info.id, - }); - } + return self.mouse_pane_focus_action(info.id); } else if let Some(info) = self.view.pane_infos.iter().find(|p| { mouse.column >= p.rect.x && mouse.column < p.rect.x + p.rect.width @@ -662,12 +651,7 @@ impl AppState { if self.mode != Mode::Terminal { self.mode = Mode::Terminal; } - if let Some(ws_idx) = self.active { - return Some(MouseAction::FocusPane { - ws_idx, - pane_id: id, - }); - } + return self.mouse_pane_focus_action(id); } } @@ -1414,6 +1398,16 @@ impl AppState { .or_else(|| self.pane_frame_at(col, row)) } + fn mouse_pane_focus_action(&self, pane_id: crate::layout::PaneId) -> Option { + let ws_idx = self.active?; + (self + .workspaces + .get(ws_idx) + .and_then(|workspace| workspace.focused_pane_id()) + != Some(pane_id)) + .then_some(MouseAction::FocusPane { ws_idx, pane_id }) + } + pub(crate) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> { self.view.pane_infos.iter().find(|info| info.id == pane_id) } @@ -2065,6 +2059,45 @@ mod tests { assert!(input_rx.try_recv().is_err()); } + #[tokio::test] + async fn captured_left_press_focuses_target_before_forwarding() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let source = ws.tabs[0].root_pane; + let target = ws.test_split(Direction::Horizontal); + ws.tabs[0].layout.focus_pane(source); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let info = app + .state + .pane_info_by_id(target) + .expect("target pane info") + .clone(); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 0, + b"\x1b[?1002h\x1b[?1006h", + 4, + ); + app.state.insert_test_runtime(target, runtime); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + info.inner_rect.x + 1, + info.inner_rect.y + 1, + )); + + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(target)); + assert_eq!( + input_rx.try_recv().expect("forwarded captured left press"), + Bytes::from_static(b"\x1b[<0;2;2M") + ); + } + #[tokio::test] async fn pane_mouse_only_forwards_moved_events_for_any_motion_apps() { let mut app = app_for_mouse_test(); @@ -2335,6 +2368,52 @@ mod tests { ); } + #[tokio::test] + async fn normal_right_click_keeps_focus_and_exposes_swap_for_reporting_pane() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let source = ws.tabs[0].root_pane; + let target = ws.test_split(Direction::Horizontal); + ws.tabs[0].layout.focus_pane(source); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); + let target_info = app + .state + .pane_info_by_id(target) + .expect("target pane info") + .clone(); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + target_info.inner_rect.width, + target_info.inner_rect.height, + 0, + b"\x1b[?1002h\x1b[?1006h", + 4, + ); + app.state.insert_test_runtime(target, runtime); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + target_info.inner_rect.x, + target_info.inner_rect.y, + )); + + assert!(input_rx.try_recv().is_err()); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); + let menu = app.state.context_menu.as_mut().expect("pane context menu"); + assert!(matches!( + menu.kind, + ContextMenuKind::Pane { + pane_id, + source_pane_id: Some(source_pane_id), + .. + } if pane_id == target && source_pane_id == source + )); + assert!(menu.items().contains(&"Swap with focused pane")); + } + #[tokio::test] async fn right_click_passthrough_requires_exact_modifier_match() { let mut app = app_for_mouse_test(); @@ -2357,6 +2436,7 @@ mod tests { app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; + app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL); let col = info.inner_rect.x + 2; diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index c1bfac87..51a3899c 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -1051,121 +1051,6 @@ mod tests { ); } - #[tokio::test] - async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_left_button() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(ratatui::layout::Direction::Vertical); - - let terminal_area = Rect::new(26, 2, 80, 18); - let pane_infos = ws.tabs[0].layout.panes(terminal_area); - let first_info = pane_infos - .iter() - .find(|p| p.id == first_pane) - .unwrap() - .clone(); - let second_info = pane_infos - .iter() - .find(|p| p.id == second_pane) - .unwrap() - .clone(); - - ws.insert_test_runtime( - first_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - first_info.inner_rect.width.max(1), - first_info.inner_rect.height.max(1), - b"", - ), - ); - ws.insert_test_runtime( - second_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - second_info.inner_rect.width.max(1), - second_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - - ws.tabs[0].layout.focus_pane(first_pane); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - second_info.inner_rect.x + 2, - second_info.inner_rect.y + 2, - )); - - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - second_pane - ); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn right_clicking_unfocused_mouse_reporting_pane_keeps_focus_for_context_menu() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(ratatui::layout::Direction::Vertical); - - let terminal_area = Rect::new(26, 2, 80, 18); - let pane_infos = ws.tabs[0].layout.panes(terminal_area); - let first_info = pane_infos - .iter() - .find(|p| p.id == first_pane) - .unwrap() - .clone(); - let second_info = pane_infos - .iter() - .find(|p| p.id == second_pane) - .unwrap() - .clone(); - - ws.insert_test_runtime( - first_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - first_info.inner_rect.width.max(1), - first_info.inner_rect.height.max(1), - b"", - ), - ); - ws.insert_test_runtime( - second_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - second_info.inner_rect.width.max(1), - second_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - - ws.tabs[0].layout.focus_pane(first_pane); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - second_info.inner_rect.x + 2, - second_info.inner_rect.y + 2, - )); - - assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), first_pane); - assert_eq!(app.state.mode, Mode::ContextMenu); - let menu = app.state.context_menu.as_ref().expect("pane context menu"); - assert!(menu.items().contains(&"Swap with focused pane")); - } - #[tokio::test] async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index a664ee05..3a3a0791 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -443,10 +443,13 @@ impl CellWide { type WritePtyCallback = dyn FnMut(&[u8]) + Send; +const MAX_CLIPBOARD_BYTES: usize = 192 * 1024; + #[derive(Default)] struct TerminalCallbackState { write_pty: Option>, pwd_changes: Vec>, + clipboard_writes: Vec>, } unsafe extern "C" fn write_pty_trampoline( @@ -470,6 +473,90 @@ unsafe extern "C" fn write_pty_trampoline( callback(bytes); } +unsafe extern "C" fn clipboard_write_trampoline( + _terminal: ffi::GhosttyTerminal, + userdata: *mut c_void, + write: *const ffi::GhosttyClipboardWrite, +) -> ffi::GhosttyClipboardWriteResult { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // SAFETY: libghostty-vt owns these values for the synchronous callback. + unsafe { capture_clipboard_write(userdata, write) } + })) + .unwrap_or(ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA) +} + +unsafe fn capture_clipboard_write( + userdata: *mut c_void, + write: *const ffi::GhosttyClipboardWrite, +) -> ffi::GhosttyClipboardWriteResult { + if userdata.is_null() || write.is_null() { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + } + + let required_size = std::mem::offset_of!(ffi::GhosttyClipboardWrite, contents_len) + + std::mem::size_of::(); + // SAFETY: size is the leading field of the live request. + if unsafe { (*write).size } < required_size { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + } + // SAFETY: the size check covers every field accessed below. + let request = unsafe { &*write }; + if request.location != ffi::GhosttyClipboardLocation_GHOSTTY_CLIPBOARD_LOCATION_STANDARD { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED; + } + + // SAFETY: userdata is the TerminalCallbackState installed with this terminal. + let state = unsafe { &mut *userdata.cast::() }; + if request.contents_len == 0 { + state.clipboard_writes.push(Vec::new()); + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS; + } + if request.contents_len != 1 { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED; + } + if request.contents.is_null() { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + } + + // SAFETY: libghostty-vt keeps the single content and its strings alive for the callback. + let content = unsafe { &*request.contents }; + // SAFETY: the MIME string is borrowed from the live callback request. + let Some(mime) = (unsafe { borrowed_bytes(content.mime) }) else { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + }; + let is_text = std::str::from_utf8(mime) + .ok() + .and_then(|mime| mime.split(';').next()) + .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/plain")); + if !is_text { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED; + } + + // SAFETY: the data string is borrowed from the live callback request. + let Some(bytes) = (unsafe { borrowed_bytes(content.data) }) else { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + }; + if bytes.is_empty() { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED; + } + if bytes.len() > MAX_CLIPBOARD_BYTES { + return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + } + state.clipboard_writes.push(bytes.to_vec()); + ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS +} + +unsafe fn borrowed_bytes<'a>(value: ffi::GhosttyString) -> Option<&'a [u8]> { + if value.len == 0 { + Some(&[]) + } else if value.ptr.is_null() { + None + } else { + // SAFETY: the callback contract keeps pointer and length valid until return. + Some(unsafe { slice::from_raw_parts(value.ptr, value.len) }) + } +} + unsafe extern "C" fn pwd_changed_trampoline(terminal: ffi::GhosttyTerminal, userdata: *mut c_void) { if terminal.is_null() || userdata.is_null() { return; @@ -660,6 +747,12 @@ impl Terminal { (pwd_changed_trampoline as *const ()).cast(), ) .into_result()?; + ffi::ghostty_terminal_set( + terminal.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE, + (clipboard_write_trampoline as *const ()).cast(), + ) + .into_result()?; ffi::ghostty_terminal_set( terminal.raw, ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL, @@ -758,6 +851,10 @@ impl Terminal { mem::take(&mut self.callback_state.pwd_changes) } + pub fn take_clipboard_writes(&mut self) -> Vec> { + mem::take(&mut self.callback_state.clipboard_writes) + } + pub fn mode_get(&self, mode: u16) -> Result { let mut out = false; unsafe { ffi::ghostty_terminal_mode_get(self.raw, mode, &mut out).into_result()? }; @@ -3640,6 +3737,98 @@ mod tests { assert_eq!(rows.selection().unwrap(), None); } + fn test_clipboard_content(mime: &[u8], data: &[u8]) -> ffi::GhosttyClipboardContent { + ffi::GhosttyClipboardContent { + mime: ffi::GhosttyString { + ptr: mime.as_ptr(), + len: mime.len(), + }, + data: ffi::GhosttyString { + ptr: data.as_ptr(), + len: data.len(), + }, + } + } + + fn invoke_clipboard_callback( + terminal: &mut Terminal, + contents: &[ffi::GhosttyClipboardContent], + size: usize, + ) -> ffi::GhosttyClipboardWriteResult { + let request = ffi::GhosttyClipboardWrite { + size, + location: ffi::GhosttyClipboardLocation_GHOSTTY_CLIPBOARD_LOCATION_STANDARD, + contents: contents.as_ptr(), + contents_len: contents.len(), + }; + // SAFETY: the request and its borrowed content live through this call. + unsafe { + clipboard_write_trampoline( + terminal.raw, + (&mut *terminal.callback_state as *mut TerminalCallbackState).cast(), + &request, + ) + } + } + + #[test] + fn clipboard_callback_rejects_writes_the_text_pipeline_cannot_represent() { + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + let full_size = std::mem::size_of::(); + let success = ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS; + let unsupported = + ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED; + let invalid = ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA; + + assert_eq!( + invoke_clipboard_callback(&mut terminal, &[], full_size), + success + ); + assert_eq!(terminal.take_clipboard_writes(), vec![Vec::::new()]); + + let empty = test_clipboard_content(b"text/plain", b""); + assert_eq!( + invoke_clipboard_callback(&mut terminal, &[empty], full_size), + unsupported + ); + let text = test_clipboard_content(b"text/plain", b"text"); + let image = test_clipboard_content(b"image/png", b"image"); + assert_eq!( + invoke_clipboard_callback(&mut terminal, &[text, image], full_size), + unsupported + ); + + let oversized = vec![b'x'; MAX_CLIPBOARD_BYTES + 1]; + let oversized = test_clipboard_content(b"text/plain", &oversized); + assert_eq!( + invoke_clipboard_callback(&mut terminal, &[oversized], full_size), + invalid + ); + assert_eq!( + invoke_clipboard_callback(&mut terminal, &[text], full_size - 1), + invalid + ); + assert!(terminal.take_clipboard_writes().is_empty()); + } + + #[test] + fn libghostty_completes_osc52_writes_for_bel_and_st_without_queries() { + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + terminal.write(b"\x1b]52;c;aGVs"); + assert!(terminal.take_clipboard_writes().is_empty()); + terminal.write(b"bG8=\x07"); + assert_eq!(terminal.take_clipboard_writes(), vec![b"hello".to_vec()]); + + terminal.write(b"\x1b]52;c;d29ybGQ=\x1b\\"); + assert_eq!(terminal.take_clipboard_writes(), vec![b"world".to_vec()]); + + terminal.write(b"\x1b]52;c;?\x07"); + assert!(terminal.take_clipboard_writes().is_empty()); + + terminal.write(b"\x1b]52;c;\x07"); + assert_eq!(terminal.take_clipboard_writes(), vec![Vec::::new()]); + } + #[test] fn row_cell_basic_data_uses_batched_vendor_reads() { let mut terminal = Terminal::new(8, 3, 100).unwrap(); diff --git a/src/pane/osc.rs b/src/pane/osc.rs index 22e76ff1..812c8edd 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -314,88 +314,6 @@ fn parse_default_color_set_events(body: &[u8]) -> Vec { .collect() } -/// 256 KiB of base64 ≈ 192 KiB of text — enough for real source-file copies -/// while still bounding memory against stream garbage. -const OSC52_MAX_PAYLOAD_BYTES: usize = 256 * 1024; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -enum Osc52ForwarderState { - #[default] - Ground, - Escape, - OscBody, - OscEscape, -} - -/// Reconstructs OSC 52 clipboard-write sequences from raw PTY bytes so the -/// main loop can re-emit them. `libghostty-vt` drops `.clipboard_contents`, -/// so child clipboard writes never reach the host terminal unless we forward -/// them ourselves. -#[derive(Debug, Default)] -pub(super) struct Osc52Forwarder { - state: Osc52ForwarderState, - body: Vec, - pending: Vec>, -} - -impl Osc52Forwarder { - pub(super) fn observe(&mut self, bytes: &[u8]) { - for &byte in bytes { - match self.state { - Osc52ForwarderState::Ground => { - if byte == 0x1b { - self.state = Osc52ForwarderState::Escape; - } - } - Osc52ForwarderState::Escape => { - if byte == b']' { - self.body.clear(); - self.state = Osc52ForwarderState::OscBody; - } else if byte == 0x1b { - self.state = Osc52ForwarderState::Escape; - } else { - self.state = Osc52ForwarderState::Ground; - } - } - Osc52ForwarderState::OscBody => match byte { - 0x07 => { - self.finalize(); - self.state = Osc52ForwarderState::Ground; - } - 0x1b => self.state = Osc52ForwarderState::OscEscape, - _ => self.body.push(byte), - }, - Osc52ForwarderState::OscEscape => { - if byte == b'\\' { - self.finalize(); - self.state = Osc52ForwarderState::Ground; - } else { - self.body.push(0x1b); - self.body.push(byte); - self.state = Osc52ForwarderState::OscBody; - } - } - } - - if self.body.len() > OSC52_MAX_PAYLOAD_BYTES { - self.body.clear(); - self.state = Osc52ForwarderState::Ground; - } - } - } - - fn finalize(&mut self) { - if let Some(content) = parse_osc52_clipboard_write(&self.body) { - self.pending.push(content); - } - self.body.clear(); - } - - pub(super) fn drain_pending(&mut self) -> Vec> { - std::mem::take(&mut self.pending) - } -} - pub(super) fn parse_reported_cwd(value: &[u8]) -> Option { let value = std::str::from_utf8(value).ok()?.trim(); if value.starts_with("file://") { @@ -405,6 +323,126 @@ pub(super) fn parse_reported_cwd(value: &[u8]) -> Option { (!path.is_empty()).then(|| PathBuf::from(path)) } +/// Collects complete OSC bodies from a raw byte stream. Consumers receive only +/// bodies, keeping the framing state machine independent from OSC commands. +#[derive(Debug, Default)] +struct OscStreamCollector { + state: OscStreamState, + body: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum OscStreamState { + #[default] + Ground, + Escape, + Body, + BodyEscape, + IgnoringString, + IgnoringStringEscape, + Discarding, + DiscardingEscape, +} + +impl OscStreamCollector { + const MAX_BODY_BYTES: usize = 4096; + + fn observe(&mut self, bytes: &[u8], mut receive: impl FnMut(&[u8])) { + for &byte in bytes { + match self.state { + OscStreamState::Ground => { + if byte == 0x1b { + self.state = OscStreamState::Escape; + } + } + OscStreamState::Escape => match byte { + b']' => { + self.body.clear(); + self.state = OscStreamState::Body; + } + 0x1b => self.state = OscStreamState::Escape, + byte if is_ignored_string_intro(byte) => { + self.state = OscStreamState::IgnoringString; + } + _ => self.state = OscStreamState::Ground, + }, + OscStreamState::Body => match byte { + 0x07 => self.finish(&mut receive), + 0x1b => self.state = OscStreamState::BodyEscape, + _ => self.push(byte), + }, + OscStreamState::BodyEscape => match byte { + b'\\' => self.finish(&mut receive), + 0x07 => { + self.push(0x1b); + if matches!(self.state, OscStreamState::Body) { + self.finish(&mut receive); + } else { + self.state = OscStreamState::Ground; + } + } + 0x1b => { + self.push(0x1b); + self.state = match self.state { + OscStreamState::Body => OscStreamState::BodyEscape, + OscStreamState::Discarding => OscStreamState::DiscardingEscape, + state => state, + }; + } + _ => { + self.push(0x1b); + if matches!(self.state, OscStreamState::Body) { + self.push(byte); + } + } + }, + OscStreamState::IgnoringString => { + if byte == 0x1b { + self.state = OscStreamState::IgnoringStringEscape; + } + } + OscStreamState::IgnoringStringEscape => { + if byte == b'\\' { + self.state = OscStreamState::Ground; + } else if byte != 0x1b { + self.state = OscStreamState::IgnoringString; + } + } + OscStreamState::Discarding => { + if byte == 0x07 { + self.state = OscStreamState::Ground; + } else if byte == 0x1b { + self.state = OscStreamState::DiscardingEscape; + } + } + OscStreamState::DiscardingEscape => { + if byte == b'\\' { + self.state = OscStreamState::Ground; + } else if byte != 0x1b { + self.state = OscStreamState::Discarding; + } + } + } + } + } + + fn push(&mut self, byte: u8) { + self.body.push(byte); + if self.body.len() > Self::MAX_BODY_BYTES { + self.body.clear(); + self.state = OscStreamState::Discarding; + } else { + self.state = OscStreamState::Body; + } + } + + fn finish(&mut self, receive: &mut impl FnMut(&[u8])) { + receive(&self.body); + self.body.clear(); + self.state = OscStreamState::Ground; + } +} + /// Maximum retained string length for agent OSC title and progress payloads. /// Title text is untrusted model output; cap it to bound memory and log size. const AGENT_OSC_MAX_CHARS: usize = 256; @@ -419,8 +457,7 @@ const AGENT_OSC_MAX_CHARS: usize = 256; /// as-is after sanitization. E.g. `"4;3;"` or `"4;0;"`. #[derive(Debug, Default)] pub(super) struct AgentOscStateTracker { - state: Osc52ForwarderState, - body: Vec, + collector: OscStreamCollector, latest_title: Option, terminal_title: Option, latest_progress: Option, @@ -428,71 +465,29 @@ pub(super) struct AgentOscStateTracker { impl AgentOscStateTracker { pub(super) fn observe(&mut self, bytes: &[u8]) { - for &byte in bytes { - match self.state { - Osc52ForwarderState::Ground => { - if byte == 0x1b { - self.state = Osc52ForwarderState::Escape; - } - } - Osc52ForwarderState::Escape => { - if byte == b']' { - self.body.clear(); - self.state = Osc52ForwarderState::OscBody; - } else if byte == 0x1b { - self.state = Osc52ForwarderState::Escape; - } else { - self.state = Osc52ForwarderState::Ground; - } - } - Osc52ForwarderState::OscBody => match byte { - 0x07 => { - self.finalize(); - self.state = Osc52ForwarderState::Ground; - } - 0x1b => self.state = Osc52ForwarderState::OscEscape, - _ => self.body.push(byte), - }, - Osc52ForwarderState::OscEscape => { - if byte == b'\\' { - self.finalize(); - self.state = Osc52ForwarderState::Ground; - } else { - self.body.push(0x1b); - self.body.push(byte); - self.state = Osc52ForwarderState::OscBody; - } - } - } - - if self.body.len() > 4096 { - self.body.clear(); - self.state = Osc52ForwarderState::Ground; - } - } - } - - fn finalize(&mut self) { - if let Some((command, payload)) = parse_agent_osc_body(&self.body) { + let (collector, latest_title, terminal_title, latest_progress) = ( + &mut self.collector, + &mut self.latest_title, + &mut self.terminal_title, + &mut self.latest_progress, + ); + collector.observe(bytes, |body| { + let Some((command, payload)) = parse_agent_osc_body(body) else { + return; + }; match command { b"0" | b"2" => { - let title = if payload.is_empty() { - None - } else { - let title = sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS); - (!title.is_empty()).then_some(title) - }; - self.latest_title.clone_from(&title); - self.terminal_title = title; + let title = sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS); + *terminal_title = (!title.is_empty()).then_some(title.clone()); + *latest_title = (!title.is_empty()).then_some(title); } b"9" => { - self.latest_progress = + *latest_progress = Some(sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS)); } _ => {} } - } - self.body.clear(); + }); } pub(super) fn terminal_title(&self) -> Option<&str> { @@ -549,8 +544,7 @@ fn sanitize_agent_osc_string(payload: &[u8], max_chars: usize) -> String { #[derive(Debug)] pub(super) struct OscDebugTracker { enabled: bool, - state: Osc52ForwarderState, - body: Vec, + collector: OscStreamCollector, pending: Vec, } @@ -564,8 +558,7 @@ impl OscDebugTracker { pub(super) fn from_env() -> Self { Self { enabled: osc_debug_enabled_from_env(), - state: Osc52ForwarderState::Ground, - body: Vec::new(), + collector: OscStreamCollector::default(), pending: Vec::new(), } } @@ -574,56 +567,12 @@ impl OscDebugTracker { if !self.enabled { return; } - - for &byte in bytes { - match self.state { - Osc52ForwarderState::Ground => { - if byte == 0x1b { - self.state = Osc52ForwarderState::Escape; - } - } - Osc52ForwarderState::Escape => { - if byte == b']' { - self.body.clear(); - self.state = Osc52ForwarderState::OscBody; - } else if byte == 0x1b { - self.state = Osc52ForwarderState::Escape; - } else { - self.state = Osc52ForwarderState::Ground; - } - } - Osc52ForwarderState::OscBody => match byte { - 0x07 => { - self.finalize(); - self.state = Osc52ForwarderState::Ground; - } - 0x1b => self.state = Osc52ForwarderState::OscEscape, - _ => self.body.push(byte), - }, - Osc52ForwarderState::OscEscape => { - if byte == b'\\' { - self.finalize(); - self.state = Osc52ForwarderState::Ground; - } else { - self.body.push(0x1b); - self.body.push(byte); - self.state = Osc52ForwarderState::OscBody; - } - } + let (collector, pending) = (&mut self.collector, &mut self.pending); + collector.observe(bytes, |body| { + if let Some(event) = parse_osc_debug_event(body) { + pending.push(event); } - - if self.body.len() > 4096 { - self.body.clear(); - self.state = Osc52ForwarderState::Ground; - } - } - } - - fn finalize(&mut self) { - if let Some(event) = parse_osc_debug_event(&self.body) { - self.pending.push(event); - } - self.body.clear(); + }); } pub(super) fn drain_pending(&mut self) -> Vec { @@ -733,22 +682,6 @@ fn hex_value(byte: u8) -> Option { } } -/// Accepts `52;c;` and `52;;`. -/// Queries (`?`) are rejected because herdr has no reply path. -/// The payload must decode as base64 before it is forwarded. -fn parse_osc52_clipboard_write(body: &[u8]) -> Option> { - use base64::Engine; - - let rest = body.strip_prefix(b"52;")?; - let sep = rest.iter().position(|b| *b == b';')?; - let selector = &rest[..sep]; - let data = &rest[sep + 1..]; - if !(selector.is_empty() || selector == b"c") || data == b"?" { - return None; - } - base64::engine::general_purpose::STANDARD.decode(data).ok() -} - fn foreground_job_is_shell(job: &crate::platform::ForegroundJob, shell_pid: u32) -> bool { job.processes.iter().any(|process| process.pid == shell_pid) } @@ -983,12 +916,25 @@ mod tests { fn enabled_osc_debug_tracker() -> OscDebugTracker { OscDebugTracker { enabled: true, - state: Osc52ForwarderState::Ground, - body: Vec::new(), + collector: OscStreamCollector::default(), pending: Vec::new(), } } + #[test] + fn osc_stream_collector_ignores_strings_and_preserves_escaped_bytes() { + let mut collector = OscStreamCollector::default(); + let mut bodies = Vec::new(); + + collector.observe( + b"\x1bPignored\x1b]0;not-osc\x07\x1b\\\x1b]9;a\x1b", + |body| bodies.push(body.to_vec()), + ); + collector.observe(b"\x1b\\\x1b]2;b\x1b\x07", |body| bodies.push(body.to_vec())); + + assert_eq!(bodies, vec![b"9;a\x1b".to_vec(), b"2;b\x1b".to_vec()]); + } + #[test] fn default_color_tracker_detects_split_osc_11_sequences() { let mut tracker = DefaultColorOscTracker::default(); @@ -1399,151 +1345,6 @@ mod tests { ); } - #[test] - fn osc52_forwarder_detects_write_with_bel() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;c;aGVsbG8=\x07"); - let pending = fw.drain_pending(); - assert_eq!(pending, vec![b"hello".to_vec()]); - } - - #[test] - fn osc52_forwarder_detects_write_with_st() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;c;aGVsbG8=\x1b\\"); - let pending = fw.drain_pending(); - assert_eq!(pending, vec![b"hello".to_vec()]); - } - - #[test] - fn osc52_forwarder_detects_empty_selector_form() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;;aGVsbG8=\x07"); - let pending = fw.drain_pending(); - assert_eq!(pending, vec![b"hello".to_vec()]); - } - - #[test] - fn osc52_forwarder_accepts_clear_clipboard() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;c;\x07"); - let pending = fw.drain_pending(); - assert_eq!(pending, vec![Vec::::new()]); - } - - #[test] - fn osc52_forwarder_ignores_query() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;c;?\x07"); - assert!(fw.drain_pending().is_empty()); - } - - #[test] - fn osc52_forwarder_ignores_empty_selector_query() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;;?\x07"); - assert!(fw.drain_pending().is_empty()); - } - - #[test] - fn osc52_forwarder_ignores_other_kinds() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;p;aGk=\x07"); - fw.observe(b"\x1b]52;s;aGk=\x07"); - fw.observe(b"\x1b]52;q;aGk=\x07"); - fw.observe(b"\x1b]52;0;aGk=\x07"); - fw.observe(b"\x1b]52;7;aGk=\x07"); - assert!(fw.drain_pending().is_empty()); - } - - #[test] - fn osc52_forwarder_ignores_invalid_base64() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;c;%%%\x07"); - fw.observe(b"\x1b]52;c;aGVs\x1b[bG8=\x07"); - assert!(fw.drain_pending().is_empty()); - } - - #[test] - fn osc52_forwarder_ignores_non_osc52() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]11;?\x07"); - fw.observe(b"\x1b]0;title\x07"); - fw.observe(b"\x1b]8;;https://example.com\x1b\\"); - assert!(fw.drain_pending().is_empty()); - } - - #[test] - fn osc52_forwarder_handles_split_sequence_mid_payload() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;c;aGVs"); - assert!(fw.drain_pending().is_empty()); - fw.observe(b"bG8gd29y"); - assert!(fw.drain_pending().is_empty()); - fw.observe(b"bGQ=\x07"); - let pending = fw.drain_pending(); - assert_eq!(pending, vec![b"hello world".to_vec()]); - } - - #[test] - fn osc52_forwarder_handles_split_before_bel() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;c;aGk="); - assert!(fw.drain_pending().is_empty()); - fw.observe(b"\x07"); - let pending = fw.drain_pending(); - assert_eq!(pending, vec![b"hi".to_vec()]); - } - - #[test] - fn osc52_forwarder_handles_split_between_esc_and_backslash() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;c;aGk=\x1b"); - assert!(fw.drain_pending().is_empty()); - fw.observe(b"\\"); - let pending = fw.drain_pending(); - assert_eq!(pending, vec![b"hi".to_vec()]); - } - - #[test] - fn osc52_forwarder_payload_size_limit() { - let mut fw = Osc52Forwarder::default(); - let mut huge = Vec::with_capacity(OSC52_MAX_PAYLOAD_BYTES + 32); - huge.extend_from_slice(b"\x1b]52;c;"); - huge.extend(std::iter::repeat_n(b'A', OSC52_MAX_PAYLOAD_BYTES + 16)); - huge.push(0x07); - fw.observe(&huge); - assert!(fw.drain_pending().is_empty()); - - fw.observe(b"\x1b]52;c;aGk=\x07"); - let pending = fw.drain_pending(); - assert_eq!(pending, vec![b"hi".to_vec()]); - } - - #[test] - fn osc52_forwarder_recovers_after_garbage() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x01\x02random\x7fbytes\x1b]52;c;aGk=\x07tail"); - let pending = fw.drain_pending(); - assert_eq!(pending, vec![b"hi".to_vec()]); - } - - #[test] - fn osc52_forwarder_multiple_in_one_chunk() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;c;aGk=\x07\x1b]52;c;Ynll\x07"); - let pending = fw.drain_pending(); - assert_eq!(pending, vec![b"hi".to_vec(), b"bye".to_vec()]); - } - - #[test] - fn osc52_forwarder_drain_clears_pending() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;c;aGk=\x07"); - assert_eq!(fw.drain_pending(), vec![b"hi".to_vec()]); - assert!(fw.drain_pending().is_empty()); - } - #[test] fn droid_scrollback_compat_matches_process_name_and_cmdline() { let name_only = crate::platform::ForegroundJob { diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 2c83f8f2..261f7025 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -31,7 +31,7 @@ use super::{ maybe_filter_primary_screen_scrollback_clear, parse_reported_cwd, restore_host_terminal_theme_if_needed, write_host_terminal_theme_selective, AgentOscStateTracker, DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker, - DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder, OscDebugTracker, + DefaultColorQuery, DefaultColorTrackedEvent, OscDebugTracker, }, xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse}, }; @@ -161,7 +161,6 @@ pub(crate) struct GhosttyPaneCore { pub default_color_event_tracker: DefaultColorEventTracker, pub child_default_foreground_changed: bool, pub child_default_background_changed: bool, - pub osc52_forwarder: Osc52Forwarder, pub osc_debug_tracker: OscDebugTracker, pub agent_osc_state: AgentOscStateTracker, pub xtgettcap_query_tracker: XtgettcapQueryTracker, @@ -915,7 +914,6 @@ impl GhosttyPaneTerminal { default_color_event_tracker: DefaultColorEventTracker::default(), child_default_foreground_changed: false, child_default_background_changed: false, - osc52_forwarder: Osc52Forwarder::default(), osc_debug_tracker: OscDebugTracker::default(), agent_osc_state: AgentOscStateTracker::default(), xtgettcap_query_tracker: XtgettcapQueryTracker::default(), @@ -1049,6 +1047,9 @@ impl GhosttyPaneTerminal { }; let _ = core.terminal.take_pwd_changes(); + // Restored history may have exercised terminal callbacks before this live PTY write. + // Those writes must not be delivered as live pane output. + let _ = core.terminal.take_clipboard_writes(); let default_color_observation = core.default_color_tracker.observe(bytes); if shell_pid > 0 && default_color_observation { if let Some(owner_pgid) = current_transient_default_color_owner(shell_pid) { @@ -1060,8 +1061,6 @@ impl GhosttyPaneTerminal { } } - core.osc52_forwarder.observe(bytes); - let clipboard_writes = core.osc52_forwarder.drain_pending(); core.osc_debug_tracker.observe(bytes); for event in core.osc_debug_tracker.drain_pending() { debug!( @@ -1116,6 +1115,7 @@ impl GhosttyPaneTerminal { xtgettcap_responses, &mut terminal_responses, ); + let clipboard_writes = core.terminal.take_clipboard_writes(); let reported_cwd = core .terminal .take_pwd_changes() @@ -3221,6 +3221,38 @@ mod tests { ); } + #[test] + fn process_pty_bytes_surfaces_clipboard_writes_without_other_results() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + + let result = pane.process_pty_bytes( + PaneId::from_raw(1), + 0, + b"output\x1b]52;c;Y2xpcGJvYXJk\x07", + &tx, + ); + + assert!(result.request_render); + assert_eq!(result.render_delay, None); + assert_eq!(result.clipboard_writes, vec![b"clipboard".to_vec()]); + assert_eq!(result.reported_cwd, None); + assert!(result.terminal_responses.is_empty()); + } + + #[test] + fn seeded_history_clipboard_write_does_not_leak_into_live_output() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + pane.seed_history_ansi("\x1b]52;c;c3RhbGU=\x07"); + + let result = pane.process_pty_bytes(PaneId::from_raw(1), 0, b"live output", &tx); + + assert!(result.clipboard_writes.is_empty()); + } + #[test] fn seeded_history_pwd_does_not_leak_into_live_output() { let (tx, _rx) = mpsc::channel(4);