feat: streaming shell output while commands run (#10808)
This commit is contained in:
@@ -23,6 +23,9 @@ use tokio_util::task::AbortOnDropHandle;
|
||||
pub use self::export::{message_to_markdown, user_projected_message_to_markdown};
|
||||
pub use builder::{build_session, SessionBuilderConfig};
|
||||
use console::Color;
|
||||
use goose::agents::platform_extensions::developer::shell::{
|
||||
parse_shell_output_notification, ShellOutputNotificationParams, ShellOutputStream,
|
||||
};
|
||||
use goose::agents::AgentEvent;
|
||||
use goose::agents::SUBAGENT_TOOL_REQUEST_TYPE;
|
||||
use goose::permission::permission_confirmation::PrincipalType;
|
||||
@@ -63,6 +66,9 @@ use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
const GOOSE_PLANNER_CONTEXT_LIMIT: &str = "GOOSE_PLANNER_CONTEXT_LIMIT";
|
||||
const SHELL_STATUS_FALLBACK_WIDTH: usize = 120;
|
||||
const SHELL_STATUS_MAX_LINES: usize = 3;
|
||||
const SHELL_STATUS_RESERVED_WIDTH: usize = 2;
|
||||
|
||||
fn planner_provider_messages(plan_messages: &Conversation) -> Conversation {
|
||||
let projected_messages = plan_messages.agent_visible_messages();
|
||||
@@ -2265,10 +2271,71 @@ fn handle_mcp_notification(
|
||||
);
|
||||
}
|
||||
}
|
||||
ServerNotification::CustomNotification(notification) => {
|
||||
if let Some(params) = parse_shell_output_notification(notification) {
|
||||
if is_stream_json_mode
|
||||
|| is_json_mode
|
||||
|| !interactive
|
||||
|| !std::io::stdout().is_terminal()
|
||||
{
|
||||
return;
|
||||
}
|
||||
display_shell_output_notification(params, progress_bars);
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn display_shell_output_notification(
|
||||
params: ShellOutputNotificationParams,
|
||||
progress_bars: &mut output::McpSpinners,
|
||||
) {
|
||||
if params.truncated {
|
||||
return;
|
||||
}
|
||||
|
||||
let max_width = console::Term::stdout()
|
||||
.size_checked()
|
||||
.map(|(_, width)| usize::from(width).saturating_sub(SHELL_STATUS_RESERVED_WIDTH))
|
||||
.unwrap_or(SHELL_STATUS_FALLBACK_WIDTH);
|
||||
let lines = latest_shell_output_lines(¶ms, max_width)
|
||||
.into_iter()
|
||||
.map(|(stream, line)| match stream {
|
||||
ShellOutputStream::Stdout => console::style(line).dim().to_string(),
|
||||
ShellOutputStream::Stderr => console::style(line).yellow().dim().to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !lines.is_empty() {
|
||||
progress_bars.log_shell_output(lines, SHELL_STATUS_MAX_LINES);
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_shell_output_lines(
|
||||
params: &ShellOutputNotificationParams,
|
||||
max_width: usize,
|
||||
) -> Vec<(ShellOutputStream, String)> {
|
||||
let mut lines = params
|
||||
.chunks
|
||||
.iter()
|
||||
.rev()
|
||||
.flat_map(|chunk| {
|
||||
chunk
|
||||
.output
|
||||
.lines()
|
||||
.rev()
|
||||
.map(move |line| (chunk.stream, line))
|
||||
})
|
||||
.take(SHELL_STATUS_MAX_LINES)
|
||||
.map(|(stream, line)| {
|
||||
let line = output::sanitize_terminal_line(line);
|
||||
(stream, safe_truncate(&line, max_width))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
lines.reverse();
|
||||
lines
|
||||
}
|
||||
|
||||
/// Format a logging notification from MCP, returns (formatted_message, subagent_id, notification_type)
|
||||
fn format_logging_notification(
|
||||
data: &Value,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use anstream::println;
|
||||
use anstream::{adapter::strip_str, println};
|
||||
use bat::WrappingMode;
|
||||
use console::{measure_text_width, style, Color, StyledObject, Term};
|
||||
use goose::config::Config;
|
||||
@@ -15,7 +15,7 @@ use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
use rmcp::model::{CallToolRequestParams, JsonObject, PromptArgument};
|
||||
use serde_json::Value;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::fmt::Display;
|
||||
use std::io::{Error, IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
@@ -559,6 +559,17 @@ fn render_tool_response(resp: &ToolResponse, debug: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sanitize_terminal_line(line: &str) -> String {
|
||||
strip_str(line)
|
||||
.flat_map(str::chars)
|
||||
.filter(|character| *character == '\t' || !character.is_control())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn print_tool_output_line(line: &str) {
|
||||
println!(" {}", style(sanitize_terminal_line(line)).dim());
|
||||
}
|
||||
|
||||
fn print_tool_output(text: &str) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
@@ -575,13 +586,13 @@ fn print_tool_output(text: &str) {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
if lines.len() <= max_lines {
|
||||
for line in &lines {
|
||||
println!(" {}", style(line).dim());
|
||||
print_tool_output_line(line);
|
||||
}
|
||||
} else {
|
||||
let head = max_lines / 2;
|
||||
let tail = max_lines - head;
|
||||
for line in &lines[..head] {
|
||||
println!(" {}", style(line).dim());
|
||||
print_tool_output_line(line);
|
||||
}
|
||||
println!(
|
||||
" {}",
|
||||
@@ -593,7 +604,7 @@ fn print_tool_output(text: &str) {
|
||||
.italic()
|
||||
);
|
||||
for line in &lines[lines.len() - tail..] {
|
||||
println!(" {}", style(line).dim());
|
||||
print_tool_output_line(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1463,7 +1474,7 @@ pub fn display_cost_usage(provider: &str, model: &str, usage: &Usage) {
|
||||
pub struct McpSpinners {
|
||||
bars: HashMap<String, ProgressBar>,
|
||||
log_spinner: Option<ProgressBar>,
|
||||
|
||||
shell_output_lines: VecDeque<String>,
|
||||
multi_bar: MultiProgress,
|
||||
}
|
||||
|
||||
@@ -1472,6 +1483,7 @@ impl McpSpinners {
|
||||
McpSpinners {
|
||||
bars: HashMap::new(),
|
||||
log_spinner: None,
|
||||
shell_output_lines: VecDeque::new(),
|
||||
multi_bar: MultiProgress::new(),
|
||||
}
|
||||
}
|
||||
@@ -1494,6 +1506,13 @@ impl McpSpinners {
|
||||
spinner.set_message(message.to_string());
|
||||
}
|
||||
|
||||
pub fn log_shell_output(&mut self, lines: Vec<String>, max_lines: usize) {
|
||||
let message = update_recent_lines(&mut self.shell_output_lines, lines, max_lines);
|
||||
if !message.is_empty() {
|
||||
self.log(&message);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, token: &str, value: f64, total: Option<f64>, message: Option<&str>) {
|
||||
let bar = self.bars.entry(token.to_string()).or_insert_with(|| {
|
||||
if let Some(total) = total {
|
||||
@@ -1520,16 +1539,69 @@ impl McpSpinners {
|
||||
if let Some(spinner) = self.log_spinner.as_mut() {
|
||||
spinner.disable_steady_tick();
|
||||
}
|
||||
self.shell_output_lines.clear();
|
||||
self.multi_bar.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fn update_recent_lines(
|
||||
recent_lines: &mut VecDeque<String>,
|
||||
lines: impl IntoIterator<Item = String>,
|
||||
max_lines: usize,
|
||||
) -> String {
|
||||
recent_lines.extend(lines);
|
||||
while recent_lines.len() > max_lines {
|
||||
recent_lines.pop_front();
|
||||
}
|
||||
recent_lines
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
|
||||
#[test]
|
||||
fn recent_lines_accumulate_across_updates() {
|
||||
let mut recent_lines = VecDeque::new();
|
||||
let mut rendered = String::new();
|
||||
|
||||
for line in ["one", "two", "three", "four"] {
|
||||
rendered = update_recent_lines(&mut recent_lines, [line.to_string()], 3);
|
||||
}
|
||||
|
||||
assert_eq!(rendered, "two\n three\n four");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_line_sanitizer_removes_escape_sequences_and_controls() {
|
||||
assert_eq!(
|
||||
sanitize_terminal_line(
|
||||
"\x1b[31mred\x1b[0m \x1b[2J\x1b[H\
|
||||
\x1b]0;spoofed title\x07\
|
||||
\x1b]52;c;Y2xpcGJvYXJk\x1b\\safe"
|
||||
),
|
||||
"red safe"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_terminal_line("before\x08after\x07\r\tvisible"),
|
||||
"beforeafter\tvisible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_line_sanitizer_preserves_plain_unicode_text() {
|
||||
assert_eq!(
|
||||
sanitize_terminal_line("goose 🪿\t日本語"),
|
||||
"goose 🪿\t日本語"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_subagent_tool_call_names() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user