ソースを参照

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
Yeachan-Heo 2 ヶ月 前
コミット
83fc672260

+ 4 - 4
rust/crates/runtime/src/conversation.rs

@@ -408,7 +408,7 @@ mod tests {
                 .sum::<i32>();
             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");

+ 189 - 29
rust/crates/rusty-claude-cli/src/main.rs

@@ -70,7 +70,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
             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<dyn std::error::Error>> {
             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<AllowedToolSet>,
         permission_mode: PermissionMode,
+        color: bool,
     },
     Login,
     Logout,
@@ -110,6 +113,7 @@ enum CliAction {
         model: String,
         allowed_tools: Option<AllowedToolSet>,
         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<CliAction, String> {
     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<CliAction, String> {
                 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<CliAction, String> {
             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<CliAction, String> {
                 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<CliAction, String> {
             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<AllowedToolSet>,
     permission_mode: PermissionMode,
+    color: bool,
 ) -> 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());
     println!("{}", cli.startup_banner());
 
@@ -945,9 +958,11 @@ struct LiveCli {
     model: String,
     allowed_tools: Option<AllowedToolSet>,
     permission_mode: PermissionMode,
+    color: bool,
     system_prompt: Vec<String>,
     runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
     session: SessionHandle,
+    renderer: TerminalRenderer,
 }
 
 impl LiveCli {
@@ -956,6 +971,7 @@ impl LiveCli {
         enable_tools: bool,
         allowed_tools: Option<AllowedToolSet>,
         permission_mode: PermissionMode,
+        color: bool,
     ) -> Result<Self, Box<dyn std::error::Error>> {
         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<AllowedToolSet>,
     permission_mode: PermissionMode,
+    color: bool,
 ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
 {
     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<AllowedToolSet>,
+    color: bool,
 }
 
 impl AnthropicRuntimeClient {
@@ -1994,6 +2028,7 @@ impl AnthropicRuntimeClient {
         model: String,
         enable_tools: bool,
         allowed_tools: Option<AllowedToolSet>,
+        color: bool,
     ) -> Result<Self, Box<dyn std::error::Error>> {
         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<String> {
         .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<AssistantEvent>,
@@ -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<Vec<AssistantEvent>, 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<AllowedToolSet>) -> Self {
+    fn new(allowed_tools: Option<AllowedToolSet>, 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"));
     }
 }

+ 169 - 32
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<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]
     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"
+        );
+    }
 }