Improve terminal output so Rust CLI renders readable rich responses
The Rust CLI was still surfacing raw markdown fragments and raw tool JSON in places where the terminal UI should present styled, human-readable output. This change routes assistant text through the terminal markdown renderer, strengthens the markdown ANSI path for headings/links/lists/code blocks, and converts common tool calls/results into concise terminal-native summaries with readable bash output and edit previews. Constraint: Must match Claude Code-style behavior without copying the upstream TypeScript source Constraint: Keep the fix scoped to rusty-claude-cli rendering and formatting paths Rejected: Port TS rendering components directly | prohibited by task constraints Rejected: Leave tool JSON and only style markdown | still fails the requested terminal UX Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep tool formatting human-readable first; do not reintroduce raw JSON dumps for common tools without a fallback-only guard Tested: cargo test -p rusty-claude-cli Tested: cargo build --release Not-tested: Live end-to-end API streaming against a real Anthropic session
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user