返回 CodeWhale
external_editor.rs
根目录 / crates / tui / src / tui / external_editor.rs
1 //! External editor support for the composer.
2 //!
3 //! Spawns `$VISUAL`/`$EDITOR` (fallback `vi`) on a temp file pre-populated with
4 //! the composer's current contents. The TUI is suspended for the duration of
5 //! the edit and re-entered on return. The temp file is cleaned up in all paths
6 //! (success, editor failure, IO error) via [`tempfile::NamedTempFile`].
7 //!
8 //! Reference: codex-rs's `tui/src/external_editor.rs` — the design here mirrors
9 //! that approach but is synchronous (called inline from the TUI event loop) and
10 //! handles its own raw-mode toggling rather than relying on the caller.
11
12 use std::env;
13 use std::fs;
14 use std::io::{self, Stdout, Write};
15 use std::process::Command;
16
17 use crossterm::{
18 event::DisableFocusChange,
19 execute,
20 terminal::{disable_raw_mode, enable_raw_mode},
21 };
22 use ratatui::Terminal;
23 use tempfile::Builder;
24
25 use super::color_compat::ColorCompatBackend;
26
27 /// Outcome of a single external-editor invocation.
28 #[derive(Debug, PartialEq, Eq)]
29 pub enum EditorOutcome {
30 /// Editor exited cleanly and the file contents differ from the seed.
31 Edited(String),
32 /// Editor exited cleanly but the contents are unchanged (or empty after
33 /// trimming). The composer should be left as-is.
34 Unchanged,
35 /// Editor exited non-zero or could not be spawned. The composer should be
36 /// left as-is and a status toast shown.
37 Cancelled,
38 }
39
40 /// Resolve the editor command, preferring `$VISUAL` over `$EDITOR`, falling
41 /// back to `vi`. Returns the raw string for the test path; `spawn_editor`
42 /// splits it via `shlex` (Unix) so users can set `EDITOR="code --wait"`.
43 fn resolve_editor() -> String {
44 env::var("VISUAL")
45 .ok()
46 .filter(|s| !s.trim().is_empty())
47 .or_else(|| env::var("EDITOR").ok().filter(|s| !s.trim().is_empty()))
48 .unwrap_or_else(|| "vi".to_string())
49 }
50
51 #[cfg(unix)]
52 fn split_command(raw: &str) -> Option<Vec<String>> {
53 shlex::split(raw)
54 }
55
56 #[cfg(not(unix))]
57 fn split_command(raw: &str) -> Option<Vec<String>> {
58 // On Windows we do not support shell-quoted editor commands; treat the
59 // full string as the program name.
60 if raw.trim().is_empty() {
61 None
62 } else {
63 Some(vec![raw.to_string()])
64 }
65 }
66
67 /// Run the external editor without touching terminal state. Exposed for tests.
68 ///
69 /// Returns:
70 /// - `Ok(EditorOutcome::Edited(new))` if the editor exited cleanly and the
71 /// contents differ from `seed`.
72 /// - `Ok(EditorOutcome::Unchanged)` if the editor exited cleanly but the
73 /// contents match `seed`.
74 /// - `Ok(EditorOutcome::Cancelled)` if the editor exited non-zero or could not
75 /// be spawned.
76 ///
77 /// The temp file is removed on every path because [`tempfile::NamedTempFile`]
78 /// is dropped at the end of the function.
79 pub fn run_editor_raw(seed: &str) -> io::Result<EditorOutcome> {
80 let mut tmp = Builder::new()
81 .prefix("deepseek-edit-")
82 .suffix(".md")
83 .tempfile()?;
84 tmp.write_all(seed.as_bytes())?;
85 tmp.flush()?;
86 let path = tmp.path().to_path_buf();
87
88 let raw = resolve_editor();
89 let parts = match split_command(&raw) {
90 Some(p) if !p.is_empty() => p,
91 _ => return Ok(EditorOutcome::Cancelled),
92 };
93
94 let mut cmd = Command::new(&parts[0]);
95 if parts.len() > 1 {
96 cmd.args(&parts[1..]);
97 }
98 cmd.arg(&path);
99
100 let status = match cmd.status() {
101 Ok(s) => s,
102 Err(_) => return Ok(EditorOutcome::Cancelled),
103 };
104 if !status.success() {
105 return Ok(EditorOutcome::Cancelled);
106 }
107
108 let new = fs::read_to_string(&path)?;
109 // tmp goes out of scope here — file is unlinked.
110 if new == seed {
111 Ok(EditorOutcome::Unchanged)
112 } else {
113 Ok(EditorOutcome::Edited(new))
114 }
115 }
116
117 /// Append the file (and optional line) arguments in the spelling `program`
118 /// understands.
119 ///
120 /// Every caller that opens a real file goes through here so the three paths
121 /// cannot drift on how a line number is spelled. Without a line this is exactly
122 /// `cmd.arg(path)`, which is what it has always been.
123 ///
124 /// `file_stem` rather than the whole program name, so an absolute path and a
125 /// Windows `.exe` suffix both still match. An editor we do not recognize gets
126 /// the bare path: opening the right file at the wrong line beats a spurious
127 /// argument the editor treats as a second file to open.
128 fn push_target_args(cmd: &mut Command, program: &str, path: &std::path::Path, line: Option<u32>) {
129 let Some(line) = line else {
130 cmd.arg(path);
131 return;
132 };
133 let stem = std::path::Path::new(program)
134 .file_stem()
135 .and_then(|s| s.to_str())
136 .unwrap_or(program)
137 .to_ascii_lowercase();
138
139 match stem.as_str() {
140 // The vi family and the editors that copied its `+N` convention.
141 "vi" | "vim" | "nvim" | "view" | "gvim" | "nano" | "pico" | "emacs" | "emacsclient"
142 | "kak" | "micro" | "joe" => {
143 cmd.arg(format!("+{line}"));
144 cmd.arg(path);
145 }
146 // VS Code and its forks need an explicit flag before `file:line`.
147 "code" | "code-insiders" | "codium" | "vscodium" | "cursor" | "windsurf" => {
148 cmd.arg("--goto");
149 cmd.arg(format!("{}:{line}", path.display()));
150 }
151 // Sublime, Zed and JetBrains launchers all take `file:line` directly.
152 "subl" | "sublime_text" | "zed" | "idea" | "pycharm" | "goland" | "clion" | "rustrover"
153 | "webstorm" => {
154 cmd.arg(format!("{}:{line}", path.display()));
155 }
156 _ => {
157 cmd.arg(path);
158 }
159 }
160 }
161
162 /// Run the external editor on a real file, in place.
163 ///
164 /// Unlike [`run_editor_raw`] there is no temp file and no seed: the file on
165 /// disk *is* the document, so a `hooks.toml` the user edits stays edited even
166 /// if the editor exits non-zero. The outcome only reports whether the bytes
167 /// moved, which is what the caller needs in order to decide whether to reload.
168 pub fn run_editor_on_path(path: &std::path::Path, line: Option<u32>) -> io::Result<EditorOutcome> {
169 let before = fs::read_to_string(path).unwrap_or_default();
170
171 let raw = resolve_editor();
172 let parts = match split_command(&raw) {
173 Some(p) if !p.is_empty() => p,
174 _ => return Ok(EditorOutcome::Cancelled),
175 };
176 let mut cmd = Command::new(&parts[0]);
177 if parts.len() > 1 {
178 cmd.args(&parts[1..]);
179 }
180 push_target_args(&mut cmd, &parts[0], path, line);
181 let status = match cmd.status() {
182 Ok(status) => status,
183 Err(_) => return Ok(EditorOutcome::Cancelled),
184 };
185
186 let after = fs::read_to_string(path).unwrap_or_default();
187 if after == before {
188 // A non-zero exit with no change is the editor being quit; a
189 // non-zero exit that *did* change the file still changed the file.
190 return Ok(if status.success() {
191 EditorOutcome::Unchanged
192 } else {
193 EditorOutcome::Cancelled
194 });
195 }
196 Ok(EditorOutcome::Edited(after))
197 }
198
199 /// Suspend the TUI, run the external editor on `path`, then re-enter it.
200 ///
201 /// The suspend/resume dance is [`spawn_editor_for_input`]'s, factored so the
202 /// composer and a file editor cannot drift on terminal-mode restoration.
203 pub(crate) fn spawn_editor_for_path(
204 terminal: &mut Terminal<ColorCompatBackend<Stdout>>,
205 use_alt_screen: bool,
206 use_mouse_capture: bool,
207 use_bracketed_paste: bool,
208 path: &std::path::Path,
209 line: Option<u32>,
210 ) -> io::Result<EditorOutcome> {
211 with_suspended_tui(
212 terminal,
213 use_alt_screen,
214 use_mouse_capture,
215 use_bracketed_paste,
216 || run_editor_on_path(path, line),
217 )
218 }
219
220 /// Suspend the TUI, run the external editor on `current`, then re-enter the
221 /// TUI. Returns the new composer text iff the user saved changes.
222 ///
223 /// On any error (raw-mode toggle, IO, editor spawn failure), the function
224 /// still attempts to fully restore the terminal before returning.
225 pub(crate) fn spawn_editor_for_input(
226 terminal: &mut Terminal<ColorCompatBackend<Stdout>>,
227 use_alt_screen: bool,
228 use_mouse_capture: bool,
229 use_bracketed_paste: bool,
230 current: &str,
231 ) -> io::Result<EditorOutcome> {
232 with_suspended_tui(
233 terminal,
234 use_alt_screen,
235 use_mouse_capture,
236 use_bracketed_paste,
237 || run_editor_raw(current),
238 )
239 }
240
241 /// Hand the terminal to a child, run `body`, and restore the TUI.
242 ///
243 /// Restoration is best-effort and runs on every path, including a `body` that
244 /// failed: leaving raw mode or the alt screen wrong is worse than the error
245 /// being reported.
246 fn with_suspended_tui(
247 terminal: &mut Terminal<ColorCompatBackend<Stdout>>,
248 use_alt_screen: bool,
249 use_mouse_capture: bool,
250 use_bracketed_paste: bool,
251 body: impl FnOnce() -> io::Result<EditorOutcome>,
252 ) -> io::Result<EditorOutcome> {
253 // 0. Stop reading the tty.
254 // #6165: suspending crossterm state is not enough. The input pump runs on
255 // its own thread and keeps calling `event::read()` whatever mode the
256 // terminal is in, so a child launched without this pause competes with
257 // Codewhale for every keystroke — the editor cannot be quit and the
258 // fragments land in the composer. Pausing here, rather than at each call
259 // site, is what makes `/hooks edit` and the composer editor correct by
260 // the same construction. Fail closed: a pump that will not stop means the
261 // handoff would reproduce the defect, so the editor does not run.
262 let input_pause = crate::tui::ui::pause_terminal_input_for_child()?;
263
264 // 1. Suspend.
265 // Focus reporting is about to be disabled. Fail closed to the quiet state
266 // so a stale FocusLost cannot authorize a surprise notification while an
267 // external editor owns the terminal.
268 crate::tui::notifications::set_terminal_focused(true);
269 // #443: pop keyboard enhancement flags first so the editor
270 // process doesn't inherit a half-configured input mode. Best-
271 // effort — matches the shutdown / panic paths in main.rs.
272 // Use the Windows-aware helper: the raw crossterm execute!() is a
273 // no-op on Windows and would leave the editor process in Kitty mode.
274 suspend_tui_child_modes(
275 terminal.backend_mut(),
276 use_mouse_capture,
277 use_bracketed_paste,
278 );
279 let _ = disable_raw_mode();
280 if use_alt_screen {
281 let _ = super::ui::leave_alt_screen(terminal.backend_mut());
282 }
283
284 // 2. Run the child (synchronous; inherits stdio).
285 let result = body();
286
287 // 3. Resume — best-effort restoration regardless of `result`.
288 let _ = enable_raw_mode();
289 if use_alt_screen {
290 let _ = super::ui::enter_alt_screen(terminal.backend_mut());
291 }
292 super::ui::recover_terminal_modes(
293 terminal.backend_mut(),
294 use_mouse_capture,
295 use_bracketed_paste,
296 );
297 // Reporting was unavailable during the handoff. Resume focused and wait
298 // for a fresh FocusLost before background-only delivery is eligible.
299 crate::tui::notifications::set_terminal_focused(true);
300 // Force a full repaint so a SIGWINCH during the edit doesn't leave the
301 // viewport stale.
302 let _ = terminal.clear();
303
304 // 4. Take the tty back, after the modes it reads under are restored.
305 drop(input_pause);
306
307 result
308 }
309
310 fn suspend_tui_child_modes<W: Write>(
311 writer: &mut W,
312 use_mouse_capture: bool,
313 use_bracketed_paste: bool,
314 ) {
315 super::ui::pop_keyboard_enhancement_flags(writer);
316 super::ui::disable_alternate_scroll_mode(writer);
317 let _ = execute!(writer, DisableFocusChange);
318 if use_mouse_capture {
319 disable_mouse_capture_for_child(writer);
320 }
321 if use_bracketed_paste {
322 super::ui::disable_bracketed_paste_mode(writer);
323 }
324 let _ = writer.flush();
325 }
326
327 fn disable_mouse_capture_for_child<W: Write>(writer: &mut W) {
328 // Crossterm's mouse-capture command takes a WinAPI path on Windows and
329 // does not emit bytes into PTY-style terminals such as mintty. External
330 // editors inherit the PTY state, so send the xterm reset sequences
331 // directly here.
332 const DISABLE_MOUSE_CAPTURE: &[u8] = b"\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
333 if let Err(err) = writer.write_all(DISABLE_MOUSE_CAPTURE) {
334 tracing::debug!(?err, "DisableMouseCapture direct reset ignored");
335 }
336 }
337
338 #[cfg(test)]
339 mod tests {
340 use super::*;
341 use crate::test_support::{EnvVarGuard, lock_test_env};
342
343 /// The file on disk is the document: a `hooks.toml` the user edits stays
344 /// edited, and the outcome only reports whether the bytes moved.
345 #[test]
346 #[cfg(unix)]
347 fn editing_a_path_in_place_reports_only_whether_the_bytes_moved() {
348 let _lock = lock_test_env();
349 let dir = tempfile::tempdir().unwrap();
350 let path = dir.path().join("hooks.toml");
351 fs::write(&path, "# seed\n").unwrap();
352
353 // An editor that saves nothing.
354 let _visual = EnvVarGuard::set("VISUAL", "true");
355 let _editor = EnvVarGuard::remove("EDITOR");
356 assert_eq!(
357 run_editor_on_path(&path, None).unwrap(),
358 EditorOutcome::Unchanged,
359 "an editor that changes nothing must not trigger a reload"
360 );
361
362 // An editor that appends a line.
363 let script = dir.path().join("append.sh");
364 fs::write(&script, "#!/bin/sh\nprintf 'x\\n' >> \"$1\"\n").unwrap();
365 use std::os::unix::fs::PermissionsExt as _;
366 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
367 // Shadow rather than reassign: both guards live, and drop order
368 // restores the original value last.
369 let _visual_script = EnvVarGuard::set("VISUAL", &script);
370 match run_editor_on_path(&path, None).unwrap() {
371 EditorOutcome::Edited(text) => assert!(text.contains("# seed") && text.contains('x')),
372 other => panic!("expected Edited, got {other:?}"),
373 }
374 assert!(
375 fs::read_to_string(&path).unwrap().contains('x'),
376 "the edit belongs to the real file, not a temp copy"
377 );
378 }
379
380 /// The line argument is spelled per editor family, and an unknown editor
381 /// gets the bare path rather than an argument it would treat as a file.
382 #[test]
383 fn push_target_args_spells_the_line_per_editor_family() {
384 use std::ffi::OsStr;
385
386 let path = std::path::Path::new("/w/src/main.rs");
387 let args_for = |program: &str, line: Option<u32>| -> Vec<String> {
388 let mut cmd = Command::new(program);
389 push_target_args(&mut cmd, program, path, line);
390 cmd.get_args()
391 .map(OsStr::to_string_lossy)
392 .map(|s| s.into_owned())
393 .collect()
394 };
395
396 // No line: byte-identical to the historical `cmd.arg(path)`.
397 assert_eq!(args_for("vim", None), vec!["/w/src/main.rs"]);
398 assert_eq!(args_for("code", None), vec!["/w/src/main.rs"]);
399
400 // vi family, including an absolute path and a .exe suffix.
401 assert_eq!(args_for("vim", Some(12)), vec!["+12", "/w/src/main.rs"]);
402 assert_eq!(args_for("nano", Some(3)), vec!["+3", "/w/src/main.rs"]);
403 assert_eq!(
404 args_for("/usr/bin/nvim", Some(9)),
405 vec!["+9", "/w/src/main.rs"]
406 );
407 // `.exe` is stripped by `file_stem` on every platform. A backslashed
408 // Windows *path* only splits on Windows, so it is not asserted here.
409 assert_eq!(args_for("vim.exe", Some(5)), vec!["+5", "/w/src/main.rs"]);
410
411 // VS Code and forks need the flag; Zed/Sublime/JetBrains take file:line.
412 assert_eq!(
413 args_for("code", Some(42)),
414 vec!["--goto", "/w/src/main.rs:42"]
415 );
416 assert_eq!(args_for("zed", Some(42)), vec!["/w/src/main.rs:42"]);
417
418 // Unknown editor: open the right file, never an invented argument.
419 assert_eq!(args_for("my-editor", Some(42)), vec!["/w/src/main.rs"]);
420 }
421
422 #[test]
423 fn resolve_editor_prefers_visual_over_editor() {
424 let _lock = lock_test_env();
425 let _visual = EnvVarGuard::set("VISUAL", "vis-cmd");
426 let _editor = EnvVarGuard::set("EDITOR", "ed-cmd");
427 assert_eq!(resolve_editor(), "vis-cmd");
428 }
429
430 #[test]
431 fn resolve_editor_falls_back_to_vi() {
432 let _lock = lock_test_env();
433 let _visual = EnvVarGuard::remove("VISUAL");
434 let _editor = EnvVarGuard::remove("EDITOR");
435 assert_eq!(resolve_editor(), "vi");
436 }
437
438 /// Editor that immediately exits 0 without touching the file ⇒ Unchanged.
439 #[test]
440 #[cfg(unix)]
441 fn run_editor_unchanged_when_editor_is_noop() {
442 let _lock = lock_test_env();
443 let _visual = EnvVarGuard::remove("VISUAL");
444 let _editor = EnvVarGuard::set("EDITOR", "true");
445 let out = run_editor_raw("seed text").expect("editor ok");
446 assert_eq!(out, EditorOutcome::Unchanged);
447 }
448
449 /// Editor that exits non-zero ⇒ Cancelled.
450 #[test]
451 #[cfg(unix)]
452 fn run_editor_cancelled_on_nonzero_exit() {
453 let _lock = lock_test_env();
454 let _visual = EnvVarGuard::remove("VISUAL");
455 let _editor = EnvVarGuard::set("EDITOR", "false");
456 let out = run_editor_raw("seed").expect("call ok");
457 assert_eq!(out, EditorOutcome::Cancelled);
458 }
459
460 /// Spawning an editor binary that doesn't exist ⇒ Cancelled (graceful).
461 #[test]
462 #[cfg(unix)]
463 fn run_editor_cancelled_when_editor_missing() {
464 let _lock = lock_test_env();
465 let _visual = EnvVarGuard::remove("VISUAL");
466 let _editor = EnvVarGuard::set("EDITOR", "/nonexistent/codewhale-test-editor");
467 let out = run_editor_raw("seed").expect("call ok");
468 assert_eq!(out, EditorOutcome::Cancelled);
469 }
470
471 /// Editor that rewrites the file ⇒ Edited(new).
472 #[test]
473 #[cfg(unix)]
474 fn run_editor_returns_edited_contents() {
475 use std::os::unix::fs::PermissionsExt;
476
477 let _lock = lock_test_env();
478 let dir = tempfile::tempdir().unwrap();
479 let script = dir.path().join("ed.sh");
480 fs::write(&script, "#!/bin/sh\nprintf 'edited body' > \"$1\"\n").unwrap();
481 let mut perms = fs::metadata(&script).unwrap().permissions();
482 perms.set_mode(0o755);
483 fs::set_permissions(&script, perms).unwrap();
484
485 let _visual = EnvVarGuard::remove("VISUAL");
486 let _editor = EnvVarGuard::set("EDITOR", &script);
487 let out = run_editor_raw("seed body").expect("editor ok");
488 assert_eq!(out, EditorOutcome::Edited("edited body".to_string()));
489 }
490
491 /// Verify that the temp file is unlinked after `run_editor_raw` returns,
492 /// regardless of outcome. We test the success path with a script that
493 /// echoes the file path to a side channel before exiting.
494 #[test]
495 #[cfg(unix)]
496 fn run_editor_cleans_up_temp_file() {
497 use std::os::unix::fs::PermissionsExt;
498
499 let _lock = lock_test_env();
500 let dir = tempfile::tempdir().unwrap();
501 let path_capture = dir.path().join("capture.txt");
502 let script = dir.path().join("ed.sh");
503 fs::write(
504 &script,
505 format!(
506 "#!/bin/sh\nprintf '%s' \"$1\" > \"{}\"\nprintf 'x' > \"$1\"\n",
507 path_capture.display()
508 ),
509 )
510 .unwrap();
511 let mut perms = fs::metadata(&script).unwrap().permissions();
512 perms.set_mode(0o755);
513 fs::set_permissions(&script, perms).unwrap();
514
515 let _visual = EnvVarGuard::remove("VISUAL");
516 let _editor = EnvVarGuard::set("EDITOR", &script);
517 let _ = run_editor_raw("seed").expect("editor ok");
518
519 let captured = fs::read_to_string(&path_capture).expect("captured path");
520 assert!(!captured.is_empty(), "editor should have received a path");
521 assert!(
522 !std::path::Path::new(&captured).exists(),
523 "temp file {captured:?} should be cleaned up after run_editor_raw returns"
524 );
525 }
526
527 #[test]
528 fn suspend_tui_child_modes_disables_every_inherited_mode() {
529 let mut out = Vec::new();
530
531 suspend_tui_child_modes(&mut out, true, true);
532
533 let seq = String::from_utf8_lossy(&out);
534 assert!(
535 seq.contains("\x1b[?1007l"),
536 "external editor suspend must disable alternate-scroll mode: {seq:?}"
537 );
538 assert!(
539 seq.contains("\x1b[?1004l"),
540 "external editor suspend must disable focus events: {seq:?}"
541 );
542 assert!(
543 seq.contains("\x1b[?2004l"),
544 "external editor suspend must disable bracketed paste: {seq:?}"
545 );
546 assert!(
547 seq.contains("\x1b[?1000l"),
548 "external editor suspend must disable mouse capture when active: {seq:?}"
549 );
550 }
551
552 #[test]
553 fn suspend_tui_child_modes_leaves_mouse_capture_alone_when_inactive() {
554 let mut out = Vec::new();
555
556 suspend_tui_child_modes(&mut out, false, true);
557
558 let seq = String::from_utf8_lossy(&out);
559 assert!(
560 !seq.contains("\x1b[?1000l"),
561 "external editor suspend must not emit mouse-capture reset when inactive: {seq:?}"
562 );
563 }
564
565 #[test]
566 fn resume_tui_child_modes_reenables_shared_terminal_modes() {
567 let mut out = Vec::new();
568
569 crate::tui::ui::recover_terminal_modes(&mut out, true, true);
570
571 let seq = String::from_utf8_lossy(&out);
572 assert!(
573 !seq.contains("\x1b[?1007h"),
574 "must not enable alternate-scroll"
575 );
576 assert!(seq.contains("\x1b[?1007l"), "must reset alternate-scroll");
577 assert!(
578 seq.contains("\x1b[?1004h"),
579 "external editor resume must restore focus events: {seq:?}"
580 );
581 assert!(
582 seq.contains("\x1b[?2004h"),
583 "external editor resume must restore bracketed paste: {seq:?}"
584 );
585 }
586
587 #[test]
588 fn resume_tui_child_modes_leaves_alternate_scroll_off_when_mouse_capture_inactive() {
589 let mut out = Vec::new();
590
591 crate::tui::ui::recover_terminal_modes(&mut out, false, true);
592
593 let seq = String::from_utf8_lossy(&out);
594 assert!(
595 !seq.contains("\x1b[?1007h"),
596 "external editor resume must not enable alternate-scroll without mouse capture: {seq:?}"
597 );
598 assert!(
599 seq.contains("\x1b[?1007l"),
600 "external editor resume must reset alternate-scroll without mouse capture: {seq:?}"
601 );
602 assert!(
603 seq.contains("\x1b[?1004h"),
604 "external editor resume must still restore focus events: {seq:?}"
605 );
606 assert!(
607 seq.contains("\x1b[?2004h"),
608 "external editor resume must still restore bracketed paste: {seq:?}"
609 );
610 }
611 }
612
612 lines RUST