fix: decode complete Win32 records in Windows pastes (#4730)

refs #4725

Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
This commit is contained in:
akbash
2026-09-29 14:33:20 +02:00
committed by GitHub
co-authored by akbash-bot JJ Liebig
parent e8e12a43c6
commit fc86d866aa
2 changed files with 162 additions and 1 deletions
@@ -113,6 +113,8 @@ Herdr's pane text copy works on Windows. Drag-select text inside a pane to copy
For text paste, use `ctrl+shift+v` in Windows Terminal. Multiline text paste is bracketed so shells and agent prompts receive it as one paste instead of submitting each line separately. Hold `shift` and right-click to use the outer terminal paste action instead of sending the click through Herdr.
Windows clients also recover text when WezTerm wraps a complete sequence of Win32 character press/release pairs in raw paste markers. Mixed text and incomplete reports are not decoded by this compatibility rule. Literal clipboard text consisting entirely of matching Win32 reports is indistinguishable from this encoding and is decoded too.
## Not supported on Windows
| Capability | Status |
+160 -1
View File
@@ -563,7 +563,16 @@ impl WindowsInputPump {
) -> Vec<crate::protocol::ClientInputEvent> {
for event in &mut events {
if let crate::raw_input::RawInputEvent::Paste(text) = event {
decode_windows_terminal_paste_enters(text);
let decoded = if self.paste_from_win32_key_records {
None
} else {
decode_win32_paste_payload(text)
};
if let Some(decoded) = decoded {
*text = decoded;
} else {
decode_windows_terminal_paste_enters(text);
}
self.paste_from_win32_key_records = false;
}
}
@@ -680,6 +689,54 @@ impl PlatformInputItem {
}
}
/// WezTerm can put serialized key records inside raw bracketed-paste markers.
/// Only recognize a complete payload of matching text press/release pairs and
/// modifier-only reports. Mixed, incomplete, or unsupported payloads stay opaque.
fn decode_win32_paste_payload(text: &str) -> Option<String> {
let mut reports = text.split_inclusive('_');
let mut units = Vec::new();
while let Some(report) = reports.next() {
let pressed = parse_win32_paste_record(report)?;
if WindowsInputMapper::key_record_is_modifier_only(pressed) {
continue;
}
if !pressed.key_down || pressed.unicode == 0 {
return None;
}
let released = parse_win32_paste_record(reports.next()?)?;
if released
!= (WindowsKeyRecord {
key_down: false,
..pressed
})
{
return None;
}
units.push(pressed.unicode);
}
if units.is_empty() {
return None;
}
// Decode locally so invalid or incomplete surrogates cannot leak into the
// next paste, and reject rather than partially replacing malformed text.
String::from_utf16(&units).ok()
}
fn parse_win32_paste_record(report: &str) -> Option<WindowsKeyRecord> {
let body = report.strip_prefix("\x1b[")?.strip_suffix('_')?;
if !body
.bytes()
.all(|byte| byte.is_ascii_digit() || byte == b';')
|| !matches!(body.split(';').nth(3), Some("0" | "1"))
{
return None;
}
let record = parse_win32_input_mode_key_record(body)?;
// This compatibility exception covers the captured one-unit records, not
// repeated key streams that could expand a small paste into large output.
(record.repeat_count == 1).then_some(record)
}
fn decode_windows_terminal_paste_enters(text: &mut String) {
const ENTER_REPORT_PAIR: &str = "\x1b[13;28;13;1;0;1_\x1b[13;28;13;0;0;1_";
@@ -2037,6 +2094,108 @@ mod tests {
);
}
#[test]
fn vti_reported_wezterm_paste_preserves_clipboard_text() {
// Exact raw Unicode fields from #4725, comments 5877227755/5877228192.
// All records have the other fields supplied by key_char. Preserve the
// seven paste batch boundaries and the preceding modifier records.
let batches = [
"\x1b[17;29;0;1;8;1_",
"\x1b[16;42;0;1;24;1_",
"\x1b[200~\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_\x1b[76;38;108;1;0;1_\x1b[76;3",
"8;108;0;0;1_\x1b[80;25;112;1;0;1_\x1b[80;25;112;0;0;1_\x1b[72;35;104;1;0;",
"1_\x1b[72;35;104;0;0;1_\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_\x1b[17;29;0;",
"1;8;1_\x1b[13;28;10;1;8;1_\x1b[13;28;10;0;8;1_\x1b[17;29;0;0;0;1_\x1b[66;48;",
"98;1;0;1_\x1b[66;48;98;0;0;1_\x1b[69;18;101;1;0;1_\x1b[69;18;101;0;0;1_\x1b[",
"84;20;116;1;0;1_\x1b[84;20;116;0;0;1_\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;",
"0;1_\x1b[201~",
];
let mut translator = WindowsInputTranslator::default();
let mut events = Vec::new();
for batch in batches {
for record in batch.chars().map(key_char) {
events.extend(translator.translate(record));
}
}
assert_eq!(
events,
vec![crate::protocol::ClientInputEvent::Paste {
text: "alpha\nbeta".into(),
}]
);
}
#[test]
fn win32_paste_payload_rejects_incomplete_or_unsupported_records() {
const PAIR: &str = "\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_";
for payload in [
format!("{PAIR}trailing text"),
format!("{PAIR}\x1b[65;30;97;1;0;"),
"\x1b[65;30;97;1;0;1_".into(),
"\x1b[65;30;97;0;0;1_".into(),
"\x1b[65;30;97;1;0;1_\x1b[66;48;98;0;0;1_".into(),
PAIR.replace(";97;1;", ";97;2;"),
PAIR.replace(";0;1_", ";0;0_"),
PAIR.replace(";0;1_", ";0;65535_"),
PAIR.replace(";97;", ";65536;"),
PAIR.replace(";97;", ";+97;"),
PAIR.replace(";97;", ";0;"),
PAIR.replace(";97;", ";55296;"),
"\x1b[17;29;0;1;8;1_\x1b[17;29;0;0;0;1_".into(),
] {
assert_eq!(decode_win32_paste_payload(&payload), None, "{payload:?}");
}
}
fn serialized_paste_text(text: &str) -> String {
text.encode_utf16()
.map(|unit| format!("\x1b[0;0;{unit};1;0;1_\x1b[0;0;{unit};0;0;1_"))
.collect()
}
#[test]
fn vti_raw_win32_paste_keeps_decoded_controls_as_text_and_surrogates_local() {
let text = "😀\x1b[201~\x1b[<0;1;1M";
let high = "\x1b[0;0;55357;1;0;1_\x1b[0;0;55357;0;0;1_";
let low = "\x1b[0;0;56832;1;0;1_\x1b[0;0;56832;0;0;1_";
let payloads = [
serialized_paste_text(text),
high.into(),
low.into(),
"plain".into(),
];
let mut translator = WindowsInputTranslator::default();
let mut events = Vec::new();
for payload in payloads {
for record in format!("\x1b[200~{payload}\x1b[201~").chars().map(key_char) {
events.extend(translator.translate(record));
}
events.extend(translator.idle());
}
assert_eq!(
events,
[text, high, low, "plain"]
.into_iter()
.map(|text| crate::protocol::ClientInputEvent::Paste { text: text.into() })
.collect::<Vec<_>>()
);
}
#[test]
fn vti_encoded_paste_preserves_reports_then_raw_paste_decodes() {
let text = serialized_paste_text("a");
let framed = format!("\x1b[200~{text}\x1b[201~");
let mut records = win32_input_mode_encoded_key_bytes(framed.as_bytes());
records.extend(framed.chars().map(key_char));
assert_eq!(
translate(records),
vec![
crate::protocol::ClientInputEvent::Paste { text },
crate::protocol::ClientInputEvent::Paste { text: "a".into() },
]
);
}
#[test]
fn vti_remote_bracketed_paste_decodes_reported_enter_records() {
let records = concat!(