返回 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 /// Terminals known to deliver `Event::Paste` for a real paste.
16 ///
17 /// `bracketed_paste_seen` alone waits for proof: it only flips after the
18 /// person has pasted once, so every session ran the rapid-keystroke
19 /// heuristic until then. That heuristic re-arms a ~120 ms Enter-suppression
20 /// window on each character, and the composer's send cue honestly reports it
21 /// — so the `[↵]` control strobed dim-to-bold once per keystroke while
22 /// someone typed at an ordinary pace. Reported from a live session.
23 ///
24 /// Naming the terminals we have verified is deliberately narrower than
25 /// trusting `EnableBracketedPaste` to have succeeded: `execute!` returning
26 /// `Ok` only means the escape was written, not that the terminal honors it,
27 /// and a terminal that ignores it would submit on a pasted newline. That is
28 /// data loss, so the fallback stays for everything not on this list.
29 /// Windows input keeps the fallback until an actual `Event::Paste`: Windows
30 /// Terminal can advertise bracketed paste while the input path still delivers
31 /// individual key events (#6427). Its session marker is not delivery proof.
32 ///
33 /// Known limitation: a non-Windows terminal that sets one of the remaining
34 /// identifiers while not honoring bracketed paste would still lose the guard.
35 pub(crate) fn terminal_delivers_bracketed_paste() -> bool {
36 use std::io::IsTerminal;
37
38 // Only a real interactive terminal gets the trust. Under `cargo test`
39 // stdout is captured, so every unit test keeps the heuristic and stays
40 // hermetic regardless of the `TERM_PROGRAM` the developer happens to be
41 // running in — the first version of this read the ambient environment
42 // and broke five paste tests on a Mac running Terminal.app.
43 terminal_identity_allows_paste_bypass(
44 std::io::stdout().is_terminal(),
45 cfg!(windows) || std::env::var_os("WT_SESSION").is_some(),
46 std::env::var("TERM_PROGRAM").ok().as_deref(),
47 )
48 }
49
50 fn terminal_identity_allows_paste_bypass(
51 interactive: bool,
52 windows_input: bool,
53 program: Option<&str>,
54 ) -> bool {
55 interactive
56 && !windows_input
57 && program.is_some_and(|program| {
58 matches!(
59 program.trim(),
60 "ghostty" | "Ghostty" | "iTerm.app" | "WezTerm" | "Apple_Terminal"
61 )
62 })
63 }
64
65 /// Process a key in the context of paste-burst detection. Returns `true`
66 /// when the key was fully handled by the paste machinery (caller skips
67 /// further input handling); `false` when the key still needs the normal
68 /// composer path.
69 pub fn handle_paste_burst_key(app: &mut App, key: &KeyEvent, now: Instant) -> bool {
70 if !app.use_paste_burst_detection {
71 return false;
72 }
73 // Once we've observed a real `Event::Paste` in this session, bracketed
74 // paste is verified working and the rapid-keystroke heuristic is
75 // unnecessary. Skipping it eliminates false positives on fast typing /
76 // IME commits / autocomplete on terminals with reliable bracketed
77 // paste (the dominant case on iTerm2 / Ghostty / WezTerm / Windows
78 // Terminal).
79 if app.bracketed_paste_seen || app.bracketed_paste_trusted {
80 return false;
81 }
82
83 let has_ctrl_alt_or_super = key.modifiers.contains(KeyModifiers::CONTROL)
84 || key.modifiers.contains(KeyModifiers::ALT)
85 || key.modifiers.contains(KeyModifiers::SUPER);
86
87 match key.code {
88 KeyCode::Enter => {
89 if in_command_context(app) {
90 // The burst buffer can hold the text the user is actually
91 // entering (fast-typed or raw paste). Command context must
92 // be judged on that text, not the composer alone, or Enter
93 // glues the lines into one multiline slash argument
94 // ("Invalid model 'qwen2.5:0.5b\n/status\n…'" — Y-7,
95 // 2026-08-31 QA). Flush what is held onto the composer line
96 // and let Enter take the ordinary submit path.
97 if let Some(pending) = app.paste_burst.flush_before_modified_input() {
98 app.insert_str(&pending);
99 }
100 return false;
101 }
102 if app.paste_burst.append_newline_if_active(now) {
103 return true;
104 }
105 if app.paste_burst.newline_should_insert_instead_of_submit(now) {
106 app.insert_char('\n');
107 // Deliberately no `extend_window` here. This Enter arrived
108 // with no burst being assembled, so it is only *maybe* a
109 // pasted newline. Re-arming on that guess let each absorbed
110 // Enter buy another 120ms, so a user pressing Enter to send
111 // never submitted — every press just added a newline. The
112 // window now always expires 120ms after the last real
113 // keystroke; newlines genuinely inside a paste are absorbed
114 // by `append_newline_if_active` above, which does re-arm.
115 return true;
116 }
117 }
118 KeyCode::Char(c) if !has_ctrl_alt_or_super => {
119 if !c.is_ascii() {
120 // IME-committed characters (Chinese, Japanese, Korean)
121 // arrive as individual KeyCode::Char events, typically with
122 // tens-of-milliseconds gaps between each committed character.
123 // Paste-burst buffering would lose characters when the IME
124 // commits slower than the burst heuristic's timing window.
125 //
126 // We still call note_plain_char + arm the suppression window
127 // so that:
128 // 1. The burst timing counter advances for non-IME fast
129 // typing on terminals without bracketed paste support.
130 // 2. The Enter-suppression window stays open during a rapid
131 // non-ASCII sequence, preventing premature submission.
132 // But the character is inserted directly into the composer
133 // rather than placed into the paste-burst buffer.
134 //
135 // The window is sized by how fast the characters are
136 // arriving: a lone IME candidate commit is ordinary typing
137 // and must not swallow the Enter that follows it, while a
138 // run of characters at paste speed keeps the full window.
139 // See `PasteBurst::arm_window_for_direct_char`.
140 if let Some(pending) = app.paste_burst.flush_before_modified_input() {
141 app.insert_str(&pending);
142 }
143 let rapid_chars = app.paste_burst.note_plain_char(now);
144 app.paste_burst.arm_window_for_direct_char(now, rapid_chars);
145 app.insert_char(c);
146 return true;
147 }
148
149 let decision = app.paste_burst.on_plain_char(c, now);
150 return handle_paste_burst_decision(app, decision, c, now);
151 }
152 _ => {}
153 }
154
155 false
156 }
157
158 /// Apply a paste-burst decision to the composer buffer. The burst never
159 /// rewrites text the user already has on the composer line: chars before
160 /// the burst stay exactly as typed, and buffering starts from the current
161 /// char (Y-7 — the old retro-grab deleted and reinserted typed text on a
162 /// timing guess and scrambled fast input).
163 pub fn handle_paste_burst_decision(
164 app: &mut App,
165 decision: CharDecision,
166 c: char,
167 now: Instant,
168 ) -> bool {
169 match decision {
170 CharDecision::RetainFirstChar => true,
171 CharDecision::BeginBufferFromPending | CharDecision::BufferAppend => {
172 app.paste_burst.append_char_to_buffer(c, now);
173 true
174 }
175 CharDecision::BeginBuffer => {
176 app.paste_burst.begin_buffer_from_now(c, now);
177 true
178 }
179 }
180 }
181
182 fn in_command_context(app: &App) -> bool {
183 let mut composite = app.input.clone();
184 composite.push_str(&app.paste_burst.held_text());
185 looks_like_slash_command_input(&composite)
186 }
187
188 #[cfg(test)]
189 mod bracketed_paste_trust_tests {
190 use super::{terminal_delivers_bracketed_paste, terminal_identity_allows_paste_bypass};
191 use crate::test_support::{EnvVarGuard, lock_test_env};
192
193 /// Under `cargo test` stdout is captured, so the probe refuses to trust
194 /// anything — the heuristic stays on and every existing paste test keeps
195 /// the behaviour it was written against, whatever terminal the developer
196 /// is in. This is the hermeticity the first version of this check broke.
197 #[test]
198 fn a_captured_stdout_is_never_trusted() {
199 let _env = lock_test_env();
200 for program in ["ghostty", "iTerm.app", "WezTerm", "some-new-terminal"] {
201 let _guard = EnvVarGuard::set("TERM_PROGRAM", program);
202 assert!(
203 !terminal_delivers_bracketed_paste(),
204 "{program} must not be trusted without a real terminal"
205 );
206 }
207 }
208
209 #[test]
210 fn windows_input_requires_observed_paste_even_with_a_known_terminal_identity() {
211 for program in [
212 None,
213 Some("Windows Terminal"),
214 Some("iTerm.app"),
215 Some("ghostty"),
216 ] {
217 assert!(
218 !terminal_identity_allows_paste_bypass(true, true, program),
219 "Windows input must retain the fallback before Event::Paste: {program:?}"
220 );
221 }
222 assert!(terminal_identity_allows_paste_bypass(
223 true,
224 false,
225 Some("iTerm.app")
226 ));
227 assert!(!terminal_identity_allows_paste_bypass(
228 false,
229 false,
230 Some("iTerm.app")
231 ));
232 assert!(!terminal_identity_allows_paste_bypass(
233 true,
234 false,
235 Some("unknown")
236 ));
237 }
238
239 #[test]
240 fn windows_terminal_is_trusted_through_its_session_marker() {
241 let _env = lock_test_env();
242 let _program = EnvVarGuard::remove("TERM_PROGRAM");
243 let _wt = EnvVarGuard::set("WT_SESSION", "abc-123");
244 assert!(
245 !terminal_delivers_bracketed_paste(),
246 "still gated on a real terminal, which a test never has"
247 );
248 }
249 }
250
251 #[cfg(test)]
252 mod tests {
253 use super::*;
254 use crate::config::Config;
255 use crate::tui::app::TuiOptions;
256 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
257 use std::path::PathBuf;
258 use std::time::{Duration, Instant};
259
260 fn test_app() -> App {
261 let options = TuiOptions {
262 ..crate::test_support::test_tui_options(PathBuf::from("."))
263 };
264 App::new(options, &Config::default())
265 }
266
267 fn plain(ch: char) -> KeyEvent {
268 KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)
269 }
270
271 #[test]
272 fn requested_bracketed_paste_keeps_raw_multiline_fallback_until_verified() {
273 for pasted in [
274 "first line\nsecond line\nthird line",
275 "多行粘贴测试\n行1\n行2\n行3",
276 ] {
277 let mut app = test_app();
278 assert!(app.use_bracketed_paste);
279 assert!(!app.bracketed_paste_seen);
280 let t0 = Instant::now();
281 for (i, ch) in pasted.chars().enumerate() {
282 let key = if ch == '\n' {
283 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
284 } else {
285 plain(ch)
286 };
287 assert!(
288 handle_paste_burst_key(&mut app, &key, t0 + Duration::from_millis(i as u64)),
289 "raw paste {ch:?} must not reach the submit path"
290 );
291 }
292 app.flush_paste_burst_if_due(t0 + Duration::from_secs(1));
293 assert_eq!(app.input, pasted);
294 assert!(
295 !handle_paste_burst_key(
296 &mut app,
297 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
298 t0 + Duration::from_secs(2),
299 ),
300 "a deliberate Enter after the paste must reach the submit path"
301 );
302 }
303 }
304
305 #[test]
306 fn windows_terminal_raw_five_line_paste_never_reaches_submit() {
307 // #6427: test the interactive startup decision, not captured stdout,
308 // then send the raw keys that the Windows input backend can produce.
309 for pasted in [
310 "first line\nsecond line\nthird line\nfourth line\nfifth line",
311 "第一行\n第二行\n第三行\n第四行\n第五行",
312 ] {
313 let mut app = test_app();
314 app.bracketed_paste_trusted =
315 terminal_identity_allows_paste_bypass(true, true, Some("iTerm.app"));
316 let start = Instant::now();
317 for (index, ch) in pasted.chars().enumerate() {
318 let key = if ch == '\n' {
319 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
320 } else {
321 plain(ch)
322 };
323 assert!(
324 handle_paste_burst_key(
325 &mut app,
326 &key,
327 start + Duration::from_millis(index as u64),
328 ),
329 "raw pasted {ch:?} must not reach submit"
330 );
331 }
332 app.flush_paste_burst_if_due(start + Duration::from_secs(1));
333 assert_eq!(app.input, pasted);
334 assert!(!handle_paste_burst_key(
335 &mut app,
336 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
337 start + Duration::from_secs(2),
338 ));
339 app.bracketed_paste_seen = true;
340 assert!(!handle_paste_burst_key(
341 &mut app,
342 &plain('x'),
343 start + Duration::from_secs(3),
344 ));
345 }
346 }
347
348 /// Y-7 regression (2026-08-31 QA, `tui-swarm-head`): a scripted driver
349 /// or fast typist enters `/model …` faster than the burst heuristic's
350 /// windows. The text sat in the burst buffer, `in_command_context`
351 /// judged the empty composer, and every Enter was absorbed as a pasted
352 /// newline — gluing `/model qwen2.5:0.5b`, `/status`, and the prompt
353 /// into one multiline argument ("Invalid model
354 /// 'qwen2.5:0.5b\n/status\n…'"). Enter must flush buffered command text
355 /// to the composer and reach the submit path.
356 #[test]
357 fn enter_on_buffered_slash_command_flushes_and_submits() {
358 let mut app = test_app();
359 let t0 = Instant::now();
360
361 for (i, ch) in "/model qwen2.5:0.5b".chars().enumerate() {
362 assert!(handle_paste_burst_key(
363 &mut app,
364 &plain(ch),
365 t0 + Duration::from_millis(2 * i as u64)
366 ));
367 }
368 assert!(
369 !handle_paste_burst_key(
370 &mut app,
371 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
372 t0 + Duration::from_millis(60),
373 ),
374 "Enter on a buffered slash command is a submit, not a pasted newline"
375 );
376 assert_eq!(app.input, "/model qwen2.5:0.5b");
377 }
378
379 /// The same burst stream ending in a normal prompt must still absorb
380 /// its Enter on terminals without bracketed paste — that absorption is
381 /// the heuristic's whole job for real multi-line pastes (#1073).
382 #[test]
383 fn enter_on_buffered_plain_text_is_still_absorbed() {
384 let mut app = test_app();
385 let t0 = Instant::now();
386
387 for (i, ch) in "hello world".chars().enumerate() {
388 assert!(handle_paste_burst_key(
389 &mut app,
390 &plain(ch),
391 t0 + Duration::from_millis(2 * i as u64)
392 ));
393 }
394 assert!(handle_paste_burst_key(
395 &mut app,
396 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
397 t0 + Duration::from_millis(60),
398 ));
399 }
400
401 #[test]
402 fn raw_short_cjk_multiline_paste_buffers_enter_instead_of_submitting() {
403 // #1302: pasting short CJK content like "请联网搜索:\nSTM32 …" used
404 // to silently submit the first line because the heuristic decided
405 // it wasn't paste-like (no whitespace + under 16 chars). The
406 // non-ASCII bypass now classifies it as a paste so the Enter is
407 // absorbed into the burst buffer.
408 let mut app = test_app();
409 let t0 = Instant::now();
410
411 let pasted = "请联网搜索:\nSTM32 商业应用案例";
412 for (i, ch) in pasted.chars().enumerate() {
413 let key = if ch == '\n' {
414 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
415 } else {
416 plain(ch)
417 };
418 let handled =
419 handle_paste_burst_key(&mut app, &key, t0 + Duration::from_millis(i as u64));
420 assert!(
421 handled,
422 "raw paste character {ch:?} must be handled by paste-burst detection"
423 );
424 }
425
426 // Non-ASCII characters are now inserted directly into the composer
427 // rather than buffered by paste burst. The Enter suppression window
428 // kept the newline from submitting prematurely.
429 assert_eq!(app.input, pasted);
430 }
431
432 #[test]
433 fn raw_multiline_paste_buffers_enter_instead_of_submitting() {
434 let mut app = test_app();
435 let t0 = Instant::now();
436
437 assert!(handle_paste_burst_key(&mut app, &plain('a'), t0));
438 assert!(handle_paste_burst_key(
439 &mut app,
440 &plain('b'),
441 t0 + Duration::from_millis(1)
442 ));
443 assert!(handle_paste_burst_key(
444 &mut app,
445 &plain('c'),
446 t0 + Duration::from_millis(2)
447 ));
448 assert!(handle_paste_burst_key(
449 &mut app,
450 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
451 t0 + Duration::from_millis(3)
452 ));
453
454 assert!(app.input.is_empty(), "paste remains buffered until idle");
455 assert!(app.flush_paste_burst_if_due(
456 t0 + Duration::from_millis(3)
457 + crate::tui::paste_burst::PasteBurst::recommended_active_flush_delay()
458 ));
459 assert_eq!(app.input, "abc\n");
460 }
461
462 /// A raw CJK paste can open with a one-character line
463 /// ("好\n…"). That single character never forms a paste-speed *run*, so
464 /// the short window is all that protects the embedded newline — the
465 /// newline arrives within the burst interval, so it must still be
466 /// absorbed rather than submitting "好" on its own (#1302).
467 #[test]
468 fn raw_paste_with_single_char_first_line_still_absorbs_its_newline() {
469 let mut app = test_app();
470 let t0 = Instant::now();
471
472 assert!(handle_paste_burst_key(&mut app, &plain('好'), t0));
473 assert!(
474 handle_paste_burst_key(
475 &mut app,
476 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
477 t0 + Duration::from_millis(1),
478 ),
479 "the newline of a raw paste lands within the burst interval and \
480 must be absorbed, not submitted"
481 );
482 assert_eq!(app.input, "好\n");
483 }
484
485 /// The IME half of the same ambiguity: one committed character followed
486 /// by a human-speed Enter is a send gesture. `handle_paste_burst_key`
487 /// must decline the Enter so it reaches the normal submit path.
488 #[test]
489 fn ime_commit_then_human_enter_falls_through_to_submit() {
490 let mut app = test_app();
491 let t0 = Instant::now();
492
493 assert!(handle_paste_burst_key(&mut app, &plain('好'), t0));
494 assert_eq!(app.input, "好");
495
496 assert!(
497 !handle_paste_burst_key(
498 &mut app,
499 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
500 t0 + Duration::from_millis(30),
501 ),
502 "Enter 30ms after an IME candidate commit is a send, not a \
503 pasted newline"
504 );
505 assert_eq!(app.input, "好", "no stray newline may be inserted");
506 }
507
508 /// A whole IME-typed CJK sentence, one commit at a time at human speed,
509 /// followed by Enter: every character lands verbatim and the Enter still
510 /// reaches the submit path.
511 #[test]
512 fn ime_typed_sentence_then_enter_falls_through_to_submit() {
513 let mut app = test_app();
514 let t0 = Instant::now();
515
516 for (i, ch) in "你好世界".chars().enumerate() {
517 let now = t0 + Duration::from_millis(50 * i as u64);
518 assert!(handle_paste_burst_key(&mut app, &plain(ch), now));
519 }
520 assert_eq!(app.input, "你好世界");
521
522 assert!(
523 !handle_paste_burst_key(
524 &mut app,
525 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
526 t0 + Duration::from_millis(180),
527 ),
528 "Enter after an IME-typed CJK message must submit"
529 );
530 assert_eq!(app.input, "你好世界");
531 }
532
533 /// Absorbing an Enter outside an active burst must not re-arm the
534 /// suppression window. It used to, so each swallowed Enter bought
535 /// another 120ms and a user pressing Enter to send only ever added
536 /// newlines. The first Enter is still absorbed (#1073 trailing-newline
537 /// protection); the next one submits.
538 #[test]
539 fn absorbed_enter_does_not_extend_the_suppression_window() {
540 let mut app = test_app();
541 let t0 = Instant::now();
542
543 // Unbracketed paste of "abc" with no trailing newline.
544 for (i, ch) in "abc".chars().enumerate() {
545 let now = t0 + Duration::from_millis(i as u64);
546 assert!(handle_paste_burst_key(&mut app, &plain(ch), now));
547 }
548 let last_char = t0 + Duration::from_millis(2);
549 assert!(app.flush_paste_burst_if_due(
550 last_char + crate::tui::paste_burst::PasteBurst::recommended_active_flush_delay()
551 ));
552 assert_eq!(app.input, "abc");
553
554 // Still inside the window: this could be the paste's trailing
555 // newline, so it is absorbed.
556 assert!(handle_paste_burst_key(
557 &mut app,
558 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
559 last_char + Duration::from_millis(60),
560 ));
561 assert_eq!(app.input, "abc\n");
562
563 // Past the window measured from the last *keystroke* — the absorbed
564 // Enter bought no extra time, so this one submits.
565 assert!(
566 !handle_paste_burst_key(
567 &mut app,
568 &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
569 last_char + Duration::from_millis(121),
570 ),
571 "the second Enter must reach the submit path"
572 );
573 assert_eq!(app.input, "abc\n", "no second newline may be inserted");
574 }
575
576 #[test]
577 fn paste_buffered_question_mark_does_not_fall_through_to_help_shortcut() {
578 let mut app = test_app();
579 let t0 = Instant::now();
580
581 assert!(handle_paste_burst_key(&mut app, &plain('?'), t0));
582
583 assert!(app.input.is_empty(), "shortcut char stays buffered first");
584 assert!(app.view_stack.is_empty(), "help modal must not open");
585 assert!(app.flush_paste_burst_if_due(
586 t0 + crate::tui::paste_burst::PasteBurst::recommended_flush_delay()
587 ));
588 assert_eq!(app.input, "?");
589 }
590
591 /// Pin the IME-input contract: macOS/Windows input methods commit
592 /// each Chinese character as a single `KeyCode::Char(c)` event
593 /// after the candidate popup closes. Each codepoint fits in a
594 /// `char` (no surrogate pair concerns for BMP chars), so a
595 /// straightforward sequence of plain-char events must land in
596 /// `app.input` verbatim — no ASCII filter, no byte-vs-char index
597 /// drift, no paste-burst false-positive that buffers the chars
598 /// indefinitely.
599 #[test]
600 fn ime_chinese_chars_route_through_to_composer() {
601 let mut app = test_app();
602 let t0 = Instant::now();
603
604 // Type the four Chinese codepoints "你好世界" one event at a
605 // time, with realistic ~50ms gaps so the paste-burst heuristic
606 // doesn't classify them as a paste burst.
607 for (i, ch) in "你好世界".chars().enumerate() {
608 let now = t0 + Duration::from_millis(50 * i as u64);
609 let _ = handle_paste_burst_key(&mut app, &plain(ch), now);
610 }
611
612 // Past the active-flush delay so any buffered burst commits.
613 let after = t0
614 + Duration::from_millis(50 * 4)
615 + crate::tui::paste_burst::PasteBurst::recommended_active_flush_delay();
616 let _ = app.flush_paste_burst_if_due(after);
617
618 assert_eq!(
619 app.input, "你好世界",
620 "IME-typed Chinese characters must land in composer verbatim"
621 );
622 assert_eq!(
623 app.cursor_position, 4,
624 "cursor advances by one per codepoint, not per UTF-8 byte"
625 );
626 }
627
628 /// Pin the bracketed-paste contract for CJK content: pasted
629 /// Chinese text (e.g. when a user copies a question from a
630 /// Chinese website and pastes into the composer) must preserve
631 /// every codepoint and not double-count multi-byte chars in the
632 /// cursor position.
633 #[test]
634 fn bracketed_paste_preserves_chinese_and_mixed_text() {
635 let mut app = test_app();
636 app.insert_paste_text("你好世界 hello 世界 café");
637 assert_eq!(app.input, "你好世界 hello 世界 café");
638 // 4 + 1 + 5 + 1 + 2 + 1 + 4 = 18 codepoints (counting é as one).
639 assert_eq!(app.cursor_position, 18);
640 }
641
642 #[test]
643 fn paste_burst_detection_can_be_disabled_without_disabling_bracketed_paste() {
644 let mut app = test_app();
645 app.use_paste_burst_detection = false;
646
647 assert!(!handle_paste_burst_key(
648 &mut app,
649 &plain('a'),
650 Instant::now()
651 ));
652 assert!(app.input.is_empty());
653
654 app.insert_paste_text("line 1\r\nline 2");
655 assert_eq!(app.input, "line 1\nline 2");
656 assert!(app.use_bracketed_paste);
657 }
658
659 /// Once the session has observed a real `Event::Paste`, the
660 /// rapid-keystroke heuristic must short-circuit. This pins the new
661 /// "auto-disable paste-burst on verified bracketed paste" behavior so
662 /// fast typing / IME commits / autocomplete on capable terminals can't
663 /// be mis-classified as a paste burst.
664 #[test]
665 fn paste_burst_short_circuits_after_bracketed_paste_observed() {
666 let mut app = test_app();
667 app.use_paste_burst_detection = true;
668 app.bracketed_paste_seen = true;
669
670 let t0 = Instant::now();
671 for (i, ch) in "abcdefgh".chars().enumerate() {
672 // Type fast enough that paste-burst would normally fire.
673 let now = t0 + Duration::from_millis(i as u64);
674 assert!(
675 !handle_paste_burst_key(&mut app, &plain(ch), now),
676 "paste-burst must NOT consume keys once bracketed paste verified"
677 );
678 }
679 // No buffering — every char fell through to the normal composer
680 // path (the test harness doesn't insert chars when the burst
681 // handler returns false; we only assert the short-circuit
682 // contract here).
683 assert!(app.input.is_empty());
684 }
685 }
686
686 lines RUST