diff --git a/codex-rs/tui/src/tui/startup.rs b/codex-rs/tui/src/tui/startup.rs index e4ecea9137..0334b1945e 100644 --- a/codex-rs/tui/src/tui/startup.rs +++ b/codex-rs/tui/src/tui/startup.rs @@ -4,14 +4,11 @@ use std::io::stdin; use std::io::stdout; use std::time::Duration; -use crossterm::event::DisableBracketedPaste; -use crossterm::event::EnableBracketedPaste; use crossterm::event::Event; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; -use crossterm::execute; use ratatui::backend::CrosstermBackend; use ratatui::layout::Position; @@ -30,11 +27,10 @@ pub(crate) struct PreparedTerminal { impl Drop for PreparedTerminal { fn drop(&mut self) { if self.active { - discard_terminal_input(); if self.terminal_modes_active { let _ = super::restore_after_exit(); } else { - let _ = disable_startup_paste_capture(); + discard_terminal_input(); } } } @@ -44,6 +40,7 @@ impl Drop for PreparedTerminal { pub(super) struct StartupInputBuffer { text: String, char_count: usize, + pending_plain_whitespace: String, } impl StartupInputBuffer { @@ -54,11 +51,19 @@ impl StartupInputBuffer { modifiers, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. - }) if modifiers.difference(KeyModifiers::SHIFT).is_empty() => match code { - KeyCode::Char(ch) if !ch.is_control() => self.push_char(ch), - KeyCode::Backspace => self.pop_char(), - _ => {} - }, + }) => { + if modifiers.difference(KeyModifiers::SHIFT).is_empty() { + match code { + KeyCode::Char(ch) if !ch.is_control() => self.push_plain_char(ch), + KeyCode::Backspace => self.pop_char(), + KeyCode::Enter => self.push_pending_plain_whitespace('\n'), + KeyCode::Tab => self.push_pending_plain_whitespace('\t'), + _ => self.pending_plain_whitespace.clear(), + } + } else { + self.pending_plain_whitespace.clear(); + } + } Event::Paste(text) => self.push_text(&text), _ => {} } @@ -71,13 +76,37 @@ impl StartupInputBuffer { } } + fn push_plain_char(&mut self, ch: char) { + self.commit_pending_plain_whitespace(); + self.push_char(ch); + } + + fn push_pending_plain_whitespace(&mut self, ch: char) { + if self.char_count + self.pending_plain_whitespace.len() < MAX_STARTUP_INPUT_CHARS { + self.pending_plain_whitespace.push(ch); + } + } + + fn commit_pending_plain_whitespace(&mut self) { + let pending = std::mem::take(&mut self.pending_plain_whitespace); + for ch in pending.chars() { + self.push_char(ch); + } + } + fn pop_char(&mut self) { + if self.pending_plain_whitespace.pop().is_some() { + return; + } if self.text.pop().is_some() { self.char_count -= 1; } } pub(super) fn push_text(&mut self, text: &str) { + if !text.is_empty() { + self.commit_pending_plain_whitespace(); + } let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { match ch { @@ -95,10 +124,20 @@ impl StartupInputBuffer { } pub(super) fn handle_probe_input(&mut self, input: &[u8]) { - for ch in String::from_utf8_lossy(input).chars() { + let input = String::from_utf8_lossy(input); + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { match ch { '\u{8}' | '\u{7f}' => self.pop_char(), - ch if !ch.is_control() => self.push_char(ch), + '\r' => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + self.push_pending_plain_whitespace('\n'); + } + '\n' => self.push_pending_plain_whitespace('\n'), + '\t' => self.push_pending_plain_whitespace('\t'), + ch if !ch.is_control() => self.push_plain_char(ch), _ => {} } } @@ -152,11 +191,6 @@ pub(crate) fn discard_terminal_input() { pub(crate) fn abandon_prepared_terminal() { discard_terminal_input(); - let _ = disable_startup_paste_capture(); -} - -fn disable_startup_paste_capture() -> Result<()> { - execute!(stdout(), DisableBracketedPaste) } pub(super) fn capture_startup_input(input: &mut StartupInputBuffer) -> Result<()> { @@ -167,7 +201,7 @@ pub(super) fn capture_startup_input(input: &mut StartupInputBuffer) -> Result<() } impl PreparedTerminal { - /// Start preserving terminal paste boundaries before slower startup work begins. + /// Claim queued terminal input before slower startup work begins. pub(crate) fn prepare() -> Result { if !stdin().is_terminal() { return Err(std::io::Error::other("stdin is not a terminal")); @@ -177,7 +211,6 @@ impl PreparedTerminal { } super::ensure_virtual_terminal_processing()?; super::set_panic_hook(); - execute!(stdout(), EnableBracketedPaste)?; Ok(Self { active: true, terminal_modes_active: false, diff --git a/codex-rs/tui/src/tui/startup_tests.rs b/codex-rs/tui/src/tui/startup_tests.rs index a2df8df0c7..d92395e088 100644 --- a/codex-rs/tui/src/tui/startup_tests.rs +++ b/codex-rs/tui/src/tui/startup_tests.rs @@ -15,10 +15,10 @@ fn startup_input_keeps_text_without_replaying_actions() { Event::Key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)), Event::Key(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::SHIFT)), Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), - Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), - Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), Event::Key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)), Event::Paste("ello\r\n\tworld".to_string()), + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), Event::Key(KeyEvent::new_with_kind( KeyCode::Char('!'), KeyModifiers::NONE, @@ -31,6 +31,36 @@ fn startup_input_keeps_text_without_replaying_actions() { assert_eq!(input.into_text(), Some("hello\n\tworld".to_string())); } +#[test] +fn startup_input_preserves_internal_plain_whitespace_but_drops_trailing_actions() { + let mut input = StartupInputBuffer::default(); + for code in [ + KeyCode::Char('a'), + KeyCode::Enter, + KeyCode::Char('b'), + KeyCode::Tab, + KeyCode::Char('c'), + KeyCode::Enter, + ] { + input.handle_event(Event::Key(KeyEvent::new(code, KeyModifiers::NONE))); + } + + assert_eq!(input.into_text(), Some("a\nb\tc".to_string())); +} + +#[test] +fn startup_probe_input_preserves_internal_plain_whitespace_across_phases() { + let mut input = StartupInputBuffer::default(); + input.handle_probe_input(b"a\r\n"); + input.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::NONE, + ))); + input.handle_probe_input(b"\t"); + + assert_eq!(input.into_text(), Some("a\nb".to_string())); +} + #[test] fn startup_input_is_bounded() { let mut input = StartupInputBuffer::default();