From 0818b6550bacacd06b9f8b175789fab1389163e4 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 11 Sep 2026 16:09:43 +0000 Subject: [PATCH] Add consistent theme-based thread colors across the TUI (#44857) ## What changed Assign thread colors deterministically from the full thread ID and the active syntax theme's accents. Use the same color for thread names and titles in the status line, status previews, agents overview, and resume/fork pickers, independent of renaming or list order. Preserve thread colors when picker rows are selected and honor `tui.status_line_use_colors`. Load the configured theme before startup pickers render and refresh the cached palette when the theme changes. Fall back to the terminal's default color when no accents are available. ## Testing Add unit and snapshot tests for palette extraction and refresh, default accent coverage, selected titles in both picker layouts, color suppression, and preview/footer consistency during automatic naming. GitOrigin-RevId: 2b83a66bb3e80d4a1daea48ab9a86dbf2b525eef --- codex-rs/tui/src/app/agents_overview.rs | 1 + codex-rs/tui/src/app/agents_overview_tests.rs | 37 +++++++++ codex-rs/tui/src/app/agents_overview_view.rs | 18 +++- ..._footer_and_respect_color_suppression.snap | 8 ++ .../tui/src/bottom_pane/status_line_style.rs | 23 ++++- .../src/bottom_pane/status_surface_preview.rs | 5 +- .../tui/src/chatwidget/status_controls.rs | 1 + .../tui/src/chatwidget/status_surfaces.rs | 1 + ...view_matches_footer_while_auto_naming.snap | 8 ++ .../tests/status_surface_previews.rs | 29 +++++++ codex-rs/tui/src/lib.rs | 11 +++ codex-rs/tui/src/render/highlight.rs | 2 +- codex-rs/tui/src/resume_picker.rs | 53 +++++++++--- codex-rs/tui/src/resume_picker_color_tests.rs | 42 ++++++++++ ...vive_selection_in_both_picker_layouts.snap | 12 +++ codex-rs/tui/src/thread_color.rs | 83 +++++++++++++++++++ codex-rs/tui/src/thread_color_tests.rs | 83 +++++++++++++++++++ 17 files changed, 398 insertions(+), 19 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_thread_colors_match_footer_and_respect_color_suppression.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__status_surface_previews__thread_color_preview_matches_footer_while_auto_naming.snap create mode 100644 codex-rs/tui/src/resume_picker_color_tests.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__thread_colors_survive_selection_in_both_picker_layouts.snap create mode 100644 codex-rs/tui/src/thread_color.rs create mode 100644 codex-rs/tui/src/thread_color_tests.rs diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 271146f3e2..ceeadb6a92 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -321,6 +321,7 @@ impl App { &self.app_server_target, self.environment_manager.as_ref(), ), + self.local_settings.tui.status_line_use_colors, self.app_event_tx.clone(), self.keymap.clone(), Arc::clone(&self.agents_overview.view_state), diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 4f3a8c24c8..b4f2b59d48 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -1,5 +1,40 @@ use super::*; +#[tokio::test] +async fn overview_thread_colors_match_footer_and_respect_color_suppression() { + let mut app = make_test_app().await; + let id = ThreadId::from_u128(/*value*/ 42); + let mut snapshot = Vec::new(); + for enabled in [true, false] { + app.local_settings.tui.status_line_use_colors = enabled; + let mut thread = overview_thread( + id, + /*parent_thread_id*/ None, + "Original prompt", + ThreadStatus::Idle, + ); + thread.name = Some("Named task".into()); + let view = app.agents_overview_view(vec![thread], Some(id)); + let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + let buffer = terminal.backend().buffer(); + for row in buffer.content.chunks(100) { + let text: String = row.iter().map(ratatui::buffer::Cell::symbol).collect(); + if let Some(x) = text.find("Named task") { + let x = text[..x].chars().count(); + snapshot.push(format!( + "{} | title style: {:?}", + text.trim_end(), + row[x].style() + )); + } + } + } + insta::assert_snapshot!(snapshot.join("\n")); +} + #[tokio::test] async fn older_server_notice_is_visible_in_agents_overview() { let mut app = make_test_app().await; @@ -1425,6 +1460,7 @@ async fn shared_overview_shows_only_root_sessions() { view.rows.clone(), Some(first_root), /*worktrees_enabled*/ false, + /*use_theme_colors*/ true, crate::app_event_sender::AppEventSender::new(event_tx), app.keymap.clone(), Arc::clone(&app.agents_overview.view_state), @@ -1666,6 +1702,7 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() { .rows, Some(first), /*worktrees_enabled*/ false, + /*use_theme_colors*/ true, crate::app_event_sender::AppEventSender::new(event_tx), app.keymap.clone(), Arc::clone(&app.agents_overview.view_state), diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index be3617d988..034bb41099 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -179,6 +179,7 @@ impl AgentsOverviewViewState { } pub(super) struct AgentsOverviewView { + use_theme_colors: bool, pub(super) rows: Vec, project_groups: Vec, selected: usize, @@ -195,6 +196,7 @@ impl AgentsOverviewView { rows: Vec, selected_thread_id: Option, worktrees_enabled: bool, + use_theme_colors: bool, app_event_tx: AppEventSender, keymap: RuntimeKeymap, state: Arc>, @@ -220,6 +222,7 @@ impl AgentsOverviewView { .map(|row| AgentsOverviewProjectGroup::for_thread(&row.thread, worktrees_enabled)) .collect(); let mut view = Self { + use_theme_colors, rows, project_groups, selected, @@ -242,6 +245,14 @@ impl AgentsOverviewView { self.rows.iter().map(|row| row.thread_id).collect() } + fn title_style(&self, thread_id: ThreadId) -> Style { + if self.use_theme_colors { + Style::default().fg(crate::thread_color::thread_color(thread_id)) + } else { + Style::default() + } + } + fn state(&self) -> MutexGuard<'_, AgentsOverviewViewState> { self.state.lock().unwrap_or_else(PoisonError::into_inner) } @@ -439,7 +450,7 @@ impl AgentsOverviewView { " ".into(), dot, " ".into(), - display_title(&row.thread).into(), + Span::styled(display_title(&row.thread), self.title_style(row.thread_id)), current.dim(), ]; if project_grouping { @@ -460,7 +471,10 @@ impl AgentsOverviewView { Line::from("Task details".bold()), Line::default(), crate::line_truncation::truncate_line_with_ellipsis_if_overflow( - display_title(&row.thread).to_owned().bold().into(), + Line::from(Span::styled( + display_title(&row.thread).to_owned(), + self.title_style(row.thread_id).bold(), + )), width, ), Line::from(vec![dot, " ".into(), status.into()]), diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_thread_colors_match_footer_and_respect_color_suppression.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_thread_colors_match_footer_and_respect_color_suppression.snap new file mode 100644 index 0000000000..80e91144e5 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_thread_colors_match_footer_and_respect_color_suppression.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "snapshot.join(\"\\n\")" +--- + › ○ Named task Ready │ | title style: Style::new().fg(Color::Rgb(242, 205, 205)).bg(Color::Reset).underline_color(Color::Reset) + │ Named task | title style: Style::new().fg(Color::Rgb(242, 205, 205)).bg(Color::Reset).underline_color(Color::Reset).bold() + › ○ Named task Ready │ | title style: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) + │ Named task | title style: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold() diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index bac7af8c5b..3e5fcacb1a 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -8,6 +8,8 @@ use ratatui::text::Span; use super::status_line_setup::StatusLineItem; use crate::render::highlight::foreground_style_for_scopes; +use crate::thread_color::thread_color; +use codex_protocol::ThreadId; const STATUS_LINE_SEPARATOR: &str = " · "; const STATUS_LINE_COLOR_SATURATION_PERCENT: u16 = 85; @@ -87,11 +89,12 @@ impl StatusLineAccent { pub(crate) fn status_line_from_segments( segments: I, use_theme_colors: bool, + thread_id: Option, ) -> Option> where I: IntoIterator, { - status_line_from_segments_with_resolver(segments, use_theme_colors, |accent| { + status_line_from_segments_with_resolver(segments, use_theme_colors, thread_id, |accent| { foreground_style_for_scopes(accent.scopes()) }) } @@ -99,6 +102,7 @@ where fn status_line_from_segments_with_resolver( segments: I, use_theme_colors: bool, + thread_id: Option, theme_style_for_accent: F, ) -> Option> where @@ -110,7 +114,15 @@ where if !spans.is_empty() { spans.push(STATUS_LINE_SEPARATOR.dim()); } - let style = if use_theme_colors { + let style = if use_theme_colors + && matches!( + item, + StatusLineItem::ThreadName | StatusLineItem::ThreadTitle + ) + && let Some(thread_id) = thread_id + { + Style::default().fg(thread_color(thread_id)) + } else if use_theme_colors { let accent = StatusLineAccent::for_item(item); soften_status_line_style( theme_style_for_accent(accent).unwrap_or_else(|| accent.fallback_style()), @@ -204,6 +216,7 @@ mod tests { (StatusLineItem::GitBranch, "main".to_string()), ], /*use_theme_colors*/ true, + /*thread_id*/ None, |_| None, ) .expect("status line"); @@ -225,6 +238,7 @@ mod tests { (StatusLineItem::ContextUsed, "Context 12% used".to_string()), ], /*use_theme_colors*/ true, + /*thread_id*/ None, |accent| match accent { StatusLineAccent::Model => Some(Style::default().red()), _ => None, @@ -247,6 +261,7 @@ mod tests { (StatusLineItem::EstimatedThreadCost, "~$0.21".to_string()), ], /*use_theme_colors*/ true, + /*thread_id*/ None, |_| None, ) .expect("thread usage status line"); @@ -262,6 +277,7 @@ mod tests { let line = status_line_from_segments_with_resolver( [(StatusLineItem::ModelName, "gpt-5".to_string())], /*use_theme_colors*/ true, + /*thread_id*/ None, |_| Some(Style::default().fg(Color::Rgb(255, 0, 0))), ) .expect("status line"); @@ -278,6 +294,7 @@ mod tests { (StatusLineItem::ContextUsed, "Context 12% used".to_string()), ], /*use_theme_colors*/ false, + /*thread_id*/ None, |_| Some(Style::default().red()), ) .expect("status line"); @@ -295,6 +312,7 @@ mod tests { let line = status_line_from_segments_with_resolver( [(StatusLineItem::PullRequestNumber, "PR #20252".to_string())], /*use_theme_colors*/ false, + /*thread_id*/ None, |_| None, ) .expect("status line"); @@ -315,6 +333,7 @@ mod tests { status_line_from_segments_with_resolver( Vec::<(StatusLineItem, String)>::new(), /*use_theme_colors*/ true, + /*thread_id*/ None, |_| None, ), None diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index 93b7ee3bd5..f2e6f9005e 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; +use codex_protocol::ThreadId; use ratatui::text::Line; use super::status_line_from_segments; @@ -126,12 +127,14 @@ struct PreviewValue { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct StatusSurfacePreviewData { + pub(crate) thread_id: Option, values: BTreeMap, } impl Default for StatusSurfacePreviewData { fn default() -> Self { let mut data = Self { + thread_id: None, values: BTreeMap::new(), }; for item in StatusSurfacePreviewItem::iter() { @@ -242,7 +245,7 @@ impl StatusSurfacePreviewData { self.value_for(item.preview_item()) .map(|value| (item, value.to_string())) }); - status_line_from_segments(segments, use_theme_colors) + status_line_from_segments(segments, use_theme_colors, self.thread_id) } } diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index de61cba2d5..6dc0ab9077 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -364,6 +364,7 @@ impl ChatWidget { .map(|value| (item, value)) }), ); + preview_data.thread_id = self.thread_id; if self.rate_limit_snapshots_by_limit_id.contains_key("codex") { for item in [ diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 1059d7886d..af547ce968 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -218,6 +218,7 @@ impl ChatWidget { self.set_status_line(status_line_from_segments( segments, self.local_settings.tui.status_line_use_colors, + self.thread_id, )); let hyperlink_url = selections .status_line_items diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__status_surface_previews__thread_color_preview_matches_footer_while_auto_naming.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__status_surface_previews__thread_color_preview_matches_footer_while_auto_naming.snap new file mode 100644 index 0000000000..b679f1889c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__status_surface_previews__thread_color_preview_matches_footer_while_auto_naming.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: "snapshot.join(\"\\n\")" +--- +pending=false colors=true: Line::from_iter([Span::from("Named task").fg(Color::Rgb(242, 205, 205)), Span::from(" · ").dim(), Span::from("Named task").fg(Color::Rgb(242, 205, 205))]) +pending=false colors=false: Line::from_iter([Span::from("Named task").dim(), Span::from(" · ").dim(), Span::from("Named task").dim()]) +pending=true colors=true: Line::from_iter([Span::from("Named task ⠋").fg(Color::Rgb(242, 205, 205)), Span::from(" · ").dim(), Span::from("Named task ⠋").fg(Color::Rgb(242, 205, 205))]) +pending=true colors=false: Line::from_iter([Span::from("Named task ⠋").dim(), Span::from(" · ").dim(), Span::from("Named task ⠋").dim()]) diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 2e8a8a5b29..df2dfceea7 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -12,6 +12,35 @@ fn line_text(line: Line<'static>) -> String { .collect() } +#[tokio::test] +async fn thread_color_preview_matches_footer_while_auto_naming() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let id = ThreadId::from_u128(/*value*/ 42); + chat.thread_id = Some(id); + chat.local_settings.tui.animations = false; + chat.thread_name = Some("Named task".into()); + let items = [StatusLineItem::ThreadName, StatusLineItem::ThreadTitle]; + let mut snapshot = Vec::new(); + for pending in [false, true] { + chat.set_thread_title_generation_pending(pending); + let preview = chat.status_surface_preview_data(); + for use_colors in [true, false] { + let line = preview.status_line_for_items(items, use_colors).unwrap(); + let footer = crate::bottom_pane::status_line_from_segments( + items + .into_iter() + .map(|item| (item, chat.status_line_value_for_item(item).unwrap())), + use_colors, + Some(id), + ) + .unwrap(); + assert_eq!(line, footer); + snapshot.push(format!("pending={pending} colors={use_colors}: {line:?}")); + } + } + insta::assert_snapshot!(snapshot.join("\n")); +} + fn status_preview_line_option(chat: &mut ChatWidget, items: &[StatusLineItem]) -> Option { let preview_data = chat.status_surface_preview_data(); preview_data diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 317583b2c4..663b3e4875 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -214,6 +214,7 @@ mod terminal_title; mod terminal_visualization_instructions; mod text_formatting; mod theme_picker; +mod thread_color; mod thread_transcript; mod token_usage; mod tooltips; @@ -1327,6 +1328,16 @@ async fn run_ratatui_app( }) }; + // Startup pickers need the current theme before selection can reload config. + // Leave the one-time override initialization below to use the final config. + if (cli.resume_picker || cli.fork_picker) + && let Some(name) = config.tui_theme.as_deref() + && let Some(theme) = + crate::render::highlight::resolve_theme_by_name(name, Some(config.codex_home.as_path())) + { + crate::render::highlight::set_syntax_theme(theme); + } + let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some(); let mut session_selection = if cli.agents_overview { resume_picker::SessionSelection::AgentsOverview diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs index e37867c854..8d186712c2 100644 --- a/codex-rs/tui/src/render/highlight.rs +++ b/codex-rs/tui/src/render/highlight.rs @@ -494,7 +494,7 @@ fn ansi_palette_color(index: u8) -> RtColor { /// `clippy::disallowed_methods` is explicitly allowed here because this helper /// intentionally constructs `ratatui::style::Color::Rgb`. #[allow(clippy::disallowed_methods)] -fn convert_syntect_color(color: SyntectColor) -> Option { +pub(crate) fn convert_syntect_color(color: SyntectColor) -> Option { match color.a { // Bat-compatible encoding used by `ansi`, `base16`, and `base16-256`: // alpha 0x00 means `r` stores an ANSI palette index, not RGB red. diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index ef4dd6df72..8219c21c4a 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -78,6 +78,10 @@ use uuid::Uuid; mod archive; mod page_loading; +#[cfg(test)] +#[path = "resume_picker_color_tests.rs"] +mod color_tests; + use page_loading::PageCwdFilter; use page_loading::PageLoadMode; use page_loading::PaginationState; @@ -333,6 +337,7 @@ struct SessionPickerViewPersistence { } struct SessionPickerRunOptions { + use_theme_colors: bool, show_all: bool, filter_cwd: Option, local_filter_cwd: Option, @@ -444,6 +449,7 @@ async fn run_resume_picker_with_launch_context( let provider_filter = picker_provider_filter(config, uses_remote_workspace); let runtime_keymap = picker_runtime_keymap(local_settings)?; let options = SessionPickerRunOptions { + use_theme_colors: local_settings.tui.status_line_use_colors, show_all, filter_cwd: cwd_filter, local_filter_cwd, @@ -504,6 +510,7 @@ pub async fn run_fork_picker_with_app_server( let provider_filter = picker_provider_filter(config, uses_remote_workspace); let runtime_keymap = picker_runtime_keymap(local_settings)?; let options = SessionPickerRunOptions { + use_theme_colors: local_settings.tui.status_line_use_colors, show_all, filter_cwd: cwd_filter, local_filter_cwd, @@ -559,6 +566,7 @@ async fn run_session_picker_with_loader( options.action, ); state.local_filter_cwd = options.local_filter_cwd; + state.use_theme_colors = options.use_theme_colors; state.worktrees_enabled = options.worktrees_enabled; state.density = options.initial_density; state.view_persistence = options.view_persistence; @@ -828,6 +836,7 @@ impl Drop for AltScreenGuard<'_> { } struct PickerState { + use_theme_colors: bool, // Resolve local filesystem membership once per cwd for each page-loading cycle. local_cwd_matches: HashMap, requester: FrameRequester, @@ -1028,6 +1037,7 @@ impl PickerState { action: SessionPickerAction, ) -> Self { Self { + use_theme_colors: true, requester, relative_time_reference: None, pagination: PaginationState::new(), @@ -2836,11 +2846,7 @@ fn render_comfortable_session_lines( ) -> Vec> { let marker = selection_marker(is_selected, is_expanded); let title = truncate_text(row.display_preview(), width.saturating_sub(2) as usize); - let title = if is_selected { - selected_session_title_span(title) - } else { - title.into() - }; + let title = session_title_span(title, row.thread_id, state.use_theme_colors, is_selected); let title_line = Line::from(vec![marker, title]); let mut lines = vec![title_line]; let row_style = if is_selected { @@ -2908,7 +2914,8 @@ fn apply_line_background(mut line: Line<'static>, style: Style, width: u16) -> L } line.style = line.style.patch(style); for span in &mut line.spans { - span.style = span.style.patch(style); + // Keep identity accents while inheriting the selected row's background. + span.style = style.patch(span.style); } line } @@ -2935,6 +2942,8 @@ fn render_dense_session_lines( marker, date: &date, title: row.display_preview(), + thread_id: row.thread_id, + use_theme_colors: state.use_theme_colors, is_selected, is_zebra, width, @@ -2946,6 +2955,8 @@ fn render_dense_session_lines( } struct DenseSummaryInput<'a> { + thread_id: Option, + use_theme_colors: bool, marker: Span<'static>, date: &'a str, title: &'a str, @@ -2958,11 +2969,12 @@ fn dense_summary_line(input: DenseSummaryInput<'_>) -> Line<'static> { let marker_width = input.marker.width(); let available = (input.width as usize).saturating_sub(marker_width); let columns = dense_columns(available); - let title = if input.is_selected { - selected_session_title_span(dense_column_text(input.title, columns.title_width)) - } else { - dense_column_text(input.title, columns.title_width).into() - }; + let title = session_title_span( + dense_column_text(input.title, columns.title_width), + input.thread_id, + input.use_theme_colors, + input.is_selected, + ); let spans = vec![ input.marker, @@ -3043,8 +3055,19 @@ fn selected_session_style() -> Style { } } -fn selected_session_title_span(title: String) -> Span<'static> { - title.set_style(selected_session_style()) +fn session_title_span( + title: String, + thread_id: Option, + use_theme_colors: bool, + is_selected: bool, +) -> Span<'static> { + if use_theme_colors && let Some(thread_id) = thread_id { + title.fg(crate::thread_color::thread_color(thread_id)) + } else if is_selected { + title.set_style(selected_session_style()) + } else { + title.into() + } } fn render_footer_lines( @@ -5595,6 +5618,8 @@ session_picker_view = "dense" marker: selection_marker(/*is_selected*/ true, /*is_expanded*/ false), date: "15m ago", title: "Selected dense row", + thread_id: None, + use_theme_colors: true, is_selected: true, is_zebra: false, width: 80, @@ -5611,6 +5636,8 @@ session_picker_view = "dense" marker: selection_marker(/*is_selected*/ false, /*is_expanded*/ false), date: "15m ago", title: "Zebra dense row", + thread_id: None, + use_theme_colors: true, is_selected: false, is_zebra: true, width: 80, diff --git a/codex-rs/tui/src/resume_picker_color_tests.rs b/codex-rs/tui/src/resume_picker_color_tests.rs new file mode 100644 index 0000000000..81e6caa316 --- /dev/null +++ b/codex-rs/tui/src/resume_picker_color_tests.rs @@ -0,0 +1,42 @@ +use super::*; + +#[test] +fn thread_colors_survive_selection_in_both_picker_layouts() { + let id = ThreadId::from_u128(/*value*/ 42); + let row = Row { + path: None, + thread_id: Some(id), + thread_name: Some("Named task".into()), + preview: "Original prompt".into(), + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }; + let mut state = PickerState::new( + FrameRequester::test_dummy(), + Arc::new(|_| {}), + ProviderFilter::Any, + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + let mut snapshot = Vec::new(); + for density in [SessionListDensity::Comfortable, SessionListDensity::Dense] { + state.density = density; + for use_colors in [true, false] { + state.use_theme_colors = use_colors; + for selected in [true, false] { + let lines = render_session_lines( + &row, &state, selected, /*is_expanded*/ false, /*is_zebra*/ false, + /*width*/ 32, + ); + snapshot.push(format!( + "{density:?} colors={use_colors} selected={selected}: {:?}", + lines[0] + )); + } + } + } + insta::assert_snapshot!(snapshot.join("\n")); +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__thread_colors_survive_selection_in_both_picker_layouts.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__thread_colors_survive_selection_in_both_picker_layouts.snap new file mode 100644 index 0000000000..45c71c8c15 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__color_tests__thread_colors_survive_selection_in_both_picker_layouts.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/resume_picker_color_tests.rs +expression: "snapshot.join(\"\\n\")" +--- +Comfortable colors=true selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("Named task").fg(Color::Rgb(242, 205, 205)), Span::from(" ").yellow()]).yellow() +Comfortable colors=true selected=false: Line::from_iter([Span::from(" "), Span::from("Named task").fg(Color::Rgb(242, 205, 205))]) +Comfortable colors=false selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("Named task").yellow(), Span::from(" ").yellow()]).yellow() +Comfortable colors=false selected=false: Line::from_iter([Span::from(" "), Span::from("Named task")]) +Dense colors=true selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("- ").dim(), Span::from("Named task ").fg(Color::Rgb(242, 205, 205))]).yellow() +Dense colors=true selected=false: Line::from_iter([Span::from(" "), Span::from("- ").dim(), Span::from("Named task ").fg(Color::Rgb(242, 205, 205))]) +Dense colors=false selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("- ").dim(), Span::from("Named task ").yellow()]).yellow() +Dense colors=false selected=false: Line::from_iter([Span::from(" "), Span::from("- ").dim(), Span::from("Named task ")]) diff --git a/codex-rs/tui/src/thread_color.rs b/codex-rs/tui/src/thread_color.rs new file mode 100644 index 0000000000..57d603b871 --- /dev/null +++ b/codex-rs/tui/src/thread_color.rs @@ -0,0 +1,83 @@ +//! Stateless thread identity colors drawn from the active syntax theme. +//! +//! Only the theme palette is cached. Assignments depend on the full thread ID, +//! never on names, list order, or other threads; palette collisions are allowed. + +use std::cell::RefCell; + +use codex_protocol::ThreadId; +use ratatui::style::Color; +use syntect::highlighting::Highlighter; +use syntect::highlighting::Theme; +use syntect::parsing::Scope; + +use crate::render::highlight::convert_syntect_color; +use crate::render::highlight::current_syntax_theme; +use crate::render::highlight::syntax_theme_revision; + +thread_local! { + static PALETTE: RefCell)>> = const { RefCell::new(None) }; +} + +pub(crate) fn thread_color(thread_id: ThreadId) -> Color { + PALETTE.with_borrow_mut(|cached| { + let revision = syntax_theme_revision(); + let (cached_revision, colors) = + cached.get_or_insert_with(|| (revision, theme_accents(¤t_syntax_theme()))); + if *cached_revision != revision { + *colors = theme_accents(¤t_syntax_theme()); + *cached_revision = revision; + } + color_for_id(thread_id, colors) + }) +} + +fn theme_accents(theme: &Theme) -> Vec { + // Syntect stores scope rules, not a named palette. Scan every rule so rare + // accents remain available, excluding base text and generic comment/punctuation colors. + // A foreground paired with a special background is not a standalone accent. + let highlighter = Highlighter::new(theme); + let mut excluded = vec![theme.settings.foreground, theme.settings.background]; + for scope in ["comment", "punctuation"] { + if let Ok(scope) = Scope::new(scope) { + excluded.push(highlighter.style_mod_for_stack(&[scope]).foreground); + } + } + + let mut colors = Vec::new(); + for color in theme + .scopes + .iter() + .filter(|rule| { + rule.style.background.is_none() || rule.style.background == theme.settings.background + }) + .filter_map(|rule| rule.style.foreground) + .chain(theme.settings.accent) + { + if !excluded.contains(&Some(color)) + && let Some(color) = convert_syntect_color(color) + && !colors.contains(&color) + { + colors.push(color); + } + } + colors +} + +fn color_for_id(thread_id: ThreadId, colors: &[Color]) -> Color { + // Explicit FNV-1a, rather than a process-random or Rust-version-specific hasher. + let hash = thread_id + .to_string() + .bytes() + .fold(0xcbf29ce484222325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3) + }); + colors + .get((hash % colors.len().max(1) as u64) as usize) + .copied() + .unwrap_or(Color::Reset) +} + +#[cfg(test)] +#[path = "thread_color_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/thread_color_tests.rs b/codex-rs/tui/src/thread_color_tests.rs new file mode 100644 index 0000000000..04d324f5be --- /dev/null +++ b/codex-rs/tui/src/thread_color_tests.rs @@ -0,0 +1,83 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::collections::HashSet; +use syntect::highlighting::Color as ThemeColor; +use syntect::highlighting::StyleModifier; +use syntect::highlighting::ThemeItem; +use two_face::theme::EmbeddedThemeName; + +#[test] +fn assignments_reach_all_fourteen_default_accents() { + let themes = two_face::theme::extra(); + for name in [ + EmbeddedThemeName::CatppuccinLatte, + EmbeddedThemeName::CatppuccinMocha, + ] { + let colors = theme_accents(themes.get(name)); + let assigned: HashSet<_> = (0..512) + .map(|id| color_for_id(ThreadId::from_u128(id), &colors)) + .collect(); + assert_eq!(assigned.len(), 14); + assert_eq!(assigned, colors.iter().copied().collect()); + } +} + +#[test] +fn theme_colors_are_preserved_without_a_replacement_palette() { + let foreground = ThemeColor { + r: 120, + g: 120, + b: 120, + a: 255, + }; + let accent = ThemeColor { + r: 121, + ..foreground + }; + let mut theme = Theme::default(); + theme.settings.foreground = Some(foreground); + theme.scopes = [foreground, accent, accent] + .into_iter() + .map(|color| ThemeItem { + scope: "keyword".parse().unwrap(), + style: StyleModifier { + foreground: Some(color), + ..StyleModifier::default() + }, + }) + .collect(); + theme.scopes.push(ThemeItem { + scope: "invalid".parse().unwrap(), + style: StyleModifier { + foreground: Some(ThemeColor { + r: 122, + ..foreground + }), + background: Some(accent), + ..StyleModifier::default() + }, + }); + let colors = theme_accents(&theme); + assert_eq!(colors, vec![convert_syntect_color(accent).unwrap()]); + assert_eq!( + color_for_id(ThreadId::from_u128(/*value*/ 1), &colors), + colors[0] + ); + theme.scopes.clear(); + assert_eq!( + color_for_id(ThreadId::from_u128(/*value*/ 1), &theme_accents(&theme)), + Color::Reset + ); +} + +#[test] +fn theme_revision_refreshes_the_cached_palette() { + let id = ThreadId::from_u128(/*value*/ 1); + let revision = syntax_theme_revision(); + PALETTE.set(Some((revision.wrapping_sub(1), Vec::new()))); + assert_eq!( + thread_color(id), + color_for_id(id, &theme_accents(¤t_syntax_theme())) + ); + PALETTE.with_borrow(|cached| assert_eq!(cached.as_ref().unwrap().0, revision)); +}