fix(cli): stop multiline paste auto-submitting line-by-line on Windows (#10326)

Co-authored-by: kursad <kursadk@neoplecxus.com>
This commit is contained in:
kursad-k
2026-07-14 19:29:52 -05:00
committed by GitHub
parent 743609d014
commit 6ccabb0f6c
5 changed files with 529 additions and 7 deletions
+6 -1
View File
@@ -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 = [
+1 -1
View File
@@ -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"))
}
}
}
+37 -5
View File
@@ -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::<String>("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<InputResult> {
}
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"
)
}
+1
View File
@@ -5,6 +5,7 @@ mod elicitation;
mod export;
mod input;
mod output;
mod paste;
pub mod streaming_buffer;
mod task_execution_display;
mod thinking;
+484
View File
@@ -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<Paste>,
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<u16> {
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<u16> = 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<u16>),
}
/// 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<PeekedBurst> {
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<INPUT_RECORD> = 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<u16> = 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<std::sync::RwLock<PasteState>>,
first: char,
) -> Option<rustyline::Cmd> {
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<std::sync::RwLock<PasteState>>,
}
impl PasteCaptureHandler {
pub(super) fn new(paste_state: Arc<std::sync::RwLock<PasteState>>) -> Self {
Self { paste_state }
}
}
impl rustyline::ConditionalEventHandler for PasteCaptureHandler {
fn handle(
&self,
event: &rustyline::Event,
_n: u16,
_positive: bool,
_ctx: &rustyline::EventContext,
) -> Option<rustyline::Cmd> {
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<std::sync::RwLock<PasteState>>,
}
impl PasteAwareEnterHandler {
pub(super) fn new(paste_state: Arc<std::sync::RwLock<PasteState>>) -> Self {
Self { paste_state }
}
}
impl rustyline::ConditionalEventHandler for PasteAwareEnterHandler {
fn handle(
&self,
_event: &rustyline::Event,
_n: u16,
_positive: bool,
_ctx: &rustyline::EventContext,
) -> Option<rustyline::Cmd> {
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<String> {
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<GooseCompleter, rustyline::history::DefaultHistory>,
paste_state: Arc<std::sync::RwLock<PasteState>>,
) -> rustyline::Result<String> {
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());
}
}