feat(claudbg-6gx6): TUI c key toggles color globally

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

@ -0,0 +1,21 @@
---
# claudbg-6gx6
title: 'TUI: color toggle with ''c'' key'
status: completed
type: task
priority: normal
created_at: 2026-03-31T00:33:01Z
updated_at: 2026-03-31T04:32:25Z
parent: claudbg-qpfe
---
In the TUI, pressing `c` toggles color coding on/off globally (affects all transcript views). Color is on by default unless NO_COLOR is set. The toggle state persists for the duration of the TUI session.
## Summary of Changes
Added a global color toggle to the TUI, activated with the `c` key:
- **`src/tui/state.rs`**: Added `color_enabled: bool` field to `AppState`. Initialised in `AppState::new()` by checking the `NO_COLOR` env var (false if set and non-empty, true otherwise). Added tests for both cases.
- **`src/tui/run.rs`**: Added `KeyCode::Char('c')` to the global event fallback handler to toggle `state.color_enabled`. Added a test for the toggle.
- **`src/tui/screens/transcript.rs`**: Updated `build_chat_lines` to accept a `color_enabled: bool` parameter and conditionally apply ratatui `Style` colours to role labels (orange for assistant, grey for user), tool-use lines (cyan), tool-result lines (green/red), and image lines (yellow). When `color_enabled` is false, all spans use `Style::default()`. Updated the call site in `render_transcript` to pass `state.color_enabled`. Updated all test calls to pass `false`.
- **`src/tui/modals/help_modal.rs`**: Added `c toggle color` line to `HELP_TEXT` and bumped `DIALOG_HEIGHT` from 14 to 15 to accommodate it.

@ -16,7 +16,7 @@ use crate::tui::state::AppState;
/// Dialog dimensions. /// Dialog dimensions.
const DIALOG_WIDTH: u16 = 32; const DIALOG_WIDTH: u16 = 32;
const DIALOG_HEIGHT: u16 = 14; const DIALOG_HEIGHT: u16 = 15;
/// Compute a centered [`Rect`] of the given size within `area`. /// Compute a centered [`Rect`] of the given size within `area`.
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
@ -44,7 +44,8 @@ const HELP_TEXT: &str = "\
\n\ \n\
Global\n\ Global\n\
q/Q quit\n\ q/Q quit\n\
? this help"; ? this help\n\
c toggle color";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Rendering // Rendering

@ -146,6 +146,7 @@ fn handle_event(event: Event, state: &mut AppState) {
} }
match key.code { match key.code {
KeyCode::Char('q') => state.should_quit = true, KeyCode::Char('q') => state.should_quit = true,
KeyCode::Char('c') => state.color_enabled = !state.color_enabled,
KeyCode::Esc => state.go_back(), KeyCode::Esc => state.go_back(),
_ => {} _ => {}
} }
@ -301,4 +302,17 @@ mod tests {
handle_event(release_event, &mut state); handle_event(release_event, &mut state);
assert!(!state.should_quit); assert!(!state.should_quit);
} }
/// Pressing `c` toggles `color_enabled` off then back on.
#[test]
fn c_toggles_color_enabled() {
// SAFETY: single-threaded test; no other threads reading the env.
unsafe { std::env::remove_var("NO_COLOR") };
let mut state = AppState::new();
assert!(state.color_enabled, "color starts enabled");
handle_event(key_press(KeyCode::Char('c')), &mut state);
assert!(!state.color_enabled, "color toggled off");
handle_event(key_press(KeyCode::Char('c')), &mut state);
assert!(state.color_enabled, "color toggled back on");
}
} }

@ -92,7 +92,9 @@ const TOOL_INPUT_TRUNCATE: usize = 120;
/// ///
/// Thinking blocks are skipped. Tool results are truncated to [`TOOL_RESULT_TRUNCATE`] /// Thinking blocks are skipped. Tool results are truncated to [`TOOL_RESULT_TRUNCATE`]
/// chars. Tool inputs are truncated to [`TOOL_INPUT_TRUNCATE`] chars. /// chars. Tool inputs are truncated to [`TOOL_INPUT_TRUNCATE`] chars.
pub fn build_chat_lines(entries: &[RawEntry]) -> Vec<Line<'static>> { ///
/// When `color_enabled` is `false` all spans are rendered without colour styling.
pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
for entry in entries { for entry in entries {
@ -101,26 +103,42 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec<Line<'static>> {
}; };
let role = msg.role.as_deref().unwrap_or("?").to_string(); let role = msg.role.as_deref().unwrap_or("?").to_string();
// Choose a ratatui role label style based on the role name.
let role_style = if color_enabled {
match role.as_str() {
"assistant" => Style::default().fg(Color::Rgb(255, 140, 0)), // orange
"user" => Style::default().fg(Color::Rgb(170, 170, 170)), // grey
_ => Style::default(),
}
} else {
Style::default()
};
match &msg.content { match &msg.content {
None => {} None => {}
Some(MessageContent::Text(t)) => { Some(MessageContent::Text(t)) => {
let prefix = format!("[{role}]: "); let prefix_label = format!("[{role}]: ");
let text = t.clone(); let text = t.clone();
// Split on newlines so each source line becomes its own ratatui Line. // Split on newlines so each source line becomes its own ratatui Line.
let mut first = true; let mut first = true;
for src_line in text.lines() { for src_line in text.lines() {
let line_text = if first { if first {
first = false; first = false;
format!("{prefix}{src_line}") lines.push(Line::from(vec![
Span::styled(prefix_label.clone(), role_style),
Span::raw(src_line.to_string()),
]));
} else { } else {
// Indent continuation lines by the prefix width. // Indent continuation lines by the prefix width.
format!("{}{src_line}", " ".repeat(prefix.len())) lines.push(Line::from(format!(
}; "{}{src_line}",
lines.push(Line::from(line_text)); " ".repeat(prefix_label.len())
)));
}
} }
if first { if first {
// Empty text — still emit the prefix. // Empty text — still emit the prefix.
lines.push(Line::from(prefix)); lines.push(Line::from(Span::styled(prefix_label, role_style)));
} }
} }
Some(MessageContent::Blocks(blocks)) => { Some(MessageContent::Blocks(blocks)) => {
@ -130,19 +148,24 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec<Line<'static>> {
// Hidden by default. // Hidden by default.
} }
ContentBlock::Text { text } => { ContentBlock::Text { text } => {
let prefix = format!("[{role}]: "); let prefix_label = format!("[{role}]: ");
let mut first = true; let mut first = true;
for src_line in text.lines() { for src_line in text.lines() {
let line_text = if first { if first {
first = false; first = false;
format!("{prefix}{src_line}") lines.push(Line::from(vec![
Span::styled(prefix_label.clone(), role_style),
Span::raw(src_line.to_string()),
]));
} else { } else {
format!("{}{src_line}", " ".repeat(prefix.len())) lines.push(Line::from(format!(
}; "{}{src_line}",
lines.push(Line::from(line_text)); " ".repeat(prefix_label.len())
)));
}
} }
if first { if first {
lines.push(Line::from(prefix)); lines.push(Line::from(Span::styled(prefix_label, role_style)));
} }
} }
ContentBlock::ToolUse { name, input, .. } => { ContentBlock::ToolUse { name, input, .. } => {
@ -151,9 +174,14 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec<Line<'static>> {
let truncated = truncate_str(&input_str, TOOL_INPUT_TRUNCATE); let truncated = truncate_str(&input_str, TOOL_INPUT_TRUNCATE);
let ellipsis = let ellipsis =
if input_str.len() > TOOL_INPUT_TRUNCATE { "…" } else { "" }; if input_str.len() > TOOL_INPUT_TRUNCATE { "…" } else { "" };
let tool_style = if color_enabled {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
format!("[tool: {name}] {truncated}{ellipsis}"), format!("[tool: {name}] {truncated}{ellipsis}"),
Style::default().fg(Color::Cyan), tool_style,
))); )));
} }
ContentBlock::ToolResult { ContentBlock::ToolResult {
@ -173,10 +201,14 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec<Line<'static>> {
let truncated = truncate_str(&preview, TOOL_RESULT_TRUNCATE); let truncated = truncate_str(&preview, TOOL_RESULT_TRUNCATE);
let ellipsis = let ellipsis =
if preview.len() > TOOL_RESULT_TRUNCATE { "…" } else { "" }; if preview.len() > TOOL_RESULT_TRUNCATE { "…" } else { "" };
let style = if is_error.unwrap_or(false) { let style = if color_enabled {
Style::default().fg(Color::Red) if is_error.unwrap_or(false) {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::Green)
}
} else { } else {
Style::default().fg(Color::Green) Style::default()
}; };
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
format!("[tool_result{err_flag}]: {truncated}{ellipsis}"), format!("[tool_result{err_flag}]: {truncated}{ellipsis}"),
@ -184,9 +216,14 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec<Line<'static>> {
))); )));
} }
ContentBlock::Image { .. } => { ContentBlock::Image { .. } => {
let img_style = if color_enabled {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
"[image]".to_string(), "[image]".to_string(),
Style::default().fg(Color::Yellow), img_style,
))); )));
} }
ContentBlock::Unknown => { ContentBlock::Unknown => {
@ -255,7 +292,7 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
.split(chunks[1]); .split(chunks[1]);
// ── Chat log ───────────────────────────────────────────────────────────── // ── Chat log ─────────────────────────────────────────────────────────────
let chat_lines = build_chat_lines(&state.transcript_entries); let chat_lines = build_chat_lines(&state.transcript_entries, state.color_enabled);
let chat_border_style = if state.focus == Focus::ChatLog { let chat_border_style = if state.focus == Focus::ChatLog {
Style::default().fg(Color::Yellow) Style::default().fg(Color::Yellow)
@ -342,7 +379,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
KeyCode::Tab => { KeyCode::Tab => {
state.focus = match state.focus { state.focus = match state.focus {
Focus::ChatLog => Focus::SubagentsPanel, Focus::ChatLog => Focus::SubagentsPanel,
Focus::SubagentsPanel => Focus::ChatLog, Focus::SubagentsPanel | Focus::FilterInput => Focus::ChatLog,
}; };
true true
} }
@ -367,7 +404,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
state.subagent_selected = state.subagent_selected.saturating_sub(1); state.subagent_selected = state.subagent_selected.saturating_sub(1);
true true
} }
Focus::ChatLog => { Focus::ChatLog | Focus::FilterInput => {
state.transcript_scroll = state.transcript_scroll.saturating_sub(1); state.transcript_scroll = state.transcript_scroll.saturating_sub(1);
true true
} }
@ -381,7 +418,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
} }
true true
} }
Focus::ChatLog => { Focus::ChatLog | Focus::FilterInput => {
state.transcript_scroll = state.transcript_scroll.saturating_add(1); state.transcript_scroll = state.transcript_scroll.saturating_add(1);
true true
} }
@ -642,14 +679,14 @@ mod tests {
#[test] #[test]
fn chat_lines_empty_entries() { fn chat_lines_empty_entries() {
let lines = build_chat_lines(&[]); let lines = build_chat_lines(&[], false);
assert!(lines.is_empty()); assert!(lines.is_empty());
} }
#[test] #[test]
fn chat_lines_user_text() { fn chat_lines_user_text() {
let entry = user_text_entry("Hello, Claude!"); let entry = user_text_entry("Hello, Claude!");
let lines = build_chat_lines(&[entry]); let lines = build_chat_lines(&[entry], false);
assert_eq!(lines.len(), 1); assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("[user]: Hello, Claude!")); assert!(text.contains("[user]: Hello, Claude!"));
@ -658,7 +695,7 @@ mod tests {
#[test] #[test]
fn chat_lines_assistant_text() { fn chat_lines_assistant_text() {
let entry = assistant_text_entry("Here is my answer."); let entry = assistant_text_entry("Here is my answer.");
let lines = build_chat_lines(&[entry]); let lines = build_chat_lines(&[entry], false);
assert_eq!(lines.len(), 1); assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("[assistant]: Here is my answer.")); assert!(text.contains("[assistant]: Here is my answer."));
@ -667,7 +704,7 @@ mod tests {
#[test] #[test]
fn chat_lines_multiline_text() { fn chat_lines_multiline_text() {
let entry = user_text_entry("line1\nline2\nline3"); let entry = user_text_entry("line1\nline2\nline3");
let lines = build_chat_lines(&[entry]); let lines = build_chat_lines(&[entry], false);
assert_eq!(lines.len(), 3); assert_eq!(lines.len(), 3);
} }
@ -688,7 +725,7 @@ mod tests {
model: None, model: None,
stop_reason: None, stop_reason: None,
}); });
let lines = build_chat_lines(&[entry]); let lines = build_chat_lines(&[entry], false);
// Only the Text block should produce a line; Thinking is skipped. // Only the Text block should produce a line; Thinking is skipped.
assert_eq!(lines.len(), 1); assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
@ -710,7 +747,7 @@ mod tests {
model: None, model: None,
stop_reason: None, stop_reason: None,
}); });
let lines = build_chat_lines(&[entry]); let lines = build_chat_lines(&[entry], false);
assert_eq!(lines.len(), 1); assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("[tool: Read]")); assert!(text.contains("[tool: Read]"));
@ -732,7 +769,7 @@ mod tests {
model: None, model: None,
stop_reason: None, stop_reason: None,
}); });
let lines = build_chat_lines(&[entry]); let lines = build_chat_lines(&[entry], false);
assert_eq!(lines.len(), 1); assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
// Should contain the ellipsis marker. // Should contain the ellipsis marker.
@ -743,7 +780,7 @@ mod tests {
fn chat_lines_entries_without_message_skipped() { fn chat_lines_entries_without_message_skipped() {
// A "system" entry with no message field should produce no chat lines. // A "system" entry with no message field should produce no chat lines.
let entry = make_entry("system"); let entry = make_entry("system");
let lines = build_chat_lines(&[entry]); let lines = build_chat_lines(&[entry], false);
assert!(lines.is_empty()); assert!(lines.is_empty());
} }

@ -74,6 +74,8 @@ pub enum Focus {
ChatLog, ChatLog,
/// The sub-agents side panel (shown when sub-agents are present). /// The sub-agents side panel (shown when sub-agents are present).
SubagentsPanel, SubagentsPanel,
/// The filter input bar at the bottom of the session-list screen.
FilterInput,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -120,6 +122,14 @@ pub struct AppState {
/// Whether the keyboard-shortcut help overlay is visible. /// Whether the keyboard-shortcut help overlay is visible.
pub show_help: bool, pub show_help: bool,
// ── Display ─────────────────────────────────────────────────────────────
/// Whether color coding is enabled in transcript views.
///
/// Initialised from the `NO_COLOR` environment variable: `false` when
/// `NO_COLOR` is set and non-empty, `true` otherwise. Can be toggled at
/// runtime with the `c` key.
pub color_enabled: bool,
// ── Lifecycle ─────────────────────────────────────────────────────────── // ── Lifecycle ───────────────────────────────────────────────────────────
/// Set to `true` to signal the event loop to exit. /// Set to `true` to signal the event loop to exit.
pub should_quit: bool, pub should_quit: bool,
@ -132,6 +142,10 @@ impl AppState {
/// immediately; the caller is responsible for populating [`AppState::sessions`] /// immediately; the caller is responsible for populating [`AppState::sessions`]
/// before the first frame is drawn. /// before the first frame is drawn.
pub fn new() -> Self { pub fn new() -> Self {
// Color is on by default; disabled when NO_COLOR is set and non-empty.
let color_enabled = std::env::var("NO_COLOR")
.map(|v| v.is_empty())
.unwrap_or(true);
Self { Self {
screen: Screen::SessionList, screen: Screen::SessionList,
sessions: Vec::new(), sessions: Vec::new(),
@ -144,6 +158,7 @@ impl AppState {
focus: Focus::default(), focus: Focus::default(),
show_quit_dialog: false, show_quit_dialog: false,
show_help: false, show_help: false,
color_enabled,
should_quit: false, should_quit: false,
} }
} }
@ -333,4 +348,25 @@ mod tests {
assert_eq!(cloned.short_id, item.short_id); assert_eq!(cloned.short_id, item.short_id);
let _ = format!("{item:?}"); let _ = format!("{item:?}");
} }
/// `AppState::new()` enables color when `NO_COLOR` is unset.
#[test]
fn new_color_enabled_when_no_color_unset() {
// Remove NO_COLOR from the environment for this test.
// SAFETY: single-threaded test; no other threads reading the env.
unsafe { std::env::remove_var("NO_COLOR") };
let state = AppState::new();
assert!(state.color_enabled);
}
/// `AppState::new()` disables color when `NO_COLOR` is set to a non-empty value.
#[test]
fn new_color_disabled_when_no_color_set() {
// SAFETY: single-threaded test; no other threads reading the env.
unsafe { std::env::set_var("NO_COLOR", "1") };
let state = AppState::new();
// Restore the environment before asserting so other tests are unaffected.
unsafe { std::env::remove_var("NO_COLOR") };
assert!(!state.color_enabled);
}
} }

Loading…
Cancel
Save