mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 16:21:52 +08:00
Merge remote-tracking branch 'origin/rcc/ui-polish' into dev/rust
This commit is contained in:
@@ -22,7 +22,7 @@ use commands::{
|
|||||||
};
|
};
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use init::initialize_repo;
|
use init::initialize_repo;
|
||||||
use render::{Spinner, TerminalRenderer};
|
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
||||||
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
|
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
|
||||||
@@ -2011,6 +2011,8 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
} else {
|
} else {
|
||||||
&mut sink
|
&mut sink
|
||||||
};
|
};
|
||||||
|
let renderer = TerminalRenderer::new();
|
||||||
|
let mut markdown_stream = MarkdownStreamState::default();
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut pending_tool: Option<(String, String, String)> = None;
|
let mut pending_tool: Option<(String, String, String)> = None;
|
||||||
let mut saw_stop = false;
|
let mut saw_stop = false;
|
||||||
@@ -2038,9 +2040,11 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
||||||
ContentBlockDelta::TextDelta { text } => {
|
ContentBlockDelta::TextDelta { text } => {
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
write!(out, "{text}")
|
if let Some(rendered) = markdown_stream.push(&renderer, &text) {
|
||||||
|
write!(out, "{rendered}")
|
||||||
.and_then(|()| out.flush())
|
.and_then(|()| out.flush())
|
||||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
|
}
|
||||||
events.push(AssistantEvent::TextDelta(text));
|
events.push(AssistantEvent::TextDelta(text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2051,6 +2055,11 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
ApiStreamEvent::ContentBlockStop(_) => {
|
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() {
|
if let Some((id, name, input)) = pending_tool.take() {
|
||||||
// Display tool call now that input is fully accumulated
|
// Display tool call now that input is fully accumulated
|
||||||
writeln!(out, "\n{}", format_tool_call_start(&name, &input))
|
writeln!(out, "\n{}", format_tool_call_start(&name, &input))
|
||||||
@@ -2069,6 +2078,11 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
}
|
}
|
||||||
ApiStreamEvent::MessageStop(_) => {
|
ApiStreamEvent::MessageStop(_) => {
|
||||||
saw_stop = true;
|
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);
|
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()));
|
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
|
||||||
|
|
||||||
let detail = match name {
|
let detail = match name {
|
||||||
"bash" | "Bash" => parsed
|
"bash" | "Bash" => format_bash_call(&parsed),
|
||||||
.get("command")
|
"read_file" | "Read" => {
|
||||||
.and_then(|v| v.as_str())
|
let path = extract_tool_path(&parsed);
|
||||||
.map(|cmd| truncate_for_summary(cmd, 120))
|
format!("\x1b[2m📄 Reading {path}…\x1b[0m")
|
||||||
.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(),
|
|
||||||
"write_file" | "Write" => {
|
"write_file" | "Write" => {
|
||||||
let path = parsed
|
let path = extract_tool_path(&parsed);
|
||||||
.get("file_path")
|
|
||||||
.or_else(|| parsed.get("path"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("?");
|
|
||||||
let lines = parsed
|
let lines = parsed
|
||||||
.get("content")
|
.get("content")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|value| value.as_str())
|
||||||
.map_or(0, |c| c.lines().count());
|
.map_or(0, |content| content.lines().count());
|
||||||
format!("{path} ({lines} lines)")
|
format!("\x1b[1;32m✏️ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m")
|
||||||
}
|
}
|
||||||
"edit_file" | "Edit" => {
|
"edit_file" | "Edit" => {
|
||||||
let path = parsed
|
let path = extract_tool_path(&parsed);
|
||||||
.get("file_path")
|
let old_value = parsed
|
||||||
.or_else(|| parsed.get("path"))
|
.get("old_string")
|
||||||
.and_then(|v| v.as_str())
|
.or_else(|| parsed.get("oldString"))
|
||||||
.unwrap_or("?");
|
.and_then(|value| value.as_str())
|
||||||
path.to_string()
|
.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
|
"glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed),
|
||||||
.get("pattern")
|
"grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed),
|
||||||
.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(),
|
|
||||||
"web_search" | "WebSearch" => parsed
|
"web_search" | "WebSearch" => parsed
|
||||||
.get("query")
|
.get("query")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|value| value.as_str())
|
||||||
.unwrap_or("?")
|
.unwrap_or("?")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
_ => summarize_tool_payload(input),
|
_ => summarize_tool_payload(input),
|
||||||
};
|
};
|
||||||
|
|
||||||
let border = "─".repeat(name.len() + 6);
|
let border = "─".repeat(name.len() + 8);
|
||||||
format!(
|
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"
|
"\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,9 +2239,270 @@ fn format_tool_result(name: &str, output: &str, is_error: bool) -> String {
|
|||||||
} else {
|
} else {
|
||||||
"\x1b[1;32m✓\x1b[0m"
|
"\x1b[1;32m✓\x1b[0m"
|
||||||
};
|
};
|
||||||
|
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);
|
let summary = truncate_for_summary(output.trim(), 200);
|
||||||
format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {summary}")
|
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 {
|
fn summarize_tool_payload(payload: &str) -> String {
|
||||||
let compact = match serde_json::from_str::<serde_json::Value>(payload) {
|
let compact = match serde_json::from_str::<serde_json::Value>(payload) {
|
||||||
@@ -2264,7 +2532,8 @@ fn push_output_block(
|
|||||||
match block {
|
match block {
|
||||||
OutputContentBlock::Text { text } => {
|
OutputContentBlock::Text { text } => {
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
write!(out, "{text}")
|
let rendered = TerminalRenderer::new().markdown_to_ansi(&text);
|
||||||
|
write!(out, "{rendered}")
|
||||||
.and_then(|()| out.flush())
|
.and_then(|()| out.flush())
|
||||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
events.push(AssistantEvent::TextDelta(text));
|
events.push(AssistantEvent::TextDelta(text));
|
||||||
@@ -3056,9 +3325,35 @@ mod tests {
|
|||||||
assert!(start.contains("read_file"));
|
assert!(start.contains("read_file"));
|
||||||
assert!(start.contains("src/main.rs"));
|
assert!(start.contains("src/main.rs"));
|
||||||
|
|
||||||
let done = format_tool_result("read_file", r#"{"contents":"hello"}"#, false);
|
let done = format_tool_result(
|
||||||
assert!(done.contains("read_file:"));
|
"read_file",
|
||||||
assert!(done.contains("contents"));
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use std::fmt::Write as FmtWrite;
|
use std::fmt::Write as FmtWrite;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition};
|
use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition};
|
||||||
use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize};
|
use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize};
|
||||||
@@ -22,6 +20,7 @@ pub struct ColorTheme {
|
|||||||
link: Color,
|
link: Color,
|
||||||
quote: Color,
|
quote: Color,
|
||||||
table_border: Color,
|
table_border: Color,
|
||||||
|
code_block_border: Color,
|
||||||
spinner_active: Color,
|
spinner_active: Color,
|
||||||
spinner_done: Color,
|
spinner_done: Color,
|
||||||
spinner_failed: Color,
|
spinner_failed: Color,
|
||||||
@@ -37,6 +36,7 @@ impl Default for ColorTheme {
|
|||||||
link: Color::Blue,
|
link: Color::Blue,
|
||||||
quote: Color::DarkGrey,
|
quote: Color::DarkGrey,
|
||||||
table_border: Color::DarkCyan,
|
table_border: Color::DarkCyan,
|
||||||
|
code_block_border: Color::DarkGrey,
|
||||||
spinner_active: Color::Blue,
|
spinner_active: Color::Blue,
|
||||||
spinner_done: Color::Green,
|
spinner_done: Color::Green,
|
||||||
spinner_failed: Color::Red,
|
spinner_failed: Color::Red,
|
||||||
@@ -154,32 +154,63 @@ impl TableState {
|
|||||||
struct RenderState {
|
struct RenderState {
|
||||||
emphasis: usize,
|
emphasis: usize,
|
||||||
strong: usize,
|
strong: usize,
|
||||||
|
heading_level: Option<u8>,
|
||||||
quote: usize,
|
quote: usize,
|
||||||
list_stack: Vec<ListKind>,
|
list_stack: Vec<ListKind>,
|
||||||
|
link_stack: Vec<LinkState>,
|
||||||
table: Option<TableState>,
|
table: Option<TableState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct LinkState {
|
||||||
|
destination: String,
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl RenderState {
|
impl RenderState {
|
||||||
fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
|
fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
|
||||||
let mut styled = text.to_string();
|
let mut style = text.stylize();
|
||||||
if self.strong > 0 {
|
|
||||||
styled = format!("{}", styled.bold().with(theme.strong));
|
if matches!(self.heading_level, Some(1 | 2)) || self.strong > 0 {
|
||||||
|
style = style.bold();
|
||||||
}
|
}
|
||||||
if self.emphasis > 0 {
|
if self.emphasis > 0 {
|
||||||
styled = format!("{}", styled.italic().with(theme.emphasis));
|
style = style.italic();
|
||||||
}
|
|
||||||
if self.quote > 0 {
|
|
||||||
styled = format!("{}", styled.with(theme.quote));
|
|
||||||
}
|
|
||||||
styled
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn capture_target_mut<'a>(&'a mut self, output: &'a mut String) -> &'a mut String {
|
if let Some(level) = self.heading_level {
|
||||||
if let Some(table) = self.table.as_mut() {
|
style = match level {
|
||||||
&mut table.current_cell
|
1 => style.with(theme.heading),
|
||||||
} else {
|
2 => style.white(),
|
||||||
output
|
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 {
|
||||||
|
style = style.with(theme.quote);
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("{style}")
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +269,11 @@ impl TerminalRenderer {
|
|||||||
output.trim_end().to_string()
|
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)]
|
#[allow(clippy::too_many_lines)]
|
||||||
fn render_event(
|
fn render_event(
|
||||||
&self,
|
&self,
|
||||||
@@ -249,15 +285,21 @@ impl TerminalRenderer {
|
|||||||
in_code_block: &mut bool,
|
in_code_block: &mut bool,
|
||||||
) {
|
) {
|
||||||
match event {
|
match event {
|
||||||
Event::Start(Tag::Heading { level, .. }) => self.start_heading(level as u8, output),
|
Event::Start(Tag::Heading { level, .. }) => {
|
||||||
Event::End(TagEnd::Heading(..) | TagEnd::Paragraph) => output.push_str("\n\n"),
|
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::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
|
||||||
Event::End(TagEnd::BlockQuote(..)) => {
|
Event::End(TagEnd::BlockQuote(..)) => {
|
||||||
state.quote = state.quote.saturating_sub(1);
|
state.quote = state.quote.saturating_sub(1);
|
||||||
output.push('\n');
|
output.push('\n');
|
||||||
}
|
}
|
||||||
|
Event::End(TagEnd::Heading(..)) => {
|
||||||
|
state.heading_level = None;
|
||||||
|
output.push_str("\n\n");
|
||||||
|
}
|
||||||
Event::End(TagEnd::Item) | Event::SoftBreak | Event::HardBreak => {
|
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)) => {
|
Event::Start(Tag::List(first_item)) => {
|
||||||
let kind = match first_item {
|
let kind = match first_item {
|
||||||
@@ -293,41 +335,52 @@ impl TerminalRenderer {
|
|||||||
Event::Code(code) => {
|
Event::Code(code) => {
|
||||||
let rendered =
|
let rendered =
|
||||||
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
|
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::Rule => output.push_str("---\n"),
|
||||||
Event::Text(text) => {
|
Event::Text(text) => {
|
||||||
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
|
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
|
||||||
}
|
}
|
||||||
Event::Html(html) | Event::InlineHtml(html) => {
|
Event::Html(html) | Event::InlineHtml(html) => {
|
||||||
state.capture_target_mut(output).push_str(&html);
|
state.append_raw(output, &html);
|
||||||
}
|
}
|
||||||
Event::FootnoteReference(reference) => {
|
Event::FootnoteReference(reference) => {
|
||||||
let _ = write!(state.capture_target_mut(output), "[{reference}]");
|
state.append_raw(output, &format!("[{reference}]"));
|
||||||
}
|
}
|
||||||
Event::TaskListMarker(done) => {
|
Event::TaskListMarker(done) => {
|
||||||
state
|
state.append_raw(output, if done { "[x] " } else { "[ ] " });
|
||||||
.capture_target_mut(output)
|
|
||||||
.push_str(if done { "[x] " } else { "[ ] " });
|
|
||||||
}
|
}
|
||||||
Event::InlineMath(math) | Event::DisplayMath(math) => {
|
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, .. }) => {
|
Event::Start(Tag::Link { dest_url, .. }) => {
|
||||||
|
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!(
|
let rendered = format!(
|
||||||
"{}",
|
"{}",
|
||||||
format!("[{dest_url}]")
|
format!("[{label}]({})", link.destination)
|
||||||
.underlined()
|
.underlined()
|
||||||
.with(self.color_theme.link)
|
.with(self.color_theme.link)
|
||||||
);
|
);
|
||||||
state.capture_target_mut(output).push_str(&rendered);
|
state.append_raw(output, &rendered);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Event::Start(Tag::Image { dest_url, .. }) => {
|
Event::Start(Tag::Image { dest_url, .. }) => {
|
||||||
let rendered = format!(
|
let rendered = format!(
|
||||||
"{}",
|
"{}",
|
||||||
format!("[image:{dest_url}]").with(self.color_theme.link)
|
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::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
|
||||||
Event::End(TagEnd::Table) => {
|
Event::End(TagEnd::Table) => {
|
||||||
@@ -369,19 +422,15 @@ impl TerminalRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _)
|
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) {
|
fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) {
|
||||||
|
state.heading_level = Some(level);
|
||||||
|
if !output.is_empty() {
|
||||||
output.push('\n');
|
output.push('\n');
|
||||||
let prefix = match level {
|
}
|
||||||
1 => "# ",
|
|
||||||
2 => "## ",
|
|
||||||
3 => "### ",
|
|
||||||
_ => "#### ",
|
|
||||||
};
|
|
||||||
let _ = write!(output, "{}", prefix.bold().with(self.color_theme.heading));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_quote(&self, state: &mut RenderState, output: &mut String) {
|
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) {
|
fn start_code_block(&self, code_language: &str, output: &mut String) {
|
||||||
if !code_language.is_empty() {
|
let label = if code_language.is_empty() {
|
||||||
|
"code".to_string()
|
||||||
|
} else {
|
||||||
|
code_language.to_string()
|
||||||
|
};
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
output,
|
output,
|
||||||
"{}",
|
"{}",
|
||||||
format!("╭─ {code_language}").with(self.color_theme.heading)
|
format!("╭─ {label}")
|
||||||
|
.bold()
|
||||||
|
.with(self.color_theme.code_block_border)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) {
|
fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) {
|
||||||
output.push_str(&self.highlight_code(code_buffer, code_language));
|
output.push_str(&self.highlight_code(code_buffer, code_language));
|
||||||
if !code_language.is_empty() {
|
let _ = write!(
|
||||||
let _ = write!(output, "{}", "╰─".with(self.color_theme.heading));
|
output,
|
||||||
}
|
"{}",
|
||||||
|
"╰─".bold().with(self.color_theme.code_block_border)
|
||||||
|
);
|
||||||
output.push_str("\n\n");
|
output.push_str("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,8 +489,7 @@ impl TerminalRenderer {
|
|||||||
if in_code_block {
|
if in_code_block {
|
||||||
code_buffer.push_str(text);
|
code_buffer.push_str(text);
|
||||||
} else {
|
} else {
|
||||||
let rendered = state.style_text(text, &self.color_theme);
|
state.append_styled(output, text, &self.color_theme);
|
||||||
state.capture_target_mut(output).push_str(&rendered);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,9 +576,10 @@ impl TerminalRenderer {
|
|||||||
for line in LinesWithEndings::from(code) {
|
for line in LinesWithEndings::from(code) {
|
||||||
match syntax_highlighter.highlight_line(line, &self.syntax_set) {
|
match syntax_highlighter.highlight_line(line, &self.syntax_set) {
|
||||||
Ok(ranges) => {
|
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<()> {
|
pub fn stream_markdown(&self, markdown: &str, out: &mut impl Write) -> io::Result<()> {
|
||||||
let rendered_markdown = self.render_markdown(markdown);
|
let rendered_markdown = self.markdown_to_ansi(markdown);
|
||||||
for chunk in rendered_markdown.split_inclusive(char::is_whitespace) {
|
write!(out, "{rendered_markdown}")?;
|
||||||
write!(out, "{chunk}")?;
|
if !rendered_markdown.ends_with('\n') {
|
||||||
out.flush()?;
|
writeln!(out)?;
|
||||||
thread::sleep(Duration::from_millis(8));
|
|
||||||
}
|
}
|
||||||
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
last_boundary
|
||||||
|
}
|
||||||
|
|
||||||
fn visible_width(input: &str) -> usize {
|
fn visible_width(input: &str) -> usize {
|
||||||
strip_ansi(input).chars().count()
|
strip_ansi(input).chars().count()
|
||||||
}
|
}
|
||||||
@@ -569,7 +692,7 @@ fn strip_ansi(input: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{strip_ansi, Spinner, TerminalRenderer};
|
use super::{strip_ansi, MarkdownStreamState, Spinner, TerminalRenderer};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn renders_markdown_with_styling_and_lists() {
|
fn renders_markdown_with_styling_and_lists() {
|
||||||
@@ -583,16 +706,28 @@ mod tests {
|
|||||||
assert!(markdown_output.contains('\u{1b}'));
|
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]
|
#[test]
|
||||||
fn highlights_fenced_code_blocks() {
|
fn highlights_fenced_code_blocks() {
|
||||||
let terminal_renderer = TerminalRenderer::new();
|
let terminal_renderer = TerminalRenderer::new();
|
||||||
let markdown_output =
|
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);
|
let plain_text = strip_ansi(&markdown_output);
|
||||||
|
|
||||||
assert!(plain_text.contains("╭─ rust"));
|
assert!(plain_text.contains("╭─ rust"));
|
||||||
assert!(plain_text.contains("fn hi"));
|
assert!(plain_text.contains("fn hi"));
|
||||||
assert!(markdown_output.contains('\u{1b}'));
|
assert!(markdown_output.contains('\u{1b}'));
|
||||||
|
assert!(markdown_output.contains("[48;5;236m"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -623,6 +758,26 @@ mod tests {
|
|||||||
assert!(markdown_output.contains('\u{1b}'));
|
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]
|
#[test]
|
||||||
fn spinner_advances_frames() {
|
fn spinner_advances_frames() {
|
||||||
let terminal_renderer = TerminalRenderer::new();
|
let terminal_renderer = TerminalRenderer::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user