diff --git a/crates/codegen/kigi-tui/src/app/agent_view/input.rs b/crates/codegen/kigi-tui/src/app/agent_view/input.rs index 832dc3f..c6ba60a 100644 --- a/crates/codegen/kigi-tui/src/app/agent_view/input.rs +++ b/crates/codegen/kigi-tui/src/app/agent_view/input.rs @@ -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 } diff --git a/crates/codegen/kigi-tui/src/app/agent_view/paste.rs b/crates/codegen/kigi-tui/src/app/agent_view/paste.rs index a00e41b..fe9198e 100644 --- a/crates/codegen/kigi-tui/src/app/agent_view/paste.rs +++ b/crates/codegen/kigi-tui/src/app/agent_view/paste.rs @@ -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; } } diff --git a/crates/codegen/kigi-tui/src/app/inline_edit.rs b/crates/codegen/kigi-tui/src/app/inline_edit.rs index 5573005..7a237f2 100644 --- a/crates/codegen/kigi-tui/src/app/inline_edit.rs +++ b/crates/codegen/kigi-tui/src/app/inline_edit.rs @@ -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] diff --git a/crates/codegen/kigi-tui/src/views/prompt_widget/mod.rs b/crates/codegen/kigi-tui/src/views/prompt_widget/mod.rs index 23e1bfe..d4654c2 100644 --- a/crates/codegen/kigi-tui/src/views/prompt_widget/mod.rs +++ b/crates/codegen/kigi-tui/src/views/prompt_widget/mod.rs @@ -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 diff --git a/crates/codegen/kigi-tui/src/views/prompt_widget/tests.rs b/crates/codegen/kigi-tui/src/views/prompt_widget/tests.rs index 5ba7868..edd511c 100644 --- a/crates/codegen/kigi-tui/src/views/prompt_widget/tests.rs +++ b/crates/codegen/kigi-tui/src/views/prompt_widget/tests.rs @@ -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());