Skip to content

Commit 37a238b

Browse files
etraut-openaicopyberry
authored andcommitted
Add consistent theme-based thread colors across the TUI
## 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
1 parent 654b0a7 commit 37a238b

17 files changed

Lines changed: 398 additions & 19 deletions

‎codex-rs/tui/src/app/agents_overview.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ impl App {
321321
&self.app_server_target,
322322
self.environment_manager.as_ref(),
323323
),
324+
self.local_settings.tui.status_line_use_colors,
324325
self.app_event_tx.clone(),
325326
self.keymap.clone(),
326327
Arc::clone(&self.agents_overview.view_state),

‎codex-rs/tui/src/app/agents_overview_tests.rs‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,40 @@
11
use super::*;
22

3+
#[tokio::test]
4+
async fn overview_thread_colors_match_footer_and_respect_color_suppression() {
5+
let mut app = make_test_app().await;
6+
let id = ThreadId::from_u128(/*value*/ 42);
7+
let mut snapshot = Vec::new();
8+
for enabled in [true, false] {
9+
app.local_settings.tui.status_line_use_colors = enabled;
10+
let mut thread = overview_thread(
11+
id,
12+
/*parent_thread_id*/ None,
13+
"Original prompt",
14+
ThreadStatus::Idle,
15+
);
16+
thread.name = Some("Named task".into());
17+
let view = app.agents_overview_view(vec![thread], Some(id));
18+
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
19+
terminal
20+
.draw(|frame| view.render(frame.area(), frame.buffer_mut()))
21+
.unwrap();
22+
let buffer = terminal.backend().buffer();
23+
for row in buffer.content.chunks(100) {
24+
let text: String = row.iter().map(ratatui::buffer::Cell::symbol).collect();
25+
if let Some(x) = text.find("Named task") {
26+
let x = text[..x].chars().count();
27+
snapshot.push(format!(
28+
"{} | title style: {:?}",
29+
text.trim_end(),
30+
row[x].style()
31+
));
32+
}
33+
}
34+
}
35+
insta::assert_snapshot!(snapshot.join("\n"));
36+
}
37+
338
#[tokio::test]
439
async fn older_server_notice_is_visible_in_agents_overview() {
540
let mut app = make_test_app().await;
@@ -1425,6 +1460,7 @@ async fn shared_overview_shows_only_root_sessions() {
14251460
view.rows.clone(),
14261461
Some(first_root),
14271462
/*worktrees_enabled*/ false,
1463+
/*use_theme_colors*/ true,
14281464
crate::app_event_sender::AppEventSender::new(event_tx),
14291465
app.keymap.clone(),
14301466
Arc::clone(&app.agents_overview.view_state),
@@ -1666,6 +1702,7 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() {
16661702
.rows,
16671703
Some(first),
16681704
/*worktrees_enabled*/ false,
1705+
/*use_theme_colors*/ true,
16691706
crate::app_event_sender::AppEventSender::new(event_tx),
16701707
app.keymap.clone(),
16711708
Arc::clone(&app.agents_overview.view_state),

‎codex-rs/tui/src/app/agents_overview_view.rs‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ impl AgentsOverviewViewState {
179179
}
180180

181181
pub(super) struct AgentsOverviewView {
182+
use_theme_colors: bool,
182183
pub(super) rows: Vec<AgentsOverviewRow>,
183184
project_groups: Vec<AgentsOverviewProjectGroup>,
184185
selected: usize,
@@ -195,6 +196,7 @@ impl AgentsOverviewView {
195196
rows: Vec<AgentsOverviewRow>,
196197
selected_thread_id: Option<ThreadId>,
197198
worktrees_enabled: bool,
199+
use_theme_colors: bool,
198200
app_event_tx: AppEventSender,
199201
keymap: RuntimeKeymap,
200202
state: Arc<Mutex<AgentsOverviewViewState>>,
@@ -220,6 +222,7 @@ impl AgentsOverviewView {
220222
.map(|row| AgentsOverviewProjectGroup::for_thread(&row.thread, worktrees_enabled))
221223
.collect();
222224
let mut view = Self {
225+
use_theme_colors,
223226
rows,
224227
project_groups,
225228
selected,
@@ -242,6 +245,14 @@ impl AgentsOverviewView {
242245
self.rows.iter().map(|row| row.thread_id).collect()
243246
}
244247

248+
fn title_style(&self, thread_id: ThreadId) -> Style {
249+
if self.use_theme_colors {
250+
Style::default().fg(crate::thread_color::thread_color(thread_id))
251+
} else {
252+
Style::default()
253+
}
254+
}
255+
245256
fn state(&self) -> MutexGuard<'_, AgentsOverviewViewState> {
246257
self.state.lock().unwrap_or_else(PoisonError::into_inner)
247258
}
@@ -439,7 +450,7 @@ impl AgentsOverviewView {
439450
" ".into(),
440451
dot,
441452
" ".into(),
442-
display_title(&row.thread).into(),
453+
Span::styled(display_title(&row.thread), self.title_style(row.thread_id)),
443454
current.dim(),
444455
];
445456
if project_grouping {
@@ -460,7 +471,10 @@ impl AgentsOverviewView {
460471
Line::from("Task details".bold()),
461472
Line::default(),
462473
crate::line_truncation::truncate_line_with_ellipsis_if_overflow(
463-
display_title(&row.thread).to_owned().bold().into(),
474+
Line::from(Span::styled(
475+
display_title(&row.thread).to_owned(),
476+
self.title_style(row.thread_id).bold(),
477+
)),
464478
width,
465479
),
466480
Line::from(vec![dot, " ".into(), status.into()]),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
source: tui/src/app/agents_overview_tests.rs
3+
expression: "snapshot.join(\"\\n\")"
4+
---
5+
› ○ Named task Ready │ | title style: Style::new().fg(Color::Rgb(242, 205, 205)).bg(Color::Reset).underline_color(Color::Reset)
6+
│ Named task | title style: Style::new().fg(Color::Rgb(242, 205, 205)).bg(Color::Reset).underline_color(Color::Reset).bold()
7+
› ○ Named task Ready │ | title style: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
8+
│ Named task | title style: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()

‎codex-rs/tui/src/bottom_pane/status_line_style.rs‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ use ratatui::text::Span;
88

99
use super::status_line_setup::StatusLineItem;
1010
use crate::render::highlight::foreground_style_for_scopes;
11+
use crate::thread_color::thread_color;
12+
use codex_protocol::ThreadId;
1113

1214
const STATUS_LINE_SEPARATOR: &str = " · ";
1315
const STATUS_LINE_COLOR_SATURATION_PERCENT: u16 = 85;
@@ -87,18 +89,20 @@ impl StatusLineAccent {
8789
pub(crate) fn status_line_from_segments<I>(
8890
segments: I,
8991
use_theme_colors: bool,
92+
thread_id: Option<ThreadId>,
9093
) -> Option<Line<'static>>
9194
where
9295
I: IntoIterator<Item = (StatusLineItem, String)>,
9396
{
94-
status_line_from_segments_with_resolver(segments, use_theme_colors, |accent| {
97+
status_line_from_segments_with_resolver(segments, use_theme_colors, thread_id, |accent| {
9598
foreground_style_for_scopes(accent.scopes())
9699
})
97100
}
98101

99102
fn status_line_from_segments_with_resolver<I, F>(
100103
segments: I,
101104
use_theme_colors: bool,
105+
thread_id: Option<ThreadId>,
102106
theme_style_for_accent: F,
103107
) -> Option<Line<'static>>
104108
where
@@ -110,7 +114,15 @@ where
110114
if !spans.is_empty() {
111115
spans.push(STATUS_LINE_SEPARATOR.dim());
112116
}
113-
let style = if use_theme_colors {
117+
let style = if use_theme_colors
118+
&& matches!(
119+
item,
120+
StatusLineItem::ThreadName | StatusLineItem::ThreadTitle
121+
)
122+
&& let Some(thread_id) = thread_id
123+
{
124+
Style::default().fg(thread_color(thread_id))
125+
} else if use_theme_colors {
114126
let accent = StatusLineAccent::for_item(item);
115127
soften_status_line_style(
116128
theme_style_for_accent(accent).unwrap_or_else(|| accent.fallback_style()),
@@ -204,6 +216,7 @@ mod tests {
204216
(StatusLineItem::GitBranch, "main".to_string()),
205217
],
206218
/*use_theme_colors*/ true,
219+
/*thread_id*/ None,
207220
|_| None,
208221
)
209222
.expect("status line");
@@ -225,6 +238,7 @@ mod tests {
225238
(StatusLineItem::ContextUsed, "Context 12% used".to_string()),
226239
],
227240
/*use_theme_colors*/ true,
241+
/*thread_id*/ None,
228242
|accent| match accent {
229243
StatusLineAccent::Model => Some(Style::default().red()),
230244
_ => None,
@@ -247,6 +261,7 @@ mod tests {
247261
(StatusLineItem::EstimatedThreadCost, "~$0.21".to_string()),
248262
],
249263
/*use_theme_colors*/ true,
264+
/*thread_id*/ None,
250265
|_| None,
251266
)
252267
.expect("thread usage status line");
@@ -262,6 +277,7 @@ mod tests {
262277
let line = status_line_from_segments_with_resolver(
263278
[(StatusLineItem::ModelName, "gpt-5".to_string())],
264279
/*use_theme_colors*/ true,
280+
/*thread_id*/ None,
265281
|_| Some(Style::default().fg(Color::Rgb(255, 0, 0))),
266282
)
267283
.expect("status line");
@@ -278,6 +294,7 @@ mod tests {
278294
(StatusLineItem::ContextUsed, "Context 12% used".to_string()),
279295
],
280296
/*use_theme_colors*/ false,
297+
/*thread_id*/ None,
281298
|_| Some(Style::default().red()),
282299
)
283300
.expect("status line");
@@ -295,6 +312,7 @@ mod tests {
295312
let line = status_line_from_segments_with_resolver(
296313
[(StatusLineItem::PullRequestNumber, "PR #20252".to_string())],
297314
/*use_theme_colors*/ false,
315+
/*thread_id*/ None,
298316
|_| None,
299317
)
300318
.expect("status line");
@@ -315,6 +333,7 @@ mod tests {
315333
status_line_from_segments_with_resolver(
316334
Vec::<(StatusLineItem, String)>::new(),
317335
/*use_theme_colors*/ true,
336+
/*thread_id*/ None,
318337
|_| None,
319338
),
320339
None

‎codex-rs/tui/src/bottom_pane/status_surface_preview.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::collections::BTreeMap;
22

3+
use codex_protocol::ThreadId;
34
use ratatui::text::Line;
45

56
use super::status_line_from_segments;
@@ -126,12 +127,14 @@ struct PreviewValue {
126127

127128
#[derive(Clone, Debug, Eq, PartialEq)]
128129
pub(crate) struct StatusSurfacePreviewData {
130+
pub(crate) thread_id: Option<ThreadId>,
129131
values: BTreeMap<StatusSurfacePreviewItem, PreviewValue>,
130132
}
131133

132134
impl Default for StatusSurfacePreviewData {
133135
fn default() -> Self {
134136
let mut data = Self {
137+
thread_id: None,
135138
values: BTreeMap::new(),
136139
};
137140
for item in StatusSurfacePreviewItem::iter() {
@@ -242,7 +245,7 @@ impl StatusSurfacePreviewData {
242245
self.value_for(item.preview_item())
243246
.map(|value| (item, value.to_string()))
244247
});
245-
status_line_from_segments(segments, use_theme_colors)
248+
status_line_from_segments(segments, use_theme_colors, self.thread_id)
246249
}
247250
}
248251

‎codex-rs/tui/src/chatwidget/status_controls.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ impl ChatWidget {
364364
.map(|value| (item, value))
365365
}),
366366
);
367+
preview_data.thread_id = self.thread_id;
367368

368369
if self.rate_limit_snapshots_by_limit_id.contains_key("codex") {
369370
for item in [

‎codex-rs/tui/src/chatwidget/status_surfaces.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ impl ChatWidget {
218218
self.set_status_line(status_line_from_segments(
219219
segments,
220220
self.local_settings.tui.status_line_use_colors,
221+
self.thread_id,
221222
));
222223
let hyperlink_url = selections
223224
.status_line_items
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
source: tui/src/chatwidget/tests/status_surface_previews.rs
3+
expression: "snapshot.join(\"\\n\")"
4+
---
5+
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))])
6+
pending=false colors=false: Line::from_iter([Span::from("Named task").dim(), Span::from(" · ").dim(), Span::from("Named task").dim()])
7+
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))])
8+
pending=true colors=false: Line::from_iter([Span::from("Named task ⠋").dim(), Span::from(" · ").dim(), Span::from("Named task ⠋").dim()])

‎codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,35 @@ fn line_text(line: Line<'static>) -> String {
1212
.collect()
1313
}
1414

15+
#[tokio::test]
16+
async fn thread_color_preview_matches_footer_while_auto_naming() {
17+
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
18+
let id = ThreadId::from_u128(/*value*/ 42);
19+
chat.thread_id = Some(id);
20+
chat.local_settings.tui.animations = false;
21+
chat.thread_name = Some("Named task".into());
22+
let items = [StatusLineItem::ThreadName, StatusLineItem::ThreadTitle];
23+
let mut snapshot = Vec::new();
24+
for pending in [false, true] {
25+
chat.set_thread_title_generation_pending(pending);
26+
let preview = chat.status_surface_preview_data();
27+
for use_colors in [true, false] {
28+
let line = preview.status_line_for_items(items, use_colors).unwrap();
29+
let footer = crate::bottom_pane::status_line_from_segments(
30+
items
31+
.into_iter()
32+
.map(|item| (item, chat.status_line_value_for_item(item).unwrap())),
33+
use_colors,
34+
Some(id),
35+
)
36+
.unwrap();
37+
assert_eq!(line, footer);
38+
snapshot.push(format!("pending={pending} colors={use_colors}: {line:?}"));
39+
}
40+
}
41+
insta::assert_snapshot!(snapshot.join("\n"));
42+
}
43+
1544
fn status_preview_line_option(chat: &mut ChatWidget, items: &[StatusLineItem]) -> Option<String> {
1645
let preview_data = chat.status_surface_preview_data();
1746
preview_data

0 commit comments

Comments
 (0)