diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b62ce7cc1..c9dc4db26 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -49,6 +49,7 @@ mod mcp_server_elicitation; mod multi_select_picker; mod request_user_input; mod status_line_setup; +mod status_surface_preview; mod title_setup; pub(crate) use app_link_view::AppLinkElicitationTarget; pub(crate) use app_link_view::AppLinkSuggestionType; @@ -107,10 +108,13 @@ pub(crate) use feedback_view::feedback_upload_consent_params; pub(crate) use skills_toggle_view::SkillsToggleItem; pub(crate) use skills_toggle_view::SkillsToggleView; pub(crate) use status_line_setup::StatusLineItem; -pub(crate) use status_line_setup::StatusLinePreviewData; pub(crate) use status_line_setup::StatusLineSetupView; +pub(crate) use status_surface_preview::StatusSurfacePreviewData; +pub(crate) use status_surface_preview::StatusSurfacePreviewItem; pub(crate) use title_setup::TerminalTitleItem; pub(crate) use title_setup::TerminalTitleSetupView; +#[cfg(test)] +pub(crate) use title_setup::preview_line_for_title_items; mod paste_burst; mod pending_input_preview; mod pending_thread_approvals; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap deleted file mode 100644 index 20aca1e33..000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: tui/src/bottom_pane/status_line_setup.rs -expression: "render_lines(&view, 72)" ---- - - Configure Status Line - Select which items to display in the status line. - - Type to search - > -› [x] model-name 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-root Project root directory (omitted when unav… - [ ] context-remaining Percentage of context window remaining (o… - [ ] context-used Percentage of context window used (omitte… - [ ] five-hour-limit Remaining usage on 5-hour usage limit (om… - - gpt-5-codex · ~/codex-rs · jif/statusline-preview - Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap deleted file mode 100644 index 9a6d41287..000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: tui/src/bottom_pane/title_setup.rs -expression: "render_lines(&view, 84)" ---- - - Configure Terminal Title - Select which items to display in the terminal title. - - Type to search - > -› [x] project Project name (falls back to current directory name) - [x] spinner Animated task spinner (omitted while idle or when animations… - [x] status Compact session status text (Ready, Working, Thinking) - [x] thread Current thread title (omitted until available) - [ ] app-name Codex app name - [ ] git-branch Current Git branch (omitted when unavailable) - [ ] model Current model name - [ ] task-progress Latest task progress from update_plan (omitted until availab… - - my-project ⠋ Working | Investigate flaky test - Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 4c7e29c05..0ae5ddd9c 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -19,8 +19,6 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::text::Line; -use std::collections::BTreeMap; use std::collections::HashSet; use strum::IntoEnumIterator; use strum_macros::Display; @@ -33,6 +31,8 @@ use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::bottom_pane_view::BottomPaneView; use crate::bottom_pane::multi_select_picker::MultiSelectItem; use crate::bottom_pane::multi_select_picker::MultiSelectPicker; +use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewData; +use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewItem; use crate::render::renderable::Renderable; /// Available items that can be displayed in the status line. @@ -138,36 +138,26 @@ impl StatusLineItem { StatusLineItem::ThreadTitle => "Current thread title (omitted unless changed by user)", } } -} -/// Runtime values used to preview the current status-line selection. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub(crate) struct StatusLinePreviewData { - values: BTreeMap, -} - -impl StatusLinePreviewData { - pub(crate) fn from_iter(values: I) -> Self - where - I: IntoIterator, - { - Self { - values: values.into_iter().collect(), - } - } - - fn line_for_items(&self, items: &[MultiSelectItem]) -> Option> { - let preview = items - .iter() - .filter(|item| item.enabled) - .filter_map(|item| item.id.parse::().ok()) - .filter_map(|item| self.values.get(&item).cloned()) - .collect::>() - .join(" · "); - if preview.is_empty() { - None - } else { - Some(Line::from(preview)) + pub(crate) fn preview_item(self) -> StatusSurfacePreviewItem { + match self { + StatusLineItem::ModelName => StatusSurfacePreviewItem::Model, + StatusLineItem::ModelWithReasoning => StatusSurfacePreviewItem::ModelWithReasoning, + StatusLineItem::CurrentDir => StatusSurfacePreviewItem::CurrentDir, + StatusLineItem::ProjectRoot => StatusSurfacePreviewItem::ProjectRoot, + StatusLineItem::GitBranch => StatusSurfacePreviewItem::GitBranch, + StatusLineItem::ContextRemaining => StatusSurfacePreviewItem::ContextRemaining, + StatusLineItem::ContextUsed => StatusSurfacePreviewItem::ContextUsed, + StatusLineItem::FiveHourLimit => StatusSurfacePreviewItem::FiveHourLimit, + StatusLineItem::WeeklyLimit => StatusSurfacePreviewItem::WeeklyLimit, + StatusLineItem::CodexVersion => StatusSurfacePreviewItem::CodexVersion, + StatusLineItem::ContextWindowSize => StatusSurfacePreviewItem::ContextWindowSize, + StatusLineItem::UsedTokens => StatusSurfacePreviewItem::UsedTokens, + StatusLineItem::TotalInputTokens => StatusSurfacePreviewItem::TotalInputTokens, + StatusLineItem::TotalOutputTokens => StatusSurfacePreviewItem::TotalOutputTokens, + StatusLineItem::SessionId => StatusSurfacePreviewItem::SessionId, + StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode, + StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle, } } } @@ -197,7 +187,7 @@ impl StatusLineSetupView { /// enabled. Remaining items are appended and marked as disabled. pub(crate) fn new( status_line_items: Option<&[String]>, - preview_data: StatusLinePreviewData, + preview_data: StatusSurfacePreviewData, app_event_tx: AppEventSender, ) -> Self { let mut used_ids = HashSet::new(); @@ -236,7 +226,15 @@ impl StatusLineSetupView { ]) .items(items) .enable_ordering() - .on_preview(move |items| preview_data.line_for_items(items)) + .on_preview(move |items| { + preview_data.line_for_items( + items + .iter() + .filter(|item| item.enabled) + .filter_map(|item| item.id.parse::().ok()) + .map(StatusLineItem::preview_item), + ) + }) .on_confirm(|ids, app_event| { let items = ids .iter() @@ -291,14 +289,7 @@ impl Renderable for StatusLineSetupView { #[cfg(test)] mod tests { use super::*; - use crate::app_event_sender::AppEventSender; - use insta::assert_snapshot; use pretty_assertions::assert_eq; - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - use tokio::sync::mpsc::unbounded_channel; - - use crate::app_event::AppEvent; #[test] fn context_used_accepts_context_usage_legacy_id() { @@ -324,130 +315,4 @@ mod tests { "context-remaining" ); } - - #[test] - fn preview_uses_runtime_values() { - let preview_data = StatusLinePreviewData::from_iter([ - (StatusLineItem::ModelName, "gpt-5".to_string()), - (StatusLineItem::CurrentDir, "/repo".to_string()), - ]); - let items = vec![ - MultiSelectItem { - id: StatusLineItem::ModelName.to_string(), - name: String::new(), - description: None, - enabled: true, - }, - MultiSelectItem { - id: StatusLineItem::CurrentDir.to_string(), - name: String::new(), - description: None, - enabled: true, - }, - ]; - - assert_eq!( - preview_data.line_for_items(&items), - Some(Line::from("gpt-5 · /repo")) - ); - } - - #[test] - fn preview_omits_items_without_runtime_values() { - let preview_data = - StatusLinePreviewData::from_iter([(StatusLineItem::ModelName, "gpt-5".to_string())]); - let items = vec![ - MultiSelectItem { - id: StatusLineItem::ModelName.to_string(), - name: String::new(), - description: None, - enabled: true, - }, - MultiSelectItem { - id: StatusLineItem::GitBranch.to_string(), - name: String::new(), - description: None, - enabled: true, - }, - ]; - - assert_eq!( - preview_data.line_for_items(&items), - Some(Line::from("gpt-5")) - ); - } - - #[test] - fn preview_includes_thread_title() { - let preview_data = StatusLinePreviewData::from_iter([ - (StatusLineItem::ModelName, "gpt-5".to_string()), - (StatusLineItem::ThreadTitle, "Roadmap cleanup".to_string()), - ]); - let items = vec![ - MultiSelectItem { - id: StatusLineItem::ModelName.to_string(), - name: String::new(), - description: None, - enabled: true, - }, - MultiSelectItem { - id: StatusLineItem::ThreadTitle.to_string(), - name: String::new(), - description: None, - enabled: true, - }, - ]; - - assert_eq!( - preview_data.line_for_items(&items), - Some(Line::from("gpt-5 · Roadmap cleanup")) - ); - } - - #[test] - fn setup_view_snapshot_uses_runtime_preview_values() { - let (tx_raw, _rx) = unbounded_channel::(); - let view = StatusLineSetupView::new( - Some(&[ - StatusLineItem::ModelName.to_string(), - StatusLineItem::CurrentDir.to_string(), - StatusLineItem::GitBranch.to_string(), - ]), - StatusLinePreviewData::from_iter([ - (StatusLineItem::ModelName, "gpt-5-codex".to_string()), - (StatusLineItem::CurrentDir, "~/codex-rs".to_string()), - ( - StatusLineItem::GitBranch, - "jif/statusline-preview".to_string(), - ), - (StatusLineItem::WeeklyLimit, "weekly 82%".to_string()), - ]), - AppEventSender::new(tx_raw), - ); - - assert_snapshot!(render_lines(&view, /*width*/ 72)); - } - - fn render_lines(view: &StatusLineSetupView, width: u16) -> String { - let height = view.desired_height(width); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - view.render(area, &mut buf); - - (0..area.height) - .map(|row| { - let mut line = String::new(); - for col in 0..area.width { - let symbol = buf[(area.x + col, area.y + row)].symbol(); - if symbol.is_empty() { - line.push(' '); - } else { - line.push_str(symbol); - } - } - line - }) - .collect::>() - .join("\n") - } } diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs new file mode 100644 index 000000000..93fca824c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -0,0 +1,173 @@ +use std::collections::BTreeMap; + +use ratatui::text::Line; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub(crate) enum StatusSurfacePreviewItem { + AppName, + ProjectName, + ProjectRoot, + CurrentDir, + Status, + ThreadTitle, + GitBranch, + ContextRemaining, + ContextUsed, + FiveHourLimit, + WeeklyLimit, + CodexVersion, + ContextWindowSize, + UsedTokens, + TotalInputTokens, + TotalOutputTokens, + SessionId, + FastMode, + Model, + ModelWithReasoning, + TaskProgress, +} + +impl StatusSurfacePreviewItem { + fn placeholder(self) -> &'static str { + match self { + StatusSurfacePreviewItem::AppName => "codex", + StatusSurfacePreviewItem::ProjectName => "my-project", + StatusSurfacePreviewItem::ProjectRoot => "my-project", + StatusSurfacePreviewItem::CurrentDir => "~/my-project/subdir", + StatusSurfacePreviewItem::Status => "Working", + StatusSurfacePreviewItem::ThreadTitle => "thread title", + StatusSurfacePreviewItem::GitBranch => "feat/awesome-feature", + StatusSurfacePreviewItem::ContextRemaining => "Context 0% left", + StatusSurfacePreviewItem::ContextUsed => "Context 0% used", + StatusSurfacePreviewItem::FiveHourLimit => "5h 0%", + StatusSurfacePreviewItem::WeeklyLimit => "weekly 0%", + StatusSurfacePreviewItem::CodexVersion => "0.0.0", + StatusSurfacePreviewItem::ContextWindowSize => "0 window", + StatusSurfacePreviewItem::UsedTokens => "0 used", + StatusSurfacePreviewItem::TotalInputTokens => "0 in", + StatusSurfacePreviewItem::TotalOutputTokens => "0 out", + StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4", + StatusSurfacePreviewItem::FastMode => "Fast on", + StatusSurfacePreviewItem::Model => "gpt-5.2-codex", + StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium", + StatusSurfacePreviewItem::TaskProgress => "Tasks 0/0", + } + } + + pub(crate) fn iter() -> impl Iterator { + [ + Self::AppName, + Self::ProjectName, + Self::ProjectRoot, + Self::CurrentDir, + Self::Status, + Self::ThreadTitle, + Self::GitBranch, + Self::ContextRemaining, + Self::ContextUsed, + Self::FiveHourLimit, + Self::WeeklyLimit, + Self::CodexVersion, + Self::ContextWindowSize, + Self::UsedTokens, + Self::TotalInputTokens, + Self::TotalOutputTokens, + Self::SessionId, + Self::FastMode, + Self::Model, + Self::ModelWithReasoning, + Self::TaskProgress, + ] + .into_iter() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PreviewValue { + text: String, + is_placeholder: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StatusSurfacePreviewData { + values: BTreeMap, +} + +impl Default for StatusSurfacePreviewData { + fn default() -> Self { + let mut data = Self { + values: BTreeMap::new(), + }; + for item in StatusSurfacePreviewItem::iter() { + data.set_placeholder(item, item.placeholder()); + } + data + } +} + +impl StatusSurfacePreviewData { + pub(crate) fn from_iter(values: I) -> Self + where + I: IntoIterator, + V: Into, + { + let mut data = Self::default(); + for (item, value) in values { + data.set_live(item, value); + } + data + } + + pub(crate) fn set_live(&mut self, item: StatusSurfacePreviewItem, value: V) + where + V: Into, + { + self.values.insert( + item, + PreviewValue { + text: value.into(), + is_placeholder: false, + }, + ); + } + + pub(crate) fn set_placeholder(&mut self, item: StatusSurfacePreviewItem, value: V) + where + V: Into, + { + if self + .values + .get(&item) + .is_some_and(|value| !value.is_placeholder) + { + return; + } + self.values.insert( + item, + PreviewValue { + text: value.into(), + is_placeholder: true, + }, + ); + } + + pub(crate) fn value_for(&self, item: StatusSurfacePreviewItem) -> Option<&str> { + self.values.get(&item).map(|value| value.text.as_str()) + } + + pub(crate) fn line_for_items(&self, items: I) -> Option> + where + I: IntoIterator, + { + let preview = items + .into_iter() + .filter_map(|item| self.value_for(item)) + .collect::>() + .join(" · "); + if preview.is_empty() { + None + } else { + Some(Line::from(preview)) + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index b9899e32d..0595a1797 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -22,6 +22,8 @@ use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::bottom_pane_view::BottomPaneView; use crate::bottom_pane::multi_select_picker::MultiSelectItem; use crate::bottom_pane::multi_select_picker::MultiSelectPicker; +use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewData; +use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewItem; use crate::render::renderable::Renderable; /// Available items that can be displayed in the terminal title. @@ -68,20 +70,16 @@ impl TerminalTitleItem { } } - /// Example text used when previewing the title picker. - /// - /// These are illustrative sample values, not live data from the current - /// session. - pub(crate) fn preview_example(self) -> &'static str { + pub(crate) fn preview_item(self) -> Option { match self { - TerminalTitleItem::AppName => "codex", - TerminalTitleItem::Project => "my-project", - TerminalTitleItem::Spinner => "⠋", - TerminalTitleItem::Status => "Working", - TerminalTitleItem::Thread => "Investigate flaky test", - TerminalTitleItem::GitBranch => "feat/awesome-feature", - TerminalTitleItem::Model => "gpt-5.2-codex", - TerminalTitleItem::TaskProgress => "Tasks 2/5", + TerminalTitleItem::AppName => Some(StatusSurfacePreviewItem::AppName), + TerminalTitleItem::Project => Some(StatusSurfacePreviewItem::ProjectName), + TerminalTitleItem::Spinner => None, + TerminalTitleItem::Status => Some(StatusSurfacePreviewItem::Status), + TerminalTitleItem::Thread => Some(StatusSurfacePreviewItem::ThreadTitle), + TerminalTitleItem::GitBranch => Some(StatusSurfacePreviewItem::GitBranch), + TerminalTitleItem::Model => Some(StatusSurfacePreviewItem::Model), + TerminalTitleItem::TaskProgress => Some(StatusSurfacePreviewItem::TaskProgress), } } @@ -103,6 +101,39 @@ impl TerminalTitleItem { } } +pub(crate) fn preview_line_for_title_items( + items: &[TerminalTitleItem], + preview_data: &StatusSurfacePreviewData, +) -> Option> { + let mut previous = None; + let preview = items + .iter() + .copied() + .fold(String::new(), |mut preview, item| { + if item == TerminalTitleItem::Spinner { + preview.push_str(item.separator_from_previous(previous)); + preview.push('⠋'); + previous = Some(item); + return preview; + } + let Some(value) = item + .preview_item() + .and_then(|preview_item| preview_data.value_for(preview_item)) + else { + return preview; + }; + preview.push_str(item.separator_from_previous(previous)); + preview.push_str(value); + previous = Some(item); + preview + }); + if preview.is_empty() { + None + } else { + Some(Line::from(preview)) + } +} + fn parse_terminal_title_items(ids: impl Iterator) -> Option> where T: AsRef, @@ -128,7 +159,11 @@ impl TerminalTitleSetupView { /// main TUI still warns about them when rendering the actual title, but the /// picker itself only exposes the selectable items it can meaningfully /// preview and persist. - pub(crate) fn new(title_items: Option<&[String]>, app_event_tx: AppEventSender) -> Self { + pub(crate) fn new( + title_items: Option<&[String]>, + preview_data: StatusSurfacePreviewData, + app_event_tx: AppEventSender, + ) -> Self { let selected_items = title_items .into_iter() .flatten() @@ -161,25 +196,14 @@ impl TerminalTitleSetupView { ]) .items(items) .enable_ordering() - .on_preview(|items| { + .on_preview(move |items| { let items = parse_terminal_title_items( items .iter() .filter(|item| item.enabled) .map(|item| item.id.as_str()), )?; - let mut preview = String::new(); - let mut previous = None; - for item in items.iter().copied() { - preview.push_str(item.separator_from_previous(previous)); - preview.push_str(item.preview_example()); - previous = Some(item); - } - if preview.is_empty() { - None - } else { - Some(Line::from(preview)) - } + preview_line_for_title_items(&items, &preview_data) }) .on_change(|items, app_event| { let Some(items) = parse_terminal_title_items( @@ -243,49 +267,7 @@ impl Renderable for TerminalTitleSetupView { #[cfg(test)] mod tests { use super::*; - use insta::assert_snapshot; use pretty_assertions::assert_eq; - use tokio::sync::mpsc::unbounded_channel; - - fn render_lines(view: &TerminalTitleSetupView, width: u16) -> String { - let height = view.desired_height(width); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - view.render(area, &mut buf); - - let lines: Vec = (0..area.height) - .map(|row| { - let mut line = String::new(); - for col in 0..area.width { - let symbol = buf[(area.x + col, area.y + row)].symbol(); - if symbol.is_empty() { - line.push(' '); - } else { - line.push_str(symbol); - } - } - line - }) - .collect(); - lines.join("\n") - } - - #[test] - fn renders_title_setup_popup() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let selected = [ - "project".to_string(), - "spinner".to_string(), - "status".to_string(), - "thread".to_string(), - ]; - let view = TerminalTitleSetupView::new(Some(&selected), tx); - assert_snapshot!( - "terminal_title_setup_basic", - render_lines(&view, /*width*/ 84) - ); - } #[test] fn parse_terminal_title_items_preserves_order() { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f93c4bc2f..9c54d322e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -52,8 +52,9 @@ use crate::app_server_session::ThreadSessionState; #[cfg(not(target_os = "linux"))] use crate::audio_device::list_realtime_audio_device_names; use crate::bottom_pane::StatusLineItem; -use crate::bottom_pane::StatusLinePreviewData; use crate::bottom_pane::StatusLineSetupView; +use crate::bottom_pane::StatusSurfacePreviewData; +use crate::bottom_pane::StatusSurfacePreviewItem; use crate::bottom_pane::TerminalTitleItem; use crate::bottom_pane::TerminalTitleSetupView; use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME; @@ -7595,10 +7596,7 @@ impl ChatWidget { let configured_status_line_items = self.configured_status_line_items(); let view = StatusLineSetupView::new( Some(configured_status_line_items.as_slice()), - StatusLinePreviewData::from_iter(StatusLineItem::iter().filter_map(|item| { - self.status_line_value_for_item(&item) - .map(|value| (item, value)) - })), + self.status_surface_preview_data(), self.app_event_tx.clone(), ); self.bottom_pane.show_view(Box::new(view)); @@ -7609,11 +7607,40 @@ impl ChatWidget { self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); let view = TerminalTitleSetupView::new( Some(configured_terminal_title_items.as_slice()), + self.terminal_title_preview_data(), self.app_event_tx.clone(), ); self.bottom_pane.show_view(Box::new(view)); } + fn status_surface_preview_data(&mut self) -> StatusSurfacePreviewData { + StatusSurfacePreviewData::from_iter(StatusSurfacePreviewItem::iter().filter_map(|item| { + self.status_surface_preview_value_for_item(item) + .map(|value| (item, value)) + })) + } + + fn terminal_title_preview_data(&mut self) -> StatusSurfacePreviewData { + let mut preview_data = self.status_surface_preview_data(); + let now = Instant::now(); + for item in [ + TerminalTitleItem::Project, + TerminalTitleItem::Thread, + TerminalTitleItem::GitBranch, + TerminalTitleItem::Model, + TerminalTitleItem::TaskProgress, + ] { + let Some(preview_item) = item.preview_item() else { + continue; + }; + let Some(value) = self.terminal_title_value_for_item(item, now) else { + continue; + }; + preview_data.set_live(preview_item, value); + } + preview_data + } + fn open_theme_picker(&mut self) { let codex_home = crate::legacy_core::config::find_codex_home().ok(); let terminal_width = self diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_hardcoded_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_hardcoded_only.snap new file mode 100644 index 000000000..ccab3ca7d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_hardcoded_only.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: status_line_popup_snapshot(&mut chat) +--- + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] project-root Project root directory (omitted when unavailable) + [x] git-branch Current Git branch (omitted when unavailable) + [x] thread-title Current thread title (omitted unless changed by user) + [ ] model-name Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] current-dir Current working directory + [ ] context-remaining Percentage of context window remaining (omitted when unknown) + [ ] context-used Percentage of context window used (omitted when unknown) + + my-project · feat/awesome-feature · thread title + Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_live_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_live_only.snap new file mode 100644 index 000000000..273e17511 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_live_only.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: status_line_popup_snapshot(&mut chat) +--- + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] project-root Project root directory (omitted when unavailable) + [x] git-branch Current Git branch (omitted when unavailable) + [x] thread-title Current thread title (omitted unless changed by user) + [ ] model-name Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] current-dir Current working directory + [ ] context-remaining Percentage of context window remaining (omitted when unknown) + [ ] context-used Percentage of context window used (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. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_mixed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_mixed.snap new file mode 100644 index 000000000..0f0bfdd24 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_mixed.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: status_line_popup_snapshot(&mut chat) +--- + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] project-root Project root directory (omitted when unavailable) + [x] git-branch Current Git branch (omitted when unavailable) + [x] thread-title Current thread title (omitted unless changed by user) + [ ] model-name Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] current-dir Current working directory + [ ] context-remaining Percentage of context window remaining (omitted when unknown) + [ ] context-used Percentage of context window used (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. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap new file mode 100644 index 000000000..abf3df3c2 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: snapshot +--- +status line: my-project · feat/awesome-feature · thread title +terminal title: thread title | feat/awesome-feature | Tasks 0/0 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_live_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_live_only.snap new file mode 100644 index 000000000..4cf1871af --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_live_only.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: snapshot +--- +status line: preview-live-root · feature/live-preview-branch · Live preview thread +terminal title: preview-live-root | Live preview thread | feature/live-preview-branch | Tasks 2/5 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_mixed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_mixed.snap new file mode 100644 index 000000000..ab50a6981 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_mixed.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: snapshot +--- +status line: my-project · feature/mixed-preview · Mixed preview thread +terminal title: project | Mixed preview thread | Tasks 0/0 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap new file mode 100644 index 000000000..eb27cdd01 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_hardcoded_only.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: terminal_title_popup_snapshot(&mut chat) +--- + Configure Terminal Title + Select which items to display in the terminal title. + + Type to search + > +› [x] thread Current thread title (omitted until available) + [x] git-branch Current Git branch (omitted when unavailable) + [x] task-progress Latest task progress from update_plan (omitted until available) + [ ] app-name Codex app name + [ ] project Project name (falls back to current directory name) + [ ] spinner Animated task spinner (omitted while idle or when animations are off) + [ ] status Compact session status text (Ready, Working, Thinking) + [ ] model Current model name + + thread title | feat/awesome-feature | Tasks 0/0 + Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap new file mode 100644 index 000000000..bc2bf2502 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_live_only.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: terminal_title_popup_snapshot(&mut chat) +--- + Configure Terminal Title + Select which items to display in the terminal title. + + Type to search + > +› [x] project Project name (falls back to current directory name) + [x] thread Current thread title (omitted until available) + [x] git-branch Current Git branch (omitted when unavailable) + [x] task-progress Latest task progress from update_plan (omitted until available) + [ ] app-name Codex app name + [ ] spinner Animated task spinner (omitted while idle or when animations are off) + [ ] status Compact session status text (Ready, Working, Thinking) + [ ] model Current model name + + preview-live-root | Live preview thread | feature/live-preview-branch | Tasks 2/5 + Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap new file mode 100644 index 000000000..4d2f3128d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_mixed.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: terminal_title_popup_snapshot(&mut chat) +--- + Configure Terminal Title + Select which items to display in the terminal title. + + Type to search + > +› [x] project Project name (falls back to current directory name) + [x] thread Current thread title (omitted until available) + [x] task-progress Latest task progress from update_plan (omitted until available) + [ ] app-name Codex app name + [ ] spinner Animated task spinner (omitted while idle or when animations are off) + [ ] status Compact session status text (Ready, Working, Thinking) + [ ] git-branch Current Git branch (omitted when unavailable) + [ ] model Current model name + + project | Mixed preview thread | Tasks 0/0 + Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel. diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index a744711c2..7045bbecf 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -505,11 +505,41 @@ impl ChatWidget { } } + pub(super) fn status_surface_preview_value_for_item( + &mut self, + item: StatusSurfacePreviewItem, + ) -> Option { + let status_line_item = match item { + StatusSurfacePreviewItem::AppName => return Some("codex".to_string()), + StatusSurfacePreviewItem::ProjectName => return self.terminal_title_project_name(), + StatusSurfacePreviewItem::ProjectRoot => StatusLineItem::ProjectRoot, + StatusSurfacePreviewItem::Status => return Some(self.terminal_title_status_text()), + StatusSurfacePreviewItem::TaskProgress => return self.terminal_title_task_progress(), + StatusSurfacePreviewItem::CurrentDir => StatusLineItem::CurrentDir, + StatusSurfacePreviewItem::ThreadTitle => StatusLineItem::ThreadTitle, + StatusSurfacePreviewItem::GitBranch => StatusLineItem::GitBranch, + StatusSurfacePreviewItem::ContextRemaining => StatusLineItem::ContextRemaining, + StatusSurfacePreviewItem::ContextUsed => StatusLineItem::ContextUsed, + StatusSurfacePreviewItem::FiveHourLimit => StatusLineItem::FiveHourLimit, + StatusSurfacePreviewItem::WeeklyLimit => StatusLineItem::WeeklyLimit, + StatusSurfacePreviewItem::CodexVersion => StatusLineItem::CodexVersion, + StatusSurfacePreviewItem::ContextWindowSize => StatusLineItem::ContextWindowSize, + StatusSurfacePreviewItem::UsedTokens => StatusLineItem::UsedTokens, + StatusSurfacePreviewItem::TotalInputTokens => StatusLineItem::TotalInputTokens, + StatusSurfacePreviewItem::TotalOutputTokens => StatusLineItem::TotalOutputTokens, + StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId, + StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode, + StatusSurfacePreviewItem::Model => StatusLineItem::ModelName, + StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning, + }; + self.status_line_value_for_item(&status_line_item) + } + /// Resolves one configured terminal-title item into a displayable segment. /// /// Returning `None` means "omit this segment for now" so callers can keep /// the configured order while hiding values that are not yet available. - fn terminal_title_value_for_item( + pub(super) fn terminal_title_value_for_item( &mut self, item: TerminalTitleItem, now: Instant, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4c4d74090..d81544e4f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -276,6 +276,7 @@ mod side; mod slash_commands; mod status_and_layout; mod status_command_tests; +mod status_surface_previews; pub(crate) use helpers::make_chatwidget_manual_with_sender; pub(crate) use helpers::set_chatgpt_auth; diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs new file mode 100644 index 000000000..b5ef67dd6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -0,0 +1,261 @@ +use super::*; +use crate::bottom_pane::preview_line_for_title_items; +use pretty_assertions::assert_eq; +use ratatui::text::Line; + +fn line_text(line: Line<'static>) -> String { + line.spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect() +} + +fn status_preview_line(chat: &mut ChatWidget, items: &[StatusLineItem]) -> String { + let preview_data = chat.status_surface_preview_data(); + let preview = preview_data + .line_for_items(items.iter().cloned().map(StatusLineItem::preview_item)) + .expect("status preview line"); + line_text(preview) +} + +fn title_preview_line(chat: &mut ChatWidget, items: &[TerminalTitleItem]) -> String { + let preview_data = chat.terminal_title_preview_data(); + let preview = + preview_line_for_title_items(items, &preview_data).expect("terminal title preview line"); + line_text(preview) +} + +fn combined_preview_snapshot( + chat: &mut ChatWidget, + status_items: &[StatusLineItem], + title_items: &[TerminalTitleItem], +) -> String { + normalize_snapshot_paths(format!( + "status line: {}\nterminal title: {}", + status_preview_line(chat, status_items), + title_preview_line(chat, title_items), + )) +} + +fn status_line_popup_snapshot(chat: &mut ChatWidget) -> String { + chat.open_status_line_setup(); + normalize_snapshot_paths(strip_osc8_for_snapshot(&render_bottom_popup( + chat, /*width*/ 100, + ))) +} + +fn terminal_title_popup_snapshot(chat: &mut ChatWidget) -> String { + chat.open_terminal_title_setup(); + normalize_snapshot_paths(strip_osc8_for_snapshot(&render_bottom_popup( + chat, /*width*/ 100, + ))) +} + +fn cache_project_root(chat: &mut ChatWidget, root_name: &str) { + chat.status_line_project_root_name_cache = Some(CachedProjectRootName { + cwd: chat.config.cwd.to_path_buf(), + root_name: Some(root_name.to_string()), + }); +} + +#[tokio::test] +async fn status_surface_preview_lines_live_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_project_root(&mut chat, "preview-live-root"); + chat.status_line_branch = Some("feature/live-preview-branch".to_string()); + chat.thread_name = Some("Live preview thread".to_string()); + chat.last_plan_progress = Some((2, 5)); + + let snapshot = combined_preview_snapshot( + &mut chat, + &[ + StatusLineItem::ProjectRoot, + StatusLineItem::GitBranch, + StatusLineItem::ThreadTitle, + ], + &[ + TerminalTitleItem::Project, + TerminalTitleItem::Thread, + TerminalTitleItem::GitBranch, + TerminalTitleItem::TaskProgress, + ], + ); + + assert_chatwidget_snapshot!("status_surface_previews_live_only", snapshot); +} + +#[tokio::test] +async fn status_line_setup_popup_live_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_project_root(&mut chat, "preview-live-root"); + chat.status_line_branch = Some("feature/live-preview-branch".to_string()); + chat.thread_name = Some("Live preview thread".to_string()); + chat.config.tui_status_line = Some(vec![ + "project-root".to_string(), + "git-branch".to_string(), + "thread-title".to_string(), + ]); + + assert_chatwidget_snapshot!( + "status_line_setup_popup_live_only", + status_line_popup_snapshot(&mut chat) + ); +} + +#[tokio::test] +async fn status_surface_preview_lines_hardcoded_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + let snapshot = combined_preview_snapshot( + &mut chat, + &[ + StatusLineItem::ProjectRoot, + StatusLineItem::GitBranch, + StatusLineItem::ThreadTitle, + ], + &[ + TerminalTitleItem::Thread, + TerminalTitleItem::GitBranch, + TerminalTitleItem::TaskProgress, + ], + ); + + assert_chatwidget_snapshot!("status_surface_previews_hardcoded_only", snapshot); +} + +#[tokio::test] +async fn status_line_setup_popup_hardcoded_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec![ + "project-root".to_string(), + "git-branch".to_string(), + "thread-title".to_string(), + ]); + + assert_chatwidget_snapshot!( + "status_line_setup_popup_hardcoded_only", + status_line_popup_snapshot(&mut chat) + ); +} + +#[tokio::test] +async fn status_surface_preview_lines_mixed_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_branch = Some("feature/mixed-preview".to_string()); + chat.thread_name = Some("Mixed preview thread".to_string()); + + let snapshot = combined_preview_snapshot( + &mut chat, + &[ + StatusLineItem::ProjectRoot, + StatusLineItem::GitBranch, + StatusLineItem::ThreadTitle, + ], + &[ + TerminalTitleItem::Project, + TerminalTitleItem::Thread, + TerminalTitleItem::TaskProgress, + ], + ); + + assert_chatwidget_snapshot!("status_surface_previews_mixed", snapshot); +} + +#[tokio::test] +async fn status_line_setup_popup_mixed_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_branch = Some("feature/mixed-preview".to_string()); + chat.thread_name = Some("Mixed preview thread".to_string()); + chat.config.tui_status_line = Some(vec![ + "project-root".to_string(), + "git-branch".to_string(), + "thread-title".to_string(), + ]); + + assert_chatwidget_snapshot!( + "status_line_setup_popup_mixed", + status_line_popup_snapshot(&mut chat) + ); +} + +#[tokio::test] +async fn terminal_title_setup_popup_live_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_project_root(&mut chat, "preview-live-root"); + chat.status_line_branch = Some("feature/live-preview-branch".to_string()); + chat.thread_name = Some("Live preview thread".to_string()); + chat.last_plan_progress = Some((2, 5)); + chat.config.tui_terminal_title = Some(vec![ + "project".to_string(), + "thread".to_string(), + "git-branch".to_string(), + "task-progress".to_string(), + ]); + + assert_chatwidget_snapshot!( + "terminal_title_setup_popup_live_only", + terminal_title_popup_snapshot(&mut chat) + ); +} + +#[tokio::test] +async fn terminal_title_setup_popup_hardcoded_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_terminal_title = Some(vec![ + "thread".to_string(), + "git-branch".to_string(), + "task-progress".to_string(), + ]); + + assert_chatwidget_snapshot!( + "terminal_title_setup_popup_hardcoded_only", + terminal_title_popup_snapshot(&mut chat) + ); +} + +#[tokio::test] +async fn terminal_title_setup_popup_mixed_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_name = Some("Mixed preview thread".to_string()); + chat.config.tui_terminal_title = Some(vec![ + "project".to_string(), + "thread".to_string(), + "task-progress".to_string(), + ]); + + assert_chatwidget_snapshot!( + "terminal_title_setup_popup_mixed", + terminal_title_popup_snapshot(&mut chat) + ); +} + +#[tokio::test] +async fn missing_project_root_uses_different_status_and_title_preview_sources() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + let status_preview = status_preview_line(&mut chat, &[StatusLineItem::ProjectRoot]); + let title_preview = title_preview_line(&mut chat, &[TerminalTitleItem::Project]); + + assert_eq!(status_preview, "my-project"); + assert_eq!(title_preview, "project"); +} + +#[tokio::test] +async fn terminal_title_preview_uses_title_truncation_for_live_values() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let long_thread = "This thread title is intentionally much longer than forty-eight characters"; + let long_branch = "feature/this-branch-name-is-intentionally-longer-than-thirty-two"; + chat.thread_name = Some(long_thread.to_string()); + chat.status_line_branch = Some(long_branch.to_string()); + + let preview = title_preview_line( + &mut chat, + &[TerminalTitleItem::Thread, TerminalTitleItem::GitBranch], + ); + let truncated_thread = + ChatWidget::truncate_terminal_title_part(long_thread.to_string(), /*max_chars*/ 48); + let truncated_branch = + ChatWidget::truncate_terminal_title_part(long_branch.to_string(), /*max_chars*/ 32); + + assert_eq!(preview, format!("{truncated_thread} | {truncated_branch}")); +}