Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Comment thread
etraut-openai marked this conversation as resolved.
let char_count = pasted.chars().count();
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
let placeholder = self.next_large_paste_placeholder(char_count);
Expand Down Expand Up @@ -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"),
}
}
Expand Down
33 changes: 27 additions & 6 deletions codex-rs/tui/src/history_cell/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ pub(crate) struct UserHistoryCell {
pub remote_image_urls: Vec<String>,
}

/// 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
Expand Down Expand Up @@ -93,6 +107,12 @@ fn trim_trailing_blank_lines(mut lines: Vec<Line<'static>>) -> Vec<Line<'static>

impl HistoryCell for UserHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let message = sanitize_user_text(&self.message);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

display_lines sanitizes restored messages, but raw mode bypasses it: UserHistoryCell::raw_lines() still emits the original self.message, which transcript reflow prints directly. A resumed message containing \x1b[...] can therefore corrupt the terminal after Alt-R or a resize in raw mode. Please sanitize the source used by raw_lines, or enforce sanitization before terminal insertion for every render mode.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, good catch. Fixed.

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 */
Expand All @@ -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')
Expand All @@ -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,
);
Expand Down Expand Up @@ -177,7 +197,8 @@ impl HistoryCell for UserHistoryCell {
}

fn raw_lines(&self) -> Vec<Line<'static>> {
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(""));
Expand Down
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion codex-rs/tui/src/history_cell/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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);
}

Expand Down
Loading