From e0398fd5bb75eaf8fcbb4f159fc9d9f2a8b3168f Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Tue, 31 Mar 2026 10:09:28 -0700 Subject: [PATCH] feat(claudbg-6gx6): TUI c key toggles color globally Co-Authored-By: Claude Sonnet 4.6 --- ...audbg-6gx6--tui-color-toggle-with-c-key.md | 21 ++++ src/tui/modals/help_modal.rs | 5 +- src/tui/run.rs | 14 +++ src/tui/screens/transcript.rs | 101 ++++++++++++------ src/tui/state.rs | 36 +++++++ 5 files changed, 143 insertions(+), 34 deletions(-) create mode 100644 .beans/claudbg-6gx6--tui-color-toggle-with-c-key.md diff --git a/.beans/claudbg-6gx6--tui-color-toggle-with-c-key.md b/.beans/claudbg-6gx6--tui-color-toggle-with-c-key.md new file mode 100644 index 0000000..992355a --- /dev/null +++ b/.beans/claudbg-6gx6--tui-color-toggle-with-c-key.md @@ -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. diff --git a/src/tui/modals/help_modal.rs b/src/tui/modals/help_modal.rs index 70d92b3..96f762b 100644 --- a/src/tui/modals/help_modal.rs +++ b/src/tui/modals/help_modal.rs @@ -16,7 +16,7 @@ use crate::tui::state::AppState; /// Dialog dimensions. 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`. fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { @@ -44,7 +44,8 @@ const HELP_TEXT: &str = "\ \n\ Global\n\ q/Q quit\n\ - ? this help"; + ? this help\n\ + c toggle color"; // --------------------------------------------------------------------------- // Rendering diff --git a/src/tui/run.rs b/src/tui/run.rs index 942f7ab..1a64343 100644 --- a/src/tui/run.rs +++ b/src/tui/run.rs @@ -146,6 +146,7 @@ fn handle_event(event: Event, state: &mut AppState) { } match key.code { KeyCode::Char('q') => state.should_quit = true, + KeyCode::Char('c') => state.color_enabled = !state.color_enabled, KeyCode::Esc => state.go_back(), _ => {} } @@ -301,4 +302,17 @@ mod tests { handle_event(release_event, &mut state); 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"); + } } diff --git a/src/tui/screens/transcript.rs b/src/tui/screens/transcript.rs index 361df3c..be39853 100644 --- a/src/tui/screens/transcript.rs +++ b/src/tui/screens/transcript.rs @@ -92,7 +92,9 @@ const TOOL_INPUT_TRUNCATE: usize = 120; /// /// Thinking blocks are skipped. Tool results are truncated to [`TOOL_RESULT_TRUNCATE`] /// chars. Tool inputs are truncated to [`TOOL_INPUT_TRUNCATE`] chars. -pub fn build_chat_lines(entries: &[RawEntry]) -> Vec> { +/// +/// When `color_enabled` is `false` all spans are rendered without colour styling. +pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec> { let mut lines: Vec> = Vec::new(); for entry in entries { @@ -101,26 +103,42 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec> { }; 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 { None => {} Some(MessageContent::Text(t)) => { - let prefix = format!("[{role}]: "); + let prefix_label = format!("[{role}]: "); let text = t.clone(); // Split on newlines so each source line becomes its own ratatui Line. let mut first = true; for src_line in text.lines() { - let line_text = if first { + if first { 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 { // Indent continuation lines by the prefix width. - format!("{}{src_line}", " ".repeat(prefix.len())) - }; - lines.push(Line::from(line_text)); + lines.push(Line::from(format!( + "{}{src_line}", + " ".repeat(prefix_label.len()) + ))); + } } if first { // 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)) => { @@ -130,19 +148,24 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec> { // Hidden by default. } ContentBlock::Text { text } => { - let prefix = format!("[{role}]: "); + let prefix_label = format!("[{role}]: "); let mut first = true; for src_line in text.lines() { - let line_text = if first { + if first { 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 { - format!("{}{src_line}", " ".repeat(prefix.len())) - }; - lines.push(Line::from(line_text)); + lines.push(Line::from(format!( + "{}{src_line}", + " ".repeat(prefix_label.len()) + ))); + } } if first { - lines.push(Line::from(prefix)); + lines.push(Line::from(Span::styled(prefix_label, role_style))); } } ContentBlock::ToolUse { name, input, .. } => { @@ -151,9 +174,14 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec> { let truncated = truncate_str(&input_str, TOOL_INPUT_TRUNCATE); let ellipsis = 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( format!("[tool: {name}] {truncated}{ellipsis}"), - Style::default().fg(Color::Cyan), + tool_style, ))); } ContentBlock::ToolResult { @@ -173,10 +201,14 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec> { let truncated = truncate_str(&preview, TOOL_RESULT_TRUNCATE); let ellipsis = if preview.len() > TOOL_RESULT_TRUNCATE { "…" } else { "" }; - let style = if is_error.unwrap_or(false) { - Style::default().fg(Color::Red) + let style = if color_enabled { + if is_error.unwrap_or(false) { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::Green) + } } else { - Style::default().fg(Color::Green) + Style::default() }; lines.push(Line::from(Span::styled( format!("[tool_result{err_flag}]: {truncated}{ellipsis}"), @@ -184,9 +216,14 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec> { ))); } ContentBlock::Image { .. } => { + let img_style = if color_enabled { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; lines.push(Line::from(Span::styled( "[image]".to_string(), - Style::default().fg(Color::Yellow), + img_style, ))); } ContentBlock::Unknown => { @@ -255,7 +292,7 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) { .split(chunks[1]); // ── 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 { Style::default().fg(Color::Yellow) @@ -342,7 +379,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool { KeyCode::Tab => { state.focus = match state.focus { Focus::ChatLog => Focus::SubagentsPanel, - Focus::SubagentsPanel => Focus::ChatLog, + Focus::SubagentsPanel | Focus::FilterInput => Focus::ChatLog, }; 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); true } - Focus::ChatLog => { + Focus::ChatLog | Focus::FilterInput => { state.transcript_scroll = state.transcript_scroll.saturating_sub(1); true } @@ -381,7 +418,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool { } true } - Focus::ChatLog => { + Focus::ChatLog | Focus::FilterInput => { state.transcript_scroll = state.transcript_scroll.saturating_add(1); true } @@ -642,14 +679,14 @@ mod tests { #[test] fn chat_lines_empty_entries() { - let lines = build_chat_lines(&[]); + let lines = build_chat_lines(&[], false); assert!(lines.is_empty()); } #[test] fn chat_lines_user_text() { 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); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("[user]: Hello, Claude!")); @@ -658,7 +695,7 @@ mod tests { #[test] fn chat_lines_assistant_text() { 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); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("[assistant]: Here is my answer.")); @@ -667,7 +704,7 @@ mod tests { #[test] fn chat_lines_multiline_text() { 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); } @@ -688,7 +725,7 @@ mod tests { model: 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. assert_eq!(lines.len(), 1); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); @@ -710,7 +747,7 @@ mod tests { model: None, stop_reason: None, }); - let lines = build_chat_lines(&[entry]); + let lines = build_chat_lines(&[entry], false); assert_eq!(lines.len(), 1); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("[tool: Read]")); @@ -732,7 +769,7 @@ mod tests { model: None, stop_reason: None, }); - let lines = build_chat_lines(&[entry]); + let lines = build_chat_lines(&[entry], false); assert_eq!(lines.len(), 1); let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); // Should contain the ellipsis marker. @@ -743,7 +780,7 @@ mod tests { fn chat_lines_entries_without_message_skipped() { // A "system" entry with no message field should produce no chat lines. let entry = make_entry("system"); - let lines = build_chat_lines(&[entry]); + let lines = build_chat_lines(&[entry], false); assert!(lines.is_empty()); } diff --git a/src/tui/state.rs b/src/tui/state.rs index 7cee7ce..fb8defb 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -74,6 +74,8 @@ pub enum Focus { ChatLog, /// The sub-agents side panel (shown when sub-agents are present). 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. 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 ─────────────────────────────────────────────────────────── /// Set to `true` to signal the event loop to exit. pub should_quit: bool, @@ -132,6 +142,10 @@ impl AppState { /// immediately; the caller is responsible for populating [`AppState::sessions`] /// before the first frame is drawn. 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 { screen: Screen::SessionList, sessions: Vec::new(), @@ -144,6 +158,7 @@ impl AppState { focus: Focus::default(), show_quit_dialog: false, show_help: false, + color_enabled, should_quit: false, } } @@ -333,4 +348,25 @@ mod tests { assert_eq!(cloned.short_id, item.short_id); 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); + } }