Skip to main content

rpi_tui/
terminal.rs

1//! Terminal abstraction for TUI.
2//!
3//! Provides a trait for terminal operations and a `ProcessTerminal` implementation
4//! using crossterm.
5
6use std::io::{self, IsTerminal, Write};
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9
10use crossterm::{
11    event::{self, Event, KeyEvent, MouseEvent},
12    terminal as cterm,
13};
14use thiserror::Error;
15
16/// Mouse tracking prevents native text selection in macOS terminal emulators.
17/// RPI only consumes wheel events, so keep the platform default selectable.
18const fn mouse_tracking_enabled_for(is_macos: bool) -> bool {
19    !is_macos
20}
21
22const fn mouse_tracking_enabled_by_default() -> bool {
23    mouse_tracking_enabled_for(cfg!(target_os = "macos"))
24}
25
26/// Terminals that advertise keyboard-enhancement support but stop delivering
27/// IME composition once the protocol is pushed (no candidate window → CJK input
28/// breaks). Otty is the reported case; it keeps the legacy input path.
29#[cfg(any(unix, test))]
30fn is_ime_incompatible_terminal(term_program: Option<&str>, term: Option<&str>) -> bool {
31    let names_otty = |value: Option<&str>| {
32        value.is_some_and(|value| value.to_ascii_lowercase().contains("otty"))
33    };
34    names_otty(term_program) || names_otty(term)
35}
36
37/// The kitty keyboard protocol (`\x1b[>7u`) is what tells `Shift+Enter` apart
38/// from `Enter` in most terminals, and it carries the key-release events
39/// push-to-talk extensions need — so keep it on everywhere the terminal
40/// survives it, macOS included. Only a terminal whose IME composition it breaks
41/// falls back to legacy input; the one emulator that cannot opt in at all
42/// (Apple Terminal) is covered by [`normalize_key_event`]'s native Shift check.
43#[cfg(any(unix, test))]
44fn keyboard_protocol_enabled_for(
45    is_macos: bool,
46    term_program: Option<&str>,
47    term: Option<&str>,
48) -> bool {
49    !is_macos || !is_ime_incompatible_terminal(term_program, term)
50}
51
52/// `RPI_KITTY_KEYBOARD=1` forces the protocol on, `RPI_KITTY_KEYBOARD=0` forces
53/// legacy input; otherwise [`keyboard_protocol_enabled_for`] decides from the
54/// platform and the terminal name.
55#[cfg(unix)]
56fn keyboard_protocol_enabled() -> bool {
57    if let Some(value) = std::env::var_os("RPI_KITTY_KEYBOARD") {
58        return value != "0";
59    }
60    keyboard_protocol_enabled_for(
61        cfg!(target_os = "macos"),
62        std::env::var("TERM_PROGRAM").ok().as_deref(),
63        std::env::var("TERM").ok().as_deref(),
64    )
65}
66
67/// Errors that can occur during terminal operations.
68#[derive(Debug, Error)]
69pub enum TerminalError {
70    #[error("IO error: {0}")]
71    Io(#[from] io::Error),
72    #[error("Terminal error: {0}")]
73    Terminal(String),
74}
75
76/// Terminal capabilities and state.
77#[derive(Debug, Clone, Default)]
78pub struct TerminalInfo {
79    pub columns: usize,
80    pub rows: usize,
81    pub supports_true_color: bool,
82    pub supports_mouse: bool,
83    pub supports_kitty_keyboard: bool,
84}
85
86/// Input event types.
87#[derive(Debug, Clone)]
88pub enum InputEvent {
89    Key(KeyEvent),
90    Mouse(MouseEvent),
91    Resize(usize, usize),
92    FocusGained,
93    FocusLost,
94}
95
96/// Terminal trait for abstracting terminal operations.
97///
98/// This allows for different terminal implementations (real terminal, virtual terminal for testing).
99pub trait Terminal: Send + Sync {
100    /// Get terminal information (columns, rows, capabilities).
101    fn info(&self) -> TerminalInfo;
102
103    /// Get current terminal width in columns.
104    fn columns(&self) -> usize {
105        self.info().columns
106    }
107
108    /// Get current terminal height in rows.
109    fn rows(&self) -> usize {
110        self.info().rows
111    }
112
113    /// Write raw bytes to the terminal.
114    fn write(&self, data: &str);
115
116    /// Hide the cursor.
117    fn hide_cursor(&self);
118
119    /// Show the cursor.
120    fn show_cursor(&self);
121
122    /// Move cursor to position (0-indexed).
123    fn move_cursor(&self, row: usize, col: usize);
124
125    /// Clear the screen.
126    fn clear_screen(&self);
127
128    /// Set terminal title.
129    fn set_title(&self, title: &str);
130
131    /// Enable mouse events.
132    fn enable_mouse(&self);
133
134    /// Disable mouse events.
135    fn disable_mouse(&self);
136
137    /// Enter terminal raw mode (required for `event::read()` to deliver
138    /// individual key events without line buffering). Pairs with
139    /// [`Terminal::stop`], which exits raw mode.
140    ///
141    /// Callers that drive their own input loop (instead of the reader thread
142    /// spawned by [`Terminal::start`]) use this to set up the tty without
143    /// spawning a competing reader — see `TuiAltScreen::start_readerless`.
144    fn enter_raw_mode(&self);
145
146    /// Leave raw mode *without* stopping the terminal, so the shell gets a
147    /// working line discipline back. Pairs with [`Terminal::enter_raw_mode`];
148    /// a `Ctrl+Z` suspend uses it, then re-enters raw mode on resume.
149    fn exit_raw_mode(&self);
150
151    /// Re-read the current terminal size into the cached [`TerminalInfo`].
152    /// Called on `Event::Resize` so subsequent renders use the new dimensions.
153    fn refresh_size(&self);
154
155    /// Start terminal raw mode.
156    fn start(
157        &self,
158        on_input: Box<dyn Fn(InputEvent) + Send + Sync>,
159        on_resize: Box<dyn Fn() + Send + Sync>,
160    );
161
162    /// Stop terminal and restore original state.
163    fn stop(&self);
164
165    /// Check if terminal is a TTY.
166    fn is_tty(&self) -> bool;
167
168    /// Set progress indicator (terminal progress bar support).
169    fn set_progress(&self, active: bool);
170
171    /// Flush output buffer.
172    fn flush(&self);
173}
174
175/// Process-based terminal using crossterm.
176pub struct ProcessTerminal {
177    info: Mutex<TerminalInfo>,
178    running: Mutex<bool>,
179    writer: Mutex<Box<dyn Write + Send + Sync>>,
180    #[cfg(any(unix, test))]
181    keyboard: Mutex<KeyboardProtocolState>,
182}
183
184#[cfg(any(unix, test))]
185#[derive(Clone, Copy)]
186enum KeyboardProtocol {
187    Kitty,
188    Legacy,
189}
190
191#[cfg(any(unix, test))]
192#[derive(Default)]
193struct KeyboardProtocolState {
194    detected: Option<bool>,
195    active: Option<KeyboardProtocol>,
196}
197
198impl ProcessTerminal {
199    /// Create a new process terminal.
200    pub fn new() -> Self {
201        let (cols, rows) = cterm::size().unwrap_or((80, 24));
202        let info = TerminalInfo {
203            columns: cols as usize,
204            rows: rows as usize,
205            supports_true_color: true, // Assume true color support
206            supports_mouse: true,
207            supports_kitty_keyboard: false, // Will be detected
208        };
209
210        Self {
211            info: Mutex::new(info),
212            running: Mutex::new(false),
213            writer: Mutex::new(Box::new(io::stdout())),
214            #[cfg(any(unix, test))]
215            keyboard: Mutex::new(KeyboardProtocolState::default()),
216        }
217    }
218
219    /// Update terminal size.
220    fn update_size(&self) {
221        if let Ok(size) = cterm::size() {
222            if let Ok(mut info) = self.info.lock() {
223                info.columns = size.0 as usize;
224                info.rows = size.1 as usize;
225            }
226        }
227    }
228
229    fn configure_mouse_tracking(&self) {
230        if mouse_tracking_enabled_by_default() {
231            self.enable_mouse();
232        } else {
233            self.disable_mouse();
234        }
235    }
236
237    /// Negotiate before starting the input reader. Cache support so resuming
238    /// never queries stdin while a reader is already running.
239    #[cfg(any(unix, test))]
240    fn enable_keyboard_protocol(&self, detect: impl FnOnce() -> bool) {
241        let mut keyboard = self.keyboard.lock().unwrap_or_else(|p| p.into_inner());
242        if keyboard.active.is_some() {
243            return;
244        }
245        let supported = match keyboard.detected {
246            Some(supported) => supported,
247            None => {
248                // Match Pi: 1: disambiguate, 2: repeat/release, 4: alternate keys.
249                // Leave text as UTF-8 for IME commits. Flag 8 replaces text
250                // with key reports; crossterm cannot consume associated text.
251                self.write("\x1b[>7u");
252                self.flush();
253                let supported = detect();
254                keyboard.detected = Some(supported);
255                if !supported {
256                    self.write("\x1b[<u");
257                }
258                if let Ok(mut info) = self.info.lock() {
259                    info.supports_kitty_keyboard = supported;
260                }
261                if supported {
262                    keyboard.active = Some(KeyboardProtocol::Kitty);
263                    return;
264                }
265                false
266            }
267        };
268        let protocol = if supported {
269            self.write("\x1b[>7u");
270            KeyboardProtocol::Kitty
271        } else {
272            // Crossterm cannot parse xterm's CSI 27;modifier;key~ encoding.
273            // Keep legacy input rather than enabling modifyOtherKeys and
274            // losing keys. Apple Terminal's Shift+Enter is normalized below.
275            KeyboardProtocol::Legacy
276        };
277        keyboard.active = Some(protocol);
278    }
279
280    #[cfg(any(unix, test))]
281    fn disable_keyboard_protocol(&self) {
282        let mut keyboard = self.keyboard.lock().unwrap_or_else(|p| p.into_inner());
283        match keyboard.active.take() {
284            Some(KeyboardProtocol::Kitty) => self.write("\x1b[<u"),
285            Some(KeyboardProtocol::Legacy) | None => {}
286        }
287    }
288}
289
290impl Default for ProcessTerminal {
291    fn default() -> Self {
292        Self::new()
293    }
294}
295
296impl Terminal for ProcessTerminal {
297    fn info(&self) -> TerminalInfo {
298        self.info.lock().map(|i| i.clone()).unwrap_or_default()
299    }
300
301    fn write(&self, data: &str) {
302        if let Ok(mut writer) = self.writer.lock() {
303            let _ = writer.write_all(data.as_bytes());
304        }
305    }
306
307    fn hide_cursor(&self) {
308        self.write("\x1b[?25l");
309    }
310
311    fn show_cursor(&self) {
312        self.write("\x1b[?25h");
313    }
314
315    fn move_cursor(&self, row: usize, col: usize) {
316        // ANSI escape: ESC [ row ; col H (1-indexed)
317        self.write(&format!("\x1b[{};{}H", row + 1, col + 1));
318    }
319
320    fn clear_screen(&self) {
321        self.write("\x1b[2J\x1b[H");
322    }
323
324    fn set_title(&self, title: &str) {
325        self.write(&format!("\x1b]2;{}\x07", title));
326    }
327
328    fn enable_mouse(&self) {
329        // Enable mouse tracking (SGR mode)
330        self.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h");
331    }
332
333    fn disable_mouse(&self) {
334        self.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l");
335    }
336
337    fn enter_raw_mode(&self) {
338        let _ = cterm::enable_raw_mode();
339        if let Ok(mut running) = self.running.lock() {
340            *running = true;
341        }
342        self.configure_mouse_tracking();
343        self.hide_cursor();
344        // Enable bracketed paste mode so pasted text arrives as a single
345        // Event::Paste instead of individual key events (which would trigger
346        // submit per line for multi-line pastes).
347        self.write("\x1b[?2004h");
348        #[cfg(unix)]
349        if keyboard_protocol_enabled() && self.is_tty() && io::stdin().is_terminal() {
350            self.enable_keyboard_protocol(|| {
351                cterm::supports_keyboard_enhancement().unwrap_or(false)
352            });
353        }
354        self.update_size();
355        self.flush();
356    }
357
358    fn exit_raw_mode(&self) {
359        // Mirror `enter_raw_mode` without tearing the terminal down: drop the
360        // bracketed-paste mode it turned on and restore the line discipline.
361        self.write("\x1b[?2004l");
362        #[cfg(unix)]
363        self.disable_keyboard_protocol();
364        self.flush();
365        let _ = cterm::disable_raw_mode();
366    }
367
368    fn refresh_size(&self) {
369        self.update_size();
370    }
371
372    fn start(
373        &self,
374        on_input: Box<dyn Fn(InputEvent) + Send + Sync>,
375        on_resize: Box<dyn Fn() + Send + Sync>,
376    ) {
377        self.enter_raw_mode();
378
379        // Update size initially
380        self.update_size();
381
382        // Spawn input handling thread
383        let running = Arc::new(Mutex::new(true));
384        let running_clone = running.clone();
385        let info_clone = Arc::new(Mutex::new(self.info()));
386
387        std::thread::spawn(move || {
388            loop {
389                // Check if we're still running
390                if !*running_clone.lock().unwrap() {
391                    break;
392                }
393
394                // Poll for events with timeout
395                match event::poll(Duration::from_millis(100)) {
396                    Ok(true) => {
397                        if let Ok(event) = event::read() {
398                            match event {
399                                Event::Key(key) => {
400                                    on_input(InputEvent::Key(normalize_key_event(key)))
401                                }
402                                Event::Mouse(mouse) => on_input(InputEvent::Mouse(mouse)),
403                                Event::Resize(cols, rows) => {
404                                    if let Ok(mut info) = info_clone.lock() {
405                                        info.columns = cols as usize;
406                                        info.rows = rows as usize;
407                                    }
408                                    on_resize();
409                                }
410                                Event::FocusGained => on_input(InputEvent::FocusGained),
411                                Event::FocusLost => on_input(InputEvent::FocusLost),
412                                Event::Paste(_) => { /* Ignore paste events for now */ }
413                            }
414                        }
415                    }
416                    Ok(false) => {}  // Timeout, continue polling
417                    Err(_) => break, // Error, stop the thread
418                }
419            }
420        });
421
422        // Store running flag
423        if let Ok(mut r) = self.running.lock() {
424            *r = true;
425        }
426    }
427
428    fn stop(&self) {
429        if let Ok(mut running) = self.running.lock() {
430            *running = false;
431        }
432
433        self.disable_mouse();
434        self.show_cursor();
435        // Disable bracketed paste mode before exiting raw mode.
436        self.write("\x1b[?2004l");
437        #[cfg(unix)]
438        self.disable_keyboard_protocol();
439        self.flush();
440
441        // Exit raw mode
442        let _ = cterm::disable_raw_mode();
443    }
444
445    fn is_tty(&self) -> bool {
446        std::io::stdout().is_terminal()
447    }
448
449    fn set_progress(&self, active: bool) {
450        // OSC 9;4 is supported by modern terminal emulators (including
451        // Windows Terminal, iTerm2-compatible terminals, and many Linux
452        // terminals). State 1 starts an indeterminate progress indicator and
453        // state 0 clears it.
454        let state = if active { 1 } else { 0 };
455        self.write(&format!("\x1b]9;4;{state};0\x07"));
456        self.flush();
457    }
458
459    fn flush(&self) {
460        if let Ok(mut writer) = self.writer.lock() {
461            let _ = writer.flush();
462        }
463    }
464}
465
466/// Apple Terminal sends the same CR for Enter and Shift+Enter. Like Pi's
467/// native helper, consult the local modifier state only for that legacy key.
468/// SSH input belongs to the remote user, not the Mac's physical keyboard.
469pub fn normalize_key_event(key: KeyEvent) -> KeyEvent {
470    #[cfg(target_os = "macos")]
471    if key.code == event::KeyCode::Enter
472        && key.modifiers.is_empty()
473        && local_apple_terminal(
474            std::env::var("TERM_PROGRAM").ok().as_deref(),
475            ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]
476                .iter()
477                .any(|name| std::env::var_os(name).is_some()),
478        )
479    {
480        #[link(name = "CoreGraphics", kind = "framework")]
481        extern "C" {
482            fn CGEventSourceFlagsState(state_id: i32) -> u64;
483        }
484        // kCGEventSourceStateCombinedSessionState = 0; maskShift = 1 << 17.
485        // This accessor reads modifier flags without creating an event tap.
486        let shift_pressed = unsafe { CGEventSourceFlagsState(0) } & (1 << 17) != 0;
487        return normalize_native_shift_enter(key, shift_pressed);
488    }
489    key
490}
491
492#[cfg(any(target_os = "macos", test))]
493fn local_apple_terminal(term_program: Option<&str>, ssh: bool) -> bool {
494    term_program == Some("Apple_Terminal") && !ssh
495}
496
497#[cfg(any(target_os = "macos", test))]
498fn normalize_native_shift_enter(mut key: KeyEvent, shift_pressed: bool) -> KeyEvent {
499    if key.code == event::KeyCode::Enter && key.modifiers.is_empty() && shift_pressed {
500        key.modifiers.insert(event::KeyModifiers::SHIFT);
501    }
502    key
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[derive(Clone)]
510    struct RecordedOutput(Arc<Mutex<Vec<u8>>>);
511    impl Write for RecordedOutput {
512        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
513            self.0.lock().unwrap().extend_from_slice(bytes);
514            Ok(bytes.len())
515        }
516        fn flush(&mut self) -> io::Result<()> {
517            Ok(())
518        }
519    }
520
521    #[test]
522    fn kitty_protocol_preserves_ime_text_and_restores_once_per_activation() {
523        let terminal = ProcessTerminal::new();
524        let output = RecordedOutput(Arc::new(Mutex::new(Vec::new())));
525        *terminal.writer.lock().unwrap() = Box::new(output.clone());
526        terminal.enable_keyboard_protocol(|| true);
527        assert!(terminal.info().supports_kitty_keyboard);
528        terminal.enable_keyboard_protocol(|| panic!("active protocol must not be pushed twice"));
529        terminal.disable_keyboard_protocol();
530        terminal.disable_keyboard_protocol();
531        terminal.enable_keyboard_protocol(|| panic!("resume must not query stdin"));
532        terminal.disable_keyboard_protocol();
533        let bytes = output.0.lock().unwrap().clone();
534        assert_eq!(bytes, b"\x1b[>7u\x1b[<u\x1b[>7u\x1b[<u");
535        // Check the actual emitted flags to prevent reintroducing all-key mode.
536        let sequence = std::str::from_utf8(&bytes)
537            .unwrap()
538            .split('u')
539            .next()
540            .unwrap();
541        let mask: u8 = sequence.strip_prefix("\x1b[>").unwrap().parse().unwrap();
542        let flags = event::KeyboardEnhancementFlags::from_bits(mask).unwrap();
543        assert!(flags.contains(event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
544        assert!(flags.contains(event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
545        assert!(flags.contains(event::KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS));
546        assert!(!flags.contains(event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
547        // Bit 16 is associated-text reporting, unsupported by crossterm 0.27.
548        assert_eq!(mask & 16, 0);
549    }
550
551    #[test]
552    fn legacy_terminal_does_not_enable_an_encoding_the_reader_cannot_parse() {
553        let terminal = ProcessTerminal::new();
554        let output = RecordedOutput(Arc::new(Mutex::new(Vec::new())));
555        *terminal.writer.lock().unwrap() = Box::new(output.clone());
556        terminal.enable_keyboard_protocol(|| false);
557        assert!(!terminal.info().supports_kitty_keyboard);
558        terminal.disable_keyboard_protocol();
559        terminal.enable_keyboard_protocol(|| panic!("resume must reuse detection"));
560        terminal.disable_keyboard_protocol();
561        assert_eq!(output.0.lock().unwrap().as_slice(), b"\x1b[>7u\x1b[<u");
562    }
563
564    #[test]
565    fn native_shift_enter_only_changes_unmodified_enter_in_local_apple_terminal() {
566        use event::{KeyCode, KeyModifiers};
567        assert!(local_apple_terminal(Some("Apple_Terminal"), false));
568        assert!(!local_apple_terminal(Some("Apple_Terminal"), true));
569        assert!(!local_apple_terminal(Some("iTerm.app"), false));
570        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
571        assert_eq!(
572            normalize_native_shift_enter(enter, true).modifiers,
573            KeyModifiers::SHIFT
574        );
575        assert_eq!(normalize_native_shift_enter(enter, false), enter);
576        for key in [
577            KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
578            KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
579            KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT),
580        ] {
581            assert_eq!(normalize_native_shift_enter(key, true), key);
582        }
583    }
584
585    #[test]
586    fn native_shift_enter_inserts_a_line_before_plain_enter_submits() {
587        use event::{KeyCode, KeyModifiers};
588        let editor = crate::Editor::simple();
589        let submitted = Arc::new(Mutex::new(Vec::new()));
590        let capture = submitted.clone();
591        editor.on_submit(Arc::new(move |text| {
592            capture.lock().unwrap().push(text.to_string())
593        }));
594        editor.insert("first");
595        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
596        editor.handle_key(normalize_native_shift_enter(enter, true));
597        editor.insert("second");
598        assert_eq!(editor.get_text(), "first\nsecond");
599        assert!(submitted.lock().unwrap().is_empty());
600        editor.handle_key(normalize_native_shift_enter(enter, false));
601        assert_eq!(submitted.lock().unwrap().as_slice(), &["first\nsecond"]);
602    }
603
604    #[test]
605    fn mouse_tracking_default_preserves_native_selection_on_macos() {
606        assert!(!mouse_tracking_enabled_for(true));
607        assert!(mouse_tracking_enabled_for(false));
608    }
609
610    #[test]
611    fn macos_keeps_the_kitty_protocol_except_where_it_breaks_ime() {
612        // iTerm2's Shift+Enter needs the protocol: disabling it macOS-wide in
613        // 0.3.21 made Shift+Enter submit instead of inserting a newline.
614        assert!(keyboard_protocol_enabled_for(
615            true,
616            Some("iTerm.app"),
617            Some("xterm-256color")
618        ));
619        assert!(keyboard_protocol_enabled_for(true, Some("ghostty"), None));
620        assert!(keyboard_protocol_enabled_for(
621            true,
622            Some("Apple_Terminal"),
623            None
624        ));
625        // An unknown local terminal keeps the protocol rather than silently
626        // losing Shift+Enter; `RPI_KITTY_KEYBOARD=0` is the escape hatch.
627        assert!(keyboard_protocol_enabled_for(
628            true,
629            None,
630            Some("xterm-256color")
631        ));
632        // Otty composes CJK input only on the legacy path.
633        assert!(!keyboard_protocol_enabled_for(
634            true,
635            Some("Otty"),
636            Some("xterm-256color")
637        ));
638        assert!(!keyboard_protocol_enabled_for(true, None, Some("otty")));
639        // Other platforms never consult the terminal name.
640        assert!(keyboard_protocol_enabled_for(
641            false,
642            Some("Otty"),
643            Some("otty")
644        ));
645    }
646
647    #[test]
648    fn ime_committed_utf8_text_reaches_editor_without_submitting() {
649        use event::{KeyCode, KeyModifiers};
650        let editor = crate::Editor::simple();
651        let submitted = Arc::new(Mutex::new(Vec::new()));
652        let capture = submitted.clone();
653        editor.on_submit(Arc::new(move |text| {
654            capture.lock().unwrap().push(text.to_string())
655        }));
656        let committed = "你好,世界𠮷 abc";
657        for ch in committed.chars() {
658            editor.handle_key(normalize_key_event(KeyEvent::new(
659                KeyCode::Char(ch),
660                KeyModifiers::NONE,
661            )));
662        }
663        assert_eq!(editor.get_text(), committed);
664        assert!(submitted.lock().unwrap().is_empty());
665        editor.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
666        assert_eq!(submitted.lock().unwrap().as_slice(), &[committed]);
667    }
668}