Make ctrl-c work like in claude code (#6900)
Some checks are pending
Canary / bundle-desktop-windows (push) Blocked by required conditions
Canary / Prepare Version (push) Waiting to run
Canary / build-cli (push) Blocked by required conditions
Canary / Upload Install Script (push) Blocked by required conditions
Canary / bundle-desktop (push) Blocked by required conditions
Canary / bundle-desktop-linux (push) Blocked by required conditions
Canary / Release (push) Blocked by required conditions
CI / changes (push) Waiting to run
CI / Check Rust Code Format (push) Blocked by required conditions
CI / Build and Test Rust Project (push) Blocked by required conditions
CI / Lint Rust Code (push) Blocked by required conditions
CI / Check OpenAPI Schema is Up-to-Date (push) Blocked by required conditions
CI / Test and Lint Electron Desktop App (push) Blocked by required conditions
Live Provider Tests / check-fork (push) Waiting to run
Live Provider Tests / changes (push) Blocked by required conditions
Live Provider Tests / Build Release Binary (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (Code Execution) (push) Blocked by required conditions
Publish Docker Image / docker (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga 2026-02-03 13:08:16 +01:00 committed by GitHub
parent 1373d9c5f9
commit 0f334dbd45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 86 additions and 24 deletions

View file

@ -32,7 +32,7 @@ wiremock = "0.6"
serial_test = "3.2.0"
test-case = "3.3.1"
base64 = "0.22.1"
reqwest = { version = "0.12.28", default-features = false }
reqwest = { version = "0.12.28", default-features = false, features = ["multipart"] }
tower = "0.5.2"
tower-http = "0.6.8"
url = "2.5.8"

View file

@ -6,11 +6,11 @@ use rustyline::{Context, Helper, Result};
use std::borrow::Cow;
use std::sync::Arc;
use super::CompletionCache;
use super::{CompletionCache, HintStatus};
/// Completer for goose CLI commands
pub struct GooseCompleter {
completion_cache: Arc<std::sync::RwLock<CompletionCache>>,
pub completion_cache: Arc<std::sync::RwLock<CompletionCache>>,
filename_completer: FilenameCompleter,
}
@ -388,15 +388,33 @@ impl Hinter for GooseCompleter {
type Hint = String;
fn hint(&self, line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<Self::Hint> {
// Only show hint when line is empty
if line.is_empty() {
let newline_key = super::input::get_newline_key().to_ascii_uppercase();
Some(format!(
"Press Enter to send, Ctrl-{} for new line",
newline_key
))
} else {
None
let cache = self.completion_cache.read().unwrap();
if !line.is_empty() && cache.hint_status != HintStatus::Default {
drop(cache);
let mut cache_write = self.completion_cache.write().unwrap();
cache_write.hint_status = HintStatus::Default;
return None;
}
if !line.is_empty() {
return None;
}
match cache.hint_status {
HintStatus::Interrupted => {
Some("Interrupted, what should goose work on instead?".to_string())
}
HintStatus::MaybeExit => {
Some("Press Ctrl+C again to exit, or type new instructions to continue".to_string())
}
HintStatus::Default => {
let newline_key = super::input::get_newline_key().to_ascii_uppercase();
Some(format!(
"Press Enter to send, Ctrl-{} for new line",
newline_key
))
}
}
}
}

View file

@ -1,9 +1,11 @@
use super::completion::GooseCompleter;
use super::{CompletionCache, HintStatus};
use anyhow::Result;
use goose::config::Config;
use rustyline::Editor;
use shlex;
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Debug)]
pub enum InputResult {
@ -37,10 +39,18 @@ pub struct PlanCommandOptions {
pub message_text: String,
}
struct CtrlCHandler;
struct CtrlCHandler {
completion_cache: Arc<std::sync::RwLock<CompletionCache>>,
}
impl CtrlCHandler {
fn new(completion_cache: Arc<std::sync::RwLock<CompletionCache>>) -> Self {
Self { completion_cache }
}
}
impl rustyline::ConditionalEventHandler for CtrlCHandler {
/// Handle Ctrl+C to clear the line if text is entered, otherwise exit the session.
/// Handle Ctrl+C to clear the line if text is entered, otherwise check if we should exit.
fn handle(
&self,
_event: &rustyline::Event,
@ -49,9 +59,21 @@ impl rustyline::ConditionalEventHandler for CtrlCHandler {
ctx: &rustyline::EventContext,
) -> Option<rustyline::Cmd> {
if !ctx.line().is_empty() {
// Clear the line if there's text
let mut cache = self.completion_cache.write().unwrap();
cache.hint_status = HintStatus::Default;
Some(rustyline::Cmd::Kill(rustyline::Movement::WholeBuffer))
} else {
Some(rustyline::Cmd::Interrupt)
let mut cache = self.completion_cache.write().unwrap();
if cache.hint_status == HintStatus::MaybeExit {
return Some(rustyline::Cmd::Interrupt);
}
cache.hint_status = HintStatus::MaybeExit;
drop(cache);
Some(rustyline::Cmd::Repaint)
}
}
}
@ -83,6 +105,11 @@ pub fn get_input(
return Ok(InputResult::Message(message));
}
let completion_cache = editor
.helper()
.map(|h| h.completion_cache.clone())
.ok_or_else(|| anyhow::anyhow!("Editor helper not set"))?;
let newline_key = get_newline_key();
editor.bind_sequence(
rustyline::KeyEvent(
@ -94,7 +121,7 @@ pub fn get_input(
editor.bind_sequence(
rustyline::KeyEvent(rustyline::KeyCode::Char('c'), rustyline::Modifiers::CTRL),
rustyline::EventHandler::Conditional(Box::new(CtrlCHandler)),
rustyline::EventHandler::Conditional(Box::new(CtrlCHandler::new(completion_cache))),
);
let prompt = get_input_prompt_string();
@ -136,10 +163,14 @@ pub fn get_input(
}
}
/// Get regular CLI input when editor mode doesn't have content
fn get_regular_input(
editor: &mut Editor<GooseCompleter, rustyline::history::DefaultHistory>,
) -> Result<InputResult> {
let completion_cache = editor
.helper()
.map(|h| h.completion_cache.clone())
.ok_or_else(|| anyhow::anyhow!("Editor helper not set"))?;
let newline_key = get_newline_key();
editor.bind_sequence(
rustyline::KeyEvent(
@ -151,7 +182,7 @@ fn get_regular_input(
editor.bind_sequence(
rustyline::KeyEvent(rustyline::KeyCode::Char('c'), rustyline::Modifiers::CTRL),
rustyline::EventHandler::Conditional(Box::new(CtrlCHandler)),
rustyline::EventHandler::Conditional(Box::new(CtrlCHandler::new(completion_cache))),
);
let prompt = get_input_prompt_string();

View file

@ -166,11 +166,19 @@ pub struct CliSession {
output_format: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HintStatus {
Default,
Interrupted,
MaybeExit,
}
// Cache structure for completion data
struct CompletionCache {
prompts: HashMap<String, Vec<String>>,
prompt_info: HashMap<String, output::PromptInfo>,
last_updated: Instant,
pub struct CompletionCache {
pub prompts: HashMap<String, Vec<String>>,
pub prompt_info: HashMap<String, output::PromptInfo>,
pub last_updated: Instant,
pub hint_status: HintStatus,
}
impl CompletionCache {
@ -179,6 +187,7 @@ impl CompletionCache {
prompts: HashMap::new(),
prompt_info: HashMap::new(),
last_updated: Instant::now(),
hint_status: HintStatus::Default,
}
}
}
@ -1095,7 +1104,11 @@ impl CliSession {
}
async fn handle_interrupted_messages(&mut self, interrupt: bool) -> Result<()> {
// First, get any tool requests from the last message if it exists
if interrupt {
let mut cache = self.completion_cache.write().unwrap();
cache.hint_status = HintStatus::Interrupted;
}
let tool_requests = self
.messages
.last()
@ -1116,6 +1129,7 @@ impl CliSession {
if !tool_requests.is_empty() {
// Interrupted during a tool request
// Create tool responses for all interrupted tool requests
// TODO(Douwe): if we need this, it should happen in agent reply
let mut response_message = Message::user();
let last_tool_name = tool_requests
.last()
@ -1142,7 +1156,6 @@ impl CliSession {
}),
));
}
// TODO(Douwe): update also db
self.push_message(response_message);
let prompt = format!(
"The existing call to {} was interrupted. How would you like to proceed?",