返回 DeepSeek-TUI-2026
paste.rs
根目录 / crates / tui / src / tui / paste.rs
1 //! Paste-burst handling — turn rapid keystrokes (terminals without bracketed
2 //! paste) into a single committed buffer instead of N individual chars.
3 //!
4 //! Extracted from `tui/ui.rs` (P1.2). The owning state machine lives on
5 //! `App.paste_burst` (`tui::paste_burst`); these helpers wire it to the key
6 //! event loop and the composer's text buffer.
7
8 use std::time::Instant;
9
10 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
11
12 use super::app::App;
13 use super::paste_burst::CharDecision;
14
15 /// Process a key in the context of paste-burst detection. Returns `true`
16 /// when the key was fully handled by the paste machinery (caller skips
17 /// further input handling); `false` when the key still needs the normal
18 /// composer path.
19 pub fn handle_paste_burst_key(app: &mut App, key: &KeyEvent, now: Instant) -> bool {
20 if !app.use_paste_burst_detection {
21 return false;
22 }
23
24 let has_ctrl_alt_or_super = key.modifiers.contains(KeyModifiers::CONTROL)
25 || key.modifiers.contains(KeyModifiers::ALT)
26 || key.modifiers.contains(KeyModifiers::SUPER);
27
28 match key.code {
29 KeyCode::Enter => {
30 if !in_command_context(app) && app.paste_burst.append_newline_if_active(now) {
31 return true;
32 }
33 if !in_command_context(app)
34 && app.paste_burst.newline_should_insert_instead_of_submit(now)
35 {
36 app.insert_char('\n');
37 app.paste_burst.extend_window(now);
38 return true;
39 }
40 }
41 KeyCode::Char(c) if !has_ctrl_alt_or_super => {
42 if !c.is_ascii() {
43 if let Some(pending) = app.paste_burst.flush_before_modified_input() {
44 app.insert_str(&pending);
45 }
46 if app.paste_burst.try_append_char_if_active(c, now) {
47 return true;
48 }
49 if let Some(decision) = app.paste_burst.on_plain_char_no_hold(now) {
50 return handle_paste_burst_decision(app, decision, c, now);
51 }
52 app.insert_char(c);
53 return true;
54 }
55
56 let decision = app.paste_burst.on_plain_char(c, now);
57 return handle_paste_burst_decision(app, decision, c, now);
58 }
59 _ => {}
60 }
61
62 false
63 }
64
65 /// Apply a paste-burst decision to the composer buffer. Some decisions
66 /// retroactively grab the last few chars from the input back into the
67 /// pending paste buffer (when the heuristic decides the recent typing was
68 /// actually a paste).
69 pub fn handle_paste_burst_decision(
70 app: &mut App,
71 decision: CharDecision,
72 c: char,
73 now: Instant,
74 ) -> bool {
75 match decision {
76 CharDecision::RetainFirstChar => true,
77 CharDecision::BeginBufferFromPending | CharDecision::BufferAppend => {
78 app.paste_burst.append_char_to_buffer(c, now);
79 true
80 }
81 CharDecision::BeginBuffer { retro_chars } => {
82 if apply_paste_burst_retro_capture(app, retro_chars as usize, c, now) {
83 return true;
84 }
85 app.insert_char(c);
86 true
87 }
88 }
89 }
90
91 fn apply_paste_burst_retro_capture(
92 app: &mut App,
93 retro_chars: usize,
94 c: char,
95 now: Instant,
96 ) -> bool {
97 let cursor_byte = app.cursor_byte_index();
98 let before = &app.composer.input[..cursor_byte];
99 let Some(grab) = app
100 .composer
101 .paste_burst
102 .decide_begin_buffer(now, before, retro_chars)
103 else {
104 return false;
105 };
106 if !grab.grabbed.is_empty() {
107 app.input.replace_range(grab.start_byte..cursor_byte, "");
108 let removed = grab.grabbed.chars().count();
109 app.cursor_position = app.cursor_position.saturating_sub(removed);
110 }
111 app.paste_burst.append_char_to_buffer(c, now);
112 true
113 }
114
115 fn in_command_context(app: &App) -> bool {
116 app.input.starts_with('/')
117 }
118
119 #[cfg(test)]
120 mod tests {
121 use super::*;
122 use crate::config::Config;
123 use crate::tui::app::TuiOptions;
124 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
125 use std::path::PathBuf;
126 use std::time::{Duration, Instant};
127
128 fn test_app() -> App {
129 let options = TuiOptions {
130 model: "deepseek-v4-pro".to_string(),
131 workspace: PathBuf::from("."),
132 config_path: None,
133 config_profile: None,
134 allow_shell: false,
135 use_alt_screen: true,
136 use_mouse_capture: false,
137 use_bracketed_paste: true,
138 max_subagents: 1,
139 skills_dir: PathBuf::from("."),
140 memory_path: PathBuf::from("memory.md"),
141 notes_path: PathBuf::from("notes.txt"),
142 mcp_config_path: PathBuf::from("mcp.json"),
143 use_memory: false,
144 start_in_agent_mode: false,
145 skip_onboarding: true,
146 yolo: false,
147 resume_session_id: None,
148 initial_input: None,
149 };
150 let mut app = App::new(options, &Config::default());
151 app.use_paste_burst_detection = true;
152 app
153 }
154
155 fn plain(ch: char) -> KeyEvent {
156 KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)
157 }
158
159 #[test]
160 fn raw_multiline_paste_buffers_enter_instead_of_submitting() {
161 let mut app = test_app();
162 let t0 = Instant::now();
163
164 assert!(handle_paste_burst_key(&mut app, &plain('a'), t0));
165 assert!(handle_paste_burst_key(
166 &mut app,
167 &plain('b'),
168 t0 + Duration::from_millis(1)
169 ));
170 assert!(handle_paste_burst_key(
171 &mut app,
172 &plain('c'),
173 t0 + Duration::from_millis(2)
174 ));
175 assert!(handle_paste_burst_key(
176 &mut app,
177 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
178 t0 + Duration::from_millis(3)
179 ));
180
181 assert!(app.input.is_empty(), "paste remains buffered until idle");
182 assert!(app.flush_paste_burst_if_due(
183 t0 + Duration::from_millis(3)
184 + crate::tui::paste_burst::PasteBurst::recommended_active_flush_delay()
185 ));
186 assert_eq!(app.input, "abc\n");
187 }
188
189 #[test]
190 fn paste_buffered_question_mark_does_not_fall_through_to_help_shortcut() {
191 let mut app = test_app();
192 let t0 = Instant::now();
193
194 assert!(handle_paste_burst_key(&mut app, &plain('?'), t0));
195
196 assert!(app.input.is_empty(), "shortcut char stays buffered first");
197 assert!(app.view_stack.is_empty(), "help modal must not open");
198 assert!(app.flush_paste_burst_if_due(
199 t0 + crate::tui::paste_burst::PasteBurst::recommended_flush_delay()
200 ));
201 assert_eq!(app.input, "?");
202 }
203
204 /// Pin the IME-input contract: macOS/Windows input methods commit
205 /// each Chinese character as a single `KeyCode::Char(c)` event
206 /// after the candidate popup closes. Each codepoint fits in a
207 /// `char` (no surrogate pair concerns for BMP chars), so a
208 /// straightforward sequence of plain-char events must land in
209 /// `app.input` verbatim — no ASCII filter, no byte-vs-char index
210 /// drift, no paste-burst false-positive that buffers the chars
211 /// indefinitely.
212 #[test]
213 fn ime_chinese_chars_route_through_to_composer() {
214 let mut app = test_app();
215 let t0 = Instant::now();
216
217 // Type the four Chinese codepoints "你好世界" one event at a
218 // time, with realistic ~50ms gaps so the paste-burst heuristic
219 // doesn't classify them as a paste burst.
220 for (i, ch) in "你好世界".chars().enumerate() {
221 let now = t0 + Duration::from_millis(50 * i as u64);
222 let _ = handle_paste_burst_key(&mut app, &plain(ch), now);
223 }
224
225 // Past the active-flush delay so any buffered burst commits.
226 let after = t0
227 + Duration::from_millis(50 * 4)
228 + crate::tui::paste_burst::PasteBurst::recommended_active_flush_delay();
229 let _ = app.flush_paste_burst_if_due(after);
230
231 assert_eq!(
232 app.input, "你好世界",
233 "IME-typed Chinese characters must land in composer verbatim"
234 );
235 assert_eq!(
236 app.cursor_position, 4,
237 "cursor advances by one per codepoint, not per UTF-8 byte"
238 );
239 }
240
241 /// Pin the bracketed-paste contract for CJK content: pasted
242 /// Chinese text (e.g. when a user copies a question from a
243 /// Chinese website and pastes into the composer) must preserve
244 /// every codepoint and not double-count multi-byte chars in the
245 /// cursor position.
246 #[test]
247 fn bracketed_paste_preserves_chinese_and_mixed_text() {
248 let mut app = test_app();
249 app.insert_paste_text("你好世界 hello 世界 café");
250 assert_eq!(app.input, "你好世界 hello 世界 café");
251 // 4 + 1 + 5 + 1 + 2 + 1 + 4 = 18 codepoints (counting é as one).
252 assert_eq!(app.cursor_position, 18);
253 }
254
255 #[test]
256 fn paste_burst_detection_can_be_disabled_without_disabling_bracketed_paste() {
257 let mut app = test_app();
258 app.use_paste_burst_detection = false;
259
260 assert!(!handle_paste_burst_key(
261 &mut app,
262 &plain('a'),
263 Instant::now()
264 ));
265 assert!(app.input.is_empty());
266
267 app.insert_paste_text("line 1\r\nline 2");
268 assert_eq!(app.input, "line 1\nline 2");
269 assert!(app.use_bracketed_paste);
270 }
271 }
272
272 lines RUST