feat: streaming shell output while commands run (#10808)

This commit is contained in:
Lifei Zhou
2026-07-30 21:06:35 +10:00
committed by GitHub
parent 2694fff7e3
commit cf312f1d1d
14 changed files with 1030 additions and 28 deletions
+67
View File
@@ -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(&params, 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,
+78 -6
View File
@@ -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!(