From 6ccabb0f6ca26a564f7097a5a2676b12e5427755 Mon Sep 17 00:00:00 2001 From: kursad-k <67970138+kursad-k@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:29:52 -0500 Subject: [PATCH] fix(cli): stop multiline paste auto-submitting line-by-line on Windows (#10326) Co-authored-by: kursad --- crates/goose-cli/Cargo.toml | 7 +- crates/goose-cli/src/session/completion.rs | 2 +- crates/goose-cli/src/session/input.rs | 42 +- crates/goose-cli/src/session/mod.rs | 1 + crates/goose-cli/src/session/paste.rs | 484 +++++++++++++++++++++ 5 files changed, 529 insertions(+), 7 deletions(-) create mode 100644 crates/goose-cli/src/session/paste.rs diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index e47f98bf5..6f729a92b 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -69,7 +69,12 @@ clap_complete_nushell = { version = "4", default-features = false } [target.'cfg(target_os = "windows")'.dependencies] anstream = { version = "1", default-features = false, features = ["wincon"] } -winapi = { workspace = true } +winapi = { workspace = true, features = [ + "consoleapi", + "processenv", + "winbase", + "wincon", +] } [features] default = [ diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs index 410acd935..fe6630910 100644 --- a/crates/goose-cli/src/session/completion.rs +++ b/crates/goose-cli/src/session/completion.rs @@ -455,7 +455,7 @@ impl Hinter for GooseCompleter { } HintStatus::Default => { let newline_key = super::input::get_newline_key().to_ascii_uppercase(); - Some(format!("Enter to send · Ctrl+{} newline", newline_key)) + Some(format!("Enter to send · Ctrl+{newline_key} newline")) } } } diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs index e7e14f980..104c8211a 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -1,4 +1,7 @@ use super::completion::GooseCompleter; +use super::paste::{ + read_paste_aware_input, PasteAwareEnterHandler, PasteCaptureHandler, PasteState, +}; use super::{CompletionCache, HintStatus}; use anyhow::Result; use goose::config::{Config, GooseMode}; @@ -83,12 +86,18 @@ impl rustyline::ConditionalEventHandler for CtrlCHandler { } } +/// The Ctrl-modified character that inserts a newline instead of submitting the +/// prompt. Configurable via `GOOSE_CLI_NEWLINE_KEY`, defaulting to `j` (Ctrl+J). +/// Characters already bound to other actions are rejected: `m` (Ctrl+M is Enter) +/// and `c` (Ctrl+C interrupts), both of which would otherwise shadow the paste +/// and interrupt handlers. pub fn get_newline_key() -> char { Config::global() .get_param::("GOOSE_CLI_NEWLINE_KEY") .ok() .and_then(|s| s.chars().next()) .map(|c| c.to_ascii_lowercase()) + .filter(|c| !matches!(c, 'm' | 'c')) .unwrap_or('j') } @@ -136,10 +145,32 @@ pub fn get_input( .map(|h| h.completion_cache.clone()) .ok_or_else(|| anyhow::anyhow!("Editor helper not set"))?; - let newline_key = get_newline_key(); + let paste_state = Arc::new(std::sync::RwLock::new(PasteState::default())); + + editor.bind_sequence( + rustyline::Event::Any, + rustyline::EventHandler::Conditional(Box::new(PasteCaptureHandler::new( + paste_state.clone(), + ))), + ); + + editor.bind_sequence( + rustyline::KeyEvent(rustyline::KeyCode::Enter, rustyline::Modifiers::NONE), + rustyline::EventHandler::Conditional(Box::new(PasteAwareEnterHandler::new( + paste_state.clone(), + ))), + ); + + editor.bind_sequence( + rustyline::KeyEvent(rustyline::KeyCode::Char('m'), rustyline::Modifiers::CTRL), + rustyline::EventHandler::Conditional(Box::new(PasteAwareEnterHandler::new( + paste_state.clone(), + ))), + ); + editor.bind_sequence( rustyline::KeyEvent( - rustyline::KeyCode::Char(newline_key), + rustyline::KeyCode::Char(get_newline_key()), rustyline::Modifiers::CTRL, ), rustyline::EventHandler::Simple(rustyline::Cmd::Newline), @@ -150,7 +181,7 @@ pub fn get_input( rustyline::EventHandler::Conditional(Box::new(CtrlCHandler::new(completion_cache))), ); - let input = match editor.readline("› ") { + let input = match read_paste_aware_input(editor, paste_state) { Ok(text) => text, Err(e) => match e { rustyline::error::ReadlineError::Interrupted => return Ok(InputResult::Exit), @@ -402,8 +433,8 @@ fn parse_plan_command(input: String) -> Option { } fn help_text() -> String { - let newline_key = get_newline_key().to_ascii_uppercase(); let modes = GooseMode::VARIANTS.join(", "); + let newline_key = get_newline_key().to_ascii_uppercase(); let additional_builtin_help = additional_builtin_help(); let additional_builtin_help = if additional_builtin_help.is_empty() { String::new() @@ -440,8 +471,9 @@ fn help_text() -> String { /clear - Clears the current chat history Navigation: -Ctrl+C - Clear current line if text is entered, otherwise exit the session +Enter - Send message Ctrl+{newline_key} - Add a newline (configurable via GOOSE_CLI_NEWLINE_KEY) +Ctrl+C - Clear current line if text is entered, otherwise exit the session Up/Down arrows - Navigate through command history" ) } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 1c02ececb..ee4be0b8f 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -5,6 +5,7 @@ mod elicitation; mod export; mod input; mod output; +mod paste; pub mod streaming_buffer; mod task_execution_display; mod thinking; diff --git a/crates/goose-cli/src/session/paste.rs b/crates/goose-cli/src/session/paste.rs new file mode 100644 index 000000000..267069962 --- /dev/null +++ b/crates/goose-cli/src/session/paste.rs @@ -0,0 +1,484 @@ +//! Windows multi-line paste capture for the interactive prompt. +//! +//! rustyline handles pastes natively via bracketed paste off Windows, but the +//! Windows console has no such mechanism, so a pasted block arrives as an +//! ordinary key-event burst. This module detects that burst, drains it from the +//! console buffer, and collapses it into a `[Pasted N lines]` chip that +//! [`expand_pastes`] restores on submit. The seam exposed to `session::input` +//! is deliberately narrow: [`PasteState`], the two handlers, and +//! [`read_paste_aware_input`]; everything else stays private. + +use super::completion::GooseCompleter; +use rustyline::Editor; +use std::sync::Arc; + +/// Minimum number of events already queued in the console input buffer for a +/// keystroke to be treated as the start of a paste rather than fast typing / +/// type-ahead. A single keypress leaves at most its own key-up event queued. +const PASTE_QUEUE_THRESHOLD: u32 = 2; +/// Collapse a paste into a chip once it spans at least this many lines. +const PASTE_CHIP_MIN_LINES: usize = 2; +/// ...or once a single-line paste is at least this many characters. +const PASTE_CHIP_MIN_CHARS: usize = 400; + +struct Paste { + marker: String, + content: String, +} + +#[derive(Default)] +pub(super) struct PasteState { + pastes: Vec, + next_id: usize, +} + +/// Number of events still queued in the console input buffer, i.e. keystrokes +/// the terminal has delivered but rustyline has not yet read. During a paste the +/// whole payload is queued at once, so a non-empty queue right after a keystroke +/// reliably distinguishes pasted input from real typing. Always 0 off Windows, +/// where rustyline handles pastes natively via bracketed paste. +#[cfg(windows)] +fn console_pending_events() -> u32 { + use winapi::um::consoleapi::GetNumberOfConsoleInputEvents; + use winapi::um::processenv::GetStdHandle; + use winapi::um::winbase::STD_INPUT_HANDLE; + + unsafe { + let handle = GetStdHandle(STD_INPUT_HANDLE); + let mut count: u32 = 0; + if GetNumberOfConsoleInputEvents(handle, &mut count) != 0 { + count + } else { + 0 + } + } +} + +#[cfg(not(windows))] +fn console_pending_events() -> u32 { + 0 +} + +/// The Unicode code unit carried by a console key event, or `None` if the record +/// carries no character. Characters normally arrive on key-down, but Unicode the +/// active keyboard layout cannot synthesize — emoji, supplementary-plane chars — +/// is injected by Windows as an Alt+numpad sequence whose composed character +/// lands on the Alt (`VK_MENU`) key-up. Accepting that one key-up, and no other, +/// mirrors rustyline's own console read loop and avoids double-counting the +/// key-up of ordinary typed characters. +#[cfg(windows)] +fn key_event_char(record: &winapi::um::wincon::INPUT_RECORD) -> Option { + use winapi::um::wincon::KEY_EVENT; + const VK_MENU: u16 = 0x12; + + if record.EventType != KEY_EVENT { + return None; + } + let key = unsafe { record.Event.KeyEvent() }; + let ch = unsafe { *key.uChar.UnicodeChar() }; + if ch != 0 && (key.bKeyDown != 0 || key.wVirtualKeyCode == VK_MENU) { + Some(ch) + } else { + None + } +} + +/// Drain the remainder of a paste burst directly from the console input buffer, +/// starting from `first` (the character that triggered detection). rustyline +/// reads events one at a time, so the rest of the payload is still queued here; +/// consuming it ourselves keeps it from being echoed line by line and lets us +/// finalize the chip in a single keystroke. Key-up and non-key records — which +/// rustyline discards but [`console_pending_events`] counts — are skipped. +#[cfg(windows)] +fn drain_console_paste(first: char) -> String { + use winapi::um::consoleapi::ReadConsoleInputW; + use winapi::um::processenv::GetStdHandle; + use winapi::um::winbase::STD_INPUT_HANDLE; + use winapi::um::wincon::INPUT_RECORD; + + let mut units: Vec = Vec::new(); + let mut buf = [0u16; 2]; + units.extend_from_slice(first.encode_utf16(&mut buf)); + + unsafe { + let handle = GetStdHandle(STD_INPUT_HANDLE); + loop { + if console_pending_events() == 0 { + // The queue can briefly empty between chunks of a large paste; + // poll a little before concluding the burst is over. + let mut more = false; + for _ in 0..4 { + std::thread::sleep(std::time::Duration::from_millis(2)); + if console_pending_events() > 0 { + more = true; + break; + } + } + if !more { + break; + } + } + + let mut record: INPUT_RECORD = std::mem::zeroed(); + let mut read: u32 = 0; + if ReadConsoleInputW(handle, &mut record, 1, &mut read) == 0 || read == 0 { + break; + } + if let Some(ch) = key_event_char(&record) { + units.push(ch); + } + } + } + + normalize_paste_text(&String::from_utf16_lossy(&units)) +} + +#[cfg(not(windows))] +fn drain_console_paste(_first: char) -> String { + String::new() +} + +/// A non-destructive look at the characters queued behind the current event. +#[cfg(windows)] +enum PeekedBurst { + /// Larger than we bother to scan — unambiguously a paste. + TooLarge, + /// The key-down characters currently queued (key-up and non-key records, + /// which rustyline discards, are omitted). May be empty. + Chars(Vec), +} + +/// Peek the queued console input without consuming it. rustyline reads one event +/// at a time, so the remainder of a paste burst is still buffered here. +#[cfg(windows)] +fn peek_pending_chars() -> Option { + use winapi::um::processenv::GetStdHandle; + use winapi::um::winbase::STD_INPUT_HANDLE; + use winapi::um::wincon::{PeekConsoleInputW, INPUT_RECORD}; + + const PEEK_CAP: u32 = 512; + let pending = console_pending_events(); + if pending == 0 { + return None; + } + if pending > PEEK_CAP { + return Some(PeekedBurst::TooLarge); + } + + unsafe { + let handle = GetStdHandle(STD_INPUT_HANDLE); + let mut records: Vec = vec![std::mem::zeroed(); pending as usize]; + let mut read: u32 = 0; + if PeekConsoleInputW(handle, records.as_mut_ptr(), pending, &mut read) == 0 { + return None; + } + + let mut chars: Vec = Vec::new(); + for record in records.iter().take(read as usize) { + if let Some(ch) = key_event_char(record) { + chars.push(ch); + } + } + Some(PeekedBurst::Chars(chars)) + } +} + +/// Distinguish a genuine multi-line paste from fast type-ahead. We treat the +/// queued burst as a paste only when a newline is followed by more input. A +/// single typed line terminated by Enter has its newline last, so it is not a +/// paste and must still submit normally. A burst too large to scan is +/// unambiguously a paste. Always `false` off Windows. +#[cfg(windows)] +fn console_burst_is_paste() -> bool { + match peek_pending_chars() { + None => false, + Some(PeekedBurst::TooLarge) => true, + Some(PeekedBurst::Chars(chars)) => chars + .iter() + .enumerate() + .any(|(i, &c)| (c == 0x0D || c == 0x0A) && i + 1 < chars.len()), + } +} + +/// Whether any printable character is queued behind the current event. Used for +/// a paste that begins with a newline: the leading newline arrives as the Enter +/// event and is consumed before we peek, so the "more input after the newline" +/// that marks a paste is whatever remains queued. Always `false` off Windows. +#[cfg(windows)] +fn console_has_pending_char() -> bool { + match peek_pending_chars() { + None => false, + Some(PeekedBurst::TooLarge) => true, + Some(PeekedBurst::Chars(chars)) => !chars.is_empty(), + } +} + +#[cfg(not(windows))] +fn console_burst_is_paste() -> bool { + false +} + +#[cfg(not(windows))] +fn console_has_pending_char() -> bool { + false +} + +/// If `first` begins a multi-line paste burst, capture the whole burst and +/// return the command that renders it — a `[Pasted N lines]` chip, or the literal +/// text when it is too small to collapse. Returns `None` for ordinary keystrokes +/// and type-ahead (and always off Windows, where rustyline handles pastes via +/// bracketed paste). When `first` is itself the newline (the Enter path), it is +/// the paste's leading newline, so any queued printable text makes it a paste. +fn capture_paste( + state: &Arc>, + first: char, +) -> Option { + if console_pending_events() < PASTE_QUEUE_THRESHOLD { + return None; + } + let is_paste = if first == '\n' || first == '\r' { + console_has_pending_char() + } else { + console_burst_is_paste() + }; + if !is_paste { + return None; + } + + let content = drain_console_paste(first); + let mut state = state.write().ok()?; + let id = state.next_id + 1; + Some(match paste_marker(&content, id) { + Some(marker) => { + state.next_id = id; + let cmd = rustyline::Cmd::Insert(1, marker.clone()); + state.pastes.push(Paste { marker, content }); + cmd + } + None => rustyline::Cmd::Insert(1, content), + }) +} + +/// Intercepts printable characters so a pasted burst is captured into +/// [`PasteState`] instead of being echoed line by line. +pub(super) struct PasteCaptureHandler { + paste_state: Arc>, +} + +impl PasteCaptureHandler { + pub(super) fn new(paste_state: Arc>) -> Self { + Self { paste_state } + } +} + +impl rustyline::ConditionalEventHandler for PasteCaptureHandler { + fn handle( + &self, + event: &rustyline::Event, + _n: u16, + _positive: bool, + _ctx: &rustyline::EventContext, + ) -> Option { + let ch = match event.get(0)? { + rustyline::KeyEvent(rustyline::KeyCode::Char(c), m) + if *m == rustyline::Modifiers::NONE || *m == rustyline::Modifiers::SHIFT => + { + *c + } + rustyline::KeyEvent(rustyline::KeyCode::Tab, rustyline::Modifiers::NONE) => '\t', + _ => return None, + }; + capture_paste(&self.paste_state, ch) + } +} + +/// Handles Enter: a newline that begins a paste burst is folded into the pasted +/// block; a genuine keystroke accepts the line. +pub(super) struct PasteAwareEnterHandler { + paste_state: Arc>, +} + +impl PasteAwareEnterHandler { + pub(super) fn new(paste_state: Arc>) -> Self { + Self { paste_state } + } +} + +impl rustyline::ConditionalEventHandler for PasteAwareEnterHandler { + fn handle( + &self, + _event: &rustyline::Event, + _n: u16, + _positive: bool, + _ctx: &rustyline::EventContext, + ) -> Option { + Some(capture_paste(&self.paste_state, '\n').unwrap_or(rustyline::Cmd::AcceptLine)) + } +} + +#[cfg(windows)] +fn normalize_paste_text(text: &str) -> String { + text.replace("\r\n", "\n").replace('\r', "\n") +} + +/// Build the chip shown in place of a pasted block, or `None` when the paste is +/// small enough to keep inline. `id` makes the marker unique to this paste +/// instance so a cleared chip can never be expanded into a later, identical- +/// looking one (see [`expand_pastes`]). +fn paste_marker(content: &str, id: usize) -> Option { + let lines = content.trim_end_matches('\n').matches('\n').count() + 1; + if lines >= PASTE_CHIP_MIN_LINES { + Some(format!("[Pasted {lines} lines #{id}]")) + } else if content.chars().count() >= PASTE_CHIP_MIN_CHARS { + Some(format!("[Pasted {} chars #{id}]", content.chars().count())) + } else { + None + } +} + +/// Expand chip markers in the submitted line back to their pasted content, in +/// the order the chips appear in the line, so `> summarize [Pasted 50 lines]` +/// submits the full text. Chips are matched by position rather than by capture +/// order, so reordering them in the prompt still expands every block. +fn expand_pastes(line: &str, pastes: &[Paste]) -> String { + if pastes.is_empty() { + return line.to_string(); + } + + let mut result = String::with_capacity(line.len()); + let mut rest = line; + while let Some((idx, paste)) = pastes + .iter() + .filter_map(|paste| rest.find(&paste.marker).map(|idx| (idx, paste))) + .min_by_key(|(idx, _)| *idx) + { + let (before, after) = rest.split_at(idx); + result.push_str(before); + result.push_str(&paste.content); + rest = after.strip_prefix(paste.marker.as_str()).unwrap_or(after); + } + result.push_str(rest); + result +} + +pub(super) fn read_paste_aware_input( + editor: &mut Editor, + paste_state: Arc>, +) -> rustyline::Result { + let input = editor.readline("> ")?; + let expanded = paste_state + .read() + .ok() + .map(|state| expand_pastes(&input, &state.pastes)) + .unwrap_or(input); + Ok(expanded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_paste_marker() { + assert_eq!(paste_marker("single line", 1), None); + assert_eq!( + paste_marker("line one\nline two", 1), + Some("[Pasted 2 lines #1]".to_string()) + ); + // Trailing newline is not counted as an extra line. + assert_eq!( + paste_marker("line one\nline two\n", 2), + Some("[Pasted 2 lines #2]".to_string()) + ); + let long = "x".repeat(PASTE_CHIP_MIN_CHARS); + assert_eq!( + paste_marker(&long, 3), + Some(format!("[Pasted {PASTE_CHIP_MIN_CHARS} chars #3]")) + ); + } + + #[test] + fn test_expand_pastes() { + assert_eq!(expand_pastes("no chips here", &[]), "no chips here"); + + let pastes = vec![Paste { + marker: "[Pasted 2 lines #1]".to_string(), + content: "a\nb".to_string(), + }]; + assert_eq!( + expand_pastes("summarize [Pasted 2 lines #1] please", &pastes), + "summarize a\nb please" + ); + + // Deleting the chip drops its content rather than corrupting the message. + assert_eq!( + expand_pastes("summarize please", &pastes), + "summarize please" + ); + } + + #[test] + fn test_expand_pastes_multiple_in_order() { + let pastes = vec![ + Paste { + marker: "[Pasted 2 lines #1]".to_string(), + content: "FIRST".to_string(), + }, + Paste { + marker: "[Pasted 3 lines #2]".to_string(), + content: "SECOND".to_string(), + }, + ]; + assert_eq!( + expand_pastes("[Pasted 2 lines #1] and [Pasted 3 lines #2]", &pastes), + "FIRST and SECOND" + ); + } + + #[test] + fn test_expand_pastes_reordered() { + // The chips are moved so a later paste appears before an earlier one. + // Matching by position (not capture order) still expands both. + let pastes = vec![ + Paste { + marker: "[Pasted 2 lines #1]".to_string(), + content: "FIRST".to_string(), + }, + Paste { + marker: "[Pasted 3 lines #2]".to_string(), + content: "SECOND".to_string(), + }, + ]; + assert_eq!( + expand_pastes("[Pasted 3 lines #2] then [Pasted 2 lines #1]", &pastes), + "SECOND then FIRST" + ); + } + + #[test] + fn test_expand_pastes_skips_cleared_paste() { + // A paste was made, cleared (Ctrl+C), then another paste with the same + // line count was made. Unique ids keep the stale entry from hijacking the + // visible chip: only the paste actually shown is expanded. + let pastes = vec![ + Paste { + marker: "[Pasted 2 lines #1]".to_string(), + content: "CLEARED".to_string(), + }, + Paste { + marker: "[Pasted 2 lines #2]".to_string(), + content: "CURRENT".to_string(), + }, + ]; + assert_eq!(expand_pastes("[Pasted 2 lines #2]", &pastes), "CURRENT"); + } + + #[test] + fn test_capture_paste_ignored_without_burst() { + // Off Windows (and on Windows with no queued burst) there is nothing to + // drain, so ordinary keystrokes are never captured as a paste. + let state = Arc::new(std::sync::RwLock::new(PasteState::default())); + assert!(capture_paste(&state, 'a').is_none()); + assert!(state.read().unwrap().pastes.is_empty()); + } +}