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"
/>
This commit is contained in:
Eric Traut
2026-04-30 22:42:48 -07:00
committed by GitHub
parent d898cc8f3f
commit a93c89f497
27 changed files with 789 additions and 138 deletions
+5
View File
@@ -634,6 +634,11 @@ pub struct Tui {
#[serde(default)]
pub status_line: Option<Vec<String>>,
/// Color status line items with colors derived from the active syntax theme.
/// Defaults to `true`.
#[serde(default = "default_true")]
pub status_line_use_colors: bool,
/// Ordered list of terminal title item identifiers.
///
/// When set, the TUI renders the selected items into the terminal window/tab title.
+5
View File
@@ -2528,6 +2528,11 @@
},
"type": "array"
},
"status_line_use_colors": {
"default": true,
"description": "Color status line items with colors derived from the active syntax theme. Defaults to `true`.",
"type": "boolean"
},
"terminal_resize_reflow_max_rows": {
"default": null,
"description": "Trim terminal resize-reflow replay to the most recent rendered terminal rows when the transcript exceeds this cap. Omit to use Codex's terminal-specific default. Set to `0` to keep all rendered rows.",
+37
View File
@@ -552,6 +552,7 @@ fn config_toml_deserializes_model_availability_nux() {
vim_mode_default: false,
alternate_screen: AltScreenMode::default(),
status_line: None,
status_line_use_colors: true,
terminal_title: None,
theme: None,
keymap: TuiKeymap::default(),
@@ -566,6 +567,37 @@ fn config_toml_deserializes_model_availability_nux() {
);
}
#[test]
fn config_toml_status_line_use_colors_defaults_to_enabled() {
let toml = r#"
[tui]
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for TUI config");
assert!(
cfg.tui
.expect("tui config should deserialize")
.status_line_use_colors
);
}
#[test]
fn config_toml_deserializes_status_line_use_colors_disabled() {
let toml = r#"
[tui]
status_line_use_colors = false
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for TUI config");
assert!(
!cfg.tui
.expect("tui config should deserialize")
.status_line_use_colors
);
}
#[test]
fn config_toml_deserializes_terminal_resize_reflow_config() {
let toml = r#"
@@ -2095,6 +2127,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
vim_mode_default: false,
alternate_screen: AltScreenMode::Auto,
status_line: None,
status_line_use_colors: true,
terminal_title: None,
theme: None,
keymap: TuiKeymap::default(),
@@ -6421,6 +6454,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
otel: OtelConfig::default(),
@@ -6618,6 +6652,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
otel: OtelConfig::default(),
@@ -6769,6 +6804,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
otel: OtelConfig::default(),
@@ -6905,6 +6941,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
otel: OtelConfig::default(),
+8
View File
@@ -104,6 +104,14 @@ pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
}
}
/// Produces a config edit that sets `[tui].status_line_use_colors`.
pub fn status_line_use_colors_edit(enabled: bool) -> ConfigEdit {
ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "status_line_use_colors".to_string()],
value: value(enabled),
}
}
/// Produces a config edit that sets `[tui].terminal_title` to an explicit ordered list.
///
/// The array is written even when it is empty so "disabled title updates" stays
+8
View File
@@ -524,6 +524,9 @@ pub struct Config {
/// When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`.
pub tui_status_line: Option<Vec<String>>,
/// Whether to color status line items with colors from the active syntax theme.
pub tui_status_line_use_colors: bool,
/// Ordered list of terminal title item identifiers for the TUI.
///
/// When unset, the TUI defaults to: `activity` and `project`.
@@ -3011,6 +3014,11 @@ impl Config {
.map(|t| t.alternate_screen)
.unwrap_or_default(),
tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()),
tui_status_line_use_colors: cfg
.tui
.as_ref()
.map(|t| t.status_line_use_colors)
.unwrap_or(true),
tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()),
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
terminal_resize_reflow,
@@ -191,6 +191,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
model_availability_nux: ModelAvailabilityNuxConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
+19 -7
View File
@@ -1785,22 +1785,29 @@ impl App {
tui.frame_requester().schedule_frame();
}
}
AppEvent::StatusLineSetup { items } => {
AppEvent::StatusLineSetup {
items,
use_theme_colors,
} => {
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
let edit = crate::legacy_core::config::edit::status_line_items_edit(&ids);
let items_edit = crate::legacy_core::config::edit::status_line_items_edit(&ids);
let colors_edit =
crate::legacy_core::config::edit::status_line_use_colors_edit(use_theme_colors);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits([edit])
.with_edits([items_edit, colors_edit])
.apply()
.await;
match apply_result {
Ok(()) => {
self.config.tui_status_line = Some(ids.clone());
self.chat_widget.setup_status_line(items);
self.config.tui_status_line_use_colors = use_theme_colors;
self.chat_widget.setup_status_line(items, use_theme_colors);
}
Err(err) => {
tracing::error!(error = %err, "failed to persist status line items; keeping previous selection");
self.chat_widget
.add_error_message(format!("Failed to save status line items: {err}"));
tracing::error!(error = %err, "failed to persist status line settings; keeping previous selection");
self.chat_widget.add_error_message(format!(
"Failed to save status line settings: {err}"
));
}
}
}
@@ -1857,15 +1864,20 @@ impl App {
crate::render::highlight::set_syntax_theme(theme);
}
self.sync_tui_theme_selection(name);
self.refresh_status_line();
}
Err(err) => {
self.restore_runtime_theme_from_config();
self.refresh_status_line();
tracing::error!(error = %err, "failed to persist theme selection");
self.chat_widget
.add_error_message(format!("Failed to save theme: {err}"));
}
}
}
AppEvent::SyntaxThemePreviewed => {
self.refresh_status_line();
}
AppEvent::OpenKeymapActionMenu { context, action } => {
self.chat_widget
.open_keymap_action_menu(context, action, &self.keymap);
+4 -2
View File
@@ -1199,8 +1199,10 @@ async fn replayed_interrupted_turn_restores_queued_input_to_composer() {
#[tokio::test]
async fn token_usage_update_refreshes_status_line_with_runtime_context_window() {
let mut app = make_test_app().await;
app.chat_widget
.setup_status_line(vec![crate::bottom_pane::StatusLineItem::ContextWindowSize]);
app.chat_widget.setup_status_line(
vec![crate::bottom_pane::StatusLineItem::ContextWindowSize],
/*use_theme_colors*/ true,
);
assert_eq!(app.chat_widget.status_line_text(), None);
+4
View File
@@ -808,6 +808,7 @@ pub(crate) enum AppEvent {
/// Apply a user-confirmed status-line item ordering/selection.
StatusLineSetup {
items: Vec<StatusLineItem>,
use_theme_colors: bool,
},
/// Dismiss the status-line setup UI without changing config.
StatusLineSetupCancelled,
@@ -828,6 +829,9 @@ pub(crate) enum AppEvent {
name: String,
},
/// Runtime syntax theme preview changed; refresh theme-derived UI colors.
SyntaxThemePreviewed,
/// Open set/remove actions for the selected keymap action.
OpenKeymapActionMenu {
context: String,
@@ -4267,7 +4267,6 @@ impl ChatComposer {
let status_line_active = uses_passive_footer_status_layout(&footer_props);
let combined_status_line = if status_line_active {
passive_footer_status_line(&footer_props)
.map(ratatui::prelude::Stylize::dim)
} else {
None
};
+10 -14
View File
@@ -684,7 +684,7 @@ fn footer_from_props_lines(
// Passive footer context can come from the configurable status line, the
// active agent label, or both combined.
if let Some(status_line) = passive_footer_status_line(props) {
return vec![status_line.dim()];
return vec![status_line];
}
match props.mode {
FooterMode::QuitShortcutReminder => {
@@ -755,10 +755,10 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<Line<'st
if let Some(active_agent_label) = props.active_agent_label.as_ref() {
if let Some(existing) = line.as_mut() {
existing.spans.push(" · ".into());
existing.spans.push(active_agent_label.clone().into());
existing.spans.push(" · ".dim());
existing.spans.push(active_agent_label.clone().dim());
} else {
line = Some(Line::from(active_agent_label.clone()));
line = Some(Line::from(active_agent_label.clone()).dim());
}
}
@@ -1300,10 +1300,9 @@ mod tests {
props.mode,
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
) {
passive_status_line
.as_ref()
.map(|line| line.clone().dim())
.map(|line| truncate_line_with_ellipsis_if_overflow(line, available_width))
passive_status_line.as_ref().map(|line| {
truncate_line_with_ellipsis_if_overflow(line.clone(), available_width)
})
} else {
None
};
@@ -1343,12 +1342,9 @@ mod tests {
if status_line_active
&& let Some(max_left) = max_left_width_for_right(area, right_width)
&& left_width > max_left
&& let Some(line) = passive_status_line
.as_ref()
.map(|line| line.clone().dim())
.map(|line| {
truncate_line_with_ellipsis_if_overflow(line, max_left as usize)
})
&& let Some(line) = passive_status_line.as_ref().map(|line| {
truncate_line_with_ellipsis_if_overflow(line.clone(), max_left as usize)
})
{
left_width = line.width() as u16;
truncated_status_line = Some(line);
+2
View File
@@ -53,6 +53,7 @@ mod mcp_server_elicitation;
mod multi_select_picker;
mod request_user_input;
mod status_line_setup;
mod status_line_style;
mod status_surface_preview;
mod title_setup;
pub(crate) use action_required_title::ACTION_REQUIRED_PREVIEW_PREFIX;
@@ -67,6 +68,7 @@ pub(crate) use approval_overlay::format_requested_permissions_rule;
pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest;
pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay;
pub(crate) use request_user_input::RequestUserInputOverlay;
pub(crate) use status_line_style::status_line_from_segments;
mod bottom_pane_view;
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -18,8 +18,22 @@
//! app_event_tx,
//! )
//! .items(vec![
//! MultiSelectItem { id: "a".into(), name: "Item A".into(), description: None, enabled: true },
//! MultiSelectItem { id: "b".into(), name: "Item B".into(), description: None, enabled: false },
//! MultiSelectItem {
//! id: "a".into(),
//! name: "Item A".into(),
//! description: None,
//! enabled: true,
//! orderable: true,
//! section_break_after: false,
//! },
//! MultiSelectItem {
//! id: "b".into(),
//! name: "Item B".into(),
//! description: None,
//! enabled: false,
//! orderable: true,
//! section_break_after: false,
//! },
//! ])
//! .on_confirm(|selected_ids, tx| { /* handle confirmation */ })
//! .build();
@@ -64,6 +78,8 @@ const SEARCH_PLACEHOLDER: &str = "Type to search";
/// Prefix displayed before the search query (mimics a command prompt).
const SEARCH_PROMPT_PREFIX: &str = "> ";
const SECTION_BREAK_ROW: &str = " ───────────────────────";
/// Direction for reordering items in the list.
enum Direction {
Up,
@@ -89,7 +105,6 @@ pub type PreviewCallback = Box<dyn Fn(&[MultiSelectItem]) -> Option<Line<'static
///
/// Each item has a unique identifier, display name, optional description,
/// and an enabled/disabled state that can be toggled by the user.
#[derive(Default)]
pub(crate) struct MultiSelectItem {
/// Unique identifier returned in the confirm callback when this item is enabled.
pub id: String,
@@ -102,6 +117,30 @@ pub(crate) struct MultiSelectItem {
/// Whether this item is currently selected/enabled.
pub enabled: bool,
/// Whether this item can be moved when ordering is enabled.
pub orderable: bool,
/// Whether to draw a divider after this item when another visible item follows.
pub section_break_after: bool,
}
impl Default for MultiSelectItem {
fn default() -> Self {
Self {
id: String::new(),
name: String::new(),
description: None,
enabled: false,
orderable: true,
section_break_after: false,
}
}
}
struct BuiltRows {
rows: Vec<GenericDisplayRow>,
state: ScrollState,
}
/// A multi-select picker widget with fuzzy search and optional reordering.
@@ -240,33 +279,61 @@ impl MultiSelectPicker {
}
/// Calculates the height needed for the row list area.
fn rows_height(&self, rows: &[GenericDisplayRow]) -> u16 {
rows.len().clamp(1, MAX_POPUP_ROWS).try_into().unwrap_or(1)
fn rows_height(&self, rows: &BuiltRows) -> u16 {
rows.rows
.len()
.clamp(1, MAX_POPUP_ROWS)
.try_into()
.unwrap_or(1)
}
/// Builds the display rows for all currently visible (filtered) items.
///
/// Each row shows: ` [x] Item Name` where `` indicates cursor position
/// and `[x]` or `[ ]` indicates enabled/disabled state.
fn build_rows(&self) -> Vec<GenericDisplayRow> {
self.filtered_indices
.iter()
.enumerate()
.filter_map(|(visible_idx, actual_idx)| {
self.items.get(*actual_idx).map(|item| {
let is_selected = self.state.selected_idx == Some(visible_idx);
let prefix = if is_selected { '' } else { ' ' };
let marker = if item.enabled { 'x' } else { ' ' };
let item_name = truncate_text(&item.name, ITEM_NAME_TRUNCATE_LEN);
let name = format!("{prefix} [{marker}] {item_name}");
GenericDisplayRow {
name,
description: item.description.clone(),
..Default::default()
}
})
})
.collect()
fn build_rows(&self) -> BuiltRows {
let mut rows = Vec::new();
let mut visible_to_row = Vec::with_capacity(self.filtered_indices.len());
for (visible_idx, actual_idx) in self.filtered_indices.iter().enumerate() {
let Some(item) = self.items.get(*actual_idx) else {
continue;
};
visible_to_row.push(rows.len());
let is_selected = self.state.selected_idx == Some(visible_idx);
let prefix = if is_selected { '' } else { ' ' };
let marker = if item.enabled { 'x' } else { ' ' };
let item_name = truncate_text(&item.name, ITEM_NAME_TRUNCATE_LEN);
let name = format!("{prefix} [{marker}] {item_name}");
rows.push(GenericDisplayRow {
name,
description: item.description.clone(),
..Default::default()
});
if item.section_break_after && visible_idx + 1 < self.filtered_indices.len() {
rows.push(GenericDisplayRow {
name: SECTION_BREAK_ROW.to_string(),
is_disabled: true,
..Default::default()
});
}
}
let selected_idx = self
.state
.selected_idx
.and_then(|visible_idx| visible_to_row.get(visible_idx).copied());
let scroll_top = visible_to_row
.get(self.state.scroll_top)
.copied()
.unwrap_or(0);
BuiltRows {
rows,
state: ScrollState {
selected_idx,
scroll_top,
},
}
}
/// Moves the selection cursor up, wrapping to the bottom if at the top.
@@ -351,12 +418,24 @@ impl MultiSelectPicker {
return;
}
if !self
.items
.get(actual_idx)
.is_some_and(|item| item.orderable)
{
return;
}
let new_idx = match direction {
Direction::Up if actual_idx > 0 => actual_idx - 1,
Direction::Down if actual_idx + 1 < len => actual_idx + 1,
_ => return,
};
if !self.items.get(new_idx).is_some_and(|item| item.orderable) {
return;
}
// move item in underlying list
self.items.swap(actual_idx, new_idx);
@@ -570,8 +649,8 @@ impl Renderable for MultiSelectPicker {
render_rows_single_line(
render_area,
buf,
&rows,
&self.state,
&rows.rows,
&rows.state,
render_area.height as usize,
"no matches",
);
@@ -793,3 +872,96 @@ pub(crate) fn match_item(
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
fn test_picker(items: Vec<MultiSelectItem>) -> MultiSelectPicker {
let (tx, _rx) = unbounded_channel::<AppEvent>();
MultiSelectPicker::builder(
"Test".to_string(),
/*subtitle*/ None,
AppEventSender::new(tx),
)
.items(items)
.enable_ordering()
.build()
}
fn item(id: &str, orderable: bool, section_break_after: bool) -> MultiSelectItem {
MultiSelectItem {
id: id.to_string(),
name: id.to_string(),
orderable,
section_break_after,
..Default::default()
}
}
#[test]
fn non_orderable_items_cannot_move_or_be_crossed() {
let mut picker = test_picker(vec![
item(
"theme-colors",
/*orderable*/ false,
/*section_break_after*/ true,
),
item(
"model", /*orderable*/ true, /*section_break_after*/ false,
),
item(
"branch", /*orderable*/ true, /*section_break_after*/ false,
),
]);
picker.move_selected_item(Direction::Down);
assert_eq!(
picker
.items
.iter()
.map(|item| item.id.as_str())
.collect::<Vec<_>>(),
vec!["theme-colors", "model", "branch"]
);
picker.move_down();
picker.move_selected_item(Direction::Up);
assert_eq!(
picker
.items
.iter()
.map(|item| item.id.as_str())
.collect::<Vec<_>>(),
vec!["theme-colors", "model", "branch"]
);
}
#[test]
fn section_break_after_item_renders_separator_row() {
let picker = test_picker(vec![
item(
"theme-colors",
/*orderable*/ false,
/*section_break_after*/ true,
),
item(
"model", /*orderable*/ true, /*section_break_after*/ false,
),
]);
let rows = picker.build_rows();
assert_eq!(
rows.rows
.iter()
.map(|row| row.name.as_str())
.collect::<Vec<_>>(),
vec![" [ ] theme-colors", SECTION_BREAK_ROW, " [ ] model"]
);
assert_eq!(rows.state.selected_idx, Some(0));
}
}
@@ -8,14 +8,14 @@ expression: "render_lines(&view, 72)"
Type to search
>
[x] model Current model name
[x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] model Current model name
[x] current-dir Current working directory
[x] git-branch Current Git branch (omitted when unavaila…
[ ] model-with-reasoning Current model name with reasoning level
[ ] project-name Project name (omitted when unavailable)
[ ] run-state Compact session run-state text (Ready, Wo…
[ ] context-remaining Percentage of context window remaining (o…
[ ] context-used Percentage of context window used (omitte…
gpt-5-codex · ~/codex-rs · jif/statusline-preview
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc
@@ -35,6 +35,8 @@ use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewData;
use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewItem;
use crate::render::renderable::Renderable;
const STATUS_LINE_USE_THEME_COLORS_ITEM_ID: &str = "status-line-use-theme-colors";
/// Available items that can be displayed in the status line.
///
/// Each variant represents a piece of information that can be shown at the
@@ -45,7 +47,7 @@ use crate::render::renderable::Renderable;
/// - Git-related items only show when in a git repository
/// - Context/limit items only show when data is available from the API
/// - Session ID only shows after a session has started
#[derive(EnumIter, EnumString, Display, Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
#[derive(EnumIter, EnumString, Display, Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
#[strum(serialize_all = "kebab_case")]
pub(crate) enum StatusLineItem {
/// The current model name.
@@ -118,7 +120,7 @@ pub(crate) enum StatusLineItem {
impl StatusLineItem {
/// User-visible description shown in the popup.
pub(crate) fn description(&self) -> &'static str {
pub(crate) fn description(self) -> &'static str {
match self {
StatusLineItem::ModelName => "Current model name",
StatusLineItem::ModelWithReasoning => "Current model name with reasoning level",
@@ -200,17 +202,27 @@ impl StatusLineSetupView {
///
/// * `status_line_items` - Currently configured item IDs (in display order),
/// or `None` to start with all items disabled
/// * `use_theme_colors` - Whether the preview and saved status line use colors from
/// the active theme
/// * `app_event_tx` - Event sender for dispatching configuration changes
///
/// Items from `status_line_items` are shown first (in order) and marked as
/// enabled. Remaining items are appended and marked as disabled.
pub(crate) fn new(
status_line_items: Option<&[String]>,
use_theme_colors: bool,
preview_data: StatusSurfacePreviewData,
app_event_tx: AppEventSender,
) -> Self {
let mut used_ids = HashSet::new();
let mut items = Vec::new();
let mut items = vec![MultiSelectItem {
id: STATUS_LINE_USE_THEME_COLORS_ITEM_ID.to_string(),
name: "Use theme colors".to_string(),
description: Some("Apply colors from the active /theme".to_string()),
enabled: use_theme_colors,
orderable: false,
section_break_after: true,
}];
if let Some(selected_items) = status_line_items.as_ref() {
for id in *selected_items {
@@ -246,21 +258,31 @@ impl StatusLineSetupView {
.items(items)
.enable_ordering()
.on_preview(move |items| {
preview_data.line_for_items(
let use_theme_colors = items
.iter()
.find(|item| item.id == STATUS_LINE_USE_THEME_COLORS_ITEM_ID)
.map(|item| item.enabled)
.unwrap_or(true);
preview_data.status_line_for_items(
items
.iter()
.filter(|item| item.enabled)
.filter_map(|item| item.id.parse::<StatusLineItem>().ok())
.map(StatusLineItem::preview_item),
.filter_map(|item| item.id.parse::<StatusLineItem>().ok()),
use_theme_colors,
)
})
.on_confirm(|ids, app_event| {
let use_theme_colors = ids
.iter()
.any(|id| id == STATUS_LINE_USE_THEME_COLORS_ITEM_ID);
let items = ids
.iter()
.map(|id| id.parse::<StatusLineItem>())
.collect::<Result<Vec<_>, _>>()
.unwrap_or_default();
app_event.send(AppEvent::StatusLineSetup { items });
.filter_map(|id| id.parse::<StatusLineItem>().ok())
.collect::<Vec<_>>();
app_event.send(AppEvent::StatusLineSetup {
items,
use_theme_colors,
});
})
.on_cancel(|app_event| {
app_event.send(AppEvent::StatusLineSetupCancelled);
@@ -276,6 +298,8 @@ impl StatusLineSetupView {
name: item.to_string(),
description: Some(item.description().to_string()),
enabled,
orderable: true,
section_break_after: false,
}
}
}
@@ -415,23 +439,29 @@ mod tests {
name: String::new(),
description: None,
enabled: true,
orderable: true,
section_break_after: false,
},
MultiSelectItem {
id: StatusLineItem::CurrentDir.to_string(),
name: String::new(),
description: None,
enabled: true,
orderable: true,
section_break_after: false,
},
];
assert_eq!(
preview_data.line_for_items(
items
.iter()
.filter_map(|item| item.id.parse::<StatusLineItem>().ok())
.map(StatusLineItem::preview_item),
line_text(
preview_data.status_line_for_items(
items
.iter()
.filter_map(|item| item.id.parse::<StatusLineItem>().ok()),
/*use_theme_colors*/ true,
)
),
Some(Line::from("gpt-5 · /repo"))
Some("gpt-5 · /repo".to_string())
);
}
@@ -447,23 +477,29 @@ mod tests {
name: String::new(),
description: None,
enabled: true,
orderable: true,
section_break_after: false,
},
MultiSelectItem {
id: StatusLineItem::GitBranch.to_string(),
name: String::new(),
description: None,
enabled: true,
orderable: true,
section_break_after: false,
},
];
assert_eq!(
preview_data.line_for_items(
items
.iter()
.filter_map(|item| item.id.parse::<StatusLineItem>().ok())
.map(StatusLineItem::preview_item),
line_text(
preview_data.status_line_for_items(
items
.iter()
.filter_map(|item| item.id.parse::<StatusLineItem>().ok()),
/*use_theme_colors*/ true,
)
),
Some(Line::from("gpt-5 · feat/awesome-feature"))
Some("gpt-5 · feat/awesome-feature".to_string())
);
}
@@ -485,23 +521,29 @@ mod tests {
name: String::new(),
description: None,
enabled: true,
orderable: true,
section_break_after: false,
},
MultiSelectItem {
id: StatusLineItem::ThreadTitle.to_string(),
name: String::new(),
description: None,
enabled: true,
orderable: true,
section_break_after: false,
},
];
assert_eq!(
preview_data.line_for_items(
items
.iter()
.filter_map(|item| item.id.parse::<StatusLineItem>().ok())
.map(StatusLineItem::preview_item),
line_text(
preview_data.status_line_for_items(
items
.iter()
.filter_map(|item| item.id.parse::<StatusLineItem>().ok()),
/*use_theme_colors*/ true,
)
),
Some(Line::from("gpt-5 · Roadmap cleanup"))
Some("gpt-5 · Roadmap cleanup".to_string())
);
}
@@ -514,6 +556,7 @@ mod tests {
StatusLineItem::CurrentDir.to_string(),
StatusLineItem::GitBranch.to_string(),
]),
/*use_theme_colors*/ true,
StatusSurfacePreviewData::from_iter([
(
StatusLineItem::ModelName.preview_item(),
@@ -560,4 +603,13 @@ mod tests {
.collect::<Vec<_>>()
.join("\n")
}
fn line_text(line: Option<Line<'static>>) -> Option<String> {
line.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
}
}
@@ -0,0 +1,270 @@
//! Theme-derived styling for the configurable footer statusline.
use ratatui::prelude::Stylize;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use super::status_line_setup::StatusLineItem;
use crate::render::highlight::foreground_style_for_scopes;
const STATUS_LINE_SEPARATOR: &str = " · ";
const STATUS_LINE_COLOR_SATURATION_PERCENT: u16 = 85;
const STATUS_LINE_COLOR_BRIGHTNESS_PERCENT: u16 = 100;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum StatusLineAccent {
Model,
Path,
Branch,
State,
Usage,
Limit,
Metadata,
Mode,
Thread,
Progress,
}
impl StatusLineAccent {
fn for_item(item: StatusLineItem) -> Self {
match item {
StatusLineItem::ModelName | StatusLineItem::ModelWithReasoning => Self::Model,
StatusLineItem::CurrentDir | StatusLineItem::ProjectRoot => Self::Path,
StatusLineItem::GitBranch => Self::Branch,
StatusLineItem::Status => Self::State,
StatusLineItem::ContextRemaining
| StatusLineItem::ContextUsed
| StatusLineItem::ContextWindowSize
| StatusLineItem::UsedTokens
| StatusLineItem::TotalInputTokens
| StatusLineItem::TotalOutputTokens => Self::Usage,
StatusLineItem::FiveHourLimit | StatusLineItem::WeeklyLimit => Self::Limit,
StatusLineItem::CodexVersion | StatusLineItem::SessionId => Self::Metadata,
StatusLineItem::FastMode => Self::Mode,
StatusLineItem::ThreadTitle => Self::Thread,
StatusLineItem::TaskProgress => Self::Progress,
}
}
fn scopes(self) -> &'static [&'static str] {
match self {
Self::Model => &["entity.name.type", "support.type", "variable"],
Self::Path => &["string", "markup.underline.link"],
Self::Branch => &["entity.name.function", "entity.name.tag"],
Self::State => &["keyword.control", "keyword"],
Self::Usage => &["constant.numeric", "constant"],
Self::Limit => &["constant.language", "storage.type"],
Self::Metadata => &["comment", "constant.other"],
Self::Mode => &["storage.modifier", "keyword.operator"],
Self::Thread => &["markup.heading", "entity.name.section"],
Self::Progress => &["markup.inserted", "constant.numeric"],
}
}
fn fallback_style(self) -> Style {
match self {
Self::Model | Self::State | Self::Metadata | Self::Mode => Style::default().cyan(),
Self::Path | Self::Usage | Self::Progress => Style::default().green(),
Self::Branch | Self::Limit | Self::Thread => Style::default().magenta(),
}
}
}
pub(crate) fn status_line_from_segments<I>(
segments: I,
use_theme_colors: bool,
) -> Option<Line<'static>>
where
I: IntoIterator<Item = (StatusLineItem, String)>,
{
status_line_from_segments_with_resolver(segments, use_theme_colors, |accent| {
foreground_style_for_scopes(accent.scopes())
})
}
fn status_line_from_segments_with_resolver<I, F>(
segments: I,
use_theme_colors: bool,
theme_style_for_accent: F,
) -> Option<Line<'static>>
where
I: IntoIterator<Item = (StatusLineItem, String)>,
F: Fn(StatusLineAccent) -> Option<Style>,
{
let mut spans = Vec::new();
for (item, text) in segments {
if !spans.is_empty() {
spans.push(STATUS_LINE_SEPARATOR.dim());
}
let style = 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()),
)
} else {
Style::default().dim()
};
spans.push(Span::styled(text, style));
}
(!spans.is_empty()).then(|| Line::from(spans))
}
fn soften_status_line_style(mut style: Style) -> Style {
if let Some(fg) = style.fg {
style.fg = Some(soften_status_line_color(fg));
}
style
}
#[allow(clippy::disallowed_methods)]
fn soften_status_line_color(color: Color) -> Color {
match color {
Color::Rgb(r, g, b) => {
let luma = weighted_luma(r, g, b);
Color::Rgb(
soften_rgb_channel(r, luma),
soften_rgb_channel(g, luma),
soften_rgb_channel(b, luma),
)
}
Color::LightRed => Color::Red,
Color::LightGreen => Color::Green,
Color::LightYellow => Color::Yellow,
Color::LightBlue => Color::Blue,
Color::LightMagenta => Color::Magenta,
Color::LightCyan => Color::Cyan,
Color::White => Color::Gray,
Color::Reset
| Color::Black
| Color::Red
| Color::Green
| Color::Yellow
| Color::Blue
| Color::Magenta
| Color::Cyan
| Color::Gray
| Color::DarkGray
| Color::Indexed(_) => color,
}
}
fn weighted_luma(r: u8, g: u8, b: u8) -> u16 {
(77 * u16::from(r) + 150 * u16::from(g) + 29 * u16::from(b)) / 256
}
fn soften_rgb_channel(channel: u8, luma: u16) -> u8 {
let channel = u16::from(channel);
let softened = (channel * STATUS_LINE_COLOR_SATURATION_PERCENT
+ luma * (100 - STATUS_LINE_COLOR_SATURATION_PERCENT)
+ 50)
/ 100;
((softened * STATUS_LINE_COLOR_BRIGHTNESS_PERCENT + 50) / 100) as u8
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::style::Modifier;
fn line_text(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
#[test]
fn status_line_segments_preserve_order_and_plain_text() {
let line = status_line_from_segments_with_resolver(
[
(StatusLineItem::ModelName, "gpt-5".to_string()),
(StatusLineItem::CurrentDir, "/repo".to_string()),
(StatusLineItem::GitBranch, "main".to_string()),
],
/*use_theme_colors*/ true,
|_| None,
)
.expect("status line");
assert_eq!(line_text(&line), "gpt-5 · /repo · main");
assert_eq!(line.spans[0].style.fg, Some(Color::Cyan));
assert!(!line.spans[0].style.add_modifier.contains(Modifier::DIM));
assert_eq!(line.spans[2].style.fg, Some(Color::Green));
assert!(!line.spans[2].style.add_modifier.contains(Modifier::DIM));
assert_eq!(line.spans[4].style.fg, Some(Color::Magenta));
assert!(!line.spans[4].style.add_modifier.contains(Modifier::DIM));
}
#[test]
fn status_line_segments_dim_separators_and_use_theme_styles_first() {
let line = status_line_from_segments_with_resolver(
[
(StatusLineItem::ModelName, "gpt-5".to_string()),
(StatusLineItem::ContextUsed, "Context 12% used".to_string()),
],
/*use_theme_colors*/ true,
|accent| match accent {
StatusLineAccent::Model => Some(Style::default().red()),
_ => None,
},
)
.expect("status line");
assert_eq!(line.spans[0].style.fg, Some(Color::Red));
assert!(!line.spans[0].style.add_modifier.contains(Modifier::DIM));
assert!(line.spans[1].style.add_modifier.contains(Modifier::DIM));
assert_eq!(line.spans[2].style.fg, Some(Color::Green));
assert!(!line.spans[2].style.add_modifier.contains(Modifier::DIM));
}
#[test]
#[allow(clippy::disallowed_methods)]
fn status_line_segments_soften_rgb_theme_styles_without_dimming_text() {
let line = status_line_from_segments_with_resolver(
[(StatusLineItem::ModelName, "gpt-5".to_string())],
/*use_theme_colors*/ true,
|_| Some(Style::default().fg(Color::Rgb(255, 0, 0))),
)
.expect("status line");
assert_eq!(line.spans[0].style.fg, Some(Color::Rgb(228, 11, 11)));
assert!(!line.spans[0].style.add_modifier.contains(Modifier::DIM));
}
#[test]
fn status_line_segments_can_disable_theme_colors() {
let line = status_line_from_segments_with_resolver(
[
(StatusLineItem::ModelName, "gpt-5".to_string()),
(StatusLineItem::ContextUsed, "Context 12% used".to_string()),
],
/*use_theme_colors*/ false,
|_| Some(Style::default().red()),
)
.expect("status line");
assert_eq!(line_text(&line), "gpt-5 · Context 12% used");
assert_eq!(line.spans[0].style.fg, None);
assert!(line.spans[0].style.add_modifier.contains(Modifier::DIM));
assert!(line.spans[1].style.add_modifier.contains(Modifier::DIM));
assert_eq!(line.spans[2].style.fg, None);
assert!(line.spans[2].style.add_modifier.contains(Modifier::DIM));
}
#[test]
fn status_line_segments_return_none_when_empty() {
assert_eq!(
status_line_from_segments_with_resolver(
Vec::<(StatusLineItem, String)>::new(),
/*use_theme_colors*/ true,
|_| None,
),
None
);
}
}
@@ -2,6 +2,9 @@ use std::collections::BTreeMap;
use ratatui::text::Line;
use super::status_line_from_segments;
use super::status_line_setup::StatusLineItem;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
pub(crate) enum StatusSurfacePreviewItem {
AppName,
@@ -155,19 +158,18 @@ impl StatusSurfacePreviewData {
self.values.get(&item).map(|value| value.text.as_str())
}
pub(crate) fn line_for_items<I>(&self, items: I) -> Option<Line<'static>>
pub(crate) fn status_line_for_items<I>(
&self,
items: I,
use_theme_colors: bool,
) -> Option<Line<'static>>
where
I: IntoIterator<Item = StatusSurfacePreviewItem>,
I: IntoIterator<Item = StatusLineItem>,
{
let preview = items
.into_iter()
.filter_map(|item| self.value_for(item))
.collect::<Vec<_>>()
.join(" · ");
if preview.is_empty() {
None
} else {
Some(Line::from(preview))
}
let segments = items.into_iter().filter_map(|item| {
self.value_for(item.preview_item())
.map(|value| (item, value.to_string()))
});
status_line_from_segments(segments, use_theme_colors)
}
}
@@ -315,6 +315,8 @@ impl TerminalTitleSetupView {
name: item.to_string(),
description: Some(item.description().to_string()),
enabled,
orderable: true,
section_break_after: false,
}
}
}
+6 -2
View File
@@ -1860,10 +1860,13 @@ impl ChatWidget {
/// Applies status-line item selection from the setup view to in-memory config.
///
/// An empty selection persists as an explicit empty list.
pub(crate) fn setup_status_line(&mut self, items: Vec<StatusLineItem>) {
tracing::info!("status line setup confirmed with items: {items:#?}");
pub(crate) fn setup_status_line(&mut self, items: Vec<StatusLineItem>, use_theme_colors: bool) {
tracing::info!(
"status line setup confirmed with items: {items:#?}, use_theme_colors: {use_theme_colors}"
);
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
self.config.tui_status_line = Some(ids);
self.config.tui_status_line_use_colors = use_theme_colors;
self.refresh_status_line();
}
@@ -6903,6 +6906,7 @@ impl ChatWidget {
let configured_status_line_items = self.configured_status_line_items();
let view = StatusLineSetupView::new(
Some(configured_status_line_items.as_slice()),
self.config.tui_status_line_use_colors,
self.status_surface_preview_data(),
self.app_event_tx.clone(),
);
@@ -7,14 +7,14 @@ expression: status_line_popup_snapshot(&mut chat)
Type to search
>
[x] project-name Project name (omitted when unavailable)
[x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] project-name Project name (omitted when unavailable)
[x] git-branch Current Git branch (omitted when unavailable)
[x] thread-title Current thread title (omitted when unavailable)
[ ] model Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] current-dir Current working directory
[ ] run-state Compact session run-state text (Ready, Working, Thinking)
[ ] context-remaining Percentage of context window remaining (omitted when unknown)
my-project · feat/awesome-feature · thread title
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
@@ -7,14 +7,14 @@ expression: status_line_popup_snapshot(&mut chat)
Type to search
>
[x] project-name Project name (omitted when unavailable)
[x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] project-name Project name (omitted when unavailable)
[x] git-branch Current Git branch (omitted when unavailable)
[x] thread-title Current thread title (omitted when unavailable)
[ ] model Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] current-dir Current working directory
[ ] run-state Compact session run-state text (Ready, Working, Thinking)
[ ] context-remaining Percentage of context window remaining (omitted when unknown)
preview-live-root · feature/live-preview-branch · Live preview thread
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
@@ -7,14 +7,14 @@ expression: status_line_popup_snapshot(&mut chat)
Type to search
>
[x] project-name Project name (omitted when unavailable)
[x] Use theme colors Apply colors from the active /theme
───────────────────────
[x] project-name Project name (omitted when unavailable)
[x] git-branch Current Git branch (omitted when unavailable)
[x] thread-title Current thread title (omitted when unavailable)
[ ] model Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] current-dir Current working directory
[ ] run-state Compact session run-state text (Ready, Working, Thinking)
[ ] context-remaining Percentage of context window remaining (omitted when unknown)
my-project · feature/mixed-preview · Mixed preview thread
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
+20 -21
View File
@@ -4,6 +4,7 @@
//! behavior easier to review without paging through the rest of `chatwidget.rs`.
use super::*;
use crate::bottom_pane::status_line_from_segments;
use crate::status::format_tokens_compact;
/// Items shown in the terminal title when the user has not configured a
@@ -149,19 +150,17 @@ impl ChatWidget {
return;
}
let mut parts = Vec::new();
let mut segments = Vec::new();
for item in &selections.status_line_items {
if let Some(value) = self.status_line_value_for_item(item) {
parts.push(value);
if let Some(value) = self.status_line_value_for_item(*item) {
segments.push((*item, value));
}
}
let line = if parts.is_empty() {
None
} else {
Some(Line::from(parts.join(" · ")))
};
self.set_status_line(line);
self.set_status_line(status_line_from_segments(
segments,
self.config.tui_status_line_use_colors,
));
}
/// Clears the terminal title Codex most recently wrote, if any.
@@ -495,7 +494,7 @@ impl ChatWidget {
/// Returning `None` means "omit this item for now", not "configuration error". Callers rely on
/// this to keep partially available status lines readable while waiting for session, token, or
/// git metadata.
pub(super) fn status_line_value_for_item(&mut self, item: &StatusLineItem) -> Option<String> {
pub(super) fn status_line_value_for_item(&mut self, item: StatusLineItem) -> Option<String> {
match item {
StatusLineItem::ModelName => Some(self.model_display_name().to_string()),
StatusLineItem::ModelWithReasoning => Some(self.model_with_reasoning_display_name()),
@@ -600,7 +599,7 @@ impl ChatWidget {
StatusSurfacePreviewItem::Model => StatusLineItem::ModelName,
StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning,
};
self.status_line_value_for_item(&status_line_item)
self.status_line_value_for_item(status_line_item)
}
/// Resolves one configured terminal-title item into a displayable segment.
///
@@ -635,34 +634,34 @@ impl ChatWidget {
Self::truncate_terminal_title_part(branch.clone(), /*max_chars*/ 32)
}),
TerminalTitleItem::ContextRemaining => self
.status_line_value_for_item(&StatusLineItem::ContextRemaining)
.status_line_value_for_item(StatusLineItem::ContextRemaining)
.map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)),
TerminalTitleItem::ContextUsed => self
.status_line_value_for_item(&StatusLineItem::ContextUsed)
.status_line_value_for_item(StatusLineItem::ContextUsed)
.map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)),
TerminalTitleItem::FiveHourLimit => self
.status_line_value_for_item(&StatusLineItem::FiveHourLimit)
.status_line_value_for_item(StatusLineItem::FiveHourLimit)
.map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)),
TerminalTitleItem::WeeklyLimit => self
.status_line_value_for_item(&StatusLineItem::WeeklyLimit)
.status_line_value_for_item(StatusLineItem::WeeklyLimit)
.map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)),
TerminalTitleItem::CodexVersion => self
.status_line_value_for_item(&StatusLineItem::CodexVersion)
.status_line_value_for_item(StatusLineItem::CodexVersion)
.map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)),
TerminalTitleItem::UsedTokens => self
.status_line_value_for_item(&StatusLineItem::UsedTokens)
.status_line_value_for_item(StatusLineItem::UsedTokens)
.map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)),
TerminalTitleItem::TotalInputTokens => self
.status_line_value_for_item(&StatusLineItem::TotalInputTokens)
.status_line_value_for_item(StatusLineItem::TotalInputTokens)
.map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)),
TerminalTitleItem::TotalOutputTokens => self
.status_line_value_for_item(&StatusLineItem::TotalOutputTokens)
.status_line_value_for_item(StatusLineItem::TotalOutputTokens)
.map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)),
TerminalTitleItem::SessionId => self
.status_line_value_for_item(&StatusLineItem::SessionId)
.status_line_value_for_item(StatusLineItem::SessionId)
.map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)),
TerminalTitleItem::FastMode => self
.status_line_value_for_item(&StatusLineItem::FastMode)
.status_line_value_for_item(StatusLineItem::FastMode)
.map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)),
TerminalTitleItem::Model => Some(Self::truncate_terminal_title_part(
self.model_display_name().to_string(),
@@ -99,7 +99,7 @@ async fn token_usage_update_uses_runtime_context_window() {
);
assert_eq!(
chat.status_line_value_for_item(&crate::bottom_pane::StatusLineItem::ContextWindowSize),
chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::ContextWindowSize),
Some("950K window".to_string())
);
assert_eq!(chat.bottom_pane.context_window_percent(), Some(100));
@@ -13,7 +13,7 @@ fn line_text(line: Line<'static>) -> String {
fn status_preview_line(chat: &mut ChatWidget, items: &[StatusLineItem]) -> String {
let preview_data = chat.status_surface_preview_data();
let preview = preview_data
.line_for_items(items.iter().cloned().map(StatusLineItem::preview_item))
.status_line_for_items(items.iter().copied(), /*use_theme_colors*/ true)
.expect("status preview line");
line_text(preview)
}
+66
View File
@@ -298,6 +298,22 @@ fn scope_background_rgb(highlighter: &Highlighter<'_>, scope_name: &str) -> Opti
Some((bg.r, bg.g, bg.b))
}
/// Query the active syntax theme for the first foreground style provided by the
/// supplied TextMate scopes.
pub(crate) fn foreground_style_for_scopes(scope_names: &[&str]) -> Option<Style> {
let theme = current_syntax_theme();
foreground_style_for_scopes_with_theme(&theme, scope_names)
}
fn foreground_style_for_scopes_with_theme(theme: &Theme, scope_names: &[&str]) -> Option<Style> {
let highlighter = Highlighter::new(theme);
scope_names.iter().find_map(|scope_name| {
let scope = Scope::new(scope_name).ok()?;
let fg = highlighter.style_mod_for_stack(&[scope]).foreground?;
convert_syntect_color(fg).map(|fg| Style::default().fg(fg))
})
}
/// Return the configured kebab-case theme name when it resolves; otherwise
/// return the adaptive auto-detected default theme name.
///
@@ -778,6 +794,28 @@ mod tests {
}
}
fn theme_item_with_foreground(scope: &str, foreground: (u8, u8, u8)) -> ThemeItem {
ThemeItem {
scope: ScopeSelectors::from_str(scope).expect("scope selector should parse"),
style: StyleModifier {
foreground: Some(SyntectColor {
r: foreground.0,
g: foreground.1,
b: foreground.2,
a: 255,
}),
..StyleModifier::default()
},
}
}
fn assert_rgb(color: Option<RtColor>, expected: (u8, u8, u8)) {
let Some(RtColor::Rgb(r, g, b)) = color else {
panic!("expected RGB color {expected:?}, got {color:?}");
};
assert_eq!((r, g, b), expected);
}
#[test]
fn highlight_rust_has_keyword_style() {
let code = "fn main() {}";
@@ -1210,6 +1248,34 @@ mod tests {
);
}
#[test]
fn foreground_style_for_scopes_reads_matching_theme_scope() {
let theme = Theme {
settings: ThemeSettings::default(),
scopes: vec![theme_item_with_foreground("keyword", (10, 20, 30))],
..Theme::default()
};
let style = foreground_style_for_scopes_with_theme(&theme, &["keyword"])
.expect("expected keyword foreground style");
assert_rgb(style.fg, (10, 20, 30));
}
#[test]
fn foreground_style_for_scopes_uses_first_scope_with_foreground() {
let theme = Theme {
settings: ThemeSettings::default(),
scopes: vec![theme_item_with_foreground("string", (40, 50, 60))],
..Theme::default()
};
let style = foreground_style_for_scopes_with_theme(&theme, &["keyword", "string"])
.expect("expected string foreground style");
assert_rgb(style.fg, (40, 50, 60));
}
#[test]
fn bundled_theme_can_provide_diff_scope_backgrounds() {
let theme = resolve_theme_by_name("github", /*codex_home*/ None)
+16 -11
View File
@@ -370,20 +370,25 @@ pub(crate) fn build_theme_picker_params(
let preview_theme_names: Vec<Option<String>> =
items.iter().map(|item| item.search_value.clone()).collect();
let preview_home = codex_home_owned.clone();
let on_selection_changed = Some(Box::new(move |idx: usize, _tx: &_| {
if let Some(Some(name)) = preview_theme_names.get(idx)
&& let Some(theme) = highlight::resolve_theme_by_name(name, preview_home.as_deref())
{
highlight::set_syntax_theme(theme);
}
})
let on_selection_changed = Some(Box::new(
move |idx: usize, tx: &crate::app_event_sender::AppEventSender| {
if let Some(Some(name)) = preview_theme_names.get(idx)
&& let Some(theme) = highlight::resolve_theme_by_name(name, preview_home.as_deref())
{
highlight::set_syntax_theme(theme);
tx.send(AppEvent::SyntaxThemePreviewed);
}
},
)
as Box<dyn Fn(usize, &crate::app_event_sender::AppEventSender) + Send + Sync>);
// Restore original theme on cancel.
let on_cancel = Some(Box::new(move |_tx: &_| {
highlight::set_syntax_theme(original_theme.clone());
})
as Box<dyn Fn(&crate::app_event_sender::AppEventSender) + Send + Sync>);
let on_cancel = Some(
Box::new(move |tx: &crate::app_event_sender::AppEventSender| {
highlight::set_syntax_theme(original_theme.clone());
tx.send(AppEvent::SyntaxThemePreviewed);
}) as Box<dyn Fn(&crate::app_event_sender::AppEventSender) + Send + Sync>,
);
SelectionViewParams {
title: Some("Select Syntax Theme".to_string()),
subtitle: Some(theme_picker_subtitle(