返回 CodeWhale
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, looks_like_slash_command_input};
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 // Once we've observed a real `Event::Paste` in this session, bracketed
24 // paste is verified working and the rapid-keystroke heuristic is
25 // unnecessary. Skipping it eliminates false positives on fast typing /
26 // IME commits / autocomplete on terminals with reliable bracketed
27 // paste (the dominant case on iTerm2 / Ghostty / WezTerm / Windows
28 // Terminal).
29 if app.bracketed_paste_seen {
30 return false;
31 }
32
33 let has_ctrl_alt_or_super = key.modifiers.contains(KeyModifiers::CONTROL)
34 || key.modifiers.contains(KeyModifiers::ALT)
35 || key.modifiers.contains(KeyModifiers::SUPER);
36
37 match key.code {
38 KeyCode::Enter => {
39 if !in_command_context(app) && app.paste_burst.append_newline_if_active(now) {
40 return true;
41 }
42 if !in_command_context(app)
43 && app.paste_burst.newline_should_insert_instead_of_submit(now)
44 {
45 app.insert_char('\n');
46 // Deliberately no `extend_window` here. This Enter arrived
47 // with no burst being assembled, so it is only *maybe* a
48 // pasted newline. Re-arming on that guess let each absorbed
49 // Enter buy another 120ms, so a user pressing Enter to send
50 // never submitted — every press just added a newline. The
51 // window now always expires 120ms after the last real
52 // keystroke; newlines genuinely inside a paste are absorbed
53 // by `append_newline_if_active` above, which does re-arm.
54 return true;
55 }
56 }
57 KeyCode::Char(c) if !has_ctrl_alt_or_super => {
58 if !c.is_ascii() {
59 // IME-committed characters (Chinese, Japanese, Korean)
60 // arrive as individual KeyCode::Char events, typically with
61 // tens-of-milliseconds gaps between each committed character.
62 // Paste-burst buffering would lose characters when the IME
63 // commits slower than the burst heuristic's timing window.
64 //
65 // We still call note_plain_char + arm the suppression window
66 // so that:
67 // 1. The burst timing counter advances for non-IME fast
68 // typing on terminals without bracketed paste support.
69 // 2. The Enter-suppression window stays open during a rapid
70 // non-ASCII sequence, preventing premature submission.
71 // But the character is inserted directly into the composer
72 // rather than placed into the paste-burst buffer.
73 //
74 // The window is sized by how fast the characters are
75 // arriving: a lone IME candidate commit is ordinary typing
76 // and must not swallow the Enter that follows it, while a
77 // run of characters at paste speed keeps the full window.
78 // See `PasteBurst::arm_window_for_direct_char`.
79 if let Some(pending) = app.paste_burst.flush_before_modified_input() {
80 app.insert_str(&pending);
81 }
82 let rapid_chars = app.paste_burst.note_plain_char(now);
83 app.paste_burst.arm_window_for_direct_char(now, rapid_chars);
84 app.insert_char(c);
85 return true;
86 }
87
88 let decision = app.paste_burst.on_plain_char(c, now);
89 return handle_paste_burst_decision(app, decision, c, now);
90 }
91 _ => {}
92 }
93
94 false
95 }
96
97 /// Apply a paste-burst decision to the composer buffer. Some decisions
98 /// retroactively grab the last few chars from the input back into the
99 /// pending paste buffer (when the heuristic decides the recent typing was
100 /// actually a paste).
101 pub fn handle_paste_burst_decision(
102 app: &mut App,
103 decision: CharDecision,
104 c: char,
105 now: Instant,
106 ) -> bool {
107 match decision {
108 CharDecision::RetainFirstChar => true,
109 CharDecision::BeginBufferFromPending | CharDecision::BufferAppend => {
110 app.paste_burst.append_char_to_buffer(c, now);
111 true
112 }
113 CharDecision::BeginBuffer { retro_chars } => {
114 if apply_paste_burst_retro_capture(app, retro_chars as usize, c, now) {
115 return true;
116 }
117 app.insert_char(c);
118 true
119 }
120 }
121 }
122
123 fn apply_paste_burst_retro_capture(
124 app: &mut App,
125 retro_chars: usize,
126 c: char,
127 now: Instant,
128 ) -> bool {
129 let cursor_byte = app.cursor_byte_index();
130 let before = &app.composer.input[..cursor_byte];
131 let Some(grab) = app
132 .composer
133 .paste_burst
134 .decide_begin_buffer(now, before, retro_chars)
135 else {
136 return false;
137 };
138 if !grab.grabbed.is_empty() {
139 app.input.replace_range(grab.start_byte..cursor_byte, "");
140 let removed = grab.grabbed.chars().count();
141 app.cursor_position = app.cursor_position.saturating_sub(removed);
142 }
143 app.paste_burst.append_char_to_buffer(c, now);
144 true
145 }
146
147 fn in_command_context(app: &App) -> bool {
148 looks_like_slash_command_input(&app.input)
149 }
150
151 #[cfg(test)]
152 mod tests {
153 use super::*;
154 use crate::config::Config;
155 use crate::tui::app::TuiOptions;
156 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
157 use std::path::PathBuf;
158 use std::time::{Duration, Instant};
159
160 fn test_app() -> App {
161 let options = TuiOptions {
162 ..crate::test_support::test_tui_options(PathBuf::from("."))
163 };
164 let mut app = App::new(options, &Config::default());
165 app.use_paste_burst_detection = true;
166 app
167 }
168
169 fn plain(ch: char) -> KeyEvent {
170 KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)
171 }
172
173 #[test]
174 fn raw_short_cjk_multiline_paste_buffers_enter_instead_of_submitting() {
175 // #1302: pasting short CJK content like "请联网搜索:\nSTM32 …" used
176 // to silently submit the first line because the heuristic decided
177 // it wasn't paste-like (no whitespace + under 16 chars). The
178 // non-ASCII bypass now classifies it as a paste so the Enter is
179 // absorbed into the burst buffer.
180 let mut app = test_app();
181 let t0 = Instant::now();
182
183 let pasted = "请联网搜索:\nSTM32 商业应用案例";
184 for (i, ch) in pasted.chars().enumerate() {
185 let key = if ch == '\n' {
186 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
187 } else {
188 plain(ch)
189 };
190 let handled =
191 handle_paste_burst_key(&mut app, &key, t0 + Duration::from_millis(i as u64));
192 assert!(
193 handled,
194 "raw paste character {ch:?} must be handled by paste-burst detection"
195 );
196 }
197
198 // Non-ASCII characters are now inserted directly into the composer
199 // rather than buffered by paste burst. The Enter suppression window
200 // kept the newline from submitting prematurely.
201 assert_eq!(app.input, pasted);
202 }
203
204 #[test]
205 fn raw_multiline_paste_buffers_enter_instead_of_submitting() {
206 let mut app = test_app();
207 let t0 = Instant::now();
208
209 assert!(handle_paste_burst_key(&mut app, &plain('a'), t0));
210 assert!(handle_paste_burst_key(
211 &mut app,
212 &plain('b'),
213 t0 + Duration::from_millis(1)
214 ));
215 assert!(handle_paste_burst_key(
216 &mut app,
217 &plain('c'),
218 t0 + Duration::from_millis(2)
219 ));
220 assert!(handle_paste_burst_key(
221 &mut app,
222 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
223 t0 + Duration::from_millis(3)
224 ));
225
226 assert!(app.input.is_empty(), "paste remains buffered until idle");
227 assert!(app.flush_paste_burst_if_due(
228 t0 + Duration::from_millis(3)
229 + crate::tui::paste_burst::PasteBurst::recommended_active_flush_delay()
230 ));
231 assert_eq!(app.input, "abc\n");
232 }
233
234 /// A raw CJK paste can open with a one-character line
235 /// ("好\n…"). That single character never forms a paste-speed *run*, so
236 /// the short window is all that protects the embedded newline — the
237 /// newline arrives within the burst interval, so it must still be
238 /// absorbed rather than submitting "好" on its own (#1302).
239 #[test]
240 fn raw_paste_with_single_char_first_line_still_absorbs_its_newline() {
241 let mut app = test_app();
242 let t0 = Instant::now();
243
244 assert!(handle_paste_burst_key(&mut app, &plain('好'), t0));
245 assert!(
246 handle_paste_burst_key(
247 &mut app,
248 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
249 t0 + Duration::from_millis(1),
250 ),
251 "the newline of a raw paste lands within the burst interval and \
252 must be absorbed, not submitted"
253 );
254 assert_eq!(app.input, "好\n");
255 }
256
257 /// The IME half of the same ambiguity: one committed character followed
258 /// by a human-speed Enter is a send gesture. `handle_paste_burst_key`
259 /// must decline the Enter so it reaches the normal submit path.
260 #[test]
261 fn ime_commit_then_human_enter_falls_through_to_submit() {
262 let mut app = test_app();
263 let t0 = Instant::now();
264
265 assert!(handle_paste_burst_key(&mut app, &plain('好'), t0));
266 assert_eq!(app.input, "好");
267
268 assert!(
269 !handle_paste_burst_key(
270 &mut app,
271 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
272 t0 + Duration::from_millis(30),
273 ),
274 "Enter 30ms after an IME candidate commit is a send, not a \
275 pasted newline"
276 );
277 assert_eq!(app.input, "好", "no stray newline may be inserted");
278 }
279
280 /// A whole IME-typed CJK sentence, one commit at a time at human speed,
281 /// followed by Enter: every character lands verbatim and the Enter still
282 /// reaches the submit path.
283 #[test]
284 fn ime_typed_sentence_then_enter_falls_through_to_submit() {
285 let mut app = test_app();
286 let t0 = Instant::now();
287
288 for (i, ch) in "你好世界".chars().enumerate() {
289 let now = t0 + Duration::from_millis(50 * i as u64);
290 assert!(handle_paste_burst_key(&mut app, &plain(ch), now));
291 }
292 assert_eq!(app.input, "你好世界");
293
294 assert!(
295 !handle_paste_burst_key(
296 &mut app,
297 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
298 t0 + Duration::from_millis(180),
299 ),
300 "Enter after an IME-typed CJK message must submit"
301 );
302 assert_eq!(app.input, "你好世界");
303 }
304
305 /// Absorbing an Enter outside an active burst must not re-arm the
306 /// suppression window. It used to, so each swallowed Enter bought
307 /// another 120ms and a user pressing Enter to send only ever added
308 /// newlines. The first Enter is still absorbed (#1073 trailing-newline
309 /// protection); the next one submits.
310 #[test]
311 fn absorbed_enter_does_not_extend_the_suppression_window() {
312 let mut app = test_app();
313 let t0 = Instant::now();
314
315 // Unbracketed paste of "abc" with no trailing newline.
316 for (i, ch) in "abc".chars().enumerate() {
317 let now = t0 + Duration::from_millis(i as u64);
318 assert!(handle_paste_burst_key(&mut app, &plain(ch), now));
319 }
320 let last_char = t0 + Duration::from_millis(2);
321 assert!(app.flush_paste_burst_if_due(
322 last_char + crate::tui::paste_burst::PasteBurst::recommended_active_flush_delay()
323 ));
324 assert_eq!(app.input, "abc");
325
326 // Still inside the window: this could be the paste's trailing
327 // newline, so it is absorbed.
328 assert!(handle_paste_burst_key(
329 &mut app,
330 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
331 last_char + Duration::from_millis(60),
332 ));
333 assert_eq!(app.input, "abc\n");
334
335 // Past the window measured from the last *keystroke* — the absorbed
336 // Enter bought no extra time, so this one submits.
337 assert!(
338 !handle_paste_burst_key(
339 &mut app,
340 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
341 last_char + Duration::from_millis(121),
342 ),
343 "the second Enter must reach the submit path"
344 );
345 assert_eq!(app.input, "abc\n", "no second newline may be inserted");
346 }
347
348 #[test]
349 fn paste_buffered_question_mark_does_not_fall_through_to_help_shortcut() {
350 let mut app = test_app();
351 let t0 = Instant::now();
352
353 assert!(handle_paste_burst_key(&mut app, &plain('?'), t0));
354
355 assert!(app.input.is_empty(), "shortcut char stays buffered first");
356 assert!(app.view_stack.is_empty(), "help modal must not open");
357 assert!(app.flush_paste_burst_if_due(
358 t0 + crate::tui::paste_burst::PasteBurst::recommended_flush_delay()
359 ));
360 assert_eq!(app.input, "?");
361 }
362
363 /// Pin the IME-input contract: macOS/Windows input methods commit
364 /// each Chinese character as a single `KeyCode::Char(c)` event
365 /// after the candidate popup closes. Each codepoint fits in a
366 /// `char` (no surrogate pair concerns for BMP chars), so a
367 /// straightforward sequence of plain-char events must land in
368 /// `app.input` verbatim — no ASCII filter, no byte-vs-char index
369 /// drift, no paste-burst false-positive that buffers the chars
370 /// indefinitely.
371 #[test]
372 fn ime_chinese_chars_route_through_to_composer() {
373 let mut app = test_app();
374 let t0 = Instant::now();
375
376 // Type the four Chinese codepoints "你好世界" one event at a
377 // time, with realistic ~50ms gaps so the paste-burst heuristic
378 // doesn't classify them as a paste burst.
379 for (i, ch) in "你好世界".chars().enumerate() {
380 let now = t0 + Duration::from_millis(50 * i as u64);
381 let _ = handle_paste_burst_key(&mut app, &plain(ch), now);
382 }
383
384 // Past the active-flush delay so any buffered burst commits.
385 let after = t0
386 + Duration::from_millis(50 * 4)
387 + crate::tui::paste_burst::PasteBurst::recommended_active_flush_delay();
388 let _ = app.flush_paste_burst_if_due(after);
389
390 assert_eq!(
391 app.input, "你好世界",
392 "IME-typed Chinese characters must land in composer verbatim"
393 );
394 assert_eq!(
395 app.cursor_position, 4,
396 "cursor advances by one per codepoint, not per UTF-8 byte"
397 );
398 }
399
400 /// Pin the bracketed-paste contract for CJK content: pasted
401 /// Chinese text (e.g. when a user copies a question from a
402 /// Chinese website and pastes into the composer) must preserve
403 /// every codepoint and not double-count multi-byte chars in the
404 /// cursor position.
405 #[test]
406 fn bracketed_paste_preserves_chinese_and_mixed_text() {
407 let mut app = test_app();
408 app.insert_paste_text("你好世界 hello 世界 café");
409 assert_eq!(app.input, "你好世界 hello 世界 café");
410 // 4 + 1 + 5 + 1 + 2 + 1 + 4 = 18 codepoints (counting é as one).
411 assert_eq!(app.cursor_position, 18);
412 }
413
414 #[test]
415 fn paste_burst_detection_can_be_disabled_without_disabling_bracketed_paste() {
416 let mut app = test_app();
417 app.use_paste_burst_detection = false;
418
419 assert!(!handle_paste_burst_key(
420 &mut app,
421 &plain('a'),
422 Instant::now()
423 ));
424 assert!(app.input.is_empty());
425
426 app.insert_paste_text("line 1\r\nline 2");
427 assert_eq!(app.input, "line 1\nline 2");
428 assert!(app.use_bracketed_paste);
429 }
430
431 /// Once the session has observed a real `Event::Paste`, the
432 /// rapid-keystroke heuristic must short-circuit. This pins the new
433 /// "auto-disable paste-burst on verified bracketed paste" behavior so
434 /// fast typing / IME commits / autocomplete on capable terminals can't
435 /// be mis-classified as a paste burst.
436 #[test]
437 fn paste_burst_short_circuits_after_bracketed_paste_observed() {
438 let mut app = test_app();
439 app.use_paste_burst_detection = true;
440 app.bracketed_paste_seen = true;
441
442 let t0 = Instant::now();
443 for (i, ch) in "abcdefgh".chars().enumerate() {
444 // Type fast enough that paste-burst would normally fire.
445 let now = t0 + Duration::from_millis(i as u64);
446 assert!(
447 !handle_paste_burst_key(&mut app, &plain(ch), now),
448 "paste-burst must NOT consume keys once bracketed paste verified"
449 );
450 }
451 // No buffering — every char fell through to the normal composer
452 // path (the test harness doesn't insert chars when the burst
453 // handler returns false; we only assert the short-circuit
454 // contract here).
455 assert!(app.input.is_empty());
456 }
457 }
458
458 lines RUST