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:
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()),
|
||||
®istry,
|
||||
);
|
||||
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());
|
||||
|
||||
Reference in new issue
Block a user