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