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
This commit is contained in:
Yeachan-Heo
2026-04-01 01:04:56 +00:00
parent d6341d54c1
commit f544125c01
3 changed files with 362 additions and 65 deletions

View File

@@ -408,7 +408,7 @@ mod tests {
.sum::<i32>(); .sum::<i32>();
Ok(total.to_string()) Ok(total.to_string())
}); });
let permission_policy = PermissionPolicy::new(PermissionMode::Prompt); let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite);
let system_prompt = SystemPromptBuilder::new() let system_prompt = SystemPromptBuilder::new()
.with_project_context(ProjectContext { .with_project_context(ProjectContext {
cwd: PathBuf::from("/tmp/project"), cwd: PathBuf::from("/tmp/project"),
@@ -487,7 +487,7 @@ mod tests {
Session::new(), Session::new(),
SingleCallApiClient, SingleCallApiClient,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::Prompt), PermissionPolicy::new(PermissionMode::WorkspaceWrite),
vec!["system".to_string()], vec!["system".to_string()],
); );
@@ -536,7 +536,7 @@ mod tests {
session, session,
SimpleApi, SimpleApi,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::Allow), PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()], vec!["system".to_string()],
); );
@@ -563,7 +563,7 @@ mod tests {
Session::new(), Session::new(),
SimpleApi, SimpleApi,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::Allow), PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()], vec!["system".to_string()],
); );
runtime.run_turn("a", None).expect("turn a"); runtime.run_turn("a", None).expect("turn a");

View File

@@ -70,7 +70,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
output_format, output_format,
allowed_tools, allowed_tools,
permission_mode, 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)?, .run_turn_with_output(&prompt, output_format)?,
CliAction::Login => run_login()?, CliAction::Login => run_login()?,
CliAction::Logout => run_logout()?, CliAction::Logout => run_logout()?,
@@ -78,7 +79,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
model, model,
allowed_tools, allowed_tools,
permission_mode, permission_mode,
} => run_repl(model, allowed_tools, permission_mode)?, color,
} => run_repl(model, allowed_tools, permission_mode, color)?,
CliAction::Help => print_help(), CliAction::Help => print_help(),
} }
Ok(()) Ok(())
@@ -103,6 +105,7 @@ enum CliAction {
output_format: CliOutputFormat, output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode, permission_mode: PermissionMode,
color: bool,
}, },
Login, Login,
Logout, Logout,
@@ -110,6 +113,7 @@ enum CliAction {
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode, permission_mode: PermissionMode,
color: bool,
}, },
// prompt-mode formatting is only supported for non-interactive runs // prompt-mode formatting is only supported for non-interactive runs
Help, Help,
@@ -140,6 +144,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut permission_mode = default_permission_mode(); let mut permission_mode = default_permission_mode();
let mut wants_version = false; let mut wants_version = false;
let mut allowed_tool_values = Vec::new(); let mut allowed_tool_values = Vec::new();
let mut color = true;
let mut rest = Vec::new(); let mut rest = Vec::new();
let mut index = 0; let mut index = 0;
@@ -149,6 +154,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
wants_version = true; wants_version = true;
index += 1; index += 1;
} }
"--no-color" => {
color = false;
index += 1;
}
"--model" => { "--model" => {
let value = args let value = args
.get(index + 1) .get(index + 1)
@@ -215,6 +224,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model, model,
allowed_tools, allowed_tools,
permission_mode, permission_mode,
color,
}); });
} }
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) { if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
@@ -241,6 +251,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format, output_format,
allowed_tools, allowed_tools,
permission_mode, permission_mode,
color,
}) })
} }
other if !other.starts_with('/') => Ok(CliAction::Prompt { other if !other.starts_with('/') => Ok(CliAction::Prompt {
@@ -249,6 +260,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format, output_format,
allowed_tools, allowed_tools,
permission_mode, permission_mode,
color,
}), }),
other => Err(format!("unknown subcommand: {other}")), other => Err(format!("unknown subcommand: {other}")),
} }
@@ -891,8 +903,9 @@ fn run_repl(
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode, permission_mode: PermissionMode,
color: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
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()); let mut editor = input::LineEditor::new(" ", slash_command_completion_candidates());
println!("{}", cli.startup_banner()); println!("{}", cli.startup_banner());
@@ -945,9 +958,11 @@ struct LiveCli {
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode, permission_mode: PermissionMode,
color: bool,
system_prompt: Vec<String>, system_prompt: Vec<String>,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
session: SessionHandle, session: SessionHandle,
renderer: TerminalRenderer,
} }
impl LiveCli { impl LiveCli {
@@ -956,6 +971,7 @@ impl LiveCli {
enable_tools: bool, enable_tools: bool,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode, permission_mode: PermissionMode,
color: bool,
) -> Result<Self, Box<dyn std::error::Error>> { ) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?; let system_prompt = build_system_prompt()?;
let session = create_managed_session_handle()?; let session = create_managed_session_handle()?;
@@ -966,14 +982,17 @@ impl LiveCli {
enable_tools, enable_tools,
allowed_tools.clone(), allowed_tools.clone(),
permission_mode, permission_mode,
color,
)?; )?;
let cli = Self { let cli = Self {
model, model,
allowed_tools, allowed_tools,
permission_mode, permission_mode,
color,
system_prompt, system_prompt,
runtime, runtime,
session, session,
renderer: TerminalRenderer::with_color(color),
}; };
cli.persist_session()?; cli.persist_session()?;
Ok(cli) Ok(cli)
@@ -997,26 +1016,33 @@ impl LiveCli {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
spinner.tick( spinner.tick(
"Waiting for Claude", "Waiting for Claude",
TerminalRenderer::new().color_theme(), self.renderer.color_theme(),
&mut stdout, &mut stdout,
)?; )?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = self.runtime.run_turn(input, Some(&mut permission_prompter)); let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
match result { match result {
Ok(_) => { Ok(summary) => {
spinner.finish( spinner.finish(
"Claude response complete", "Claude response complete",
TerminalRenderer::new().color_theme(), self.renderer.color_theme(),
&mut stdout, &mut stdout,
)?; )?;
println!(); println!();
println!(
"{}",
self.renderer.token_usage_summary(
u64::from(summary.usage.input_tokens),
u64::from(summary.usage.output_tokens)
)
);
self.persist_session()?; self.persist_session()?;
Ok(()) Ok(())
} }
Err(error) => { Err(error) => {
spinner.fail( spinner.fail(
"Claude request failed", "Claude request failed",
TerminalRenderer::new().color_theme(), self.renderer.color_theme(),
&mut stdout, &mut stdout,
)?; )?;
Err(Box::new(error)) Err(Box::new(error))
@@ -1197,6 +1223,7 @@ impl LiveCli {
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
self.permission_mode, self.permission_mode,
self.color,
)?; )?;
self.model.clone_from(&model); self.model.clone_from(&model);
println!( println!(
@@ -1239,6 +1266,7 @@ impl LiveCli {
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
self.permission_mode, self.permission_mode,
self.color,
)?; )?;
println!( println!(
"{}", "{}",
@@ -1263,6 +1291,7 @@ impl LiveCli {
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
self.permission_mode, self.permission_mode,
self.color,
)?; )?;
println!( println!(
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
@@ -1297,6 +1326,7 @@ impl LiveCli {
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
self.permission_mode, self.permission_mode,
self.color,
)?; )?;
self.session = handle; self.session = handle;
println!( println!(
@@ -1373,6 +1403,7 @@ impl LiveCli {
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
self.permission_mode, self.permission_mode,
self.color,
)?; )?;
self.session = handle; self.session = handle;
println!( println!(
@@ -1402,6 +1433,7 @@ impl LiveCli {
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
self.permission_mode, self.permission_mode,
self.color,
)?; )?;
self.persist_session()?; self.persist_session()?;
println!("{}", format_compact_report(removed, kept, skipped)); println!("{}", format_compact_report(removed, kept, skipped));
@@ -1924,12 +1956,13 @@ fn build_runtime(
enable_tools: bool, enable_tools: bool,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode, permission_mode: PermissionMode,
color: bool,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{ {
Ok(ConversationRuntime::new( Ok(ConversationRuntime::new(
session, session,
AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone())?, AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone(), color)?,
CliToolExecutor::new(allowed_tools), CliToolExecutor::new(allowed_tools, color),
permission_policy(permission_mode), permission_policy(permission_mode),
system_prompt, system_prompt,
)) ))
@@ -1987,6 +2020,7 @@ struct AnthropicRuntimeClient {
model: String, model: String,
enable_tools: bool, enable_tools: bool,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
color: bool,
} }
impl AnthropicRuntimeClient { impl AnthropicRuntimeClient {
@@ -1994,6 +2028,7 @@ impl AnthropicRuntimeClient {
model: String, model: String,
enable_tools: bool, enable_tools: bool,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
color: bool,
) -> Result<Self, Box<dyn std::error::Error>> { ) -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self { Ok(Self {
runtime: tokio::runtime::Runtime::new()?, runtime: tokio::runtime::Runtime::new()?,
@@ -2001,6 +2036,7 @@ impl AnthropicRuntimeClient {
model, model,
enable_tools, enable_tools,
allowed_tools, allowed_tools,
color,
}) })
} }
} }
@@ -2037,6 +2073,7 @@ impl ApiClient for AnthropicRuntimeClient {
stream: true, stream: true,
}; };
let renderer = TerminalRenderer::with_color(self.color);
self.runtime.block_on(async { self.runtime.block_on(async {
let mut stream = self let mut stream = self
.client .client
@@ -2056,11 +2093,18 @@ impl ApiClient for AnthropicRuntimeClient {
match event { match event {
ApiStreamEvent::MessageStart(start) => { ApiStreamEvent::MessageStart(start) => {
for block in start.message.content { 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) => { ApiStreamEvent::ContentBlockStart(start) => {
push_output_block( push_output_block(
&renderer,
start.content_block, start.content_block,
&mut stdout, &mut stdout,
&mut events, &mut events,
@@ -2126,7 +2170,7 @@ impl ApiClient for AnthropicRuntimeClient {
}) })
.await .await
.map_err(|error| RuntimeError::new(error.to_string()))?; .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<String> {
.collect() .collect()
} }
fn format_tool_call_start(name: &str, input: &str) -> String { fn format_tool_call_start(renderer: &TerminalRenderer, name: &str, input: &str) -> String {
format!( format!(
"Tool call "{} {} {} {}",
Name {name} renderer.warning("Tool call:"),
Input {}", renderer.info(name),
renderer.warning("args="),
summarize_tool_payload(input) summarize_tool_payload(input)
) )
} }
fn format_tool_result(name: &str, output: &str, is_error: bool) -> String { fn format_tool_result(
let status = if is_error { "error" } else { "ok" }; renderer: &TerminalRenderer,
name: &str,
output: &str,
is_error: bool,
) -> String {
let status = if is_error {
renderer.error("error")
} else {
renderer.success("ok")
};
format!( format!(
"### Tool `{name}` "### {} {}
- Status: {status} - Status: {status}
- Output: - 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) prettify_tool_payload(output)
) )
} }
@@ -2189,6 +2245,7 @@ fn truncate_for_summary(value: &str, limit: usize) -> String {
} }
fn push_output_block( fn push_output_block(
renderer: &TerminalRenderer,
block: OutputContentBlock, block: OutputContentBlock,
out: &mut impl Write, out: &mut impl Write,
events: &mut Vec<AssistantEvent>, events: &mut Vec<AssistantEvent>,
@@ -2208,7 +2265,7 @@ fn push_output_block(
out, out,
" "
{}", {}",
format_tool_call_start(&name, &input.to_string()) format_tool_call_start(renderer, &name, &input.to_string())
) )
.and_then(|()| out.flush()) .and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?; .map_err(|error| RuntimeError::new(error.to_string()))?;
@@ -2219,6 +2276,7 @@ fn push_output_block(
} }
fn response_to_events( fn response_to_events(
renderer: &TerminalRenderer,
response: MessageResponse, response: MessageResponse,
out: &mut impl Write, out: &mut impl Write,
) -> Result<Vec<AssistantEvent>, RuntimeError> { ) -> Result<Vec<AssistantEvent>, RuntimeError> {
@@ -2226,7 +2284,7 @@ fn response_to_events(
let mut pending_tool = None; let mut pending_tool = None;
for block in response.content { 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() { if let Some((id, name, input)) = pending_tool.take() {
events.push(AssistantEvent::ToolUse { id, name, input }); events.push(AssistantEvent::ToolUse { id, name, input });
} }
@@ -2248,9 +2306,9 @@ struct CliToolExecutor {
} }
impl CliToolExecutor { impl CliToolExecutor {
fn new(allowed_tools: Option<AllowedToolSet>) -> Self { fn new(allowed_tools: Option<AllowedToolSet>, color: bool) -> Self {
Self { Self {
renderer: TerminalRenderer::new(), renderer: TerminalRenderer::with_color(color),
allowed_tools, allowed_tools,
} }
} }
@@ -2271,14 +2329,14 @@ impl ToolExecutor for CliToolExecutor {
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
match execute_tool(tool_name, &value) { match execute_tool(tool_name, &value) {
Ok(output) => { Ok(output) => {
let markdown = format_tool_result(tool_name, &output, false); let markdown = format_tool_result(&self.renderer, tool_name, &output, false);
self.renderer self.renderer
.stream_markdown(&markdown, &mut io::stdout()) .stream_markdown(&markdown, &mut io::stdout())
.map_err(|error| ToolError::new(error.to_string()))?; .map_err(|error| ToolError::new(error.to_string()))?;
Ok(output) Ok(output)
} }
Err(error) => { Err(error) => {
let markdown = format_tool_result(tool_name, &error, true); let markdown = format_tool_result(&self.renderer, tool_name, &error, true);
self.renderer self.renderer
.stream_markdown(&markdown, &mut io::stdout()) .stream_markdown(&markdown, &mut io::stdout())
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?; .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!(" --output-format FORMAT Non-interactive output format: text or json");
println!(" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"); 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!(" --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!(" --version, -V Print version and build information locally");
println!(); println!();
println!("Interactive slash commands:"); println!("Interactive slash commands:");
@@ -2386,6 +2445,77 @@ fn print_help() {
println!(" rusty-claude-cli login"); 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)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
@@ -2397,6 +2527,7 @@ mod tests {
render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, render_memory_report, render_repl_help, resume_supported_slash_commands, status_context,
CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
}; };
use crate::{print_help_text_for_test, render::TerminalRenderer};
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -2408,6 +2539,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::WorkspaceWrite,
color: true,
} }
); );
} }
@@ -2427,6 +2559,7 @@ mod tests {
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::WorkspaceWrite,
color: true,
} }
); );
} }
@@ -2448,6 +2581,27 @@ mod tests {
output_format: CliOutputFormat::Json, output_format: CliOutputFormat::Json,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, 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(), model: DEFAULT_MODEL.to_string(),
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::ReadOnly, permission_mode: PermissionMode::ReadOnly,
color: true,
} }
); );
} }
@@ -2495,6 +2650,7 @@ mod tests {
.collect() .collect()
), ),
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::WorkspaceWrite,
color: true,
} }
); );
} }
@@ -2797,7 +2953,7 @@ mod tests {
fn status_context_reads_real_workspace_metadata() { fn status_context_reads_real_workspace_metadata() {
let context = status_context(None).expect("status context should load"); let context = status_context(None).expect("status context should load");
assert!(context.cwd.is_absolute()); 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); assert!(context.loaded_config_files <= context.discovered_config_files);
} }
@@ -2891,17 +3047,21 @@ mod tests {
let help = render_repl_help(); let help = render_repl_help();
assert!(help.contains("Up/Down")); assert!(help.contains("Up/Down"));
assert!(help.contains("Tab")); assert!(help.contains("Tab"));
assert!(print_help_text_for_test().contains("--no-color"));
assert!(help.contains("Shift+Enter/Ctrl+J")); assert!(help.contains("Shift+Enter/Ctrl+J"));
} }
#[test] #[test]
fn tool_rendering_helpers_compact_output() { fn tool_rendering_helpers_compact_output() {
let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#); let renderer = TerminalRenderer::with_color(false);
assert!(start.contains("Tool call")); 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")); assert!(start.contains("src/main.rs"));
let done = format_tool_result("read_file", r#"{"contents":"hello"}"#, false); let done = format_tool_result(&renderer, "read_file", r#"{"contents":"hello"}"#, false);
assert!(done.contains("Tool `read_file`")); assert!(done.contains("Tool"));
assert!(done.contains("`read_file`"));
assert!(done.contains("contents")); assert!(done.contains("contents"));
} }
} }

View File

@@ -15,12 +15,17 @@ use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ColorTheme { pub struct ColorTheme {
enabled: bool,
heading: Color, heading: Color,
emphasis: Color, emphasis: Color,
strong: Color, strong: Color,
inline_code: Color, inline_code: Color,
link: Color, link: Color,
quote: Color, quote: Color,
info: Color,
warning: Color,
success: Color,
error: Color,
spinner_active: Color, spinner_active: Color,
spinner_done: Color, spinner_done: Color,
spinner_failed: Color, spinner_failed: Color,
@@ -29,12 +34,17 @@ pub struct ColorTheme {
impl Default for ColorTheme { impl Default for ColorTheme {
fn default() -> Self { fn default() -> Self {
Self { Self {
heading: Color::Cyan, enabled: true,
emphasis: Color::Magenta, heading: Color::Blue,
emphasis: Color::Blue,
strong: Color::Yellow, strong: Color::Yellow,
inline_code: Color::Green, inline_code: Color::Green,
link: Color::Blue, link: Color::Blue,
quote: Color::DarkGrey, quote: Color::DarkGrey,
info: Color::Blue,
warning: Color::Yellow,
success: Color::Green,
error: Color::Red,
spinner_active: Color::Blue, spinner_active: Color::Blue,
spinner_done: Color::Green, spinner_done: Color::Green,
spinner_failed: Color::Red, 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)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Spinner { pub struct Spinner {
frame_index: usize, frame_index: usize,
@@ -67,12 +92,19 @@ impl Spinner {
out, out,
SavePosition, SavePosition,
MoveToColumn(0), MoveToColumn(0),
Clear(ClearType::CurrentLine), Clear(ClearType::CurrentLine)
)?;
if theme.enabled() {
queue!(
out,
SetForegroundColor(theme.spinner_active), SetForegroundColor(theme.spinner_active),
Print(format!("{frame} {label}")), Print(format!("{frame} {label}")),
ResetColor, ResetColor,
RestorePosition RestorePosition
)?; )?;
} else {
queue!(out, Print(format!("{frame} {label}")), RestorePosition)?;
}
out.flush() out.flush()
} }
@@ -83,14 +115,17 @@ impl Spinner {
out: &mut impl Write, out: &mut impl Write,
) -> io::Result<()> { ) -> io::Result<()> {
self.frame_index = 0; self.frame_index = 0;
execute!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
if theme.enabled() {
execute!( execute!(
out, out,
MoveToColumn(0),
Clear(ClearType::CurrentLine),
SetForegroundColor(theme.spinner_done), SetForegroundColor(theme.spinner_done),
Print(format!("{label}\n")), Print(format!("{label}\n")),
ResetColor ResetColor
)?; )?;
} else {
execute!(out, Print(format!("{label}\n")))?;
}
out.flush() out.flush()
} }
@@ -101,14 +136,17 @@ impl Spinner {
out: &mut impl Write, out: &mut impl Write,
) -> io::Result<()> { ) -> io::Result<()> {
self.frame_index = 0; self.frame_index = 0;
execute!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
if theme.enabled() {
execute!( execute!(
out, out,
MoveToColumn(0),
Clear(ClearType::CurrentLine),
SetForegroundColor(theme.spinner_failed), SetForegroundColor(theme.spinner_failed),
Print(format!("{label}\n")), Print(format!("{label}\n")),
ResetColor ResetColor
)?; )?;
} else {
execute!(out, Print(format!("{label}\n")))?;
}
out.flush() out.flush()
} }
} }
@@ -123,6 +161,9 @@ struct RenderState {
impl RenderState { impl RenderState {
fn style_text(&self, text: &str, theme: &ColorTheme) -> String { fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
if !theme.enabled() {
return text.to_string();
}
if self.strong > 0 { if self.strong > 0 {
format!("{}", text.bold().with(theme.strong)) format!("{}", text.bold().with(theme.strong))
} else if self.emphasis > 0 { } else if self.emphasis > 0 {
@@ -163,11 +204,70 @@ impl TerminalRenderer {
Self::default() 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] #[must_use]
pub fn color_theme(&self) -> &ColorTheme { pub fn color_theme(&self) -> &ColorTheme {
&self.color_theme &self.color_theme
} }
fn paint(&self, text: impl AsRef<str>, 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<str>, 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<str>, 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<str>) -> String {
self.paint(text, self.color_theme.info)
}
#[must_use]
pub fn warning(&self, text: impl AsRef<str>) -> String {
self.paint(text, self.color_theme.warning)
}
#[must_use]
pub fn success(&self, text: impl AsRef<str>) -> String {
self.paint(text, self.color_theme.success)
}
#[must_use]
pub fn error(&self, text: impl AsRef<str>) -> String {
self.paint(text, self.color_theme.error)
}
#[must_use] #[must_use]
pub fn render_markdown(&self, markdown: &str) -> String { pub fn render_markdown(&self, markdown: &str) -> String {
let mut output = String::new(); let mut output = String::new();
@@ -235,7 +335,7 @@ impl TerminalRenderer {
let _ = write!( let _ = write!(
output, output,
"{}", "{}",
format!("`{code}`").with(self.color_theme.inline_code) self.paint(format!("`{code}`"), self.color_theme.inline_code)
); );
} }
Event::Rule => output.push_str("---\n"), Event::Rule => output.push_str("---\n"),
@@ -252,16 +352,14 @@ impl TerminalRenderer {
let _ = write!( let _ = write!(
output, output,
"{}", "{}",
format!("[{dest_url}]") self.paint_underlined(format!("[{dest_url}]"), self.color_theme.link)
.underlined()
.with(self.color_theme.link)
); );
} }
Event::Start(Tag::Image { dest_url, .. }) => { Event::Start(Tag::Image { dest_url, .. }) => {
let _ = write!( let _ = write!(
output, output,
"{}", "{}",
format!("[image:{dest_url}]").with(self.color_theme.link) self.paint(format!("[image:{dest_url}]"), self.color_theme.link)
); );
} }
Event::Start( Event::Start(
@@ -294,12 +392,16 @@ impl TerminalRenderer {
3 => "### ", 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) { fn start_quote(&self, state: &mut RenderState, output: &mut String) {
state.quote += 1; 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) { fn start_item(state: &RenderState, output: &mut String) {
@@ -312,7 +414,7 @@ impl TerminalRenderer {
let _ = writeln!( let _ = writeln!(
output, 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) { 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() { 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"); output.push_str("\n\n");
} }
@@ -342,6 +444,10 @@ impl TerminalRenderer {
#[must_use] #[must_use]
pub fn highlight_code(&self, code: &str, language: &str) -> String { pub fn highlight_code(&self, code: &str, language: &str) -> String {
if !self.color_theme.enabled() {
return code.to_string();
}
let syntax = self let syntax = self
.syntax_set .syntax_set
.find_syntax_by_token(language) .find_syntax_by_token(language)
@@ -370,6 +476,16 @@ impl TerminalRenderer {
} }
writeln!(out) 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)] #[cfg(test)]
@@ -437,4 +553,25 @@ mod tests {
let output = String::from_utf8_lossy(&out); let output = String::from_utf8_lossy(&out);
assert!(output.contains("Working")); 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"
);
}
} }