From c4646cf0e2363e77b938f3953f7450f8199900b9 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Tue, 31 Mar 2026 10:05:40 -0700 Subject: [PATCH] feat(claudbg-d8ht): color-coded transcript label prefixes in CLI Co-Authored-By: Claude Sonnet 4.6 --- ...t--color-coded-transcript-output-in-cli.md | 40 ++++++++ .../claudbg-qpfe--transcript-color-coding.md | 10 ++ src/commands/agents.rs | 32 +++++-- src/commands/sessions.rs | 32 +++++-- src/output/color.rs | 94 +++++++++++++++++++ src/output/mod.rs | 1 + 6 files changed, 193 insertions(+), 16 deletions(-) create mode 100644 .beans/claudbg-d8ht--color-coded-transcript-output-in-cli.md create mode 100644 .beans/claudbg-qpfe--transcript-color-coding.md create mode 100644 src/output/color.rs diff --git a/.beans/claudbg-d8ht--color-coded-transcript-output-in-cli.md b/.beans/claudbg-d8ht--color-coded-transcript-output-in-cli.md new file mode 100644 index 0000000..aac5296 --- /dev/null +++ b/.beans/claudbg-d8ht--color-coded-transcript-output-in-cli.md @@ -0,0 +1,40 @@ +--- +# claudbg-d8ht +title: Color-coded transcript output in CLI +status: completed +type: task +priority: normal +created_at: 2026-03-31T00:32:52Z +updated_at: 2026-03-31T04:28:11Z +parent: claudbg-qpfe +--- + +Apply ANSI colors to transcript output in `sessions transcribe` and `agents transcribe`: +- `[assistant]` → orange +- `[user]` → grey +- `[tool: Foo]` → blue +- `[tool_result]` → green +- `[tool_result (error)]` → red + +Colors are enabled by default in interactive terminals (isatty check). Controlled by the --[no-]color flag and NO_COLOR env var. + +## Summary of Changes + +Added ANSI color support to transcript output in both `sessions transcribe` and `agents transcribe`. + +### New file: `src/output/color.rs` +A small color helper module with five functions — `orange`, `grey`, `blue`, `green`, `red` — each taking a `&str` and a `color_enabled: bool` flag. When disabled the string is returned unchanged; when enabled the string is wrapped with the appropriate ANSI 256-color (or standard) escape code followed by `\x1b[0m` reset. Includes 3 unit tests. + +### Modified: `src/output/mod.rs` +Declared and exported the new `color` sub-module. + +### Modified: `src/commands/sessions.rs` and `src/commands/agents.rs` +Updated `render_entry_text` in both files (they are independent copies) to: +- Call `opts.color_enabled()` once and pass the flag to color helpers. +- Color `[assistant]` labels orange (`\x1b[38;5;208m`). +- Color `[user]` labels grey (`\x1b[38;5;245m`). +- Color `[tool: X]` labels blue (`\x1b[38;5;33m`). +- Color `[tool_result]` labels green (`\x1b[32m`). +- Color `[tool_result (error)]` labels red (`\x1b[31m`). + +Only the label prefix is colored; the message body is left plain. No new crate dependencies were added. diff --git a/.beans/claudbg-qpfe--transcript-color-coding.md b/.beans/claudbg-qpfe--transcript-color-coding.md new file mode 100644 index 0000000..462ecb1 --- /dev/null +++ b/.beans/claudbg-qpfe--transcript-color-coding.md @@ -0,0 +1,10 @@ +--- +# claudbg-qpfe +title: Transcript color coding +status: todo +type: epic +created_at: 2026-03-31T00:32:44Z +updated_at: 2026-03-31T00:32:44Z +--- + +Color-code transcript output to visually distinguish message types. Covers CLI color output, --[no-]color global flag, NO_COLOR env var, and TUI color toggle. diff --git a/src/commands/agents.rs b/src/commands/agents.rs index 0e56417..99d6ef4 100644 --- a/src/commands/agents.rs +++ b/src/commands/agents.rs @@ -45,20 +45,32 @@ fn content_preview(entry: &crate::models::session::RawEntry, max_len: usize) -> /// /// Thinking blocks are shown only when `opts.include.thinking` is set. /// Tool result blocks are always shown, truncated to 200 chars unless `opts.verbose`. +/// Label prefixes are color-coded when `opts.color_enabled()` returns `true`. fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli::GlobalOpts) { let Some(msg) = &entry.message else { return }; let role = msg.role.as_deref().unwrap_or("?"); + let color = opts.color_enabled(); + + // Returns a colored role label like "[assistant]" or "[user]". + let role_label = |r: &str| -> String { + let label = format!("[{r}]"); + match r { + "assistant" => crate::output::color::orange(&label, color), + "user" => crate::output::color::grey(&label, color), + _ => label, + } + }; match &msg.content { None => {} Some(crate::models::session::MessageContent::Text(t)) => { - println!("[{role}]: {t}"); + println!("{}: {t}", role_label(role)); } Some(crate::models::session::MessageContent::Blocks(blocks)) => { for block in blocks { match block { crate::models::session::ContentBlock::Text { text } => { - println!("[{role}]: {text}"); + println!("{}: {text}", role_label(role)); } crate::models::session::ContentBlock::Thinking { thinking } => { if opts.include.thinking { @@ -71,15 +83,19 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli let boundary = input_preview.floor_char_boundary(cap); let input_short = &input_preview[..boundary]; let ellipsis = if !opts.verbose && input_preview.len() > 120 { "…" } else { "" }; - println!("[tool: {name}] {input_short}{ellipsis}"); + let label = crate::output::color::blue(&format!("[tool: {name}]"), color); + println!("{label} {input_short}{ellipsis}"); } crate::models::session::ContentBlock::ToolResult { content, is_error, .. } => { - let err_flag = if is_error.unwrap_or(false) { - " (error)" + let is_err = is_error.unwrap_or(false); + let err_flag = if is_err { " (error)" } else { "" }; + let label_text = format!("[tool_result{err_flag}]"); + let label = if is_err { + crate::output::color::red(&label_text, color) } else { - "" + crate::output::color::green(&label_text, color) }; let preview = content .as_ref() @@ -88,11 +104,11 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli content.as_ref().map(|c| c.to_string()).unwrap_or_default() }); if opts.verbose { - println!("[tool_result{err_flag}]: {preview}"); + println!("{label}: {preview}"); } else { let boundary = preview.floor_char_boundary(200); let short = &preview[..boundary]; - println!("[tool_result{err_flag}]: {short}"); + println!("{label}: {short}"); } } crate::models::session::ContentBlock::Image { .. } => { diff --git a/src/commands/sessions.rs b/src/commands/sessions.rs index bed4f5e..34ce84f 100644 --- a/src/commands/sessions.rs +++ b/src/commands/sessions.rs @@ -84,20 +84,32 @@ fn content_preview(entry: &crate::models::session::RawEntry, max_len: usize) -> /// /// Thinking blocks are shown only when `opts.include.thinking` is set. /// Tool result blocks are always shown, truncated to 200 chars unless `opts.verbose`. +/// Label prefixes are color-coded when `opts.color_enabled()` returns `true`. fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli::GlobalOpts) { let Some(msg) = &entry.message else { return }; let role = msg.role.as_deref().unwrap_or("?"); + let color = opts.color_enabled(); + + // Returns a colored role label like "[assistant]" or "[user]". + let role_label = |r: &str| -> String { + let label = format!("[{r}]"); + match r { + "assistant" => crate::output::color::orange(&label, color), + "user" => crate::output::color::grey(&label, color), + _ => label, + } + }; match &msg.content { None => {} Some(crate::models::session::MessageContent::Text(t)) => { - println!("[{role}]: {t}"); + println!("{}: {t}", role_label(role)); } Some(crate::models::session::MessageContent::Blocks(blocks)) => { for block in blocks { match block { crate::models::session::ContentBlock::Text { text } => { - println!("[{role}]: {text}"); + println!("{}: {text}", role_label(role)); } crate::models::session::ContentBlock::Thinking { thinking } => { if opts.include.thinking { @@ -110,15 +122,19 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli let boundary = input_preview.floor_char_boundary(cap); let input_short = &input_preview[..boundary]; let ellipsis = if !opts.verbose && input_preview.len() > 120 { "…" } else { "" }; - println!("[tool: {name}] {input_short}{ellipsis}"); + let label = crate::output::color::blue(&format!("[tool: {name}]"), color); + println!("{label} {input_short}{ellipsis}"); } crate::models::session::ContentBlock::ToolResult { content, is_error, .. } => { - let err_flag = if is_error.unwrap_or(false) { - " (error)" + let is_err = is_error.unwrap_or(false); + let err_flag = if is_err { " (error)" } else { "" }; + let label_text = format!("[tool_result{err_flag}]"); + let label = if is_err { + crate::output::color::red(&label_text, color) } else { - "" + crate::output::color::green(&label_text, color) }; let preview = content .as_ref() @@ -127,11 +143,11 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli content.as_ref().map(|c| c.to_string()).unwrap_or_default() }); if opts.verbose { - println!("[tool_result{err_flag}]: {preview}"); + println!("{label}: {preview}"); } else { let boundary = preview.floor_char_boundary(200); let short = &preview[..boundary]; - println!("[tool_result{err_flag}]: {short}"); + println!("{label}: {short}"); } } crate::models::session::ContentBlock::Image { .. } => { diff --git a/src/output/color.rs b/src/output/color.rs new file mode 100644 index 0000000..3f53d4f --- /dev/null +++ b/src/output/color.rs @@ -0,0 +1,94 @@ +//! ANSI color helpers for terminal output. +//! +//! All functions take a `color_enabled` flag; when `false` the string is +//! returned unchanged so callers never need to branch on their own. + +/// Wrap `s` in ANSI escape codes when `color_enabled` is true. +/// +/// `open` is the escape sequence to start the color and `RESET` (`\x1b[0m`) +/// is appended automatically. +fn colorize(s: &str, open: &str, color_enabled: bool) -> String { + if color_enabled { + format!("{open}{s}\x1b[0m") + } else { + s.to_string() + } +} + +/// Orange — used for `[assistant]` labels. +/// +/// Uses 256-color code 208 (bright orange). +pub fn orange(s: &str, color_enabled: bool) -> String { + colorize(s, "\x1b[38;5;208m", color_enabled) +} + +/// Grey — used for `[user]` labels. +/// +/// Uses 256-color code 245 (mid-grey). +pub fn grey(s: &str, color_enabled: bool) -> String { + colorize(s, "\x1b[38;5;245m", color_enabled) +} + +/// Blue — used for `[tool: X]` labels. +/// +/// Uses 256-color code 33 (dodger blue). +pub fn blue(s: &str, color_enabled: bool) -> String { + colorize(s, "\x1b[38;5;33m", color_enabled) +} + +/// Green — used for `[tool_result]` labels. +/// +/// Uses 256-color code 34 (green). +pub fn green(s: &str, color_enabled: bool) -> String { + colorize(s, "\x1b[32m", color_enabled) +} + +/// Red — used for `[tool_result (error)]` labels. +/// +/// Uses standard ANSI red (code 31). +pub fn red(s: &str, color_enabled: bool) -> String { + colorize(s, "\x1b[31m", color_enabled) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// When color is disabled, the string is returned unchanged. + #[test] + fn color_disabled_returns_plain_string() { + assert_eq!(orange("hello", false), "hello"); + assert_eq!(grey("hello", false), "hello"); + assert_eq!(blue("hello", false), "hello"); + assert_eq!(green("hello", false), "hello"); + assert_eq!(red("hello", false), "hello"); + } + + /// When color is enabled, output contains the escape code and a reset. + #[test] + fn color_enabled_wraps_with_escapes() { + let out = orange("[assistant]", true); + assert!(out.starts_with("\x1b["), "should start with escape"); + assert!(out.ends_with("\x1b[0m"), "should end with reset"); + assert!(out.contains("[assistant]"), "should contain original text"); + } + + /// Each color function produces distinct output. + #[test] + fn each_color_function_is_distinct() { + let text = "X"; + let colors = [ + orange(text, true), + grey(text, true), + blue(text, true), + green(text, true), + red(text, true), + ]; + // All pairs must differ. + for i in 0..colors.len() { + for j in (i + 1)..colors.len() { + assert_ne!(colors[i], colors[j], "colors[{i}] == colors[{j}]"); + } + } + } +} diff --git a/src/output/mod.rs b/src/output/mod.rs index f19aee2..ebf2f70 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -1,5 +1,6 @@ //! Output format renderers: table, JSON, XML. +pub mod color; pub mod json; pub mod table; pub mod xml;