mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user