diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 76a5760658fb..b07e83945328 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -211,6 +211,7 @@ use super::slash_commands::BuiltinCommandFlags; use super::slash_commands::ServiceTierCommand; use super::slash_commands::SlashCommandItem; use crate::bottom_pane::paste_burst::FlushResult; +use crate::history_cell::sanitize_user_text; use crate::key_hint::KeyBindingListExt; use crate::keymap::EditorKeymap; use crate::keymap::RuntimeKeymap; @@ -887,6 +888,7 @@ impl ChatComposer { /// the next user Enter key, then syncs popup state. pub fn handle_paste(&mut self, pasted: String) -> bool { let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); + let pasted = sanitize_user_text(&pasted); let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); @@ -7559,15 +7561,17 @@ mod tests { /*disable_paste_burst*/ false, ); - let needs_redraw = composer.handle_paste("hello".to_string()); + let sanitized = "_count_rows\tindent\ntwo"; + let needs_redraw = + composer.handle_paste("_count_r\x1b[13;2:3uows\tindent\n\0two\u{7f}".to_string()); assert!(needs_redraw); - assert_eq!(composer.draft.textarea.text(), "hello"); + assert_eq!(composer.draft.textarea.text(), sanitized); assert!(composer.draft.pending_pastes.is_empty()); let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted { text, .. } => assert_eq!(text, "hello"), + InputResult::Submitted { text, .. } => assert_eq!(text, sanitized), _ => panic!("expected Submitted"), } } diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index 787f999cf1ee..350ae81db172 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -11,6 +11,20 @@ pub(crate) struct UserHistoryCell { pub remote_image_urls: Vec, } +/// Remove CSI sequences and control characters, preserving tabs and newlines. +pub(crate) fn sanitize_user_text(text: &str) -> String { + let mut sanitized = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\x1b' && chars.next_if_eq(&'[').is_some() { + let _ = chars.find(|ch| ('@'..='~').contains(ch)); + } else if matches!(ch, '\n' | '\t') || !ch.is_control() { + sanitized.push(ch); + } + } + sanitized +} + /// Build logical lines for a user message with styled text elements. /// /// This preserves explicit newlines while interleaving element spans and skips @@ -93,6 +107,12 @@ fn trim_trailing_blank_lines(mut lines: Vec>) -> Vec impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { + let message = sanitize_user_text(&self.message); + let text_elements = if message == self.message { + self.text_elements.as_slice() + } else { + &[] + }; let wrap_width = width .saturating_sub( LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ @@ -117,10 +137,10 @@ impl HistoryCell for UserHistoryCell { )) }; - let wrapped_message = if self.message.is_empty() && self.text_elements.is_empty() { + let wrapped_message = if message.is_empty() && text_elements.is_empty() { None - } else if self.text_elements.is_empty() { - let message_without_trailing_newlines = self.message.trim_end_matches(['\r', '\n']); + } else if text_elements.is_empty() { + let message_without_trailing_newlines = message.trim_end_matches(['\r', '\n']); let wrapped = adaptive_wrap_lines( message_without_trailing_newlines .split('\n') @@ -133,8 +153,8 @@ impl HistoryCell for UserHistoryCell { (!wrapped.is_empty()).then_some(wrapped) } else { let raw_lines = build_user_message_lines_with_elements( - &self.message, - &self.text_elements, + &message, + text_elements, style, element_style, ); @@ -177,7 +197,8 @@ impl HistoryCell for UserHistoryCell { } fn raw_lines(&self) -> Vec> { - let mut lines = raw_lines_from_source(self.message.trim_end_matches(['\r', '\n'])); + let message = sanitize_user_text(&self.message); + let mut lines = raw_lines_from_source(message.trim_end_matches(['\r', '\n'])); if !self.remote_image_urls.is_empty() { if !lines.is_empty() { lines.push(Line::from("")); diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap index 51ce9e312fa2..beaad7984de2 100644 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap @@ -1,9 +1,6 @@ --- -source: tui/src/history_cell.rs +source: tui/src/history_cell/tests.rs expression: rendered --- - -› one two - three - four five - six seven +› _count_ro + ws diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 42dadb8535bc..315d84b3d98a 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1969,7 +1969,7 @@ fn ran_cell_multiline_with_stderr_snapshot() { } #[test] fn user_history_cell_wraps_and_prefixes_each_line_snapshot() { - let msg = "one two three four five six seven"; + let msg = "_count_r\x1b[13;2:3uows"; let cell = UserHistoryCell { message: msg.to_string(), text_elements: Vec::new(), @@ -1982,6 +1982,7 @@ fn user_history_cell_wraps_and_prefixes_each_line_snapshot() { let lines = cell.display_lines(width); let rendered = render_lines(&lines).join("\n"); + assert_eq!(render_lines(&cell.raw_lines()), ["_count_rows"]); insta::assert_snapshot!(rendered); }