diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 45f6922..0e9c8cf 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -22,7 +22,7 @@ use commands::{ }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; -use render::{Spinner, TerminalRenderer}; +use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest, @@ -2011,6 +2011,8 @@ impl ApiClient for AnthropicRuntimeClient { } else { &mut sink }; + let renderer = TerminalRenderer::new(); + let mut markdown_stream = MarkdownStreamState::default(); let mut events = Vec::new(); let mut pending_tool: Option<(String, String, String)> = None; let mut saw_stop = false; @@ -2038,9 +2040,11 @@ impl ApiClient for AnthropicRuntimeClient { ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { ContentBlockDelta::TextDelta { text } => { if !text.is_empty() { - write!(out, "{text}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; + if let Some(rendered) = markdown_stream.push(&renderer, &text) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } events.push(AssistantEvent::TextDelta(text)); } } @@ -2051,6 +2055,11 @@ impl ApiClient for AnthropicRuntimeClient { } }, ApiStreamEvent::ContentBlockStop(_) => { + if let Some(rendered) = markdown_stream.flush(&renderer) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } if let Some((id, name, input)) = pending_tool.take() { // Display tool call now that input is fully accumulated writeln!(out, "\n{}", format_tool_call_start(&name, &input)) @@ -2069,6 +2078,11 @@ impl ApiClient for AnthropicRuntimeClient { } ApiStreamEvent::MessageStop(_) => { saw_stop = true; + if let Some(rendered) = markdown_stream.flush(&renderer) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } events.push(AssistantEvent::MessageStop); } } @@ -2171,56 +2185,49 @@ fn format_tool_call_start(name: &str, input: &str) -> String { serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); let detail = match name { - "bash" | "Bash" => parsed - .get("command") - .and_then(|v| v.as_str()) - .map(|cmd| truncate_for_summary(cmd, 120)) - .unwrap_or_default(), - "read_file" | "Read" => parsed - .get("file_path") - .or_else(|| parsed.get("path")) - .and_then(|v| v.as_str()) - .unwrap_or("?") - .to_string(), + "bash" | "Bash" => format_bash_call(&parsed), + "read_file" | "Read" => { + let path = extract_tool_path(&parsed); + format!("\x1b[2m📄 Reading {path}…\x1b[0m") + } "write_file" | "Write" => { - let path = parsed - .get("file_path") - .or_else(|| parsed.get("path")) - .and_then(|v| v.as_str()) - .unwrap_or("?"); + let path = extract_tool_path(&parsed); let lines = parsed .get("content") - .and_then(|v| v.as_str()) - .map_or(0, |c| c.lines().count()); - format!("{path} ({lines} lines)") + .and_then(|value| value.as_str()) + .map_or(0, |content| content.lines().count()); + format!("\x1b[1;32m✏️ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m") } "edit_file" | "Edit" => { - let path = parsed - .get("file_path") - .or_else(|| parsed.get("path")) - .and_then(|v| v.as_str()) - .unwrap_or("?"); - path.to_string() + let path = extract_tool_path(&parsed); + let old_value = parsed + .get("old_string") + .or_else(|| parsed.get("oldString")) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let new_value = parsed + .get("new_string") + .or_else(|| parsed.get("newString")) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + format!( + "\x1b[1;33m📝 Editing {path}\x1b[0m{}", + format_patch_preview(old_value, new_value) + .map(|preview| format!("\n{preview}")) + .unwrap_or_default() + ) } - "glob_search" | "Glob" => parsed - .get("pattern") - .and_then(|v| v.as_str()) - .unwrap_or("?") - .to_string(), - "grep_search" | "Grep" => parsed - .get("pattern") - .and_then(|v| v.as_str()) - .unwrap_or("?") - .to_string(), + "glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed), + "grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed), "web_search" | "WebSearch" => parsed .get("query") - .and_then(|v| v.as_str()) + .and_then(|value| value.as_str()) .unwrap_or("?") .to_string(), _ => summarize_tool_payload(input), }; - let border = "─".repeat(name.len() + 6); + let border = "─".repeat(name.len() + 8); format!( "\x1b[38;5;245m╭─ \x1b[1;36m{name}\x1b[0;38;5;245m ─╮\x1b[0m\n\x1b[38;5;245m│\x1b[0m {detail}\n\x1b[38;5;245m╰{border}╯\x1b[0m" ) @@ -2232,8 +2239,269 @@ fn format_tool_result(name: &str, output: &str, is_error: bool) -> String { } else { "\x1b[1;32m✓\x1b[0m" }; - let summary = truncate_for_summary(output.trim(), 200); - format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {summary}") + if is_error { + let summary = truncate_for_summary(output.trim(), 160); + return if summary.is_empty() { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m") + } else { + format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n\x1b[38;5;203m{summary}\x1b[0m") + }; + } + + let parsed: serde_json::Value = + serde_json::from_str(output).unwrap_or(serde_json::Value::String(output.to_string())); + match name { + "bash" | "Bash" => format_bash_result(icon, &parsed), + "read_file" | "Read" => format_read_result(icon, &parsed), + "write_file" | "Write" => format_write_result(icon, &parsed), + "edit_file" | "Edit" => format_edit_result(icon, &parsed), + "glob_search" | "Glob" => format_glob_result(icon, &parsed), + "grep_search" | "Grep" => format_grep_result(icon, &parsed), + _ => { + let summary = truncate_for_summary(output.trim(), 200); + format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {summary}") + } + } +} + +fn extract_tool_path(parsed: &serde_json::Value) -> String { + parsed + .get("file_path") + .or_else(|| parsed.get("filePath")) + .or_else(|| parsed.get("path")) + .and_then(|value| value.as_str()) + .unwrap_or("?") + .to_string() +} + +fn format_search_start(label: &str, parsed: &serde_json::Value) -> String { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m") +} + +fn format_patch_preview(old_value: &str, new_value: &str) -> Option { + if old_value.is_empty() && new_value.is_empty() { + return None; + } + Some(format!( + "\x1b[38;5;203m- {}\x1b[0m\n\x1b[38;5;70m+ {}\x1b[0m", + truncate_for_summary(first_visible_line(old_value), 72), + truncate_for_summary(first_visible_line(new_value), 72) + )) +} + +fn format_bash_call(parsed: &serde_json::Value) -> String { + let command = parsed + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if command.is_empty() { + String::new() + } else { + format!( + "\x1b[48;5;236;38;5;255m $ {} \x1b[0m", + truncate_for_summary(command, 160) + ) + } +} + +fn first_visible_line(text: &str) -> &str { + text.lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or(text) +} + +fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String { + let mut lines = vec![format!("{icon} \x1b[38;5;245mbash\x1b[0m")]; + if let Some(task_id) = parsed + .get("backgroundTaskId") + .and_then(|value| value.as_str()) + { + lines[0].push_str(&format!(" backgrounded ({task_id})")); + } else if let Some(status) = parsed + .get("returnCodeInterpretation") + .and_then(|value| value.as_str()) + .filter(|status| !status.is_empty()) + { + lines[0].push_str(&format!(" {status}")); + } + + if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) { + if !stdout.trim().is_empty() { + lines.push(stdout.trim_end().to_string()); + } + } + if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) { + if !stderr.trim().is_empty() { + lines.push(format!("\x1b[38;5;203m{}\x1b[0m", stderr.trim_end())); + } + } + + lines.join("\n\n") +} + +fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { + let file = parsed.get("file").unwrap_or(parsed); + let path = extract_tool_path(file); + let start_line = file + .get("startLine") + .and_then(|value| value.as_u64()) + .unwrap_or(1); + let num_lines = file + .get("numLines") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let total_lines = file + .get("totalLines") + .and_then(|value| value.as_u64()) + .unwrap_or(num_lines); + let content = file + .get("content") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let end_line = start_line.saturating_add(num_lines.saturating_sub(1)); + + format!( + "{icon} \x1b[2m📄 Read {path} (lines {}-{} of {})\x1b[0m\n{}", + start_line, + end_line.max(start_line), + total_lines, + content + ) +} + +fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String { + let path = extract_tool_path(parsed); + let kind = parsed + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or("write"); + let line_count = parsed + .get("content") + .and_then(|value| value.as_str()) + .map(|content| content.lines().count()) + .unwrap_or(0); + format!( + "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m", + if kind == "create" { "Wrote" } else { "Updated" }, + ) +} + +fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option { + let hunks = parsed.get("structuredPatch")?.as_array()?; + let mut preview = Vec::new(); + for hunk in hunks.iter().take(2) { + let lines = hunk.get("lines")?.as_array()?; + for line in lines.iter().filter_map(|value| value.as_str()).take(6) { + match line.chars().next() { + Some('+') => preview.push(format!("\x1b[38;5;70m{line}\x1b[0m")), + Some('-') => preview.push(format!("\x1b[38;5;203m{line}\x1b[0m")), + _ => preview.push(line.to_string()), + } + } + } + if preview.is_empty() { + None + } else { + Some(preview.join("\n")) + } +} + +fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { + let path = extract_tool_path(parsed); + let suffix = if parsed + .get("replaceAll") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + " (replace all)" + } else { + "" + }; + let preview = format_structured_patch_preview(parsed).or_else(|| { + let old_value = parsed + .get("oldString") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let new_value = parsed + .get("newString") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + format_patch_preview(old_value, new_value) + }); + + match preview { + Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"), + None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"), + } +} + +fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { + let num_files = parsed + .get("numFiles") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let filenames = parsed + .get("filenames") + .and_then(|value| value.as_array()) + .map(|files| { + files + .iter() + .filter_map(|value| value.as_str()) + .take(8) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if filenames.is_empty() { + format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files") + } else { + format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files\n{filenames}") + } +} + +fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { + let num_matches = parsed + .get("numMatches") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let num_files = parsed + .get("numFiles") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let content = parsed + .get("content") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let filenames = parsed + .get("filenames") + .and_then(|value| value.as_array()) + .map(|files| { + files + .iter() + .filter_map(|value| value.as_str()) + .take(8) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + let summary = format!( + "{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files" + ); + if !content.trim().is_empty() { + format!("{summary}\n{}", content.trim_end()) + } else if !filenames.is_empty() { + format!("{summary}\n{filenames}") + } else { + summary + } } fn summarize_tool_payload(payload: &str) -> String { @@ -2264,7 +2532,8 @@ fn push_output_block( match block { OutputContentBlock::Text { text } => { if !text.is_empty() { - write!(out, "{text}") + let rendered = TerminalRenderer::new().markdown_to_ansi(&text); + write!(out, "{rendered}") .and_then(|()| out.flush()) .map_err(|error| RuntimeError::new(error.to_string()))?; events.push(AssistantEvent::TextDelta(text)); @@ -3056,9 +3325,35 @@ mod tests { assert!(start.contains("read_file")); assert!(start.contains("src/main.rs")); - let done = format_tool_result("read_file", r#"{"contents":"hello"}"#, false); - assert!(done.contains("read_file:")); - assert!(done.contains("contents")); + let done = format_tool_result( + "read_file", + r#"{"file":{"filePath":"src/main.rs","content":"hello","numLines":1,"startLine":1,"totalLines":1}}"#, + false, + ); + assert!(done.contains("📄 Read src/main.rs")); + assert!(done.contains("hello")); + } + + #[test] + fn push_output_block_renders_markdown_text() { + let mut out = Vec::new(); + let mut events = Vec::new(); + let mut pending_tool = None; + + push_output_block( + OutputContentBlock::Text { + text: "# Heading".to_string(), + }, + &mut out, + &mut events, + &mut pending_tool, + false, + ) + .expect("text block should render"); + + let rendered = String::from_utf8(out).expect("utf8"); + assert!(rendered.contains("Heading")); + assert!(rendered.contains('\u{1b}')); } #[test] diff --git a/rust/crates/rusty-claude-cli/src/render.rs b/rust/crates/rusty-claude-cli/src/render.rs index 18423b3..465c5a4 100644 --- a/rust/crates/rusty-claude-cli/src/render.rs +++ b/rust/crates/rusty-claude-cli/src/render.rs @@ -1,7 +1,5 @@ use std::fmt::Write as FmtWrite; use std::io::{self, Write}; -use std::thread; -use std::time::Duration; use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition}; use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize}; @@ -22,6 +20,7 @@ pub struct ColorTheme { link: Color, quote: Color, table_border: Color, + code_block_border: Color, spinner_active: Color, spinner_done: Color, spinner_failed: Color, @@ -37,6 +36,7 @@ impl Default for ColorTheme { link: Color::Blue, quote: Color::DarkGrey, table_border: Color::DarkCyan, + code_block_border: Color::DarkGrey, spinner_active: Color::Blue, spinner_done: Color::Green, spinner_failed: Color::Red, @@ -154,33 +154,64 @@ impl TableState { struct RenderState { emphasis: usize, strong: usize, + heading_level: Option, quote: usize, list_stack: Vec, + link_stack: Vec, table: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct LinkState { + destination: String, + text: String, +} + impl RenderState { fn style_text(&self, text: &str, theme: &ColorTheme) -> String { - let mut styled = text.to_string(); - if self.strong > 0 { - styled = format!("{}", styled.bold().with(theme.strong)); + let mut style = text.stylize(); + + if matches!(self.heading_level, Some(1 | 2)) || self.strong > 0 { + style = style.bold(); } if self.emphasis > 0 { - styled = format!("{}", styled.italic().with(theme.emphasis)); + style = style.italic(); } + + if let Some(level) = self.heading_level { + style = match level { + 1 => style.with(theme.heading), + 2 => style.white(), + 3 => style.with(Color::Blue), + _ => style.with(Color::Grey), + }; + } else if self.strong > 0 { + style = style.with(theme.strong); + } else if self.emphasis > 0 { + style = style.with(theme.emphasis); + } + if self.quote > 0 { - styled = format!("{}", styled.with(theme.quote)); + style = style.with(theme.quote); } - styled + + format!("{style}") } - fn capture_target_mut<'a>(&'a mut self, output: &'a mut String) -> &'a mut String { - if let Some(table) = self.table.as_mut() { - &mut table.current_cell + fn append_raw(&mut self, output: &mut String, text: &str) { + if let Some(link) = self.link_stack.last_mut() { + link.text.push_str(text); + } else if let Some(table) = self.table.as_mut() { + table.current_cell.push_str(text); } else { - output + output.push_str(text); } } + + fn append_styled(&mut self, output: &mut String, text: &str, theme: &ColorTheme) { + let styled = self.style_text(text, theme); + self.append_raw(output, &styled); + } } #[derive(Debug)] @@ -238,6 +269,11 @@ impl TerminalRenderer { output.trim_end().to_string() } + #[must_use] + pub fn markdown_to_ansi(&self, markdown: &str) -> String { + self.render_markdown(markdown) + } + #[allow(clippy::too_many_lines)] fn render_event( &self, @@ -249,15 +285,21 @@ impl TerminalRenderer { in_code_block: &mut bool, ) { match event { - Event::Start(Tag::Heading { level, .. }) => self.start_heading(level as u8, output), - Event::End(TagEnd::Heading(..) | TagEnd::Paragraph) => output.push_str("\n\n"), + Event::Start(Tag::Heading { level, .. }) => { + self.start_heading(state, level as u8, output) + } + Event::End(TagEnd::Paragraph) => output.push_str("\n\n"), Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output), Event::End(TagEnd::BlockQuote(..)) => { state.quote = state.quote.saturating_sub(1); output.push('\n'); } + Event::End(TagEnd::Heading(..)) => { + state.heading_level = None; + output.push_str("\n\n"); + } Event::End(TagEnd::Item) | Event::SoftBreak | Event::HardBreak => { - state.capture_target_mut(output).push('\n'); + state.append_raw(output, "\n"); } Event::Start(Tag::List(first_item)) => { let kind = match first_item { @@ -293,41 +335,52 @@ impl TerminalRenderer { Event::Code(code) => { let rendered = format!("{}", format!("`{code}`").with(self.color_theme.inline_code)); - state.capture_target_mut(output).push_str(&rendered); + state.append_raw(output, &rendered); } Event::Rule => output.push_str("---\n"), Event::Text(text) => { self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block); } Event::Html(html) | Event::InlineHtml(html) => { - state.capture_target_mut(output).push_str(&html); + state.append_raw(output, &html); } Event::FootnoteReference(reference) => { - let _ = write!(state.capture_target_mut(output), "[{reference}]"); + state.append_raw(output, &format!("[{reference}]")); } Event::TaskListMarker(done) => { - state - .capture_target_mut(output) - .push_str(if done { "[x] " } else { "[ ] " }); + state.append_raw(output, if done { "[x] " } else { "[ ] " }); } Event::InlineMath(math) | Event::DisplayMath(math) => { - state.capture_target_mut(output).push_str(&math); + state.append_raw(output, &math); } Event::Start(Tag::Link { dest_url, .. }) => { - let rendered = format!( - "{}", - format!("[{dest_url}]") - .underlined() - .with(self.color_theme.link) - ); - state.capture_target_mut(output).push_str(&rendered); + state.link_stack.push(LinkState { + destination: dest_url.to_string(), + text: String::new(), + }); + } + Event::End(TagEnd::Link) => { + if let Some(link) = state.link_stack.pop() { + let label = if link.text.is_empty() { + link.destination.clone() + } else { + link.text + }; + let rendered = format!( + "{}", + format!("[{label}]({})", link.destination) + .underlined() + .with(self.color_theme.link) + ); + state.append_raw(output, &rendered); + } } Event::Start(Tag::Image { dest_url, .. }) => { let rendered = format!( "{}", format!("[image:{dest_url}]").with(self.color_theme.link) ); - state.capture_target_mut(output).push_str(&rendered); + state.append_raw(output, &rendered); } Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()), Event::End(TagEnd::Table) => { @@ -369,19 +422,15 @@ impl TerminalRenderer { } } Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _) - | Event::End(TagEnd::Link | TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {} + | Event::End(TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {} } } - fn start_heading(&self, level: u8, output: &mut String) { - output.push('\n'); - let prefix = match level { - 1 => "# ", - 2 => "## ", - 3 => "### ", - _ => "#### ", - }; - let _ = write!(output, "{}", prefix.bold().with(self.color_theme.heading)); + fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) { + state.heading_level = Some(level); + if !output.is_empty() { + output.push('\n'); + } } fn start_quote(&self, state: &mut RenderState, output: &mut String) { @@ -405,20 +454,27 @@ impl TerminalRenderer { } fn start_code_block(&self, code_language: &str, output: &mut String) { - if !code_language.is_empty() { - let _ = writeln!( - output, - "{}", - format!("╭─ {code_language}").with(self.color_theme.heading) - ); - } + let label = if code_language.is_empty() { + "code".to_string() + } else { + code_language.to_string() + }; + let _ = writeln!( + output, + "{}", + format!("╭─ {label}") + .bold() + .with(self.color_theme.code_block_border) + ); } fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) { output.push_str(&self.highlight_code(code_buffer, code_language)); - if !code_language.is_empty() { - let _ = write!(output, "{}", "╰─".with(self.color_theme.heading)); - } + let _ = write!( + output, + "{}", + "╰─".bold().with(self.color_theme.code_block_border) + ); output.push_str("\n\n"); } @@ -433,8 +489,7 @@ impl TerminalRenderer { if in_code_block { code_buffer.push_str(text); } else { - let rendered = state.style_text(text, &self.color_theme); - state.capture_target_mut(output).push_str(&rendered); + state.append_styled(output, text, &self.color_theme); } } @@ -521,9 +576,10 @@ impl TerminalRenderer { for line in LinesWithEndings::from(code) { match syntax_highlighter.highlight_line(line, &self.syntax_set) { Ok(ranges) => { - colored_output.push_str(&as_24_bit_terminal_escaped(&ranges[..], false)); + let escaped = as_24_bit_terminal_escaped(&ranges[..], false); + colored_output.push_str(&apply_code_block_background(&escaped)); } - Err(_) => colored_output.push_str(line), + Err(_) => colored_output.push_str(&apply_code_block_background(line)), } } @@ -531,16 +587,83 @@ impl TerminalRenderer { } pub fn stream_markdown(&self, markdown: &str, out: &mut impl Write) -> io::Result<()> { - let rendered_markdown = self.render_markdown(markdown); - for chunk in rendered_markdown.split_inclusive(char::is_whitespace) { - write!(out, "{chunk}")?; - out.flush()?; - thread::sleep(Duration::from_millis(8)); + let rendered_markdown = self.markdown_to_ansi(markdown); + write!(out, "{rendered_markdown}")?; + if !rendered_markdown.ends_with('\n') { + writeln!(out)?; } - writeln!(out) + out.flush() } } +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct MarkdownStreamState { + pending: String, +} + +impl MarkdownStreamState { + #[must_use] + pub fn push(&mut self, renderer: &TerminalRenderer, delta: &str) -> Option { + self.pending.push_str(delta); + let split = find_stream_safe_boundary(&self.pending)?; + let ready = self.pending[..split].to_string(); + self.pending.drain(..split); + Some(renderer.markdown_to_ansi(&ready)) + } + + #[must_use] + pub fn flush(&mut self, renderer: &TerminalRenderer) -> Option { + if self.pending.trim().is_empty() { + self.pending.clear(); + None + } else { + let pending = std::mem::take(&mut self.pending); + Some(renderer.markdown_to_ansi(&pending)) + } + } +} + +fn apply_code_block_background(line: &str) -> String { + let trimmed = line.trim_end_matches('\n'); + let trailing_newline = if trimmed.len() == line.len() { + "" + } else { + "\n" + }; + let with_background = trimmed.replace("\u{1b}[0m", "\u{1b}[0;48;5;236m"); + format!("\u{1b}[48;5;236m{with_background}\u{1b}[0m{trailing_newline}") +} + +fn find_stream_safe_boundary(markdown: &str) -> Option { + let mut in_fence = false; + let mut last_boundary = None; + + for (offset, line) in markdown.split_inclusive('\n').scan(0usize, |cursor, line| { + let start = *cursor; + *cursor += line.len(); + Some((start, line)) + }) { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + in_fence = !in_fence; + if !in_fence { + last_boundary = Some(offset + line.len()); + } + continue; + } + + if in_fence { + continue; + } + + if trimmed.is_empty() { + last_boundary = Some(offset + line.len()); + } + } + + last_boundary +} + fn visible_width(input: &str) -> usize { strip_ansi(input).chars().count() } @@ -569,7 +692,7 @@ fn strip_ansi(input: &str) -> String { #[cfg(test)] mod tests { - use super::{strip_ansi, Spinner, TerminalRenderer}; + use super::{strip_ansi, MarkdownStreamState, Spinner, TerminalRenderer}; #[test] fn renders_markdown_with_styling_and_lists() { @@ -583,16 +706,28 @@ mod tests { assert!(markdown_output.contains('\u{1b}')); } + #[test] + fn renders_links_as_colored_markdown_labels() { + let terminal_renderer = TerminalRenderer::new(); + let markdown_output = + terminal_renderer.render_markdown("See [Claw](https://example.com/docs) now."); + let plain_text = strip_ansi(&markdown_output); + + assert!(plain_text.contains("[Claw](https://example.com/docs)")); + assert!(markdown_output.contains('\u{1b}')); + } + #[test] fn highlights_fenced_code_blocks() { let terminal_renderer = TerminalRenderer::new(); let markdown_output = - terminal_renderer.render_markdown("```rust\nfn hi() { println!(\"hi\"); }\n```"); + terminal_renderer.markdown_to_ansi("```rust\nfn hi() { println!(\"hi\"); }\n```"); let plain_text = strip_ansi(&markdown_output); assert!(plain_text.contains("╭─ rust")); assert!(plain_text.contains("fn hi")); assert!(markdown_output.contains('\u{1b}')); + assert!(markdown_output.contains("[48;5;236m")); } #[test] @@ -623,6 +758,26 @@ mod tests { assert!(markdown_output.contains('\u{1b}')); } + #[test] + fn streaming_state_waits_for_complete_blocks() { + let renderer = TerminalRenderer::new(); + let mut state = MarkdownStreamState::default(); + + assert_eq!(state.push(&renderer, "# Heading"), None); + let flushed = state + .push(&renderer, "\n\nParagraph\n\n") + .expect("completed block"); + let plain_text = strip_ansi(&flushed); + assert!(plain_text.contains("Heading")); + assert!(plain_text.contains("Paragraph")); + + assert_eq!(state.push(&renderer, "```rust\nfn main() {}\n"), None); + let code = state + .push(&renderer, "```\n") + .expect("closed code fence flushes"); + assert!(strip_ansi(&code).contains("fn main()")); + } + #[test] fn spinner_advances_frames() { let terminal_renderer = TerminalRenderer::new();