diff --git a/Cargo.toml b/Cargo.toml
index 02325d1..66ba529 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -39,8 +39,10 @@ wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", default-features = false, features = [
"BinaryType",
+ "ClipboardEvent",
"CloseEvent",
"console",
+ "DataTransfer",
"Document",
"DomRectReadOnly",
"Element",
@@ -54,6 +56,7 @@ web-sys = { version = "0.3", default-features = false, features = [
"ResizeObserver",
"ResizeObserverEntry",
"WebSocket",
+ "WheelEvent",
"Window",
] }
diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md
index da0970a..ddae227 100644
--- a/TERMINAL_ROADMAP.md
+++ b/TERMINAL_ROADMAP.md
@@ -29,6 +29,7 @@
| `clear`(含 `ESC[3J` 清 scrollback) | `core.rs::contains_erase_scrollback` |
| alt-screen 滚轮 → 方向键翻页 | `component.rs::handle_wheel` |
| 基础按键:Ctrl-C/D/L、方向键、Enter/Tab/Esc/Backspace | `component.rs::map_key_to_terminal_input` |
+| 粘贴(Ctrl-V / 浏览器菜单,含 bracketed paste) | `component.rs::handle_paste` / `prepare_paste` |
---
@@ -36,14 +37,15 @@
### P0 — 复制 / 粘贴 + 文本选区 ⭐ 投入产出比最高
-**现状**:完全没有。无法选中终端文本、无法粘贴。这是「能用 vs 好用」的分界线。
+**现状**:粘贴**已完成**(见下);选区 / 复制尚未做。
**计划实现**:
-1. **粘贴(先做,最简单)**
- - 监听 `on:paste`(`ClipboardEvent`),读 `clipboard_data().get_data("text")`。
- - 直接作为 `ClientTerminalMessage::Input` 发送。
- - 若应用开启 bracketed paste 模式(vt100 的 `screen().bracketed_paste()` 为真),把粘贴内容包进 `ESC[200~ … ESC[201~`,避免 vim 等把粘贴当连续输入触发自动缩进。
-2. **选区(较复杂)**
+1. ~~**粘贴(先做,最简单)**~~ ✅ **已完成**
+ - `on:paste`(`ClipboardEvent`)读 `clipboard_data().get_data("text")`,归一换行为 `\r` 后作为 `Input` 发送。
+ - bracketed paste:应用开启时(`core.bracketed_paste()`)包进 `ESC[200~ … ESC[201~`。
+ - 实现于 `component.rs::handle_paste` + 纯函数 `prepare_paste`(有单元测试)。
+ - 右键不拦截,走浏览器默认菜单(避免依赖剪贴板读权限);Ctrl-V 为主路径。
+2. **选区(较复杂,下一步)**
- 因为渲染是 DOM(每行一个 `
`、每段一个 ``),可优先尝试**浏览器原生选区**:给可见行允许 `user-select: text`,监听 `copy` 事件,从 `window.getSelection()` 取文本。
- 但虚拟滚动只渲染可见窗口,跨窗口选区会断 → 需要把「逻辑行号 → 文本」的映射暴露给选区逻辑,从 `TerminalCore` 的 `history_rows + screen_rows` 直接取纯文本(已有 `row` → 文本的能力,测试里 `row_text` 即是)。
- MVP 可只支持**可见范围内选区 + 复制**,跨滚动选区列为后续。
diff --git a/app/src/terminal/component.rs b/app/src/terminal/component.rs
index 6c04df4..3b2bca2 100644
--- a/app/src/terminal/component.rs
+++ b/app/src/terminal/component.rs
@@ -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
>
{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 {
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);
diff --git a/app/src/terminal/core.rs b/app/src/terminal/core.rs
index eb3f930..ebbde7a 100644
--- a/app/src/terminal/core.rs
+++ b/app/src/terminal/core.rs
@@ -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).