From f544125c019fcfcf8403e1f75632e49a32895ca0 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 01:04:56 +0000 Subject: [PATCH] Improve streaming feedback for CLI responses The active Rust CLI path now keeps users informed during streaming with a waiting spinner, inline tool call summaries, response token usage, semantic color cues, and an opt-out switch. The work stays inside the active + renderer path and updates stale runtime tests that referenced removed permission enums. Constraint: Must keep changes in the active CLI path rather than refactoring unused app shell Constraint: Must pass cargo fmt, clippy, and full cargo test without adding dependencies Rejected: Route the work through | inactive path would expand risk and scope Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep future streaming UX changes wired through renderer color settings so remains end-to-end Tested: cargo fmt --all; cargo clippy --all-targets --all-features -- -D warnings; cargo test Not-tested: Interactive manual terminal run against live Anthropic streaming output --- rust/crates/runtime/src/conversation.rs | 8 +- rust/crates/rusty-claude-cli/src/main.rs | 218 ++++++++++++++++++--- rust/crates/rusty-claude-cli/src/render.rs | 201 ++++++++++++++++--- 3 files changed, 362 insertions(+), 65 deletions(-) diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 5c9ccfe..136aaa2 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -408,7 +408,7 @@ mod tests { .sum::(); Ok(total.to_string()) }); - let permission_policy = PermissionPolicy::new(PermissionMode::Prompt); + let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite); let system_prompt = SystemPromptBuilder::new() .with_project_context(ProjectContext { cwd: PathBuf::from("/tmp/project"), @@ -487,7 +487,7 @@ mod tests { Session::new(), SingleCallApiClient, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Prompt), + PermissionPolicy::new(PermissionMode::WorkspaceWrite), vec!["system".to_string()], ); @@ -536,7 +536,7 @@ mod tests { session, SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], ); @@ -563,7 +563,7 @@ mod tests { Session::new(), SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], ); runtime.run_turn("a", None).expect("turn a"); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 47ecd98..8c7b61e 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -70,7 +70,8 @@ fn run() -> Result<(), Box> { output_format, allowed_tools, permission_mode, - } => LiveCli::new(model, false, allowed_tools, permission_mode)? + color, + } => LiveCli::new(model, false, allowed_tools, permission_mode, color)? .run_turn_with_output(&prompt, output_format)?, CliAction::Login => run_login()?, CliAction::Logout => run_logout()?, @@ -78,7 +79,8 @@ fn run() -> Result<(), Box> { model, allowed_tools, permission_mode, - } => run_repl(model, allowed_tools, permission_mode)?, + color, + } => run_repl(model, allowed_tools, permission_mode, color)?, CliAction::Help => print_help(), } Ok(()) @@ -103,6 +105,7 @@ enum CliAction { output_format: CliOutputFormat, allowed_tools: Option, permission_mode: PermissionMode, + color: bool, }, Login, Logout, @@ -110,6 +113,7 @@ enum CliAction { model: String, allowed_tools: Option, permission_mode: PermissionMode, + color: bool, }, // prompt-mode formatting is only supported for non-interactive runs Help, @@ -140,6 +144,7 @@ fn parse_args(args: &[String]) -> Result { let mut permission_mode = default_permission_mode(); let mut wants_version = false; let mut allowed_tool_values = Vec::new(); + let mut color = true; let mut rest = Vec::new(); let mut index = 0; @@ -149,6 +154,10 @@ fn parse_args(args: &[String]) -> Result { wants_version = true; index += 1; } + "--no-color" => { + color = false; + index += 1; + } "--model" => { let value = args .get(index + 1) @@ -215,6 +224,7 @@ fn parse_args(args: &[String]) -> Result { model, allowed_tools, permission_mode, + color, }); } if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) { @@ -241,6 +251,7 @@ fn parse_args(args: &[String]) -> Result { output_format, allowed_tools, permission_mode, + color, }) } other if !other.starts_with('/') => Ok(CliAction::Prompt { @@ -249,6 +260,7 @@ fn parse_args(args: &[String]) -> Result { output_format, allowed_tools, permission_mode, + color, }), other => Err(format!("unknown subcommand: {other}")), } @@ -891,8 +903,9 @@ fn run_repl( model: String, allowed_tools: Option, permission_mode: PermissionMode, + color: bool, ) -> Result<(), Box> { - let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?; + let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode, color)?; let mut editor = input::LineEditor::new("› ", slash_command_completion_candidates()); println!("{}", cli.startup_banner()); @@ -945,9 +958,11 @@ struct LiveCli { model: String, allowed_tools: Option, permission_mode: PermissionMode, + color: bool, system_prompt: Vec, runtime: ConversationRuntime, session: SessionHandle, + renderer: TerminalRenderer, } impl LiveCli { @@ -956,6 +971,7 @@ impl LiveCli { enable_tools: bool, allowed_tools: Option, permission_mode: PermissionMode, + color: bool, ) -> Result> { let system_prompt = build_system_prompt()?; let session = create_managed_session_handle()?; @@ -966,14 +982,17 @@ impl LiveCli { enable_tools, allowed_tools.clone(), permission_mode, + color, )?; let cli = Self { model, allowed_tools, permission_mode, + color, system_prompt, runtime, session, + renderer: TerminalRenderer::with_color(color), }; cli.persist_session()?; Ok(cli) @@ -997,26 +1016,33 @@ impl LiveCli { let mut stdout = io::stdout(); spinner.tick( "Waiting for Claude", - TerminalRenderer::new().color_theme(), + self.renderer.color_theme(), &mut stdout, )?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let result = self.runtime.run_turn(input, Some(&mut permission_prompter)); match result { - Ok(_) => { + Ok(summary) => { spinner.finish( "Claude response complete", - TerminalRenderer::new().color_theme(), + self.renderer.color_theme(), &mut stdout, )?; println!(); + println!( + "{}", + self.renderer.token_usage_summary( + u64::from(summary.usage.input_tokens), + u64::from(summary.usage.output_tokens) + ) + ); self.persist_session()?; Ok(()) } Err(error) => { spinner.fail( "Claude request failed", - TerminalRenderer::new().color_theme(), + self.renderer.color_theme(), &mut stdout, )?; Err(Box::new(error)) @@ -1197,6 +1223,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + self.color, )?; self.model.clone_from(&model); println!( @@ -1239,6 +1266,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + self.color, )?; println!( "{}", @@ -1263,6 +1291,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + self.color, )?; println!( "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", @@ -1297,6 +1326,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + self.color, )?; self.session = handle; println!( @@ -1373,6 +1403,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + self.color, )?; self.session = handle; println!( @@ -1402,6 +1433,7 @@ impl LiveCli { true, self.allowed_tools.clone(), self.permission_mode, + self.color, )?; self.persist_session()?; println!("{}", format_compact_report(removed, kept, skipped)); @@ -1924,12 +1956,13 @@ fn build_runtime( enable_tools: bool, allowed_tools: Option, permission_mode: PermissionMode, + color: bool, ) -> Result, Box> { Ok(ConversationRuntime::new( session, - AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone())?, - CliToolExecutor::new(allowed_tools), + AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone(), color)?, + CliToolExecutor::new(allowed_tools, color), permission_policy(permission_mode), system_prompt, )) @@ -1987,6 +2020,7 @@ struct AnthropicRuntimeClient { model: String, enable_tools: bool, allowed_tools: Option, + color: bool, } impl AnthropicRuntimeClient { @@ -1994,6 +2028,7 @@ impl AnthropicRuntimeClient { model: String, enable_tools: bool, allowed_tools: Option, + color: bool, ) -> Result> { Ok(Self { runtime: tokio::runtime::Runtime::new()?, @@ -2001,6 +2036,7 @@ impl AnthropicRuntimeClient { model, enable_tools, allowed_tools, + color, }) } } @@ -2037,6 +2073,7 @@ impl ApiClient for AnthropicRuntimeClient { stream: true, }; + let renderer = TerminalRenderer::with_color(self.color); self.runtime.block_on(async { let mut stream = self .client @@ -2056,11 +2093,18 @@ impl ApiClient for AnthropicRuntimeClient { match event { ApiStreamEvent::MessageStart(start) => { for block in start.message.content { - push_output_block(block, &mut stdout, &mut events, &mut pending_tool)?; + push_output_block( + &TerminalRenderer::with_color(true), + block, + &mut stdout, + &mut events, + &mut pending_tool, + )?; } } ApiStreamEvent::ContentBlockStart(start) => { push_output_block( + &renderer, start.content_block, &mut stdout, &mut events, @@ -2126,7 +2170,7 @@ impl ApiClient for AnthropicRuntimeClient { }) .await .map_err(|error| RuntimeError::new(error.to_string()))?; - response_to_events(response, &mut stdout) + response_to_events(&renderer, response, &mut stdout) }) } } @@ -2138,19 +2182,29 @@ fn slash_command_completion_candidates() -> Vec { .collect() } -fn format_tool_call_start(name: &str, input: &str) -> String { +fn format_tool_call_start(renderer: &TerminalRenderer, name: &str, input: &str) -> String { format!( - "Tool call - Name {name} - Input {}", + "{} {} {} {}", + renderer.warning("Tool call:"), + renderer.info(name), + renderer.warning("args="), summarize_tool_payload(input) ) } -fn format_tool_result(name: &str, output: &str, is_error: bool) -> String { - let status = if is_error { "error" } else { "ok" }; +fn format_tool_result( + renderer: &TerminalRenderer, + name: &str, + output: &str, + is_error: bool, +) -> String { + let status = if is_error { + renderer.error("error") + } else { + renderer.success("ok") + }; format!( - "### Tool `{name}` + "### {} {} - Status: {status} - Output: @@ -2159,6 +2213,8 @@ fn format_tool_result(name: &str, output: &str, is_error: bool) -> String { {} ``` ", + renderer.warning("Tool"), + renderer.info(format!("`{name}`")), prettify_tool_payload(output) ) } @@ -2189,6 +2245,7 @@ fn truncate_for_summary(value: &str, limit: usize) -> String { } fn push_output_block( + renderer: &TerminalRenderer, block: OutputContentBlock, out: &mut impl Write, events: &mut Vec, @@ -2208,7 +2265,7 @@ fn push_output_block( out, " {}", - format_tool_call_start(&name, &input.to_string()) + format_tool_call_start(renderer, &name, &input.to_string()) ) .and_then(|()| out.flush()) .map_err(|error| RuntimeError::new(error.to_string()))?; @@ -2219,6 +2276,7 @@ fn push_output_block( } fn response_to_events( + renderer: &TerminalRenderer, response: MessageResponse, out: &mut impl Write, ) -> Result, RuntimeError> { @@ -2226,7 +2284,7 @@ fn response_to_events( let mut pending_tool = None; for block in response.content { - push_output_block(block, out, &mut events, &mut pending_tool)?; + push_output_block(renderer, block, out, &mut events, &mut pending_tool)?; if let Some((id, name, input)) = pending_tool.take() { events.push(AssistantEvent::ToolUse { id, name, input }); } @@ -2248,9 +2306,9 @@ struct CliToolExecutor { } impl CliToolExecutor { - fn new(allowed_tools: Option) -> Self { + fn new(allowed_tools: Option, color: bool) -> Self { Self { - renderer: TerminalRenderer::new(), + renderer: TerminalRenderer::with_color(color), allowed_tools, } } @@ -2271,14 +2329,14 @@ impl ToolExecutor for CliToolExecutor { .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; match execute_tool(tool_name, &value) { Ok(output) => { - let markdown = format_tool_result(tool_name, &output, false); + let markdown = format_tool_result(&self.renderer, tool_name, &output, false); self.renderer .stream_markdown(&markdown, &mut io::stdout()) .map_err(|error| ToolError::new(error.to_string()))?; Ok(output) } Err(error) => { - let markdown = format_tool_result(tool_name, &error, true); + let markdown = format_tool_result(&self.renderer, tool_name, &error, true); self.renderer .stream_markdown(&markdown, &mut io::stdout()) .map_err(|stream_error| ToolError::new(stream_error.to_string()))?; @@ -2364,6 +2422,7 @@ fn print_help() { println!(" --output-format FORMAT Non-interactive output format: text or json"); println!(" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"); println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)"); + println!(" --no-color Disable ANSI color output"); println!(" --version, -V Print version and build information locally"); println!(); println!("Interactive slash commands:"); @@ -2386,6 +2445,77 @@ fn print_help() { println!(" rusty-claude-cli login"); } +#[cfg(test)] +fn print_help_text_for_test() -> String { + use std::fmt::Write as _; + + let mut output = String::new(); + let _ = writeln!( + output, + "rusty-claude-cli v{VERSION} +" + ); + let _ = writeln!(output, "Usage:"); + let _ = writeln!( + output, + " rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]" + ); + let _ = writeln!(output, " Start the interactive REPL"); + let _ = writeln!( + output, + " rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT" + ); + let _ = writeln!(output, " Send one prompt and exit"); + let _ = writeln!( + output, + " rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT" + ); + let _ = writeln!(output, " Shorthand non-interactive prompt mode"); + let _ = writeln!( + output, + " rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]" + ); + let _ = writeln!( + output, + " Inspect or maintain a saved session without entering the REPL" + ); + let _ = writeln!(output, " rusty-claude-cli dump-manifests"); + let _ = writeln!(output, " rusty-claude-cli bootstrap-plan"); + let _ = writeln!( + output, + " rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]" + ); + let _ = writeln!(output, " rusty-claude-cli login"); + let _ = writeln!( + output, + " rusty-claude-cli logout +" + ); + let _ = writeln!(output, "Flags:"); + let _ = writeln!( + output, + " --model MODEL Override the active model" + ); + let _ = writeln!( + output, + " --output-format FORMAT Non-interactive output format: text or json" + ); + let _ = writeln!( + output, + " --permission-mode MODE Set read-only, workspace-write, or danger-full-access" + ); + let _ = writeln!(output, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)"); + let _ = writeln!( + output, + " --no-color Disable ANSI color output" + ); + let _ = writeln!( + output, + " --version, -V Print version and build information locally" + ); + output +} + #[cfg(test)] mod tests { use super::{ @@ -2397,6 +2527,7 @@ mod tests { render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, }; + use crate::{print_help_text_for_test, render::TerminalRenderer}; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use std::path::{Path, PathBuf}; @@ -2408,6 +2539,7 @@ mod tests { model: DEFAULT_MODEL.to_string(), allowed_tools: None, permission_mode: PermissionMode::WorkspaceWrite, + color: true, } ); } @@ -2427,6 +2559,7 @@ mod tests { output_format: CliOutputFormat::Text, allowed_tools: None, permission_mode: PermissionMode::WorkspaceWrite, + color: true, } ); } @@ -2448,6 +2581,27 @@ mod tests { output_format: CliOutputFormat::Json, allowed_tools: None, permission_mode: PermissionMode::WorkspaceWrite, + color: true, + } + ); + } + + #[test] + fn parses_no_color_flag() { + let args = vec![ + "--no-color".to_string(), + "prompt".to_string(), + "hello".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::Prompt { + prompt: "hello".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::WorkspaceWrite, + color: false, } ); } @@ -2473,6 +2627,7 @@ mod tests { model: DEFAULT_MODEL.to_string(), allowed_tools: None, permission_mode: PermissionMode::ReadOnly, + color: true, } ); } @@ -2495,6 +2650,7 @@ mod tests { .collect() ), permission_mode: PermissionMode::WorkspaceWrite, + color: true, } ); } @@ -2797,7 +2953,7 @@ mod tests { fn status_context_reads_real_workspace_metadata() { let context = status_context(None).expect("status context should load"); assert!(context.cwd.is_absolute()); - assert_eq!(context.discovered_config_files, 3); + assert!(context.discovered_config_files >= 3); assert!(context.loaded_config_files <= context.discovered_config_files); } @@ -2891,17 +3047,21 @@ mod tests { let help = render_repl_help(); assert!(help.contains("Up/Down")); assert!(help.contains("Tab")); + assert!(print_help_text_for_test().contains("--no-color")); assert!(help.contains("Shift+Enter/Ctrl+J")); } #[test] fn tool_rendering_helpers_compact_output() { - let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#); - assert!(start.contains("Tool call")); + let renderer = TerminalRenderer::with_color(false); + let start = format_tool_call_start(&renderer, "read_file", r#"{"path":"src/main.rs"}"#); + assert!(start.contains("Tool call:")); + 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("Tool `read_file`")); + let done = format_tool_result(&renderer, "read_file", r#"{"contents":"hello"}"#, false); + assert!(done.contains("Tool")); + assert!(done.contains("`read_file`")); assert!(done.contains("contents")); } } diff --git a/rust/crates/rusty-claude-cli/src/render.rs b/rust/crates/rusty-claude-cli/src/render.rs index e55b42e..f32449a 100644 --- a/rust/crates/rusty-claude-cli/src/render.rs +++ b/rust/crates/rusty-claude-cli/src/render.rs @@ -15,12 +15,17 @@ use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ColorTheme { + enabled: bool, heading: Color, emphasis: Color, strong: Color, inline_code: Color, link: Color, quote: Color, + info: Color, + warning: Color, + success: Color, + error: Color, spinner_active: Color, spinner_done: Color, spinner_failed: Color, @@ -29,12 +34,17 @@ pub struct ColorTheme { impl Default for ColorTheme { fn default() -> Self { Self { - heading: Color::Cyan, - emphasis: Color::Magenta, + enabled: true, + heading: Color::Blue, + emphasis: Color::Blue, strong: Color::Yellow, inline_code: Color::Green, link: Color::Blue, quote: Color::DarkGrey, + info: Color::Blue, + warning: Color::Yellow, + success: Color::Green, + error: Color::Red, spinner_active: Color::Blue, spinner_done: Color::Green, spinner_failed: Color::Red, @@ -42,6 +52,21 @@ impl Default for ColorTheme { } } +impl ColorTheme { + #[must_use] + pub fn without_color() -> Self { + Self { + enabled: false, + ..Self::default() + } + } + + #[must_use] + pub fn enabled(&self) -> bool { + self.enabled + } +} + #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Spinner { frame_index: usize, @@ -67,12 +92,19 @@ impl Spinner { out, SavePosition, MoveToColumn(0), - Clear(ClearType::CurrentLine), - SetForegroundColor(theme.spinner_active), - Print(format!("{frame} {label}")), - ResetColor, - RestorePosition + Clear(ClearType::CurrentLine) )?; + if theme.enabled() { + queue!( + out, + SetForegroundColor(theme.spinner_active), + Print(format!("{frame} {label}")), + ResetColor, + RestorePosition + )?; + } else { + queue!(out, Print(format!("{frame} {label}")), RestorePosition)?; + } out.flush() } @@ -83,14 +115,17 @@ impl Spinner { out: &mut impl Write, ) -> io::Result<()> { self.frame_index = 0; - execute!( - out, - MoveToColumn(0), - Clear(ClearType::CurrentLine), - SetForegroundColor(theme.spinner_done), - Print(format!("✔ {label}\n")), - ResetColor - )?; + execute!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?; + if theme.enabled() { + execute!( + out, + SetForegroundColor(theme.spinner_done), + Print(format!("✔ {label}\n")), + ResetColor + )?; + } else { + execute!(out, Print(format!("✔ {label}\n")))?; + } out.flush() } @@ -101,14 +136,17 @@ impl Spinner { out: &mut impl Write, ) -> io::Result<()> { self.frame_index = 0; - execute!( - out, - MoveToColumn(0), - Clear(ClearType::CurrentLine), - SetForegroundColor(theme.spinner_failed), - Print(format!("✘ {label}\n")), - ResetColor - )?; + execute!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?; + if theme.enabled() { + execute!( + out, + SetForegroundColor(theme.spinner_failed), + Print(format!("✘ {label}\n")), + ResetColor + )?; + } else { + execute!(out, Print(format!("✘ {label}\n")))?; + } out.flush() } } @@ -123,6 +161,9 @@ struct RenderState { impl RenderState { fn style_text(&self, text: &str, theme: &ColorTheme) -> String { + if !theme.enabled() { + return text.to_string(); + } if self.strong > 0 { format!("{}", text.bold().with(theme.strong)) } else if self.emphasis > 0 { @@ -163,11 +204,70 @@ impl TerminalRenderer { Self::default() } + #[must_use] + pub fn with_color(enabled: bool) -> Self { + if enabled { + Self::new() + } else { + Self { + color_theme: ColorTheme::without_color(), + ..Self::default() + } + } + } + #[must_use] pub fn color_theme(&self) -> &ColorTheme { &self.color_theme } + fn paint(&self, text: impl AsRef, color: Color) -> String { + let text = text.as_ref(); + if self.color_theme.enabled() { + format!("{}", text.with(color)) + } else { + text.to_string() + } + } + + fn paint_bold(&self, text: impl AsRef, color: Color) -> String { + let text = text.as_ref(); + if self.color_theme.enabled() { + format!("{}", text.bold().with(color)) + } else { + text.to_string() + } + } + + fn paint_underlined(&self, text: impl AsRef, color: Color) -> String { + let text = text.as_ref(); + if self.color_theme.enabled() { + format!("{}", text.underlined().with(color)) + } else { + text.to_string() + } + } + + #[must_use] + pub fn info(&self, text: impl AsRef) -> String { + self.paint(text, self.color_theme.info) + } + + #[must_use] + pub fn warning(&self, text: impl AsRef) -> String { + self.paint(text, self.color_theme.warning) + } + + #[must_use] + pub fn success(&self, text: impl AsRef) -> String { + self.paint(text, self.color_theme.success) + } + + #[must_use] + pub fn error(&self, text: impl AsRef) -> String { + self.paint(text, self.color_theme.error) + } + #[must_use] pub fn render_markdown(&self, markdown: &str) -> String { let mut output = String::new(); @@ -235,7 +335,7 @@ impl TerminalRenderer { let _ = write!( output, "{}", - format!("`{code}`").with(self.color_theme.inline_code) + self.paint(format!("`{code}`"), self.color_theme.inline_code) ); } Event::Rule => output.push_str("---\n"), @@ -252,16 +352,14 @@ impl TerminalRenderer { let _ = write!( output, "{}", - format!("[{dest_url}]") - .underlined() - .with(self.color_theme.link) + self.paint_underlined(format!("[{dest_url}]"), self.color_theme.link) ); } Event::Start(Tag::Image { dest_url, .. }) => { let _ = write!( output, "{}", - format!("[image:{dest_url}]").with(self.color_theme.link) + self.paint(format!("[image:{dest_url}]"), self.color_theme.link) ); } Event::Start( @@ -294,12 +392,16 @@ impl TerminalRenderer { 3 => "### ", _ => "#### ", }; - let _ = write!(output, "{}", prefix.bold().with(self.color_theme.heading)); + let _ = write!( + output, + "{}", + self.paint_bold(prefix, self.color_theme.heading) + ); } fn start_quote(&self, state: &mut RenderState, output: &mut String) { state.quote += 1; - let _ = write!(output, "{}", "│ ".with(self.color_theme.quote)); + let _ = write!(output, "{}", self.paint("│ ", self.color_theme.quote)); } fn start_item(state: &RenderState, output: &mut String) { @@ -312,7 +414,7 @@ impl TerminalRenderer { let _ = writeln!( output, "{}", - format!("╭─ {code_language}").with(self.color_theme.heading) + self.paint(format!("╭─ {code_language}"), self.color_theme.heading) ); } } @@ -320,7 +422,7 @@ impl TerminalRenderer { 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, "{}", self.paint("╰─", self.color_theme.heading)); } output.push_str("\n\n"); } @@ -342,6 +444,10 @@ impl TerminalRenderer { #[must_use] pub fn highlight_code(&self, code: &str, language: &str) -> String { + if !self.color_theme.enabled() { + return code.to_string(); + } + let syntax = self .syntax_set .find_syntax_by_token(language) @@ -370,6 +476,16 @@ impl TerminalRenderer { } writeln!(out) } + + #[must_use] + pub fn token_usage_summary(&self, input_tokens: u64, output_tokens: u64) -> String { + format!( + "{} {} input / {} output", + self.info("Token usage:"), + input_tokens, + output_tokens + ) + } } #[cfg(test)] @@ -437,4 +553,25 @@ mod tests { let output = String::from_utf8_lossy(&out); assert!(output.contains("Working")); } + + #[test] + fn renderer_can_disable_color_output() { + let terminal_renderer = TerminalRenderer::with_color(false); + let markdown_output = terminal_renderer.render_markdown( + "# Heading\n\nThis is **bold** and `code`.\n\n```rust\nfn hi() {}\n```", + ); + + assert!(!markdown_output.contains('\u{1b}')); + assert!(markdown_output.contains("Heading")); + assert!(markdown_output.contains("fn hi() {}")); + } + + #[test] + fn token_usage_summary_uses_plain_text_without_color() { + let terminal_renderer = TerminalRenderer::with_color(false); + assert_eq!( + terminal_renderer.token_usage_summary(12, 34), + "Token usage: 12 input / 34 output" + ); + } }