diff options
| author | akbash <akbash@herdr.dev> | 2026-08-21 15:16:43 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-21 15:16:43 +0300 |
| commit | 8fe09d5ddf77da5c377760682b33b386ebc37640 (patch) | |
| tree | c63a23bb630d17e8347c93dc1b01e3ff95bfc531 | |
| parent | 624dfd4796559042ec13ccf4d4b54374902ab81d (diff) | |
fix: preserve multiline paste in terminal attach (#3056)
refs #3054
Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
| -rw-r--r-- | docs/next/CHANGELOG.md | 1 | ||||
| -rw-r--r-- | src/client/mod.rs | 49 | ||||
| -rw-r--r-- | src/raw_input.rs | 28 | ||||
| -rw-r--r-- | src/server/headless.rs | 50 | ||||
| -rw-r--r-- | tests/client_mode.rs | 13 |
5 files changed, 117 insertions, 24 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index ac755840..91e08af8 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Fixed +- Direct terminal attaches now preserve multiline pastes as one paste instead of submitting each line separately. (#3054) - Windows panes now keep bare `cursor-agent` launches detected after Cursor hands off to its bundled Node process. (#3032) - Oversized Kitty images no longer prevent smaller images shown later in the same pane from rendering. (#3033) - Claude Code panes now use visible turn, background shell, and background agent activity as working-state fallbacks when OSC titles are unavailable or disabled. (#1630, #2241) diff --git a/src/client/mod.rs b/src/client/mod.rs index 27a2e9c4..a5493ca2 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -114,6 +114,7 @@ struct AttachEscapeState { #[cfg(unix)] enum AttachInputAction { Forward(Vec<u8>), + ForwardAfterPendingPrefix(Vec<u8>), Scroll { source: AttachScrollSource, direction: AttachScrollDirection, @@ -136,6 +137,14 @@ impl AttachEscapeState { ) -> AttachInputAction { const PREFIX: u8 = 0x02; // Ctrl+B + if crate::raw_input::is_complete_text_bracketed_paste(&data) { + return if std::mem::take(&mut self.pending_prefix) { + AttachInputAction::ForwardAfterPendingPrefix(data) + } else { + AttachInputAction::Forward(data) + }; + } + let mut output = Vec::with_capacity(data.len()); for byte in data { if self.pending_prefix { @@ -384,6 +393,7 @@ fn setup_terminal_with_capabilities( } else { set_mouse_capture(false, false)?; } + execute!(io::stdout(), EnableBracketedPaste)?; } #[cfg(windows)] @@ -1528,6 +1538,13 @@ async fn run_client_loop( state.mouse_scroll_lines, ) { AttachInputAction::Forward(data) => data, + AttachInputAction::ForwardAfterPendingPrefix(data) => { + let prefix = ClientMessage::Input { data: vec![0x02] }; + if let Err(e) = write_to_server(&mut write_stream, &prefix) { + return Err(ClientError::ConnectionLost(e)); + } + data + } AttachInputAction::Scroll { source, direction, @@ -3135,6 +3152,38 @@ mod tests { #[cfg(unix)] #[test] + fn attach_escape_does_not_interpret_bracketed_paste_contents() { + let mut escape = AttachEscapeState::default(); + let paste = b"\x1b[200~one\x02q\ntwo\x1b[201~".to_vec(); + + match escape.filter_input(paste.clone(), 24, 3) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, paste), + other => panic!("expected opaque paste, got {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn attach_escape_flushes_pending_prefix_before_bracketed_paste() { + let mut escape = AttachEscapeState::default(); + let paste = b"\x1b[200~one\ntwo\x1b[201~".to_vec(); + assert!(matches!( + escape.filter_input(vec![0x02], 24, 3), + AttachInputAction::None + )); + + assert!(matches!( + escape.filter_input(paste.clone(), 24, 3), + AttachInputAction::ForwardAfterPendingPrefix(bytes) if bytes == paste + )); + assert!(matches!( + escape.filter_input(vec![b'q'], 24, 3), + AttachInputAction::Forward(bytes) if bytes == b"q" + )); + } + + #[cfg(unix)] + #[test] fn attach_escape_forwards_prefix_before_non_escape_key() { let mut escape = AttachEscapeState::default(); assert!(matches!( diff --git a/src/raw_input.rs b/src/raw_input.rs index 510768fc..5f750ca8 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -108,19 +108,22 @@ pub(crate) const GHOSTTY_COLOR_SCHEME_LIGHT_REPORT: &[u8] = b"\x1b[?997;2n"; const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~"; const BRACKETED_PASTE_END: &[u8] = b"\x1b[201~"; -/// Returns whether `data` is exactly one complete bracketed-paste sequence. -/// +/// Returns the UTF-8 payload when `data` is exactly one complete bracketed paste. +pub(crate) fn complete_text_bracketed_paste(data: &[u8]) -> Option<&str> { + if !data.starts_with(BRACKETED_PASTE_START) { + return None; + } + let end = find_subsequence(data, BRACKETED_PASTE_END)?; + if end + BRACKETED_PASTE_END.len() != data.len() { + return None; + } + std::str::from_utf8(&data[BRACKETED_PASTE_START.len()..end]).ok() +} + /// Client transport uses this to distinguish recoverable oversized interactive /// pastes from generic oversized input, which remains a protocol violation. pub(crate) fn is_complete_text_bracketed_paste(data: &[u8]) -> bool { - if !data.starts_with(BRACKETED_PASTE_START) { - return false; - } - let Some(end) = find_subsequence(data, BRACKETED_PASTE_END) else { - return false; - }; - end + BRACKETED_PASTE_END.len() == data.len() - && std::str::from_utf8(&data[BRACKETED_PASTE_START.len()..end]).is_ok() + complete_text_bracketed_paste(data).is_some() } #[derive(Debug)] @@ -1424,7 +1427,10 @@ mod tests { #[test] fn complete_text_bracketed_paste_requires_one_exact_utf8_sequence() { - assert!(is_complete_text_bracketed_paste(b"\x1b[200~hello\x1b[201~")); + assert_eq!( + complete_text_bracketed_paste(b"\x1b[200~hello\x1b[201~"), + Some("hello") + ); assert!(!is_complete_text_bracketed_paste(b"\x1b[200~hello")); assert!(!is_complete_text_bracketed_paste( b"\x1b[200~hello\x1b[201~rest" diff --git a/src/server/headless.rs b/src/server/headless.rs index 9161cd34..f72b58ea 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -411,9 +411,15 @@ fn apply_terminal_attach_input( data: Vec<u8>, ) -> Result<(), String> { runtime.scroll_reset(); - runtime - .try_send_bytes(Bytes::from(data)) - .map_err(|err| format!("terminal attach input failed: {err}")) + if let Some(text) = crate::raw_input::complete_text_bracketed_paste(&data) { + runtime + .try_send_paste(text.to_owned()) + .map_err(|err| format!("terminal attach paste failed: {err}")) + } else { + runtime + .try_send_bytes(Bytes::from(data)) + .map_err(|err| format!("terminal attach input failed: {err}")) + } } #[cfg(windows)] @@ -7236,7 +7242,7 @@ next_tab = "" rt.shutdown_timeout(Duration::from_millis(100)); } - fn with_terminal_attach_page_key_runtime( + fn with_terminal_attach_runtime( initial_bytes: &[u8], initial_scroll: usize, test: impl FnOnce(&crate::terminal::TerminalRuntime, &mut mpsc::Receiver<Bytes>), @@ -7281,8 +7287,34 @@ next_tab = "" } #[test] + fn terminal_attach_paste_uses_plain_text_when_runtime_did_not_enable_brackets() { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { + apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) + .expect("attach paste"); + + assert_eq!( + input_rx.try_recv().expect("forwarded paste"), + Bytes::from_static(b"line one\nline two") + ); + }); + } + + #[test] + fn terminal_attach_paste_preserves_brackets_when_runtime_enabled_them() { + with_terminal_attach_runtime(b"\x1b[?2004h", 0, |runtime, input_rx| { + apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec()) + .expect("attach paste"); + + assert_eq!( + input_rx.try_recv().expect("forwarded paste"), + Bytes::from_static(b"\x1b[200~line one\nline two\x1b[201~") + ); + }); + } + + #[test] fn terminal_attach_page_key_host_scrolls_plain_terminal() { - with_terminal_attach_page_key_runtime(b"", 0, |runtime, input_rx| { + with_terminal_attach_runtime(b"", 0, |runtime, input_rx| { apply_terminal_attach_page_up(runtime); assert_eq!( @@ -7298,7 +7330,7 @@ next_tab = "" #[test] fn terminal_attach_page_key_forwards_when_mouse_reporting() { - with_terminal_attach_page_key_runtime(b"\x1b[?1000h", 3, |runtime, input_rx| { + with_terminal_attach_runtime(b"\x1b[?1000h", 3, |runtime, input_rx| { apply_terminal_attach_page_up(runtime); assert_eq!( @@ -7317,7 +7349,7 @@ next_tab = "" #[test] fn terminal_attach_page_key_forwards_when_application_cursor() { - with_terminal_attach_page_key_runtime(b"\x1b[?1h", 3, |runtime, input_rx| { + with_terminal_attach_runtime(b"\x1b[?1h", 3, |runtime, input_rx| { apply_terminal_attach_page_up(runtime); assert_eq!( @@ -7336,7 +7368,7 @@ next_tab = "" #[test] fn terminal_attach_page_key_host_scrolls_shell_like_decckm_with_bracketed_paste() { - with_terminal_attach_page_key_runtime(b"\x1b[?1h\x1b[?2004h", 0, |runtime, input_rx| { + with_terminal_attach_runtime(b"\x1b[?1h\x1b[?2004h", 0, |runtime, input_rx| { apply_terminal_attach_page_up(runtime); assert_eq!( @@ -7352,7 +7384,7 @@ next_tab = "" #[test] fn terminal_attach_page_key_forwards_in_alternate_screen_without_mouse_reporting() { - with_terminal_attach_page_key_runtime(b"\x1b[?1049h", 3, |runtime, input_rx| { + with_terminal_attach_runtime(b"\x1b[?1049h", 3, |runtime, input_rx| { apply_terminal_attach_page_up(runtime); assert_eq!( diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 998df2be..5112182d 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -450,7 +450,13 @@ fn direct_attach_initial_mouse_capture_follows_config() { "mouse capture disabled must not enable host mouse reporting; output: {:?}", read_output(&output) ); + assert!( + read_output(&output).contains("\x1b[?2004h"), + "direct attach must enable host bracketed paste; output: {:?}", + read_output(&output) + ); + let restore_watermark = output_len(&output); attach ._master .as_ref() @@ -459,11 +465,10 @@ fn direct_attach_initial_mouse_capture_follows_config() { .expect("direct attach PTY writer") .write_all(b"\x02q") .expect("detach direct attach client"); + let restore_output = drain_until_client_exits(&mut attach, &output, restore_watermark); assert!( - wait_until(Duration::from_secs(5), Duration::from_millis(20), || { - attach.child.try_wait().ok().flatten().is_some() - }), - "direct attach client should detach" + restore_output.contains("\x1b[?2004l"), + "direct attach must disable host bracketed paste on restore; output: {restore_output:?}" ); drop(attach); |
