Browse Source

Merge remote-tracking branch 'origin/rcc/ui-polish' into dev/rust

Yeachan-Heo 2 tháng trước cách đây
mục cha
commit
0175ee0a90
2 tập tin đã thay đổi với 559 bổ sung109 xóa
  1. 342 47
      rust/crates/rusty-claude-cli/src/main.rs
  2. 217 62
      rust/crates/rusty-claude-cli/src/render.rs

+ 342 - 47
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<String> {
+    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<String> {
+    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::<Vec<_>>()
+                .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::<Vec<_>>()
+                .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]

+ 217 - 62
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<u8>,
     quote: usize,
     list_stack: Vec<ListKind>,
+    link_stack: Vec<LinkState>,
     table: Option<TableState>,
 }
 
+#[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,14 +587,81 @@ 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)?;
+        }
+        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<String> {
+        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<String> {
+        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<usize> {
+    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());
         }
-        writeln!(out)
     }
+
+    last_boundary
 }
 
 fn visible_width(input: &str) -> usize {
@@ -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();