From 4bda5176e0bd9497765f4c53a3dee583870c939a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eckhart=20K=C3=B6ppen?= Date: Sat, 14 Feb 2026 22:10:05 +0200 Subject: [PATCH] fix: Accumulate text messages for batched rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In cases where the text format returned by the LLM is structured such as Markdown, streaming responses cannot reliably be rendered if the text messages arrive in separate blocks. Collect all text messages in a turn and render them at once after the turn completes. Signed-off-by: Eckhart Köppen --- crates/goose-cli/src/session/mod.rs | 25 +++--- crates/goose-cli/src/session/output.rs | 115 +++++++++++++++---------- 2 files changed, 85 insertions(+), 55 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 1757d85aeb06..cafead961830 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -164,6 +164,7 @@ pub struct CliSession { edit_mode: Option, retry_config: Option, output_format: String, + renderer: output::Renderer, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -259,6 +260,7 @@ impl CliSession { edit_mode, retry_config, output_format, + renderer: output::Renderer::new(), } } @@ -769,7 +771,7 @@ impl CliSession { self.messages.clear(); tracing::info!("Chat context cleared by user."); - output::render_message( + self.renderer.render_message( &Message::assistant().with_text("Chat context cleared.\n"), self.debug, ); @@ -847,7 +849,7 @@ impl CliSession { &[], ) .await?; - output::render_message(&plan_response, self.debug); + self.renderer.render_message(&plan_response, self.debug); output::hide_thinking(); let planner_response_type = classify_planner_response( &self.session_id, @@ -1033,7 +1035,7 @@ impl CliSession { if is_stream_json_mode { emit_stream_event(&StreamEvent::Message { message: message.clone() }); } else if !is_json_mode { - output::render_message(&message, self.debug); + self.renderer.render_message(&message, self.debug); } } } @@ -1074,7 +1076,10 @@ impl CliSession { } break; } - None => break, + None => { + self.renderer.finish(); + break; + } } } _ = cancel_token_clone.cancelled() => { @@ -1171,7 +1176,7 @@ impl CliSession { } self.push_message(response_message); self.push_message(Message::assistant().with_text(interrupt_prompt)); - output::render_message( + self.renderer.render_message( &Message::assistant().with_text(interrupt_prompt), self.debug, ); @@ -1180,7 +1185,7 @@ impl CliSession { match last_msg.content.first() { Some(MessageContent::ToolResponse(_)) => { self.push_message(Message::assistant().with_text(interrupt_prompt)); - output::render_message( + self.renderer.render_message( &Message::assistant().with_text(interrupt_prompt), self.debug, ); @@ -1189,7 +1194,7 @@ impl CliSession { self.messages.pop(); let assistant_msg = Message::assistant().with_text(interrupt_prompt); self.push_message(assistant_msg.clone()); - output::render_message(&assistant_msg, self.debug); + self.renderer.render_message(&assistant_msg, self.debug); } None => { // Empty message content — nothing to do, just continue gracefully @@ -1246,7 +1251,7 @@ impl CliSession { } /// Render all past messages from the session history - pub fn render_message_history(&self) { + pub fn render_message_history(&mut self) { if self.messages.is_empty() { return; } @@ -1259,7 +1264,7 @@ impl CliSession { // Render each message for message in self.messages.iter() { - output::render_message(message, self.debug); + self.renderer.render_message(message, self.debug); } println!(); @@ -1363,7 +1368,7 @@ impl CliSession { } if msg.role == rmcp::model::Role::User { - output::render_message(&msg, self.debug); + self.renderer.render_message(&msg, self.debug); } self.push_message(msg); } diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index a293349dc914..b2d89881b080 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -214,62 +214,87 @@ pub fn set_thinking_message(s: &String) { } } -pub fn render_message(message: &Message, debug: bool) { - let theme = get_theme(); - - for content in &message.content { - match content { - MessageContent::ActionRequired(action) => match &action.data { - ActionRequiredData::ToolConfirmation { tool_name, .. } => { - println!("action_required(tool_confirmation): {}", tool_name) - } - ActionRequiredData::Elicitation { message, .. } => { - println!("action_required(elicitation): {}", message) +pub struct Renderer { + pub text_messages: String, +} + +impl Renderer { + pub fn new() -> Self { + Self { + text_messages: String::new(), + } + } + + pub fn reset(&mut self) { + self.text_messages.clear(); + } + + pub fn finish(&mut self) { + print_markdown(&self.text_messages, get_theme()); + self.reset(); + } + + pub fn render_message(&mut self, message: &Message, debug: bool) { + let theme = get_theme(); + + for content in &message.content { + match content { + MessageContent::ActionRequired(action) => match &action.data { + ActionRequiredData::ToolConfirmation { tool_name, .. } => { + println!("action_required(tool_confirmation): {}", tool_name) + } + ActionRequiredData::Elicitation { message, .. } => { + println!("action_required(elicitation): {}", message) + } + ActionRequiredData::ElicitationResponse { id, .. } => { + println!("action_required(elicitation_response): {}", id) + } + }, + MessageContent::Text(text) => self.add_text_message(&text.text), + MessageContent::ToolRequest(req) => render_tool_request(req, theme, debug), + MessageContent::ToolResponse(resp) => render_tool_response(resp, theme, debug), + MessageContent::Image(image) => { + println!("Image: [data: {}, type: {}]", image.data, image.mime_type); } - ActionRequiredData::ElicitationResponse { id, .. } => { - println!("action_required(elicitation_response): {}", id) + MessageContent::Thinking(thinking) => { + if std::env::var("GOOSE_CLI_SHOW_THINKING").is_ok() + && std::io::stdout().is_terminal() + { + println!("\n{}", style("Thinking:").dim().italic()); + print_markdown(&thinking.thinking, theme); + } } - }, - MessageContent::Text(text) => print_markdown(&text.text, theme), - MessageContent::ToolRequest(req) => render_tool_request(req, theme, debug), - MessageContent::ToolResponse(resp) => render_tool_response(resp, theme, debug), - MessageContent::Image(image) => { - println!("Image: [data: {}, type: {}]", image.data, image.mime_type); - } - MessageContent::Thinking(thinking) => { - if std::env::var("GOOSE_CLI_SHOW_THINKING").is_ok() - && std::io::stdout().is_terminal() - { + MessageContent::RedactedThinking(_) => { + // For redacted thinking, print thinking was redacted println!("\n{}", style("Thinking:").dim().italic()); - print_markdown(&thinking.thinking, theme); + print_markdown("Thinking was redacted", theme); } - } - MessageContent::RedactedThinking(_) => { - // For redacted thinking, print thinking was redacted - println!("\n{}", style("Thinking:").dim().italic()); - print_markdown("Thinking was redacted", theme); - } - MessageContent::SystemNotification(notification) => { - use goose::conversation::message::SystemNotificationType; + MessageContent::SystemNotification(notification) => { + use goose::conversation::message::SystemNotificationType; - match notification.notification_type { - SystemNotificationType::ThinkingMessage => { - show_thinking(); - set_thinking_message(¬ification.msg); - } - SystemNotificationType::InlineMessage => { - hide_thinking(); - println!("\n{}", style(¬ification.msg).yellow()); + match notification.notification_type { + SystemNotificationType::ThinkingMessage => { + show_thinking(); + set_thinking_message(¬ification.msg); + } + SystemNotificationType::InlineMessage => { + hide_thinking(); + println!("\n{}", style(¬ification.msg).yellow()); + } } } - } - _ => { - println!("WARNING: Message content type could not be rendered"); + _ => { + println!("WARNING: Message content type could not be rendered"); + } } } + + let _ = std::io::stdout().flush(); } - let _ = std::io::stdout().flush(); + fn add_text_message(&mut self, message: &str) { + self.text_messages.push_str(message); + } } pub fn render_text(text: &str, color: Option, dim: bool) {