fix(tui): pasted U+2028 and U+2029 become line breaks

str::lines() splits on neither separator, so a paste carrying them counted
as one line and the invisible characters broke the textarea's width model.
Bare \r, U+2028 and U+2029 now normalize to \n on every paste route (the
prompt, the inline paste key and the previous-message editor); dropped
file paths keep their bytes verbatim because a separator in a filename is
payload.

Ported from upstream 1.0.18 (grok-build prompt_widget normalize_line_breaks).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
This commit is contained in:
雷电芽衣 committed 2026-09-13 10:16:22 -04:00
1 parent d8609267a1
commit 901203c2b2
5 files changed
+73 -21

No files matched your search

@@ -734,7 +734,8 @@ impl AgentView {
Event::Mouse(mouse) => self.handle_inline_edit_mouse(mouse),
Event::Paste(text) => {
if let Some(ref mut edit) = self.inline_edit {
edit.textarea.insert_str(text);
edit.textarea
.insert_str(&crate::views::prompt_widget::normalize_line_breaks(text));
}
InputOutcome::Changed
}
@@ -344,7 +344,10 @@ impl AgentView {
self.prompt.textarea.begin_undo_group();
group_open = true;
}
if matches!(self.prompt.handle_paste(&to_insert), PromptEvent::Edited) {
if matches!(
self.prompt.insert_dropped_path(&to_insert),
PromptEvent::Edited
) {
inserted_non_image = true;
}
}
@@ -290,6 +290,27 @@ mod tests {
agent
}
/// The previous-message editor is its own paste route; separators normalize there too.
#[test]
fn inline_edit_paste_normalizes_line_separators() {
let mut agent = agent_with_prompt();
assert!(agent.enter_inline_edit(0));
let registry = crate::actions::ActionRegistry::defaults();
agent.handle_input(
&crossterm::event::Event::Paste("a\u{2028}b\u{2029}c".into()),
&registry,
);
let text = agent
.inline_edit
.as_ref()
.expect("editing state")
.textarea
.text()
.to_owned();
assert!(text.contains("a\nb\nc"), "{text:?}");
assert!(!text.contains('\u{2028}'), "{text:?}");
}
/// Entering edit mode on a plain user prompt captures the original text,
/// resolves the rewind target, and selects the entry.
#[test]
@@ -1580,7 +1580,7 @@ impl PromptWidget {
// without creating a [Pasted: N lines] element.
if crate::input::key::is_inline_paste_key(key) {
if let Some(text) = system_clipboard_get() {
let text = normalize_cr(&text);
let text = normalize_line_breaks(&text);
if text.is_empty() {
crate::clipboard::log_paste_key_empty_host_clipboard("prompt_widget_inline");
return PromptEvent::Ignored;
@@ -2082,13 +2082,18 @@ impl PromptWidget {
/// Repasting a chip's exact content while the cursor is on or right
/// after it expands that chip instead of inserting a duplicate.
pub fn handle_paste(&mut self, text: &str) -> PromptEvent {
self.paste_prepared(&normalize_line_breaks(text))
}
/// Paste a dropped path verbatim: a separator inside a filename is payload, not a line break.
pub fn insert_dropped_path(&mut self, text: &str) -> PromptEvent {
self.paste_prepared(text)
}
/// The paste path after line-break normalization: selection replacement, chips, image records.
fn paste_prepared(&mut self, text: &str) -> PromptEvent {
if text.is_empty() {
return PromptEvent::Ignored;
}
self.post_insert_image_preview = None;
let text = normalize_cr(text);
let text = &text;
let replacing_selection = self.textarea.selection_range().is_some();
// Repaste-to-expand: "paste didn't do what I want? paste again."
@@ -3420,19 +3425,15 @@ fn chip_line(label: String) -> Line<'static> {
])
}
/// Normalize bare `\r` to `\n`, leaving `\r\n` pairs intact.
///
/// Some terminals send bare `\r` for line breaks in bracketed-paste content.
/// Rust's `str::lines()` only splits on `\n` and `\r\n`, so without this
/// normalization multi-line pastes would be treated as a single line.
fn normalize_cr(text: &str) -> String {
/// Bare `\r`, U+2028 and U+2029 become `\n` (`\r\n` stays): `str::lines()` splits on none of them, and the invisible separators break the width model.
pub(crate) fn normalize_line_breaks(text: &str) -> String {
let mut s = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '\r' && chars.peek() != Some(&'\n') {
s.push('\n');
} else {
s.push(c);
match c {
'\r' if chars.peek() != Some(&'\n') => s.push('\n'),
'\u{2028}' | '\u{2029}' => s.push('\n'),
_ => s.push(c),
}
}
s
@@ -1887,22 +1887,48 @@
#[test]
fn normalize_cr_bare_cr() {
assert_eq!(normalize_cr("a\rb\rc"), "a\nb\nc");
assert_eq!(normalize_line_breaks("a\rb\rc"), "a\nb\nc");
}
#[test]
fn normalize_cr_crlf_preserved() {
assert_eq!(normalize_cr("a\r\nb\r\nc"), "a\r\nb\r\nc");
assert_eq!(normalize_line_breaks("a\r\nb\r\nc"), "a\r\nb\r\nc");
}
/// A dropped file name may contain U+2028; the inserted path must still name that file.
#[test]
fn insert_dropped_path_keeps_unicode_separators() {
let mut pw = PromptWidget::new();
pw.insert_dropped_path("/tmp/p16\u{2028}note.txt ");
assert_eq!(pw.text(), "/tmp/p16\u{2028}note.txt ");
}
/// A dropped path takes the full paste path: it replaces the selection like any paste.
#[test]
fn insert_dropped_path_replaces_the_selection() {
let mut pw = PromptWidget::new();
pw.handle_paste("/tmp/old.txt ");
let len = pw.textarea.text().len();
pw.textarea.set_selection(0, len);
assert_eq!(pw.insert_dropped_path("/tmp/new.txt "), PromptEvent::Edited);
assert_eq!(pw.textarea.text(), "/tmp/new.txt ");
assert!(pw.textarea.selection_range().is_none());
}
#[test]
fn normalize_line_breaks_unicode_separators() {
assert_eq!(normalize_line_breaks("a\u{2028}b\u{2029}c"), "a\nb\nc");
assert_eq!(normalize_line_breaks("a\u{2028}\r\nb"), "a\n\r\nb");
}
#[test]
fn normalize_cr_mixed() {
assert_eq!(normalize_cr("a\r\nb\rc"), "a\r\nb\nc");
assert_eq!(normalize_line_breaks("a\r\nb\rc"), "a\r\nb\nc");
}
#[test]
fn normalize_cr_no_cr() {
assert_eq!(normalize_cr("no cr\nhere"), "no cr\nhere");
assert_eq!(normalize_line_breaks("no cr\nhere"), "no cr\nhere");
}
@@ -1911,7 +1937,7 @@
let mut pw = PromptWidget::new();
let text = "line1\nline2\nline3";
// Simulate Ctrl+Shift+V: insert_str directly, no element.
let normalized = normalize_cr(text);
let normalized = normalize_line_breaks(text);
pw.textarea.insert_str(&normalized);
assert_eq!(pw.textarea.text(), text);
assert!(pw.textarea.elements().is_empty());