Files
codex/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs
T
Eric TrautandGitHub a93c89f497 Color TUI statusline from active theme (#19631)
## Why

Users have shared that the TUI can feel too visually flat because themes
mostly show up in code syntax highlighting. The configurable statusline
is a natural place to make the active theme more visible, while still
letting users keep the existing monotone statusline if they prefer it.

## What Changed

- Added a statusline styling helper that builds the rendered statusline
from `(StatusLineItem, text)` segments, preserving item identity while
keeping the plain text output unchanged.
- Derived foreground accent colors from the active syntax theme by
looking up TextMate scopes through the existing syntax highlighter, with
conservative ANSI fallbacks when a scope does not provide a foreground.
- Tuned theme-derived colors to keep the accents visible without making
the statusline feel overly bright.
- Added `[tui].status_line_use_colors`, defaulting to `true`, plus a
separated `/statusline` toggle so users can enable or disable
theme-derived statusline colors from the setup UI.
- Updated the live statusline and `/statusline` preview to use the same
styled builder, while keeping terminal-title preview text plain.
- Kept statusline separators and active-agent add-ons subdued while
removing blanket dimming from the whole passive statusline.

## Verification

- `cargo test -p codex-tui status_line`
- `cargo test -p codex-tui theme_picker`
- `cargo test -p codex-tui foreground_style_for_scopes`
- `cargo test -p codex-tui`
- `cargo test -p codex-config`
- `cargo test -p codex-core status_line_use_colors`
- `cargo insta pending-snapshots --manifest-path tui/Cargo.toml`

## Visual

<img width="369" height="23" alt="Screenshot 2026-04-30 at 6 16 08 PM"
src="https://github.com/user-attachments/assets/11d03efb-8e4f-4450-8f4d-00a9659ef4cd"
/>

<img width="385" height="23" alt="Screenshot 2026-04-30 at 6 16 02 PM"
src="https://github.com/user-attachments/assets/a3d89f36-bdc1-42e8-8e84-61350e3999e2"
/>
2026-04-30 22:42:48 -07:00

262 lines
8.8 KiB
Rust

use super::*;
use crate::bottom_pane::preview_line_for_title_items;
use pretty_assertions::assert_eq;
use ratatui::text::Line;
fn line_text(line: Line<'static>) -> String {
line.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect()
}
fn status_preview_line(chat: &mut ChatWidget, items: &[StatusLineItem]) -> String {
let preview_data = chat.status_surface_preview_data();
let preview = preview_data
.status_line_for_items(items.iter().copied(), /*use_theme_colors*/ true)
.expect("status preview line");
line_text(preview)
}
fn title_preview_line(chat: &mut ChatWidget, items: &[TerminalTitleItem]) -> String {
let preview_data = chat.terminal_title_preview_data();
let preview =
preview_line_for_title_items(items, &preview_data).expect("terminal title preview line");
line_text(preview)
}
fn combined_preview_snapshot(
chat: &mut ChatWidget,
status_items: &[StatusLineItem],
title_items: &[TerminalTitleItem],
) -> String {
normalize_snapshot_paths(format!(
"status line: {}\nterminal title: {}",
status_preview_line(chat, status_items),
title_preview_line(chat, title_items),
))
}
fn status_line_popup_snapshot(chat: &mut ChatWidget) -> String {
chat.open_status_line_setup();
normalize_snapshot_paths(strip_osc8_for_snapshot(&render_bottom_popup(
chat, /*width*/ 100,
)))
}
fn terminal_title_popup_snapshot(chat: &mut ChatWidget) -> String {
chat.open_terminal_title_setup();
normalize_snapshot_paths(strip_osc8_for_snapshot(&render_bottom_popup(
chat, /*width*/ 100,
)))
}
fn cache_project_root(chat: &mut ChatWidget, root_name: &str) {
chat.status_line_project_root_name_cache = Some(CachedProjectRootName {
cwd: chat.config.cwd.to_path_buf(),
root_name: Some(root_name.to_string()),
});
}
#[tokio::test]
async fn status_surface_preview_lines_live_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_project_root(&mut chat, "preview-live-root");
chat.status_line_branch = Some("feature/live-preview-branch".to_string());
chat.thread_name = Some("Live preview thread".to_string());
chat.last_plan_progress = Some((2, 5));
let snapshot = combined_preview_snapshot(
&mut chat,
&[
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
StatusLineItem::ThreadTitle,
],
&[
TerminalTitleItem::Project,
TerminalTitleItem::Thread,
TerminalTitleItem::GitBranch,
TerminalTitleItem::TaskProgress,
],
);
assert_chatwidget_snapshot!("status_surface_previews_live_only", snapshot);
}
#[tokio::test]
async fn status_line_setup_popup_live_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_project_root(&mut chat, "preview-live-root");
chat.status_line_branch = Some("feature/live-preview-branch".to_string());
chat.thread_name = Some("Live preview thread".to_string());
chat.config.tui_status_line = Some(vec![
"project-name".to_string(),
"git-branch".to_string(),
"thread-title".to_string(),
]);
assert_chatwidget_snapshot!(
"status_line_setup_popup_live_only",
status_line_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn status_surface_preview_lines_hardcoded_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let snapshot = combined_preview_snapshot(
&mut chat,
&[
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
StatusLineItem::ThreadTitle,
],
&[
TerminalTitleItem::Thread,
TerminalTitleItem::GitBranch,
TerminalTitleItem::TaskProgress,
],
);
assert_chatwidget_snapshot!("status_surface_previews_hardcoded_only", snapshot);
}
#[tokio::test]
async fn status_line_setup_popup_hardcoded_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec![
"project-name".to_string(),
"git-branch".to_string(),
"thread-title".to_string(),
]);
assert_chatwidget_snapshot!(
"status_line_setup_popup_hardcoded_only",
status_line_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn status_surface_preview_lines_mixed_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_branch = Some("feature/mixed-preview".to_string());
chat.thread_name = Some("Mixed preview thread".to_string());
let snapshot = combined_preview_snapshot(
&mut chat,
&[
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
StatusLineItem::ThreadTitle,
],
&[
TerminalTitleItem::Project,
TerminalTitleItem::Thread,
TerminalTitleItem::TaskProgress,
],
);
assert_chatwidget_snapshot!("status_surface_previews_mixed", snapshot);
}
#[tokio::test]
async fn status_line_setup_popup_mixed_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_branch = Some("feature/mixed-preview".to_string());
chat.thread_name = Some("Mixed preview thread".to_string());
chat.config.tui_status_line = Some(vec![
"project-name".to_string(),
"git-branch".to_string(),
"thread-title".to_string(),
]);
assert_chatwidget_snapshot!(
"status_line_setup_popup_mixed",
status_line_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn terminal_title_setup_popup_live_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_project_root(&mut chat, "preview-live-root");
chat.status_line_branch = Some("feature/live-preview-branch".to_string());
chat.thread_name = Some("Live preview thread".to_string());
chat.last_plan_progress = Some((2, 5));
chat.config.tui_terminal_title = Some(vec![
"project-name".to_string(),
"thread-title".to_string(),
"git-branch".to_string(),
"task-progress".to_string(),
]);
assert_chatwidget_snapshot!(
"terminal_title_setup_popup_live_only",
terminal_title_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn terminal_title_setup_popup_hardcoded_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_terminal_title = Some(vec![
"thread-title".to_string(),
"git-branch".to_string(),
"task-progress".to_string(),
]);
assert_chatwidget_snapshot!(
"terminal_title_setup_popup_hardcoded_only",
terminal_title_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn terminal_title_setup_popup_mixed_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_name = Some("Mixed preview thread".to_string());
chat.config.tui_terminal_title = Some(vec![
"project-name".to_string(),
"thread-title".to_string(),
"task-progress".to_string(),
]);
assert_chatwidget_snapshot!(
"terminal_title_setup_popup_mixed",
terminal_title_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn missing_project_root_uses_different_status_and_title_preview_sources() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let status_preview = status_preview_line(&mut chat, &[StatusLineItem::ProjectRoot]);
let title_preview = title_preview_line(&mut chat, &[TerminalTitleItem::Project]);
assert_eq!(status_preview, "my-project");
assert_eq!(title_preview, "project");
}
#[tokio::test]
async fn terminal_title_preview_uses_title_truncation_for_live_values() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let long_thread = "This thread title is intentionally much longer than forty-eight characters";
let long_branch = "feature/this-branch-name-is-intentionally-longer-than-thirty-two";
chat.thread_name = Some(long_thread.to_string());
chat.status_line_branch = Some(long_branch.to_string());
let preview = title_preview_line(
&mut chat,
&[TerminalTitleItem::Thread, TerminalTitleItem::GitBranch],
);
let truncated_thread =
ChatWidget::truncate_terminal_title_part(long_thread.to_string(), /*max_chars*/ 48);
let truncated_branch =
ChatWidget::truncate_terminal_title_part(long_branch.to_string(), /*max_chars*/ 32);
assert_eq!(preview, format!("{truncated_thread} | {truncated_branch}"));
}