diff --git a/README.md b/README.md index c8baea1a..5f33da25 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ dirge --provider glm # defaults to glm-4 | Shift+Enter / Meta+Enter | Insert newline (multi-line input) | | Tab | Insert 2 spaces | | `@` | File picker (Tab/Enter select, Esc cancel) | +| Paste (≥4 lines) | Collapses to `[N lines pasted]`; re-paste same content to expand inline | **Agent control** diff --git a/src/event.rs b/src/event.rs index f1b87133..f7a40504 100644 --- a/src/event.rs +++ b/src/event.rs @@ -39,4 +39,5 @@ pub enum UserEvent { row: u16, col: u16, }, + Paste(String), } diff --git a/src/tests/input_tests.rs b/src/tests/input_tests.rs index 08d2c751..d7c7a0da 100644 --- a/src/tests/input_tests.rs +++ b/src/tests/input_tests.rs @@ -731,3 +731,160 @@ fn meta_enter_with_picker_active_inserts_newline() { let result = editor.handle_key(meta_enter()); assert!(result.is_none()); } + +// ── Paste collapse ────────────────────────────────────────── + +#[test] +fn short_paste_inserts_raw() { + // Single-line paste — should go in as plain text, no placeholder. + let mut editor = InputEditor::new(); + editor.handle_paste("hello world"); + assert_eq!(editor.buffer.as_str(), "hello world"); + assert_eq!(editor.cursor, "hello world".len()); +} + +#[test] +fn three_line_paste_still_raw() { + // Threshold is 4 lines — three lines should not collapse. + let mut editor = InputEditor::new(); + editor.handle_paste("a\nb\nc"); + assert_eq!(editor.buffer.as_str(), "a\nb\nc"); +} + +#[test] +fn four_line_paste_collapses_to_placeholder() { + let mut editor = InputEditor::new(); + editor.handle_paste("a\nb\nc\nd"); + // Buffer holds a marker block; render_line should show "[4 lines pasted]". + assert!(editor.buffer.contains('\u{0001}')); + let raw_line = editor.buffer.as_str(); + let (display, _) = editor.render_line(raw_line, editor.cursor); + assert_eq!(display, "[4 lines pasted]"); + // Expanded text is the original paste. + assert_eq!(editor.expanded().as_str(), "a\nb\nc\nd"); +} + +#[test] +fn submit_expands_placeholders() { + let mut editor = InputEditor::new(); + type_str(&mut editor, "before "); + editor.handle_paste("L1\nL2\nL3\nL4"); + type_str(&mut editor, " after"); + let submitted = editor.handle_key(press(KeyCode::Enter)).unwrap(); + assert_eq!(submitted.as_str(), "before L1\nL2\nL3\nL4 after"); + // Buffer and pastes both cleared after submit. + assert!(editor.buffer.is_empty()); +} + +#[test] +fn left_right_skip_placeholder_as_unit() { + let mut editor = InputEditor::new(); + type_str(&mut editor, "x"); + editor.handle_paste("a\nb\nc\nd"); + type_str(&mut editor, "y"); + // Buffer now: "x" + marker + "y" + let end = editor.cursor; + // One Left should jump from after 'y' to between marker close and 'y'. + editor.handle_key(press(KeyCode::Left)); + assert!(editor.cursor < end); + // Next Left should skip the entire marker block, landing just after 'x'. + let after_first = editor.cursor; + editor.handle_key(press(KeyCode::Left)); + assert_eq!(editor.cursor, 1); // just after 'x' + assert!(editor.cursor < after_first); +} + +#[test] +fn backspace_deletes_whole_placeholder() { + let mut editor = InputEditor::new(); + type_str(&mut editor, "a"); + editor.handle_paste("L1\nL2\nL3\nL4"); + type_str(&mut editor, "b"); + let len_with_marker = editor.buffer.len(); + // Move left past 'b', so cursor sits just after the marker. + editor.handle_key(press(KeyCode::Left)); + // Backspace removes the whole marker block in one go. + editor.handle_key(press(KeyCode::Backspace)); + assert!(editor.buffer.len() < len_with_marker); + assert_eq!(editor.buffer.as_str(), "ab"); +} + +#[test] +fn second_paste_of_same_content_expands_inline() { + let mut editor = InputEditor::new(); + editor.handle_paste("a\nb\nc\nd"); + // After first paste: buffer holds a marker; expanded length matches input. + assert!(editor.buffer.contains('\u{0001}')); + // Second paste of identical content expands the existing placeholder. + editor.handle_paste("a\nb\nc\nd"); + assert_eq!(editor.buffer.as_str(), "a\nb\nc\nd"); + assert!(!editor.buffer.contains('\u{0001}')); +} + +#[test] +fn second_paste_of_different_content_creates_new_placeholder() { + let mut editor = InputEditor::new(); + editor.handle_paste("a\nb\nc\nd"); + editor.handle_paste("X\nY\nZ\nW"); + // Two distinct placeholder markers in the buffer. + let marker_count = editor.buffer.matches('\u{0001}').count(); + assert_eq!(marker_count, 4); // two open + two close + // Expanded contains both bodies in order. + assert_eq!(editor.expanded().as_str(), "a\nb\nc\ndX\nY\nZ\nW"); +} + +#[test] +fn paste_mark_chars_stripped_from_input() { + // A malicious paste containing PASTE_MARK shouldn't break the parser. + let mut editor = InputEditor::new(); + editor.handle_paste("a\nb\n\u{0001}\nc\nd"); + // PASTE_MARK chars are stripped before storage; line count after strip is 5 + // (still >= 4) so it should collapse. + assert_eq!(editor.expanded().as_str(), "a\nb\n\nc\nd"); +} + +#[test] +fn ctrl_w_does_not_split_paste_marker() { + // Regression: word-skip-back from position past a paste marker used + // prev_word_boundary directly, which treats \x01 as punctuation and + // would happily land mid-marker — corrupting it. + let mut editor = InputEditor::new(); + type_str(&mut editor, "before "); + editor.handle_paste("L1\nL2\nL3\nL4"); + type_str(&mut editor, " after"); + // Cursor at end of buffer. Ctrl+W should kill "after"; the marker stays. + editor.handle_key(ctrl(KeyCode::Char('w'))); + // Buffer should still contain the paste marker intact. + let mark_count = editor.buffer.matches('\u{0001}').count(); + assert_eq!(mark_count, 2, "marker bytes corrupted by Ctrl+W"); + assert_eq!(editor.expanded().as_str(), "before L1\nL2\nL3\nL4 "); +} + +#[test] +fn meta_b_skips_past_paste_marker() { + // Meta+B from after the marker should land before the marker, not inside. + let mut editor = InputEditor::new(); + editor.handle_paste("L1\nL2\nL3\nL4"); + type_str(&mut editor, " trailing"); + // Meta+B once: lands at the start of "trailing". + editor.handle_key(meta(KeyCode::Char('b'))); + // Meta+B again: should jump past the entire marker block. + let before = editor.cursor; + editor.handle_key(meta(KeyCode::Char('b'))); + // Now cursor should be at byte 0 (before the marker). + assert_eq!(editor.cursor, 0); + assert!(editor.cursor < before); + // Marker still intact. + assert_eq!(editor.buffer.matches('\u{0001}').count(), 2); +} + +#[test] +fn paste_during_picker_is_ignored() { + // When the picker is active, paste should not insert marker bytes into + // the buffer (the picker doesn't know about them, would render badly). + let mut editor = InputEditor::new(); + editor.handle_key(press(KeyCode::Char('@'))); + let buffer_before = editor.buffer.to_string(); + editor.handle_paste("a\nb\nc\nd"); + assert_eq!(editor.buffer.as_str(), buffer_before); +} diff --git a/src/ui/input.rs b/src/ui/input.rs index 50bf0c1d..227076d7 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -139,6 +139,18 @@ fn next_line_start(s: &str, cursor: usize) -> Option { after.find('\n').map(|p| cursor + p + 1) } +/// Threshold for collapsing pastes: anything with >= this many newlines becomes a +/// `[N lines pasted]` placeholder. Single-line and short pastes go in raw so a +/// quick paste-of-a-command isn't surprising. +const PASTE_COLLAPSE_LINES: usize = 4; + +/// Sentinel character bracketing a paste placeholder in the buffer. The buffer +/// stores `\x01\x01`, where `` is the decimal index into +/// `pastes`. Because `\x01` is filtered out of bracketed-paste content (see +/// `handle_paste`) and ignored as a typeable key, it can't appear in normal +/// input — so its presence reliably marks a placeholder block. +const PASTE_MARK: char = '\x01'; + pub struct InputEditor { pub buffer: CompactString, pub cursor: usize, @@ -149,6 +161,145 @@ pub struct InputEditor { kill_ring: Vec, last_action_was_kill: bool, yank_state: Option, + /// Pasted text bodies indexed by the digits appearing between `\x01` marks + /// in the buffer. `None` entries are tombstones for expanded pastes (so + /// existing indices remain valid). + pastes: Vec>, +} + +/// Find the marker block `\x01\x01` containing or starting at +/// `cursor`. Returns `(start_of_opening_mark, byte_after_closing_mark, index)`. +fn marker_containing(s: &str, cursor: usize) -> Option<(usize, usize, usize)> { + let bytes = s.as_bytes(); + // Walk back from cursor to find an opening PASTE_MARK. + let mut i = cursor.min(bytes.len()); + while i > 0 && bytes[i - 1] != PASTE_MARK as u8 { + i -= 1; + } + if i == 0 { + return None; + } + // i is just after a PASTE_MARK; the opening mark is at i-1. + let open = i - 1; + let rest = &bytes[i..]; + let close_rel = rest.iter().position(|&b| b == PASTE_MARK as u8)?; + let close = i + close_rel; + if cursor > close { + return None; + } + let digits = std::str::from_utf8(&bytes[i..close]).ok()?; + let idx = digits.parse::().ok()?; + Some((open, close + 1, idx)) +} + +/// If `pos` falls strictly inside a marker block `(start, end)`, return +/// `start` (so cursor motion moves *before* the block). Otherwise return +/// `pos` unchanged. +fn skip_left_over_marker(s: &str, pos: usize) -> usize { + for (start, end, _) in marker_blocks(s) { + if pos > start && pos < end { + return start; + } + } + pos +} + +/// If `pos` falls strictly inside a marker block `(start, end)`, return +/// `end` (so cursor motion moves *after* the block). Otherwise return +/// `pos` unchanged. +fn skip_right_over_marker(s: &str, pos: usize) -> usize { + for (start, end, _) in marker_blocks(s) { + if pos > start && pos < end { + return end; + } + } + pos +} + +/// Move one cursor step left, treating any marker block as a single unit. +fn prev_pos(s: &str, cursor: usize) -> usize { + skip_left_over_marker(s, prev_char_boundary(s, cursor)) +} + +/// Move one cursor step right, treating any marker block as a single unit. +fn next_pos(s: &str, cursor: usize) -> usize { + skip_right_over_marker(s, next_char_boundary(s, cursor)) +} + +/// Word-skip left, but never land mid-marker. `prev_word_boundary` is +/// marker-blind (it sees `\x01` as punctuation and would happily split the +/// marker open), so we post-process with `skip_left_over_marker` to round any +/// in-marker landing back to the marker's left edge. +fn prev_word_pos(s: &str, cursor: usize) -> usize { + skip_left_over_marker(s, prev_word_boundary(s, cursor)) +} + +/// Word-skip right, with the symmetric marker-safety post-process. +fn next_word_pos(s: &str, cursor: usize) -> usize { + skip_right_over_marker(s, next_word_boundary(s, cursor)) +} + +/// What range a backspace at `cursor` should remove. If the character to the +/// left is the closing mark of a placeholder, return the whole block; +/// otherwise return a single char. +fn backspace_range(s: &str, cursor: usize) -> Option<(usize, usize)> { + if cursor == 0 { + return None; + } + if let Some((start, end, _)) = marker_containing(s, cursor.saturating_sub(1)) { + if cursor == end { + return Some((start, end)); + } + } + Some((prev_char_boundary(s, cursor), cursor)) +} + +/// What range a delete at `cursor` should remove. If the cursor sits at the +/// opening of a placeholder, return the whole block; otherwise a single char. +fn delete_range(s: &str, cursor: usize) -> Option<(usize, usize)> { + if cursor >= s.len() { + return None; + } + if let Some((start, end, _)) = marker_containing(s, cursor + 1) { + if cursor == start { + return Some((start, end)); + } + } + Some((cursor, next_char_boundary(s, cursor))) +} + +/// Scan `s` and return each marker block as `(start, end, index)` in order. +fn marker_blocks(s: &str) -> Vec<(usize, usize, usize)> { + let bytes = s.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == PASTE_MARK as u8 { + let start = i; + let body_start = i + 1; + if let Some(rel) = bytes[body_start..] + .iter() + .position(|&b| b == PASTE_MARK as u8) + { + let close = body_start + rel; + if let Ok(digits) = std::str::from_utf8(&bytes[body_start..close]) { + if let Ok(idx) = digits.parse::() { + out.push((start, close + 1, idx)); + i = close + 1; + continue; + } + } + } + } + i += 1; + } + out +} + +/// Compute the placeholder display string for a paste body. +fn placeholder_display(text: &str) -> String { + let lines = text.matches('\n').count() + 1; + format!("[{} lines pasted]", lines) } impl InputEditor { @@ -163,9 +314,147 @@ impl InputEditor { kill_ring: Vec::new(), last_action_was_kill: false, yank_state: None, + pastes: Vec::new(), } } + /// Insert pasted text. If it spans `PASTE_COLLAPSE_LINES` or more lines, + /// store it and insert a `[N lines pasted]` placeholder; otherwise insert + /// raw. If the same content was already pasted and is still represented + /// by a placeholder, expand that placeholder inline instead (so a second + /// paste of the same content reveals the body). + pub fn handle_paste(&mut self, text: &str) { + // The file picker (`@query`) maintains its own filter state. A paste + // landing here would write marker bytes into the buffer that the + // picker doesn't know about, leaving a stale/corrupt query. Easiest + // to just ignore pastes while the picker is active — the user can + // close the picker (Esc) and re-paste. + if self.picker.as_ref().is_some_and(|p| p.active) { + return; + } + // Strip PASTE_MARK so it can never appear in paste content and confuse + // the marker parser. + let cleaned: String = text.chars().filter(|&c| c != PASTE_MARK).collect(); + if cleaned.is_empty() { + return; + } + let line_count = cleaned.matches('\n').count() + 1; + if line_count < PASTE_COLLAPSE_LINES { + self.insert_str(&cleaned); + return; + } + // Auto-expand on repeat: if this body matches an existing placeholder + // in the buffer, expand it inline rather than inserting another + // placeholder. + if let Some((start, end, idx)) = + marker_blocks(&self.buffer).into_iter().find(|(_, _, idx)| { + self.pastes + .get(*idx) + .and_then(|opt| opt.as_ref()) + .map(|s| s.as_str() == cleaned.as_str()) + .unwrap_or(false) + }) + { + let body = self.pastes[idx].take().unwrap(); + self.buffer.replace_range(start..end, body.as_str()); + // Place cursor at end of expanded text. + self.cursor = start + body.len(); + self.history_pos = None; + self.reset_kill_accumulation(); + return; + } + let idx = self.pastes.len(); + self.pastes.push(Some(CompactString::from(cleaned))); + let marker = format!("{}{}{}", PASTE_MARK, idx, PASTE_MARK); + self.insert_str(&marker); + } + + fn insert_str(&mut self, s: &str) { + self.buffer.insert_str(self.cursor, s); + self.cursor += s.len(); + self.history_pos = None; + self.reset_kill_accumulation(); + } + + /// Remove a byte range from the buffer and place the cursor at `start`. + /// If the range fully contains a placeholder marker block, the + /// corresponding `pastes` slot is tombstoned so its body can be GC'd + /// (idempotent — repeat removes are fine). + fn remove_range(&mut self, start: usize, end: usize) { + // Detect any marker block fully contained in the removed range and + // free its stored body. + for (mstart, mend, idx) in marker_blocks(&self.buffer) { + if mstart >= start && mend <= end { + if let Some(slot) = self.pastes.get_mut(idx) { + *slot = None; + } + } + } + self.buffer.replace_range(start..end, ""); + self.cursor = start; + } + + /// Return the buffer with all placeholder markers expanded to their + /// original paste bodies. Used at submit time so the agent receives the + /// real text. + pub fn expanded(&self) -> CompactString { + let blocks = marker_blocks(&self.buffer); + if blocks.is_empty() { + return self.buffer.clone(); + } + let mut out = String::with_capacity(self.buffer.len()); + let mut cur = 0; + for (start, end, idx) in blocks { + out.push_str(&self.buffer[cur..start]); + if let Some(Some(body)) = self.pastes.get(idx) { + out.push_str(body); + } + cur = end; + } + out.push_str(&self.buffer[cur..]); + out.into() + } + + /// Return (display_text, display_cursor_col) for a logical line of the + /// buffer with placeholders rendered as `[N lines pasted]`. Used by the + /// renderer so the input bar shows a compact representation. + pub fn render_line(&self, line: &str, cursor_in_line: usize) -> (String, usize) { + let blocks = marker_blocks(line); + if blocks.is_empty() { + return (line.to_string(), cursor_in_line); + } + let mut out = String::with_capacity(line.len()); + let mut display_cursor = cursor_in_line; + let mut cur = 0; + for (start, end, idx) in blocks { + // Carry plain text before the block. + if cur < start { + out.push_str(&line[cur..start]); + } + let placeholder = self + .pastes + .get(idx) + .and_then(|o| o.as_ref()) + .map(|s| placeholder_display(s)) + .unwrap_or_else(|| "[expanded]".to_string()); + // Adjust the displayed cursor position if it lies after this block. + if cursor_in_line >= end { + let block_len = end - start; + display_cursor = display_cursor - block_len + placeholder.len(); + } else if cursor_in_line > start && cursor_in_line < end { + // Cursor logically inside a marker — pin it to the placeholder + // boundary so it never appears mid-marker. + display_cursor = out.len() + placeholder.len(); + } + out.push_str(&placeholder); + cur = end; + } + if cur < line.len() { + out.push_str(&line[cur..]); + } + (out, display_cursor) + } + pub fn set_monochrome(&mut self, monochrome: bool) { self.monochrome = monochrome; if let Some(picker) = self.picker.as_mut() { @@ -330,16 +619,24 @@ impl InputEditor { self.history_pos = None; return None; } - // Plain Enter → submit - let text = self.buffer.clone(); - if !text.is_empty() { - self.history.push(text.clone()); + // Plain Enter → submit. Expand any paste placeholders so the + // agent receives the original text. Store the expanded form in + // history too — history navigation can't rely on paste-index + // continuity across turns. + let submitted = self.expanded(); + if !submitted.is_empty() { + self.history.push(submitted.clone()); } self.history_pos = None; self.buffer.clear(); self.cursor = 0; + self.pastes.clear(); self.reset_kill_accumulation(); - if text.is_empty() { None } else { Some(text) } + if submitted.is_empty() { + None + } else { + Some(submitted) + } } // Ctrl+A → start of line @@ -359,7 +656,7 @@ impl InputEditor { // Ctrl+B → left one char KeyCode::Char('b') if ctrl => { if self.cursor > 0 { - self.cursor = prev_char_boundary(&self.buffer, self.cursor); + self.cursor = prev_pos(&self.buffer, self.cursor); } self.reset_kill_accumulation(); None @@ -368,7 +665,7 @@ impl InputEditor { // Ctrl+F → right one char KeyCode::Char('f') if ctrl => { if self.cursor < self.buffer.len() { - self.cursor = next_char_boundary(&self.buffer, self.cursor); + self.cursor = next_pos(&self.buffer, self.cursor); } self.reset_kill_accumulation(); None @@ -398,7 +695,7 @@ impl InputEditor { // Ctrl+W → kill word before KeyCode::Char('w') if ctrl => { if self.cursor > 0 { - let start = prev_word_boundary(&self.buffer, self.cursor); + let start = prev_word_pos(&self.buffer, self.cursor); let killed: CompactString = self.buffer[start..self.cursor].into(); self.buffer.replace_range(start..self.cursor, ""); self.cursor = start; @@ -409,9 +706,8 @@ impl InputEditor { // Ctrl+H or Backspace (plain) KeyCode::Char('h') if ctrl => { - if self.cursor > 0 { - self.cursor = prev_char_boundary(&self.buffer, self.cursor); - self.buffer.remove(self.cursor); + if let Some((start, end)) = backspace_range(&self.buffer, self.cursor) { + self.remove_range(start, end); } self.reset_kill_accumulation(); None @@ -473,7 +769,7 @@ impl InputEditor { // Meta+D → delete word after KeyCode::Char('d') if alt => { if self.cursor < self.buffer.len() { - let end = next_word_boundary(&self.buffer, self.cursor); + let end = next_word_pos(&self.buffer, self.cursor); self.buffer.replace_range(self.cursor..end, ""); } self.reset_kill_accumulation(); @@ -483,7 +779,7 @@ impl InputEditor { // Meta+B → prev word (Emacs style) KeyCode::Char('b') if alt => { if self.cursor > 0 { - self.cursor = prev_word_boundary(&self.buffer, self.cursor); + self.cursor = prev_word_pos(&self.buffer, self.cursor); } self.reset_kill_accumulation(); None @@ -492,7 +788,7 @@ impl InputEditor { // Meta+F → next word (Emacs style) KeyCode::Char('f') if alt => { if self.cursor < self.buffer.len() { - self.cursor = next_word_boundary(&self.buffer, self.cursor); + self.cursor = next_word_pos(&self.buffer, self.cursor); } else { self.cursor = self.buffer.len(); } @@ -503,7 +799,7 @@ impl InputEditor { // Meta+Left → prev word KeyCode::Left if alt => { if self.cursor > 0 { - self.cursor = prev_word_boundary(&self.buffer, self.cursor); + self.cursor = prev_word_pos(&self.buffer, self.cursor); } self.reset_kill_accumulation(); None @@ -512,7 +808,7 @@ impl InputEditor { // Meta+Right → next word KeyCode::Right if alt => { if self.cursor < self.buffer.len() { - self.cursor = next_word_boundary(&self.buffer, self.cursor); + self.cursor = next_word_pos(&self.buffer, self.cursor); } else { self.cursor = self.buffer.len(); } @@ -523,7 +819,7 @@ impl InputEditor { // Meta+Backspace → delete word before KeyCode::Backspace if alt => { if self.cursor > 0 { - let start = prev_word_boundary(&self.buffer, self.cursor); + let start = prev_word_pos(&self.buffer, self.cursor); self.buffer.replace_range(start..self.cursor, ""); self.cursor = start; } @@ -548,17 +844,16 @@ impl InputEditor { } KeyCode::Backspace => { - if self.cursor > 0 { - self.cursor = prev_char_boundary(&self.buffer, self.cursor); - self.buffer.remove(self.cursor); + if let Some((start, end)) = backspace_range(&self.buffer, self.cursor) { + self.remove_range(start, end); } self.reset_kill_accumulation(); None } KeyCode::Delete => { - if self.cursor < self.buffer.len() { - self.buffer.remove(self.cursor); + if let Some((start, end)) = delete_range(&self.buffer, self.cursor) { + self.remove_range(start, end); } self.reset_kill_accumulation(); None @@ -566,7 +861,7 @@ impl InputEditor { KeyCode::Left => { if self.cursor > 0 { - self.cursor = prev_char_boundary(&self.buffer, self.cursor); + self.cursor = prev_pos(&self.buffer, self.cursor); } self.reset_kill_accumulation(); None @@ -574,7 +869,7 @@ impl InputEditor { KeyCode::Right => { if self.cursor < self.buffer.len() { - self.cursor = next_char_boundary(&self.buffer, self.cursor); + self.cursor = next_pos(&self.buffer, self.cursor); } self.reset_kill_accumulation(); None diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 452bd4a7..08a24f0a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -160,8 +160,7 @@ pub async fn run_interactive( render_session(&mut renderer, session, cli, cfg, context)?; renderer.draw_bottom( - "", - 0, + &input, &StatusLine::render( session, false, @@ -214,6 +213,11 @@ pub async fn run_interactive( } _ => {} }, + Ok(event::Event::Paste(text)) => { + if user_tx_clone.blocking_send(UserEvent::Paste(text)).is_err() { + break; + } + } Ok(event::Event::Resize(_, _)) => {} Err(_) => break, _ => {} @@ -229,8 +233,7 @@ pub async fn run_interactive( renderer.scroll_line_up(); renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -240,8 +243,7 @@ pub async fn run_interactive( renderer.scroll_line_down(); renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -255,7 +257,7 @@ pub async fn run_interactive( renderer.selection_end = Some(idx); renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -268,13 +270,22 @@ pub async fn run_interactive( renderer.selection_end = Some(idx); renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; } continue; } + UserEvent::Paste(text) => { + input.handle_paste(&text); + renderer.draw_bottom( + &input, + &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), + is_running, + )?; + continue; + } UserEvent::MouseUp { row, col: _ } => { if renderer.selection_active { if let Some(idx) = renderer.buffer_line_at_row(row) { @@ -286,7 +297,7 @@ pub async fn run_interactive( renderer.clear_selection(); renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -303,7 +314,7 @@ pub async fn run_interactive( rewind_picker.deactivate(); renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -313,7 +324,7 @@ pub async fn run_interactive( search_active = false; renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -329,8 +340,7 @@ pub async fn run_interactive( } renderer.write_line("interrupted", C_ERROR)?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -348,7 +358,7 @@ pub async fn run_interactive( renderer.clear_selection(); renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -363,7 +373,7 @@ pub async fn run_interactive( search_selected = 0; update_search(&renderer, &search_query, &mut search_matches, &mut search_selected); renderer.draw_bottom( - &input.buffer, input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -378,7 +388,7 @@ pub async fn run_interactive( last_esc = None; renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -391,7 +401,7 @@ pub async fn run_interactive( search_active = false; renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -418,7 +428,7 @@ pub async fn run_interactive( _ => {} } renderer.draw_bottom( - &input.buffer, input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -430,7 +440,7 @@ pub async fn run_interactive( rewind_picker.deactivate(); renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -441,7 +451,7 @@ pub async fn run_interactive( renderer.clear_selection(); renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -458,8 +468,7 @@ pub async fn run_interactive( } renderer.write_line("interrupted (Esc)", C_ERROR)?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -476,8 +485,7 @@ pub async fn run_interactive( renderer.render_viewport()?; } renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -495,8 +503,7 @@ pub async fn run_interactive( open_rewind_picker(session, &mut rewind_picker); rewind_picker.draw()?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -506,8 +513,7 @@ pub async fn run_interactive( last_esc = Some(now); renderer.write_line("Press Esc again to rewind...", Color::DarkGrey)?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -527,8 +533,7 @@ pub async fn run_interactive( Color::White, )?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -540,8 +545,7 @@ pub async fn run_interactive( renderer.scroll_page_up(); renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -551,8 +555,7 @@ pub async fn run_interactive( renderer.scroll_page_down(); renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -562,8 +565,7 @@ pub async fn run_interactive( renderer.scroll_to_top(); renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -572,8 +574,7 @@ pub async fn run_interactive( KeyCode::End => { renderer.scroll_to_bottom()?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -586,7 +587,7 @@ pub async fn run_interactive( && input.handle_picker_key(key) { renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -601,8 +602,7 @@ pub async fn run_interactive( if loop_state.as_ref().is_some_and(|ls| ls.active) && !text.starts_with('/') { renderer.write_line("loop active: /loop stop to cancel", C_ERROR)?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -615,8 +615,7 @@ pub async fn run_interactive( if is_running { renderer.write_line("agent is busy, wait or interrupt first", C_ERROR)?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -656,8 +655,7 @@ pub async fn run_interactive( } } renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -670,8 +668,7 @@ pub async fn run_interactive( ) { renderer.write_line("agent is busy — wait, interrupt (Ctrl+C), or use /quit", C_ERROR)?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -856,8 +853,7 @@ pub async fn run_interactive( } } renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -1260,8 +1256,7 @@ pub async fn run_interactive( } } renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -1340,8 +1335,7 @@ pub async fn run_interactive( renderer.render_viewport()?; renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; @@ -1351,8 +1345,7 @@ pub async fn run_interactive( } _ = tokio::time::sleep(tokio::time::Duration::from_millis(200)), if is_running => { renderer.draw_bottom( - &input.buffer, - input.cursor, + &input, &StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), is_running, )?; diff --git a/src/ui/renderer.rs b/src/ui/renderer.rs index db8751cc..c5ea8c08 100644 --- a/src/ui/renderer.rs +++ b/src/ui/renderer.rs @@ -456,14 +456,16 @@ impl Renderer { pub fn draw_bottom( &mut self, - full_input: &str, - full_cursor: usize, + editor: &crate::ui::input::InputEditor, status: &str, is_running: bool, ) -> io::Result<()> { let (cols, rows) = crossterm::terminal::size()?; let mut stdout = io::stdout(); + let full_input: &str = editor.buffer.as_str(); + let full_cursor: usize = editor.cursor; + let input_row = rows.saturating_sub(2); let status_row = rows.saturating_sub(1); let prompt = if is_running { @@ -473,7 +475,8 @@ impl Renderer { "> " }; - // Extract the current logical line for display + // Extract the current logical line, then render with paste + // placeholders substituted so the input bar stays compact. let line_start = full_input[..full_cursor] .rfind('\n') .map(|p| p + 1) @@ -482,10 +485,12 @@ impl Renderer { .find('\n') .map(|p| full_cursor + p) .unwrap_or(full_input.len()); - let visible_line = &full_input[line_start..line_end]; - let col_in_line = full_cursor - line_start; + let raw_line = &full_input[line_start..line_end]; + let raw_col = full_cursor - line_start; + let (visible_line_owned, col_in_line) = editor.render_line(raw_line, raw_col); + let visible_line: &str = visible_line_owned.as_str(); - // Count lines for status display + // Count lines for status display (buffer-logical, not paste-expanded). let line_count = if full_input.is_empty() { 1 } else { @@ -516,7 +521,9 @@ impl Renderer { .collect(); write!(stdout, "{}", visible)?; - let token_est = full_input.len() as u64 / 4; + // Token estimate counts the expanded text, since that's what the agent + // actually receives. + let token_est = editor.expanded().len() as u64 / 4; if token_est > 0 { write!( stdout, diff --git a/src/ui/terminal.rs b/src/ui/terminal.rs index 1b690a7e..779462b8 100644 --- a/src/ui/terminal.rs +++ b/src/ui/terminal.rs @@ -1,7 +1,9 @@ use std::io::Write; use crossterm::ExecutableCommand; -use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, +}; use crossterm::terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}; pub struct TerminalGuard; @@ -12,6 +14,11 @@ impl TerminalGuard { stdout.execute(EnterAlternateScreen)?; stdout.execute(Clear(ClearType::All))?; stdout.execute(EnableMouseCapture)?; + // Bracketed paste lets the terminal deliver a multi-line paste as a + // single Event::Paste, rather than a flood of keystroke events. The + // input editor relies on this to compress long pastes into a + // `[N lines pasted]` placeholder. + stdout.execute(EnableBracketedPaste)?; terminal::enable_raw_mode()?; Ok(TerminalGuard) } @@ -21,6 +28,7 @@ impl Drop for TerminalGuard { fn drop(&mut self) { let _ = terminal::disable_raw_mode(); let mut stdout = std::io::stdout(); + let _ = stdout.execute(DisableBracketedPaste); let _ = stdout.execute(DisableMouseCapture); let _ = stdout.execute(LeaveAlternateScreen); let _ = stdout.flush();