feat(terminal): clipboard paste with bracketed-paste support

Pasting (Ctrl-V or browser menu) now sends clipboard text to the PTY.

- on:paste reads clipboard_data().get_data("text"); newlines normalized
  to CR (the line-submit byte) via the pure, unit-tested prepare_paste()
- when the app enabled bracketed paste (DECSET 2004, exposed via the new
  TerminalCore::bracketed_paste() proxy), the text is wrapped in
  ESC[200~ / ESC[201~ so vim et al. don't auto-indent each pasted line
- pasting returns the view to live mode + scrolls to bottom, matching the
  keydown handler
- Cargo.toml: add ClipboardEvent/DataTransfer (and explicit WheelEvent)
  to the web-sys feature allowlist
- right-click is left to the browser's default menu (avoids requiring
  clipboard-read permission); Ctrl-V is the primary path

Adds a prepare_paste unit test (14 terminal tests total) and marks paste
done in TERMINAL_ROADMAP.md. Also fixes a clippy manual_contains lint in
an existing test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zhangheng
2026-06-11 20:59:03 +08:00
parent a018609a28
commit c886befac7
4 changed files with 96 additions and 9 deletions

View File

@@ -7,8 +7,8 @@ use wasm_bindgen::JsCast;
use wasm_bindgen::closure::Closure;
use web_sys::js_sys::{Array, Function};
use web_sys::{
BinaryType, ErrorEvent, Event, HtmlElement, KeyboardEvent, MessageEvent,
ResizeObserver, ResizeObserverEntry, WebSocket, WheelEvent,
BinaryType, ClipboardEvent, ErrorEvent, Event, HtmlElement, KeyboardEvent,
MessageEvent, ResizeObserver, ResizeObserverEntry, WebSocket, WheelEvent,
};
use super::core::{TerminalCore, TerminalRow, TerminalSegment};
@@ -386,6 +386,50 @@ pub fn TerminalPanel() -> impl IntoView {
}
};
// -- Paste handler ------------------------------------------------------
//
// Send clipboard text to the PTY. If the running application enabled
// bracketed paste (vim, many shells), the text is wrapped in
// ESC[200~ … ESC[201~ so the app can tell a paste from typed input (and
// skip e.g. auto-indenting every pasted line).
let handle_paste = {
let paste_mode = view_mode;
let paste_prog_scroll = programmatic_scroll_top;
let paste_core = core_signal;
move |event: ClipboardEvent| {
let Some(socket) = ws_signal.get_untracked() else {
return;
};
let Some(raw) = event
.clipboard_data()
.and_then(|dt| dt.get_data("text").ok())
else {
return;
};
if raw.is_empty() {
return;
}
let bracketed = paste_core
.try_with_untracked(|core| core.bracketed_paste())
== Some(true);
let data = prepare_paste(&raw, bracketed);
// Pasting is an interaction at the prompt → return to live view.
if !paste_mode.get_untracked().is_live() {
paste_mode.set(ViewMode::Live);
sync_scroll_to_bottom(terminal_ref, paste_prog_scroll);
}
event.prevent_default();
send_terminal_message(
&socket,
&ClientTerminalMessage::Input { data },
);
}
};
let handle_click = move |_| {
focus_terminal(terminal_ref);
};
@@ -437,6 +481,7 @@ pub fn TerminalPanel() -> impl IntoView {
on:keydown=handle_keydown
on:scroll=handle_scroll
on:wheel=handle_wheel
on:paste=handle_paste
>
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
{MEASURE_SAMPLE_TEXT}
@@ -813,6 +858,20 @@ fn wheel_delta_to_rows(delta_y: f64, delta_mode: u32) -> usize {
(rows.round() as usize).clamp(1, MAX_ROWS_PER_EVENT)
}
/// Prepare clipboard text for sending to the PTY.
///
/// Normalises line endings to CR (`\r`), the byte a terminal expects for line
/// submission (Enter sends `\r`). When the application enabled bracketed paste,
/// wraps the text in `ESC[200~ … ESC[201~`.
fn prepare_paste(raw: &str, bracketed: bool) -> String {
let normalized = raw.replace("\r\n", "\r").replace('\n', "\r");
if bracketed {
format!("\u{1b}[200~{normalized}\u{1b}[201~")
} else {
normalized
}
}
fn map_key_to_terminal_input(event: &KeyboardEvent) -> Option<String> {
let key = event.key();
@@ -872,6 +931,22 @@ mod tests {
assert_eq!(wheel_delta_to_rows(100_000.0, 0), 12);
}
#[test]
fn prepare_paste_normalizes_newlines_and_wraps_when_bracketed() {
// Plain paste: CRLF and LF both become CR, no wrapping.
assert_eq!(prepare_paste("a\r\nb\nc", false), "a\rb\rc");
assert_eq!(prepare_paste("echo hi", false), "echo hi");
// Bracketed paste: same normalization, wrapped in 200~/201~.
let esc = '\u{1b}';
assert_eq!(
prepare_paste("a\nb", true),
format!("{esc}[200~a\rb{esc}[201~")
);
// Empty-ish content still wraps consistently when bracketed.
assert_eq!(prepare_paste("", true), format!("{esc}[200~{esc}[201~"));
}
#[test]
fn live_mode_window_is_pinned_to_bottom_regardless_of_scroll_top() {
let core = core_with_lines(1000);

View File

@@ -242,6 +242,13 @@ impl TerminalCore {
pub fn alternate_screen(&self) -> bool {
self.parser.screen().alternate_screen()
}
/// Whether the running application has enabled bracketed paste mode
/// (DECSET 2004). When true, pasted text must be wrapped in
/// `ESC[200~ … ESC[201~` so the app can tell paste from typed input.
pub fn bracketed_paste(&self) -> bool {
self.parser.screen().bracketed_paste()
}
}
impl Default for TerminalCore {
@@ -754,7 +761,7 @@ mod tests {
// must be present in history.
let needle = format!("ln{}", lines - 100);
assert!(
history.iter().any(|r| *r == needle),
history.contains(&needle),
"late line {needle:?} must be captured into history"
);
// And an early line is still there too (under the cap, nothing dropped).