From e5d46fdd20833c5b716ee377afff5937ea5cc4d5 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sun, 15 Feb 2026 12:22:34 +0100 Subject: [PATCH 1/7] Streaming markdown --- crates/goose-cli/src/session/mod.rs | 9 ++- crates/goose-cli/src/session/output.rs | 105 +++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 1757d85aeb06..e927a0516fc9 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -5,6 +5,7 @@ mod elicitation; mod export; mod input; mod output; +pub mod streaming_buffer; mod task_execution_display; mod thinking; @@ -961,6 +962,7 @@ impl CliSession { let mut progress_bars = output::McpSpinners::new(); let cancel_token_clone = cancel_token.clone(); + let mut markdown_buffer = streaming_buffer::MarkdownBuffer::new(); use futures::StreamExt; loop { @@ -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); + output::render_message_streaming(&message, &mut markdown_buffer, self.debug); } } } @@ -1087,6 +1089,11 @@ impl CliSession { } } + // Flush any remaining buffered markdown content + if !is_json_mode && !is_stream_json_mode { + output::flush_markdown_buffer_current_theme(&mut markdown_buffer); + } + if is_json_mode { let metadata = match self .agent diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index a293349dc914..0c4b7e0b8e55 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -19,6 +19,8 @@ use std::path::Path; use std::sync::Arc; use std::time::Duration; +use super::streaming_buffer::MarkdownBuffer; + pub const DEFAULT_MIN_PRIORITY: f32 = 0.0; pub const DEFAULT_CLI_LIGHT_THEME: &str = "GitHub"; pub const DEFAULT_CLI_DARK_THEME: &str = "zenburn"; @@ -272,6 +274,109 @@ pub fn render_message(message: &Message, debug: bool) { let _ = std::io::stdout().flush(); } +/// Render a streaming message, using a buffer to accumulate text content +/// and only render when markdown constructs are complete. +/// Returns true if the message contained text content (for tracking purposes). +pub fn render_message_streaming( + message: &Message, + buffer: &mut MarkdownBuffer, + debug: bool, +) -> bool { + let theme = get_theme(); + let mut had_text = false; + + for content in &message.content { + match content { + MessageContent::Text(text) => { + had_text = true; + // Push to buffer and render any safe content + if let Some(safe_content) = buffer.push(&text.text) { + eprintln!("[DEBUG] Rendering safe content: {:?}", safe_content); + print_markdown(&safe_content, theme); + } else { + eprintln!("[DEBUG] Buffering text: {:?}", text.text); + } + } + // For non-text content, flush the buffer first then render normally + MessageContent::ToolRequest(req) => { + flush_markdown_buffer(buffer, theme); + render_tool_request(req, theme, debug); + } + MessageContent::ToolResponse(resp) => { + flush_markdown_buffer(buffer, theme); + render_tool_response(resp, theme, debug); + } + MessageContent::ActionRequired(action) => { + flush_markdown_buffer(buffer, theme); + 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::Image(image) => { + flush_markdown_buffer(buffer, theme); + 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() + { + flush_markdown_buffer(buffer, theme); + println!("\n{}", style("Thinking:").dim().italic()); + print_markdown(&thinking.thinking, theme); + } + } + MessageContent::RedactedThinking(_) => { + flush_markdown_buffer(buffer, theme); + println!("\n{}", style("Thinking:").dim().italic()); + print_markdown("Thinking was redacted", theme); + } + MessageContent::SystemNotification(notification) => { + use goose::conversation::message::SystemNotificationType; + + match notification.notification_type { + SystemNotificationType::ThinkingMessage => { + show_thinking(); + set_thinking_message(¬ification.msg); + } + SystemNotificationType::InlineMessage => { + flush_markdown_buffer(buffer, theme); + hide_thinking(); + println!("\n{}", style(¬ification.msg).yellow()); + } + } + } + _ => { + flush_markdown_buffer(buffer, theme); + println!("WARNING: Message content type could not be rendered"); + } + } + } + + let _ = std::io::stdout().flush(); + had_text +} + +/// Flush any remaining content in the markdown buffer +pub fn flush_markdown_buffer(buffer: &mut MarkdownBuffer, theme: Theme) { + let remaining = buffer.flush(); + if !remaining.is_empty() { + print_markdown(&remaining, theme); + } +} + +/// Convenience function to flush with the current theme +pub fn flush_markdown_buffer_current_theme(buffer: &mut MarkdownBuffer) { + flush_markdown_buffer(buffer, get_theme()); +} + pub fn render_text(text: &str, color: Option, dim: bool) { render_text_no_newlines(format!("\n{}\n\n", text).as_str(), color, dim); } From 7e1664eb1f04db7fd92d6667d31db1e06dffaade Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sun, 15 Feb 2026 13:32:22 +0100 Subject: [PATCH 2/7] Add the file --- crates/goose-cli/src/session/output.rs | 3 - .../goose-cli/src/session/streaming_buffer.rs | 636 ++++++++++++++++++ 2 files changed, 636 insertions(+), 3 deletions(-) create mode 100644 crates/goose-cli/src/session/streaming_buffer.rs diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 0c4b7e0b8e55..8e2ebee127f8 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -291,10 +291,7 @@ pub fn render_message_streaming( had_text = true; // Push to buffer and render any safe content if let Some(safe_content) = buffer.push(&text.text) { - eprintln!("[DEBUG] Rendering safe content: {:?}", safe_content); print_markdown(&safe_content, theme); - } else { - eprintln!("[DEBUG] Buffering text: {:?}", text.text); } } // For non-text content, flush the buffer first then render normally diff --git a/crates/goose-cli/src/session/streaming_buffer.rs b/crates/goose-cli/src/session/streaming_buffer.rs new file mode 100644 index 000000000000..9bfccae77e66 --- /dev/null +++ b/crates/goose-cli/src/session/streaming_buffer.rs @@ -0,0 +1,636 @@ +//! Streaming markdown buffer for safe incremental rendering. +//! +//! This module provides a buffer that accumulates streaming markdown chunks +//! and determines safe points to flush content for rendering. It tracks +//! open markdown constructs (code blocks, bold, links, etc.) to ensure +//! we only output complete, well-formed markdown. +//! +//! # Example +//! +//! ``` +//! use goose_cli::session::streaming_buffer::MarkdownBuffer; +//! +//! let mut buf = MarkdownBuffer::new(); +//! +//! // Partial bold - buffers until closed +//! assert_eq!(buf.push("Hello **wor"), Some("Hello ".to_string())); +//! assert_eq!(buf.push("ld**!"), Some("**world**!".to_string())); +//! +//! // At end of stream, flush remaining content +//! let remaining = buf.flush(); +//! ``` + +use regex::Regex; +use std::sync::LazyLock; + +/// Regex that tokenizes markdown inline elements. +/// Order matters: longer/more-specific patterns first. +static INLINE_TOKEN_RE: LazyLock = LazyLock::new(|| { + Regex::new(concat!( + r"(", + r"\\.", // Escaped char (highest priority) + r"|`+", // Inline code (variable length backticks) + r"|\*\*\*", // Bold+italic + r"|\*\*", // Bold + r"|\*", // Italic + r"|___", // Bold+italic (underscore) + r"|__", // Bold (underscore) + r"|_", // Italic (underscore) + r"|~~", // Strikethrough + r"|\!\[", // Image start + r"|\]\(", // Link URL start + r"|\[", // Link text start + r"|\]", // Bracket close (without following paren) + r"|\)", // Link URL end + r"|[^\\\*_`~\[\]!()]+", // Plain text (no special chars) + r"|.", // Any other single char + r")" + )) + .unwrap() +}); + +/// A streaming markdown buffer that tracks open constructs. +/// +/// Accumulates chunks and returns content that is safe to render, +/// holding back any incomplete markdown constructs. +#[derive(Default)] +pub struct MarkdownBuffer { + buffer: String, +} + +/// Tracks the current parsing state for markdown constructs. +#[derive(Default, Debug, Clone, PartialEq)] +struct ParseState { + // Block-level state (line-aware) + in_code_block: bool, + code_fence_char: char, + code_fence_len: usize, + in_table: bool, + pending_heading: bool, + + // Inline state (regex tokenizer) + in_inline_code: bool, + inline_code_len: usize, + in_bold: bool, + in_italic: bool, + in_strikethrough: bool, + in_link_text: bool, + in_link_url: bool, + in_image_alt: bool, +} + +impl ParseState { + /// Returns true if no markdown constructs are currently open. + fn is_clean(&self) -> bool { + !self.in_code_block + && !self.in_table + && !self.pending_heading + && !self.in_inline_code + && !self.in_bold + && !self.in_italic + && !self.in_strikethrough + && !self.in_link_text + && !self.in_link_url + && !self.in_image_alt + } +} + +// SAFETY: All string slicing in this impl is safe because: +// - We only slice at positions derived from ASCII characters (newlines, #, |, etc.) +// - The regex tokenizer operates on valid UTF-8 and returns byte positions at char boundaries +// - Code fence detection uses chars().take_while() which respects UTF-8 +#[allow(clippy::string_slice)] +impl MarkdownBuffer { + /// Create a new empty buffer. + pub fn new() -> Self { + Self::default() + } + + /// Add a chunk of markdown text to the buffer. + /// + /// Returns any content that is safe to render, or None if the buffer + /// contains only incomplete constructs. + pub fn push(&mut self, chunk: &str) -> Option { + self.buffer.push_str(chunk); + let safe_end = self.find_safe_end(); + + if safe_end > 0 { + // SAFETY: safe_end is always at a valid UTF-8 char boundary because: + // - We only set it after processing complete regex tokens (which match + // valid UTF-8 sequences) or at newline positions (ASCII, single byte) + // - The regex tokenizer operates on &str which guarantees UTF-8 + let to_render = self.buffer[..safe_end].to_string(); + self.buffer = self.buffer[safe_end..].to_string(); + Some(to_render) + } else { + None + } + } + + /// Flush any remaining content from the buffer. + /// + /// Call this at the end of a stream to get any buffered content, + /// even if markdown constructs are unclosed. + pub fn flush(&mut self) -> String { + std::mem::take(&mut self.buffer) + } + + /// Find the last byte position where the parse state is "clean". + fn find_safe_end(&self) -> usize { + let mut state = ParseState::default(); + let mut last_safe: usize = 0; + let bytes = self.buffer.as_bytes(); + let len = bytes.len(); + let mut pos: usize = 0; + + while pos < len { + // Check for block-level constructs at line starts + let at_line_start = pos == 0 || bytes[pos - 1] == b'\n'; + + if at_line_start { + // Process block-level constructs + if let Some(new_pos) = self.process_line_start(&mut state, pos) { + pos = new_pos; + + // After processing line start, check if state is clean + if state.is_clean() { + last_safe = pos; + } + continue; + } + } + + // Inside a code block, just scan for newlines + if state.in_code_block { + // Find next newline or end of buffer + while pos < len && bytes[pos] != b'\n' { + pos += 1; + } + if pos < len { + pos += 1; // Skip the newline + } + continue; + } + + // Process inline constructs using the regex tokenizer + let remaining = &self.buffer[pos..]; + + // Find next newline to bound our inline processing + let line_end = remaining.find('\n').map(|i| pos + i + 1).unwrap_or(len); + + // Tokenize up to the newline + let line_content = &self.buffer[pos..line_end]; + + for cap in INLINE_TOKEN_RE.find_iter(line_content) { + let token = cap.as_str(); + let token_end = pos + cap.end(); + + self.process_inline_token(&mut state, token); + + if state.is_clean() { + last_safe = token_end; + } + } + + // If we processed a complete line (ending with newline), clear pending_heading + if line_end <= len && line_end > pos && bytes[line_end - 1] == b'\n' { + state.pending_heading = false; + // Update last_safe if state is now clean after clearing heading + if state.is_clean() { + last_safe = line_end; + } + } + + pos = line_end; + } + + last_safe + } + + /// Process block-level constructs at the start of a line. + /// + /// Returns the new position after processing, or None if no block construct found. + fn process_line_start(&self, state: &mut ParseState, pos: usize) -> Option { + let remaining = &self.buffer[pos..]; + + // If we were pending a heading, the newline completes it + if state.pending_heading { + state.pending_heading = false; + } + + // Check for code fence (``` or ~~~) + if let Some(fence_result) = self.check_code_fence(remaining, state) { + return Some(pos + fence_result); + } + + // If in code block, don't process other block constructs + if state.in_code_block { + return None; + } + + // Check for heading (# at start of line) + if remaining.starts_with('#') { + // Count the # characters + let hashes = remaining.chars().take_while(|&c| c == '#').count(); + // Valid heading: 1-6 hashes followed by space or newline + if hashes <= 6 { + let after_hashes = &remaining[hashes..]; + if after_hashes.is_empty() + || after_hashes.starts_with(' ') + || after_hashes.starts_with('\n') + { + state.pending_heading = true; + // Don't advance pos, let inline processing handle the content + return None; + } + } + } + + // Check for table row (| at start of line) + if remaining.starts_with('|') { + state.in_table = true; + return None; // Let inline processing handle the row + } + + // Check for blank line (closes table) + if (remaining.starts_with('\n') || remaining.is_empty()) && state.in_table { + state.in_table = false; + return Some(pos + 1); + } + + // If in table but line doesn't start with |, close table + if state.in_table && !remaining.starts_with('|') { + state.in_table = false; + } + + None + } + + /// Check for a code fence and update state accordingly. + /// + /// Returns the position after the fence line if found, None otherwise. + fn check_code_fence(&self, line: &str, state: &mut ParseState) -> Option { + let trimmed = line.trim_start(); + + // Determine fence character and count + let fence_char = trimmed.chars().next()?; + if fence_char != '`' && fence_char != '~' { + return None; + } + + let fence_len = trimmed.chars().take_while(|&c| c == fence_char).count(); + if fence_len < 3 { + return None; + } + + let after_fence = &trimmed[fence_len..]; + + if state.in_code_block { + // Check if this is a valid closing fence + if fence_char == state.code_fence_char + && fence_len >= state.code_fence_len + && (after_fence.is_empty() + || after_fence.starts_with('\n') + || after_fence.trim().is_empty()) + { + // Valid closing fence + state.in_code_block = false; + state.code_fence_char = '\0'; + state.code_fence_len = 0; + + // Return position after the fence line + if let Some(newline_pos) = line.find('\n') { + return Some(newline_pos + 1); + } else { + return Some(line.len()); + } + } + } else { + // Opening fence - can have info string after it + state.in_code_block = true; + state.code_fence_char = fence_char; + state.code_fence_len = fence_len; + + // Don't return safe position - we're now in a code block + if let Some(newline_pos) = line.find('\n') { + return Some(newline_pos + 1); + } else { + return Some(line.len()); + } + } + + None + } + + /// Process an inline token and update state. + fn process_inline_token(&self, state: &mut ParseState, token: &str) { + // Escaped characters don't affect state + if token.starts_with('\\') && token.len() == 2 { + return; + } + + // Inline code (backticks) + if token.starts_with('`') { + let tick_count = token.len(); + if state.in_inline_code { + if tick_count == state.inline_code_len { + state.in_inline_code = false; + state.inline_code_len = 0; + } + } else { + state.in_inline_code = true; + state.inline_code_len = tick_count; + } + return; + } + + // Inside inline code, nothing else matters + if state.in_inline_code { + return; + } + + // Bold/italic markers + match token { + "***" | "___" => { + // Toggle both bold and italic + if state.in_bold && state.in_italic { + state.in_bold = false; + state.in_italic = false; + } else if state.in_bold { + state.in_italic = !state.in_italic; + } else if state.in_italic { + state.in_bold = !state.in_bold; + } else { + state.in_bold = true; + state.in_italic = true; + } + } + "**" | "__" => { + state.in_bold = !state.in_bold; + } + "*" | "_" => { + state.in_italic = !state.in_italic; + } + "~~" => { + state.in_strikethrough = !state.in_strikethrough; + } + "![" => { + state.in_image_alt = true; + } + "[" => { + if !state.in_link_text && !state.in_image_alt { + state.in_link_text = true; + } + } + "](" => { + if state.in_link_text { + state.in_link_text = false; + state.in_link_url = true; + } else if state.in_image_alt { + state.in_image_alt = false; + state.in_link_url = true; + } + } + "]" => { + // Unmatched ] - could be part of incomplete link + // Don't close link_text here, wait for ]( or next chunk + } + ")" => { + if state.in_link_url { + state.in_link_url = false; + } + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_case::test_case; + + /// Process chunks through the buffer and return all outputs (skipping None, including flush) + fn stream(chunks: &[&str]) -> Vec { + let mut buf = MarkdownBuffer::new(); + let mut results: Vec = chunks.iter().filter_map(|chunk| buf.push(chunk)).collect(); + let remaining = buf.flush(); + if !remaining.is_empty() { + results.push(remaining); + } + results + } + + // =========================================== + // Realistic LLM streaming scenarios + // =========================================== + + #[test_case( + &["I'll", " help", " you", " with", " that", "!"], + &["I'll", " help", " you", " with", " that", "!"] + ; "simple sentence streams through immediately without markdown" + )] + #[test_case( + &["Here's the **important", "** part."], + &["Here's the ", "**important** part."] + ; "bold split mid-word" + )] + #[test_case( + &["Use the `println!", "` macro."], + &["Use the ", "`println!` macro."] + ; "inline code split" + )] + #[test_case( + &["Check [the docs](https://doc", "s.rs) for more."], + &["Check ", "[the docs](https://docs.rs) for more."] + ; "link url split" + )] + fn test_inline_streaming(chunks: &[&str], expected: &[&str]) { + assert_eq!(stream(chunks), expected); + } + + // =========================================== + // Code blocks (most important for bat rendering) + // =========================================== + + #[test_case( + &["```rust\n", "fn main() {\n", " println!(\"hello\");\n", "}\n", "```\n"], + &["```rust\nfn main() {\n println!(\"hello\");\n}\n```\n"] + ; "rust code block streamed line by line" + )] + #[test_case( + &["Here's an exa", "mple:\n\n```python\nprint(\"``", "`nested```\")\n```\n\nNice!"], + &["Here's an exa", "mple:\n", "\n```python\nprint(\"```nested```\")\n```\n\nNice!"] + ; "code block with backticks in string literal" + )] + #[test_case( + &["````md\n", "```\ninner\n```\n", "````\n"], + &["````md\n```\ninner\n```\n````\n"] + ; "nested code fence with longer outer fence" + )] + #[test_case( + &["~~~bash\n", "echo 'hello'\n", "~", "~~\n"], + &["~~~bash\necho 'hello'\n~~~\n"] + ; "tilde code fence" + )] + #[test_case( + &["```\ncode"], + &["```\ncode"] + ; "unclosed code block flushes at end" + )] + fn test_code_blocks(chunks: &[&str], expected: &[&str]) { + assert_eq!(stream(chunks), expected); + } + + // =========================================== + // Headings + // =========================================== + + #[test_case( + &["# Getting St", "arted\n\nFirst, install..."], + &["# Getting Started\n\nFirst, install..."] + ; "heading split mid-word" + )] + #[test_case( + &["## API Reference\n\n###", " Methods\n\n"], + &["## API Reference\n\n", "### Methods\n\n"] + ; "multiple headings in one chunk" + )] + fn test_headings(chunks: &[&str], expected: &[&str]) { + assert_eq!(stream(chunks), expected); + } + + // =========================================== + // Tables + // =========================================== + + #[test_case( + &["| Name | Value |\n", "|------|-------|\n", "| foo | 42 |\n", "\nMore text"], + &["| Name | Value |\n|------|-------|\n| foo | 42 |\n\nMore text"] + ; "table streamed row by row" + )] + #[test_case( + &["| A | B |\n|---|---|\n| 1 | 2 |\n\n"], + &["| A | B |\n|---|---|\n| 1 | 2 |\n\n"] + ; "table followed by blank line" + )] + fn test_tables(chunks: &[&str], expected: &[&str]) { + assert_eq!(stream(chunks), expected); + } + + // =========================================== + // Mixed formatting (realistic assistant responses) + // =========================================== + + #[test_case( + &[ + "Here's how to do it:\n\n", + "1. First, run `cargo", " build`\n", + "2. Then check the **out", "put**\n\n", + "```rust\n", + "fn main() {}\n", + "```\n" + ], + &[ + "Here's how to do it:\n\n", + "1. First, run ", + "`cargo build`\n", + "2. Then check the ", + "**output**\n\n", + "```rust\nfn main() {}\n```\n" + ] + ; "typical assistant response with list code and formatting" + )] + #[test_case( + &[ + "See the [**Rust Book**](https://doc.rust-l", + "ang.org/book/) for more info.\n\n", + "Key points:\n- Use `Result` for errors\n- Prefer `Option` over null" + ], + &[ + "See the ", + "[**Rust Book**](https://doc.rust-lang.org/book/) for more info.\n\n", + "Key points:\n- Use `Result` for errors\n- Prefer `Option` over null" + ] + ; "link with nested bold and list" + )] + #[test_case( + &[ + "![screenshot](./img/sc", + "reen.png)\n\nAs shown above..." + ], + &[ + "![screenshot](./img/screen.png)\n\nAs shown above..." + ] + ; "image with split url" + )] + fn test_mixed_content(chunks: &[&str], expected: &[&str]) { + assert_eq!(stream(chunks), expected); + } + + // =========================================== + // Edge cases and escapes + // =========================================== + + #[test_case( + &["Use \\* for bullet points, not \\`code\\`"], + &["Use \\* for bullet points, not \\`code\\`"] + ; "escaped markdown characters" + )] + #[test_case( + &["Price: $100 * 2 = $200"], + &["Price: $100 ", "* 2 = $200"] + ; "asterisk in math context treated as italic marker" + )] + #[test_case( + &[""], + &[] as &[&str] + ; "empty input produces no output" + )] + #[test_case( + &["Hello 世界! Here's some **太字** text."], + &["Hello 世界! Here's some **太字** text."] + ; "unicode content" + )] + #[test_case( + &["**bold *and italic* together**"], + &["**bold *and italic* together**"] + ; "nested bold and italic" + )] + #[test_case( + &["***bold italic***"], + &["***bold italic***"] + ; "combined bold italic marker" + )] + #[test_case( + &["~~stri", "ke~~ and **bo", "ld**"], + &["~~strike~~ and ", "**bold**"] + ; "strikethrough and bold split" + )] + fn test_edge_cases(chunks: &[&str], expected: &[&str]) { + assert_eq!(stream(chunks), expected); + } + + // =========================================== + // Incomplete constructs at stream end + // =========================================== + + #[test_case( + &["This is **incomplete bold"], + &["This is ", "**incomplete bold"] + ; "unclosed bold flushes" + )] + #[test_case( + &["Check [broken link](http://"], + &["Check ", "[broken link](http://"] + ; "unclosed link flushes" + )] + #[test_case( + &["Start of `code"], + &["Start of ", "`code"] + ; "unclosed inline code flushes" + )] + fn test_incomplete_constructs(chunks: &[&str], expected: &[&str]) { + assert_eq!(stream(chunks), expected); + } +} From de1f0a9a395062809ef860327c8eb8574def8beb Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 16 Feb 2026 17:56:31 +0100 Subject: [PATCH 3/7] Tables --- Cargo.lock | 59 ++++++-- crates/goose-cli/Cargo.toml | 1 + crates/goose-cli/src/session/output.rs | 182 +++++++++++++++++++++++-- 3 files changed, 222 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60662921430a..a4513bd3bb36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1918,6 +1918,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "compact_str" version = "0.7.1" @@ -2258,6 +2269,29 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix 1.1.3", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -3031,7 +3065,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3303,7 +3337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4279,6 +4313,7 @@ dependencies = [ "clap_complete", "clap_mangen", "cliclack", + "comfy-table", "console 0.16.2", "dotenvy", "etcetera 0.11.0", @@ -4842,7 +4877,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.2", "system-configuration 0.7.0", "tokio", "tower-service", @@ -4862,7 +4897,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.62.2", ] [[package]] @@ -5309,7 +5344,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6038,7 +6073,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7549,7 +7584,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.36", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -7586,9 +7621,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -8234,7 +8269,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10067,7 +10102,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.3", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11637,7 +11672,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index dbbdcacc2612..d4c4e39d3975 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -61,6 +61,7 @@ open = "5.3.2" url = { workspace = true } urlencoding = { workspace = true } clap_complete = "4.5.62" +comfy-table = "7.2.2" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 8e2ebee127f8..dfa2608cc6d6 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -846,19 +846,185 @@ pub fn env_no_color() -> bool { fn print_markdown(content: &str, theme: Theme) { if std::io::stdout().is_terminal() { - bat::PrettyPrinter::new() - .input(bat::Input::from_bytes(content.as_bytes())) - .theme(theme.as_str()) - .colored_output(env_no_color()) - .language("Markdown") - .wrapping_mode(WrappingMode::NoWrapping(true)) - .print() - .unwrap(); + // Check if content contains a markdown table and render it specially + if let Some((before, table, after)) = extract_markdown_table(content) { + // Print content before table + if !before.is_empty() { + print_markdown_raw(&before, theme); + } + // Render table with comfy-table + print_table(&table); + // Print content after table + if !after.is_empty() { + print_markdown(after, theme); + } + } else { + print_markdown_raw(content, theme); + } } else { print!("{}", content); } } +/// Renders markdown content using bat (no table processing) +fn print_markdown_raw(content: &str, theme: Theme) { + bat::PrettyPrinter::new() + .input(bat::Input::from_bytes(content.as_bytes())) + .theme(theme.as_str()) + .colored_output(env_no_color()) + .language("Markdown") + .wrapping_mode(WrappingMode::NoWrapping(true)) + .print() + .unwrap(); +} + +/// Extracts a markdown table from content, returning (before, table_lines, after) +fn extract_markdown_table(content: &str) -> Option<(String, Vec<&str>, &str)> { + let lines: Vec<&str> = content.lines().collect(); + let mut table_start = None; + let mut table_end = None; + + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed.starts_with('|') && trimmed.ends_with('|') { + if table_start.is_none() { + table_start = Some(i); + } + table_end = Some(i); + } else if table_start.is_some() { + // Line doesn't look like a table row, table has ended + break; + } + } + + // Need at least 2 rows (header + separator or header + data) + let start = table_start?; + let end = table_end?; + if end < start + 1 { + return None; + } + + // Verify we have a separator row (contains |---|) + let has_separator = lines[start..=end] + .iter() + .any(|line| line.contains("---") || line.contains(":-") || line.contains("-:")); + if !has_separator { + return None; + } + + let before = lines[..start].join("\n"); + let before = if before.is_empty() { + before + } else { + before + "\n" + }; + let table = lines[start..=end].to_vec(); + let after_lines = &lines[end + 1..]; + + // Calculate byte offset for after content + let after = if after_lines.is_empty() { + "" + } else { + // Find where the remaining content starts in the original string + let table_end_line = lines[end]; + let table_end_pos = content.find(table_end_line).unwrap() + table_end_line.len(); + // Skip the newline after the table (safely handle UTF-8) + content.get(table_end_pos + 1..).unwrap_or("") + }; + + Some((before, table, after)) +} + +/// Parses and renders a markdown table using comfy-table (ASCII format for valid markdown) +fn print_table(table_lines: &[&str]) { + use comfy_table::{presets, Cell, CellAlignment, ContentArrangement, Table}; + + let mut table = Table::new(); + table.set_content_arrangement(ContentArrangement::Dynamic); + + // Use ASCII markdown preset - output remains valid markdown + table.load_preset(presets::ASCII_MARKDOWN); + + let mut rows: Vec> = Vec::new(); + let mut alignments: Vec = Vec::new(); + let mut separator_idx = None; + + for (i, line) in table_lines.iter().enumerate() { + let cells: Vec = line + .trim() + .trim_matches('|') + .split('|') + .map(|s| s.trim().to_string()) + .collect(); + + // Check if this is the separator row + if cells.iter().all(|c| { + let t = c.trim(); + t.chars().all(|ch| ch == '-' || ch == ':') && t.contains('-') + }) { + separator_idx = Some(i); + // Parse alignments from separator + alignments = cells + .iter() + .map(|c| { + let t = c.trim(); + if t.starts_with(':') && t.ends_with(':') { + CellAlignment::Center + } else if t.ends_with(':') { + CellAlignment::Right + } else { + CellAlignment::Left + } + }) + .collect(); + } else { + rows.push(cells); + } + } + + // If no separator found, treat first row as header anyway + if separator_idx.is_none() && !rows.is_empty() { + alignments = vec![CellAlignment::Left; rows[0].len()]; + } + + // Set header (first row) + if let Some(header) = rows.first() { + let header_cells: Vec = header + .iter() + .enumerate() + .map(|(i, text)| { + let cell = Cell::new(text); + if let Some(align) = alignments.get(i) { + cell.set_alignment(*align) + } else { + cell + } + }) + .collect(); + table.set_header(header_cells); + } + + // Add data rows + for row in rows.iter().skip(1) { + let cells: Vec = row + .iter() + .enumerate() + .map(|(i, text)| { + let cell = Cell::new(text); + if let Some(align) = alignments.get(i) { + cell.set_alignment(*align) + } else { + cell + } + }) + .collect(); + table.add_row(cells); + } + + let table_str = table.to_string(); + println!("{table_str}"); +} + const INDENT: &str = " "; fn print_value_with_prefix(prefix: &String, value: &Value, debug: bool) { From bfbcb4fbab7c3725db92f6d558521093cd8854db Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 16 Feb 2026 18:07:13 +0100 Subject: [PATCH 4/7] Simplify --- crates/goose-cli/src/session/mod.rs | 1 - crates/goose-cli/src/session/output.rs | 36 +++----------------------- 2 files changed, 4 insertions(+), 33 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index e927a0516fc9..17a512fe2eff 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1089,7 +1089,6 @@ impl CliSession { } } - // Flush any remaining buffered markdown content if !is_json_mode && !is_stream_json_mode { output::flush_markdown_buffer_current_theme(&mut markdown_buffer); } diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index dfa2608cc6d6..e8538f9d419b 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -277,24 +277,16 @@ pub fn render_message(message: &Message, debug: bool) { /// Render a streaming message, using a buffer to accumulate text content /// and only render when markdown constructs are complete. /// Returns true if the message contained text content (for tracking purposes). -pub fn render_message_streaming( - message: &Message, - buffer: &mut MarkdownBuffer, - debug: bool, -) -> bool { +pub fn render_message_streaming(message: &Message, buffer: &mut MarkdownBuffer, debug: bool) { let theme = get_theme(); - let mut had_text = false; for content in &message.content { match content { MessageContent::Text(text) => { - had_text = true; - // Push to buffer and render any safe content if let Some(safe_content) = buffer.push(&text.text) { print_markdown(&safe_content, theme); } } - // For non-text content, flush the buffer first then render normally MessageContent::ToolRequest(req) => { flush_markdown_buffer(buffer, theme); render_tool_request(req, theme, debug); @@ -358,10 +350,8 @@ pub fn render_message_streaming( } let _ = std::io::stdout().flush(); - had_text } -/// Flush any remaining content in the markdown buffer pub fn flush_markdown_buffer(buffer: &mut MarkdownBuffer, theme: Theme) { let remaining = buffer.flush(); if !remaining.is_empty() { @@ -369,7 +359,6 @@ pub fn flush_markdown_buffer(buffer: &mut MarkdownBuffer, theme: Theme) { } } -/// Convenience function to flush with the current theme pub fn flush_markdown_buffer_current_theme(buffer: &mut MarkdownBuffer) { flush_markdown_buffer(buffer, get_theme()); } @@ -846,15 +835,11 @@ pub fn env_no_color() -> bool { fn print_markdown(content: &str, theme: Theme) { if std::io::stdout().is_terminal() { - // Check if content contains a markdown table and render it specially if let Some((before, table, after)) = extract_markdown_table(content) { - // Print content before table if !before.is_empty() { print_markdown_raw(&before, theme); } - // Render table with comfy-table print_table(&table); - // Print content after table if !after.is_empty() { print_markdown(after, theme); } @@ -878,7 +863,6 @@ fn print_markdown_raw(content: &str, theme: Theme) { .unwrap(); } -/// Extracts a markdown table from content, returning (before, table_lines, after) fn extract_markdown_table(content: &str) -> Option<(String, Vec<&str>, &str)> { let lines: Vec<&str> = content.lines().collect(); let mut table_start = None; @@ -892,19 +876,16 @@ fn extract_markdown_table(content: &str) -> Option<(String, Vec<&str>, &str)> { } table_end = Some(i); } else if table_start.is_some() { - // Line doesn't look like a table row, table has ended break; } } - // Need at least 2 rows (header + separator or header + data) let start = table_start?; let end = table_end?; if end < start + 1 { return None; } - // Verify we have a separator row (contains |---|) let has_separator = lines[start..=end] .iter() .any(|line| line.contains("---") || line.contains(":-") || line.contains("-:")); @@ -921,28 +902,23 @@ fn extract_markdown_table(content: &str) -> Option<(String, Vec<&str>, &str)> { let table = lines[start..=end].to_vec(); let after_lines = &lines[end + 1..]; - // Calculate byte offset for after content let after = if after_lines.is_empty() { "" } else { - // Find where the remaining content starts in the original string let table_end_line = lines[end]; let table_end_pos = content.find(table_end_line).unwrap() + table_end_line.len(); - // Skip the newline after the table (safely handle UTF-8) content.get(table_end_pos + 1..).unwrap_or("") }; Some((before, table, after)) } -/// Parses and renders a markdown table using comfy-table (ASCII format for valid markdown) fn print_table(table_lines: &[&str]) { use comfy_table::{presets, Cell, CellAlignment, ContentArrangement, Table}; let mut table = Table::new(); table.set_content_arrangement(ContentArrangement::Dynamic); - // Use ASCII markdown preset - output remains valid markdown table.load_preset(presets::ASCII_MARKDOWN); let mut rows: Vec> = Vec::new(); @@ -957,13 +933,12 @@ fn print_table(table_lines: &[&str]) { .map(|s| s.trim().to_string()) .collect(); - // Check if this is the separator row - if cells.iter().all(|c| { + let is_separator = cells.iter().all(|c| { let t = c.trim(); t.chars().all(|ch| ch == '-' || ch == ':') && t.contains('-') - }) { + }); + if is_separator { separator_idx = Some(i); - // Parse alignments from separator alignments = cells .iter() .map(|c| { @@ -982,12 +957,10 @@ fn print_table(table_lines: &[&str]) { } } - // If no separator found, treat first row as header anyway if separator_idx.is_none() && !rows.is_empty() { alignments = vec![CellAlignment::Left; rows[0].len()]; } - // Set header (first row) if let Some(header) = rows.first() { let header_cells: Vec = header .iter() @@ -1004,7 +977,6 @@ fn print_table(table_lines: &[&str]) { table.set_header(header_cells); } - // Add data rows for row in rows.iter().skip(1) { let cells: Vec = row .iter() From 1d2523e8c5c5a768e0d913622f27aac4a1a18d2f Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 16 Feb 2026 19:31:43 +0100 Subject: [PATCH 5/7] Works now inside a table too --- crates/goose-cli/src/session/output.rs | 6 +-- .../goose-cli/src/session/streaming_buffer.rs | 46 ++----------------- 2 files changed, 6 insertions(+), 46 deletions(-) diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index e8538f9d419b..8de7f65d1bce 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -839,7 +839,7 @@ fn print_markdown(content: &str, theme: Theme) { if !before.is_empty() { print_markdown_raw(&before, theme); } - print_table(&table); + print_table(&table, theme); if !after.is_empty() { print_markdown(after, theme); } @@ -913,7 +913,7 @@ fn extract_markdown_table(content: &str) -> Option<(String, Vec<&str>, &str)> { Some((before, table, after)) } -fn print_table(table_lines: &[&str]) { +fn print_table(table_lines: &[&str], theme: Theme) { use comfy_table::{presets, Cell, CellAlignment, ContentArrangement, Table}; let mut table = Table::new(); @@ -994,7 +994,7 @@ fn print_table(table_lines: &[&str]) { } let table_str = table.to_string(); - println!("{table_str}"); + print_markdown_raw(&table_str, theme); } const INDENT: &str = " "; diff --git a/crates/goose-cli/src/session/streaming_buffer.rs b/crates/goose-cli/src/session/streaming_buffer.rs index 9bfccae77e66..d886205d1f37 100644 --- a/crates/goose-cli/src/session/streaming_buffer.rs +++ b/crates/goose-cli/src/session/streaming_buffer.rs @@ -61,14 +61,11 @@ pub struct MarkdownBuffer { /// Tracks the current parsing state for markdown constructs. #[derive(Default, Debug, Clone, PartialEq)] struct ParseState { - // Block-level state (line-aware) in_code_block: bool, code_fence_char: char, code_fence_len: usize, in_table: bool, pending_heading: bool, - - // Inline state (regex tokenizer) in_inline_code: bool, inline_code_len: usize, in_bold: bool, @@ -144,15 +141,11 @@ impl MarkdownBuffer { let mut pos: usize = 0; while pos < len { - // Check for block-level constructs at line starts let at_line_start = pos == 0 || bytes[pos - 1] == b'\n'; if at_line_start { - // Process block-level constructs if let Some(new_pos) = self.process_line_start(&mut state, pos) { pos = new_pos; - - // After processing line start, check if state is clean if state.is_clean() { last_safe = pos; } @@ -160,25 +153,18 @@ impl MarkdownBuffer { } } - // Inside a code block, just scan for newlines if state.in_code_block { - // Find next newline or end of buffer while pos < len && bytes[pos] != b'\n' { pos += 1; } if pos < len { - pos += 1; // Skip the newline + pos += 1; } continue; } - // Process inline constructs using the regex tokenizer let remaining = &self.buffer[pos..]; - - // Find next newline to bound our inline processing let line_end = remaining.find('\n').map(|i| pos + i + 1).unwrap_or(len); - - // Tokenize up to the newline let line_content = &self.buffer[pos..line_end]; for cap in INLINE_TOKEN_RE.find_iter(line_content) { @@ -192,10 +178,8 @@ impl MarkdownBuffer { } } - // If we processed a complete line (ending with newline), clear pending_heading if line_end <= len && line_end > pos && bytes[line_end - 1] == b'\n' { state.pending_heading = false; - // Update last_safe if state is now clean after clearing heading if state.is_clean() { last_safe = line_end; } @@ -213,26 +197,20 @@ impl MarkdownBuffer { fn process_line_start(&self, state: &mut ParseState, pos: usize) -> Option { let remaining = &self.buffer[pos..]; - // If we were pending a heading, the newline completes it if state.pending_heading { state.pending_heading = false; } - // Check for code fence (``` or ~~~) if let Some(fence_result) = self.check_code_fence(remaining, state) { return Some(pos + fence_result); } - // If in code block, don't process other block constructs if state.in_code_block { return None; } - // Check for heading (# at start of line) if remaining.starts_with('#') { - // Count the # characters let hashes = remaining.chars().take_while(|&c| c == '#').count(); - // Valid heading: 1-6 hashes followed by space or newline if hashes <= 6 { let after_hashes = &remaining[hashes..]; if after_hashes.is_empty() @@ -240,25 +218,21 @@ impl MarkdownBuffer { || after_hashes.starts_with('\n') { state.pending_heading = true; - // Don't advance pos, let inline processing handle the content return None; } } } - // Check for table row (| at start of line) if remaining.starts_with('|') { state.in_table = true; - return None; // Let inline processing handle the row + return None; } - // Check for blank line (closes table) if (remaining.starts_with('\n') || remaining.is_empty()) && state.in_table { state.in_table = false; return Some(pos + 1); } - // If in table but line doesn't start with |, close table if state.in_table && !remaining.starts_with('|') { state.in_table = false; } @@ -272,7 +246,6 @@ impl MarkdownBuffer { fn check_code_fence(&self, line: &str, state: &mut ParseState) -> Option { let trimmed = line.trim_start(); - // Determine fence character and count let fence_char = trimmed.chars().next()?; if fence_char != '`' && fence_char != '~' { return None; @@ -286,19 +259,16 @@ impl MarkdownBuffer { let after_fence = &trimmed[fence_len..]; if state.in_code_block { - // Check if this is a valid closing fence if fence_char == state.code_fence_char && fence_len >= state.code_fence_len && (after_fence.is_empty() || after_fence.starts_with('\n') || after_fence.trim().is_empty()) { - // Valid closing fence state.in_code_block = false; state.code_fence_char = '\0'; state.code_fence_len = 0; - // Return position after the fence line if let Some(newline_pos) = line.find('\n') { return Some(newline_pos + 1); } else { @@ -306,12 +276,10 @@ impl MarkdownBuffer { } } } else { - // Opening fence - can have info string after it state.in_code_block = true; state.code_fence_char = fence_char; state.code_fence_len = fence_len; - // Don't return safe position - we're now in a code block if let Some(newline_pos) = line.find('\n') { return Some(newline_pos + 1); } else { @@ -324,12 +292,10 @@ impl MarkdownBuffer { /// Process an inline token and update state. fn process_inline_token(&self, state: &mut ParseState, token: &str) { - // Escaped characters don't affect state if token.starts_with('\\') && token.len() == 2 { return; } - // Inline code (backticks) if token.starts_with('`') { let tick_count = token.len(); if state.in_inline_code { @@ -344,15 +310,12 @@ impl MarkdownBuffer { return; } - // Inside inline code, nothing else matters if state.in_inline_code { return; } - // Bold/italic markers match token { "***" | "___" => { - // Toggle both bold and italic if state.in_bold && state.in_italic { state.in_bold = false; state.in_italic = false; @@ -391,10 +354,7 @@ impl MarkdownBuffer { state.in_link_url = true; } } - "]" => { - // Unmatched ] - could be part of incomplete link - // Don't close link_text here, wait for ]( or next chunk - } + "]" => {} ")" => { if state.in_link_url { state.in_link_url = false; From 7c085aa11acf07d0a930da3313f1519d13d9a68c Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 16 Feb 2026 19:33:52 +0100 Subject: [PATCH 6/7] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- crates/goose-cli/src/session/output.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 8de7f65d1bce..4b4e9d169165 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -276,7 +276,7 @@ pub fn render_message(message: &Message, debug: bool) { /// Render a streaming message, using a buffer to accumulate text content /// and only render when markdown constructs are complete. -/// Returns true if the message contained text content (for tracking purposes). + pub fn render_message_streaming(message: &Message, buffer: &mut MarkdownBuffer, debug: bool) { let theme = get_theme(); @@ -906,8 +906,13 @@ fn extract_markdown_table(content: &str) -> Option<(String, Vec<&str>, &str)> { "" } else { let table_end_line = lines[end]; - let table_end_pos = content.find(table_end_line).unwrap() + table_end_line.len(); - content.get(table_end_pos + 1..).unwrap_or("") + let content_ptr = content.as_ptr() as usize; + let line_ptr = table_end_line.as_ptr() as usize; + let table_end_pos = line_ptr.saturating_sub(content_ptr) + table_end_line.len(); + content + .get(table_end_pos..) + .and_then(|s| s.strip_prefix('\n')) + .unwrap_or("") }; Some((before, table, after)) From 355f096c2cfecc4384b868bfd901180fb2257c6d Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Mon, 16 Feb 2026 19:58:17 +0100 Subject: [PATCH 7/7] Update --- crates/goose-cli/src/session/output.rs | 49 +++++++++++++++++++++----- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 8de7f65d1bce..077af5fdfc9f 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -865,11 +865,31 @@ fn print_markdown_raw(content: &str, theme: Theme) { fn extract_markdown_table(content: &str) -> Option<(String, Vec<&str>, &str)> { let lines: Vec<&str> = content.lines().collect(); + + // Track newline positions for safe slicing later + let newline_indices: Vec = content + .bytes() + .enumerate() + .filter_map(|(i, b)| if b == b'\n' { Some(i) } else { None }) + .collect(); + + // Skip tables inside code blocks + let mut in_code_block = false; let mut table_start = None; let mut table_end = None; for (i, line) in lines.iter().enumerate() { let trimmed = line.trim(); + + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + in_code_block = !in_code_block; + continue; + } + + if in_code_block { + continue; + } + if trimmed.starts_with('|') && trimmed.ends_with('|') { if table_start.is_none() { table_start = Some(i); @@ -882,14 +902,26 @@ fn extract_markdown_table(content: &str) -> Option<(String, Vec<&str>, &str)> { let start = table_start?; let end = table_end?; + + // Need at least header + separator (2 rows minimum) if end < start + 1 { return None; } - let has_separator = lines[start..=end] - .iter() - .any(|line| line.contains("---") || line.contains(":-") || line.contains("-:")); - if !has_separator { + // Require separator to be the second row with proper format + let separator_line = lines.get(start + 1)?; + let is_valid_separator = separator_line.trim().starts_with('|') + && separator_line.trim().ends_with('|') + && separator_line + .trim() + .trim_matches('|') + .split('|') + .all(|cell| { + let t = cell.trim(); + !t.is_empty() && t.chars().all(|c| c == '-' || c == ':' || c == ' ') + }); + + if !is_valid_separator { return None; } @@ -900,14 +932,13 @@ fn extract_markdown_table(content: &str) -> Option<(String, Vec<&str>, &str)> { before + "\n" }; let table = lines[start..=end].to_vec(); - let after_lines = &lines[end + 1..]; - let after = if after_lines.is_empty() { + let after = if end + 1 >= lines.len() { "" + } else if let Some(&newline_pos) = newline_indices.get(end) { + content.get(newline_pos + 1..).unwrap_or("") } else { - let table_end_line = lines[end]; - let table_end_pos = content.find(table_end_line).unwrap() + table_end_line.len(); - content.get(table_end_pos + 1..).unwrap_or("") + "" }; Some((before, table, after))