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

@@ -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());