feat(claudbg-d8ht): color-coded transcript label prefixes in CLI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent 8d72b0e971
commit c4646cf0e2

@ -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.

@ -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.

@ -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. /// Thinking blocks are shown only when `opts.include.thinking` is set.
/// Tool result blocks are always shown, truncated to 200 chars unless `opts.verbose`. /// 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) { fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli::GlobalOpts) {
let Some(msg) = &entry.message else { return }; let Some(msg) = &entry.message else { return };
let role = msg.role.as_deref().unwrap_or("?"); 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 { match &msg.content {
None => {} None => {}
Some(crate::models::session::MessageContent::Text(t)) => { Some(crate::models::session::MessageContent::Text(t)) => {
println!("[{role}]: {t}"); println!("{}: {t}", role_label(role));
} }
Some(crate::models::session::MessageContent::Blocks(blocks)) => { Some(crate::models::session::MessageContent::Blocks(blocks)) => {
for block in blocks { for block in blocks {
match block { match block {
crate::models::session::ContentBlock::Text { text } => { crate::models::session::ContentBlock::Text { text } => {
println!("[{role}]: {text}"); println!("{}: {text}", role_label(role));
} }
crate::models::session::ContentBlock::Thinking { thinking } => { crate::models::session::ContentBlock::Thinking { thinking } => {
if opts.include.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 boundary = input_preview.floor_char_boundary(cap);
let input_short = &input_preview[..boundary]; let input_short = &input_preview[..boundary];
let ellipsis = if !opts.verbose && input_preview.len() > 120 { "…" } else { "" }; 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 { crate::models::session::ContentBlock::ToolResult {
content, is_error, .. content, is_error, ..
} => { } => {
let err_flag = if is_error.unwrap_or(false) { let is_err = is_error.unwrap_or(false);
" (error)" 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 { } else {
"" crate::output::color::green(&label_text, color)
}; };
let preview = content let preview = content
.as_ref() .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() content.as_ref().map(|c| c.to_string()).unwrap_or_default()
}); });
if opts.verbose { if opts.verbose {
println!("[tool_result{err_flag}]: {preview}"); println!("{label}: {preview}");
} else { } else {
let boundary = preview.floor_char_boundary(200); let boundary = preview.floor_char_boundary(200);
let short = &preview[..boundary]; let short = &preview[..boundary];
println!("[tool_result{err_flag}]: {short}"); println!("{label}: {short}");
} }
} }
crate::models::session::ContentBlock::Image { .. } => { crate::models::session::ContentBlock::Image { .. } => {

@ -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. /// Thinking blocks are shown only when `opts.include.thinking` is set.
/// Tool result blocks are always shown, truncated to 200 chars unless `opts.verbose`. /// 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) { fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli::GlobalOpts) {
let Some(msg) = &entry.message else { return }; let Some(msg) = &entry.message else { return };
let role = msg.role.as_deref().unwrap_or("?"); 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 { match &msg.content {
None => {} None => {}
Some(crate::models::session::MessageContent::Text(t)) => { Some(crate::models::session::MessageContent::Text(t)) => {
println!("[{role}]: {t}"); println!("{}: {t}", role_label(role));
} }
Some(crate::models::session::MessageContent::Blocks(blocks)) => { Some(crate::models::session::MessageContent::Blocks(blocks)) => {
for block in blocks { for block in blocks {
match block { match block {
crate::models::session::ContentBlock::Text { text } => { crate::models::session::ContentBlock::Text { text } => {
println!("[{role}]: {text}"); println!("{}: {text}", role_label(role));
} }
crate::models::session::ContentBlock::Thinking { thinking } => { crate::models::session::ContentBlock::Thinking { thinking } => {
if opts.include.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 boundary = input_preview.floor_char_boundary(cap);
let input_short = &input_preview[..boundary]; let input_short = &input_preview[..boundary];
let ellipsis = if !opts.verbose && input_preview.len() > 120 { "…" } else { "" }; 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 { crate::models::session::ContentBlock::ToolResult {
content, is_error, .. content, is_error, ..
} => { } => {
let err_flag = if is_error.unwrap_or(false) { let is_err = is_error.unwrap_or(false);
" (error)" 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 { } else {
"" crate::output::color::green(&label_text, color)
}; };
let preview = content let preview = content
.as_ref() .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() content.as_ref().map(|c| c.to_string()).unwrap_or_default()
}); });
if opts.verbose { if opts.verbose {
println!("[tool_result{err_flag}]: {preview}"); println!("{label}: {preview}");
} else { } else {
let boundary = preview.floor_char_boundary(200); let boundary = preview.floor_char_boundary(200);
let short = &preview[..boundary]; let short = &preview[..boundary];
println!("[tool_result{err_flag}]: {short}"); println!("{label}: {short}");
} }
} }
crate::models::session::ContentBlock::Image { .. } => { crate::models::session::ContentBlock::Image { .. } => {

@ -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}]");
}
}
}
}

@ -1,5 +1,6 @@
//! Output format renderers: table, JSON, XML. //! Output format renderers: table, JSON, XML.
pub mod color;
pub mod json; pub mod json;
pub mod table; pub mod table;
pub mod xml; pub mod xml;

Loading…
Cancel
Save