aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-08-21 15:16:43 +0300
committerGitHub <noreply@github.com>2026-08-21 15:16:43 +0300
commit8fe09d5ddf77da5c377760682b33b386ebc37640 (patch)
treec63a23bb630d17e8347c93dc1b01e3ff95bfc531 /src
parent624dfd4796559042ec13ccf4d4b54374902ab81d (diff)
fix: preserve multiline paste in terminal attach (#3056)
refs #3054 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Diffstat (limited to 'src')
-rw-r--r--src/client/mod.rs49
-rw-r--r--src/raw_input.rs28
-rw-r--r--src/server/headless.rs50
3 files changed, 107 insertions, 20 deletions
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!(