diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 8e9550c092f..2fdab347e11 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1046,6 +1046,14 @@ "default": true, "description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.", "type": "boolean" + }, + "status_line": { + "default": null, + "description": "Ordered list of status line item identifiers.\n\nWhen set, the TUI renders the selected items as the status line.", + "items": { + "type": "string" + }, + "type": "array" } }, "type": "object" diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index cf874518211..94cd7267118 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -55,6 +55,24 @@ pub enum ConfigEdit { ClearPath { segments: Vec }, } +pub fn status_line_items_edit(items: &[String]) -> ConfigEdit { + if items.is_empty() { + return ConfigEdit::ClearPath { + segments: vec!["tui".to_string(), "status_line".to_string()], + }; + } + + let mut array = toml_edit::Array::new(); + for item in items { + array.push(item.clone()); + } + + ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "status_line".to_string()], + value: TomlItem::Value(array.into()), + } +} + // TODO(jif) move to a dedicated file mod document_helpers { use crate::config::types::McpServerConfig; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e22cfe65a1a..36bf32aa1f3 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -224,6 +224,9 @@ pub struct Config { /// - `never`: Never use alternate screen (inline mode, preserves scrollback). pub tui_alternate_screen: AltScreenMode, + /// Ordered list of status line item identifiers for the TUI. + pub tui_status_line: Option>, + /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -1694,6 +1697,7 @@ impl Config { .as_ref() .map(|t| t.alternate_screen) .unwrap_or_default(), + tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); @@ -1931,6 +1935,7 @@ persistence = "none" show_tooltips: true, experimental_mode: None, alternate_screen: AltScreenMode::Auto, + status_line: None, } ); } @@ -3880,6 +3885,7 @@ model_verbosity = "high" analytics_enabled: Some(true), feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, + tui_status_line: None, otel: OtelConfig::default(), }, o3_profile_config @@ -3965,6 +3971,7 @@ model_verbosity = "high" analytics_enabled: Some(true), feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, + tui_status_line: None, otel: OtelConfig::default(), }; @@ -4065,6 +4072,7 @@ model_verbosity = "high" analytics_enabled: Some(false), feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, + tui_status_line: None, otel: OtelConfig::default(), }; @@ -4151,6 +4159,7 @@ model_verbosity = "high" analytics_enabled: Some(true), feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, + tui_status_line: None, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 7ffcf3a8e2c..2eb868cc34c 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -486,6 +486,12 @@ pub struct Tui { /// scrollback in terminal multiplexers like Zellij that follow the xterm spec. #[serde(default)] pub alternate_screen: AltScreenMode, + + /// Ordered list of status line item identifiers. + /// + /// When set, the TUI renders the selected items as the status line. + #[serde(default)] + pub status_line: Option>, } const fn default_true() -> bool { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 99164efa61a..16914c23001 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -535,6 +535,8 @@ pub(crate) struct App { /// Controls the animation thread that sends CommitTick events. pub(crate) commit_anim_running: Arc, + // Shared across ChatWidget instances so invalid status-line config warnings only emit once. + status_line_invalid_items_warned: Arc, // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, @@ -605,6 +607,7 @@ impl App { is_first_run: false, feedback_audience: self.feedback_audience, model: Some(self.chat_widget.current_model().to_string()), + status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), otel_manager: self.otel_manager.clone(), } } @@ -906,6 +909,7 @@ impl App { for event in snapshot.events { self.handle_codex_event_replay(event); } + self.refresh_status_line(); } #[allow(clippy::too_many_arguments)] @@ -982,6 +986,15 @@ impl App { codex_core::terminal::user_agent(), SessionSource::Cli, ); + if config + .tui_status_line + .as_ref() + .is_some_and(|cmd| !cmd.is_empty()) + { + otel_manager.counter("codex.status_line", 1, &[]); + } + + let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); let enhanced_keys_supported = tui.enhanced_keys_supported(); let mut chat_widget = match session_selection { @@ -1003,6 +1016,7 @@ impl App { is_first_run, feedback_audience, model: Some(model.clone()), + status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), otel_manager: otel_manager.clone(), }; ChatWidget::new(init, thread_manager.clone()) @@ -1032,6 +1046,7 @@ impl App { is_first_run, feedback_audience, model: config.model.clone(), + status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), otel_manager: otel_manager.clone(), }; ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) @@ -1061,6 +1076,7 @@ impl App { is_first_run, feedback_audience, model: config.model.clone(), + status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), otel_manager: otel_manager.clone(), }; ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) @@ -1092,6 +1108,7 @@ impl App { deferred_history_lines: Vec::new(), has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), + status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: feedback.clone(), @@ -1220,6 +1237,13 @@ impl App { tui: &mut tui::Tui, event: TuiEvent, ) -> Result { + if matches!(event, TuiEvent::Draw) { + let size = tui.terminal.size()?; + if size != tui.terminal.last_known_screen_size { + self.refresh_status_line(); + } + } + if self.overlay.is_some() { let _ = self.handle_backtrack_overlay_event(tui, event).await?; } else { @@ -1293,6 +1317,7 @@ impl App { is_first_run: false, feedback_audience: self.feedback_audience, model: Some(model), + status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), otel_manager: self.otel_manager.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); @@ -1562,12 +1587,15 @@ impl App { } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); + self.refresh_status_line(); } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); + self.refresh_status_line(); } AppEvent::UpdateCollaborationMode(mask) => { self.chat_widget.set_collaboration_mask(mask); + self.refresh_status_line(); } AppEvent::UpdatePersonality(personality) => { self.on_update_personality(personality); @@ -2201,11 +2229,45 @@ impl App { )); } }, + AppEvent::StatusLineSetup { items } => { + let ids = items.iter().map(ToString::to_string).collect::>(); + let edit = codex_core::config::edit::status_line_items_edit(&ids); + let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await; + match apply_result { + Ok(()) => { + self.config.tui_status_line = if ids.is_empty() { + None + } else { + Some(ids.clone()) + }; + self.chat_widget.setup_status_line(items); + } + 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}")); + } + } + } + AppEvent::StatusLineBranchUpdated { cwd, branch } => { + self.chat_widget.set_status_line_branch(cwd, branch); + self.refresh_status_line(); + } + AppEvent::StatusLineSetupCancelled => { + self.chat_widget.cancel_status_line_setup(); + } } Ok(AppRunControl::Continue) } fn handle_codex_event_now(&mut self, event: Event) { + let needs_refresh = matches!( + event.msg, + EventMsg::SessionConfigured(_) | EventMsg::TokenCount(_) + ); if self.suppress_shutdown_complete && matches!(event.msg, EventMsg::ShutdownComplete) { self.suppress_shutdown_complete = false; return; @@ -2217,6 +2279,10 @@ impl App { } self.handle_backtrack_event(&event.msg); self.chat_widget.handle_codex_event(event); + + if needs_refresh { + self.refresh_status_line(); + } } fn handle_codex_event_replay(&mut self, event: Event) { @@ -2471,6 +2537,10 @@ impl App { }; } + fn refresh_status_line(&mut self) { + self.chat_widget.refresh_status_line(); + } + #[cfg(target_os = "windows")] fn spawn_world_writable_scan( cwd: PathBuf, @@ -2627,6 +2697,7 @@ mod tests { has_emitted_history_lines: false, enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), + status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), @@ -2680,6 +2751,7 @@ mod tests { has_emitted_history_lines: false, enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), + status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index bbd228e1145..bd48e5de1b8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -19,6 +19,7 @@ use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use crate::bottom_pane::ApprovalRequest; +use crate::bottom_pane::StatusLineItem; use crate::history_cell::HistoryCell; use codex_core::features::Feature; @@ -292,6 +293,18 @@ pub(crate) enum AppEvent { /// Launch the external editor after a normal draw has completed. LaunchExternalEditor, + + /// Async update of the current git branch for status line rendering. + StatusLineBranchUpdated { + cwd: PathBuf, + branch: Option, + }, + /// Apply a user-confirmed status-line item ordering/selection. + StatusLineSetup { + items: Vec, + }, + /// Dismiss the status-line setup UI without changing config. + StatusLineSetupCancelled, } /// The exit strategy requested by the UI layer. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 116e9c3abe2..4afbfea01ab 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -82,9 +82,12 @@ //! edits and renders a placeholder prompt instead of the editable textarea. This is part of the //! overall state machine, since it affects which transitions are even possible from a given UI //! state. +use crate::bottom_pane::footer::mode_indicator_line; +use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::has_ctrl_or_alt; +use crate::ui_consts::FOOTER_INDENT_COLS; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -118,6 +121,7 @@ use super::footer::footer_height; use super::footer::footer_hint_items_width; use super::footer::footer_line_width; use super::footer::inset_footer_hint_area; +use super::footer::max_left_width_for_right; use super::footer::render_context_right; use super::footer::render_footer_from_props; use super::footer::render_footer_hint_items; @@ -295,6 +299,8 @@ pub(crate) struct ChatComposer { connectors_enabled: bool, personality_command_enabled: bool, windows_degraded_sandbox_active: bool, + status_line_value: Option>, + status_line_enabled: bool, } #[derive(Clone, Debug)] @@ -384,6 +390,8 @@ impl ChatComposer { connectors_enabled: false, personality_command_enabled: false, windows_degraded_sandbox_active: false, + status_line_value: None, + status_line_enabled: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -458,7 +466,7 @@ impl ChatComposer { let footer_props = self.footer_props(); let footer_hint_height = self .custom_footer_height() - .unwrap_or_else(|| footer_height(footer_props)); + .unwrap_or_else(|| footer_height(&footer_props)); let footer_spacing = Self::footer_spacing(footer_hint_height); let footer_total_height = footer_hint_height + footer_spacing; let popup_constraint = match &self.active_popup { @@ -2524,6 +2532,8 @@ impl ChatComposer { is_wsl, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, + status_line_value: self.status_line_value.clone(), + status_line_enabled: self.status_line_enabled, } } @@ -3006,6 +3016,14 @@ impl ChatComposer { self.footer_mode = reset_mode_after_activity(self.footer_mode); } } + + pub(crate) fn set_status_line(&mut self, status_line: Option>) { + self.status_line_value = status_line; + } + + pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) { + self.status_line_enabled = enabled; + } } fn skill_display_name(skill: &SkillMetadata) -> &str { @@ -3046,7 +3064,7 @@ impl Renderable for ChatComposer { let footer_props = self.footer_props(); let footer_hint_height = self .custom_footer_height() - .unwrap_or_else(|| footer_height(footer_props)); + .unwrap_or_else(|| footer_height(&footer_props)); let footer_spacing = Self::footer_spacing(footer_hint_height); let footer_total_height = footer_hint_height + footer_spacing; const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; @@ -3099,14 +3117,9 @@ impl ChatComposer { | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, }; - let context_line = context_window_line( - footer_props.context_window_percent, - footer_props.context_window_used_tokens, - ); - let context_width = context_line.width() as u16; let custom_height = self.custom_footer_height(); let footer_hint_height = - custom_height.unwrap_or_else(|| footer_height(footer_props)); + custom_height.unwrap_or_else(|| footer_height(&footer_props)); let footer_spacing = Self::footer_spacing(footer_hint_height); let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { let [_, hint_rect] = Layout::vertical([ @@ -3118,24 +3131,80 @@ impl ChatComposer { } else { popup_rect }; - let left_width = if self.footer_flash_visible() { + let available_width = + hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize; + let status_line = footer_props + .status_line_value + .as_ref() + .map(|line| line.clone().dim()); + let status_line_candidate = footer_props.status_line_enabled + && matches!( + footer_props.mode, + FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft + ); + let mut truncated_status_line = if status_line_candidate { + status_line.as_ref().map(|line| { + truncate_line_with_ellipsis_if_overflow(line.clone(), available_width) + }) + } else { + None + }; + let status_line_active = status_line_candidate && truncated_status_line.is_some(); + let left_mode_indicator = if status_line_active { + None + } else { + self.collaboration_mode_indicator + }; + let mut left_width = if self.footer_flash_visible() { self.footer_flash .as_ref() .map(|flash| flash.line.width() as u16) .unwrap_or(0) } else if let Some(items) = self.footer_hint_override.as_ref() { footer_hint_items_width(items) + } else if status_line_active { + truncated_status_line + .as_ref() + .map(|line| line.width() as u16) + .unwrap_or(0) } else { footer_line_width( - footer_props, - self.collaboration_mode_indicator, + &footer_props, + left_mode_indicator, show_cycle_hint, show_shortcuts_hint, show_queue_hint, ) }; + let right_line = if status_line_active { + let full = + mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint); + let compact = mode_indicator_line(self.collaboration_mode_indicator, false); + let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0); + if can_show_left_with_context(hint_rect, left_width, full_width) { + full + } else { + compact + } + } else { + Some(context_window_line( + footer_props.context_window_percent, + footer_props.context_window_used_tokens, + )) + }; + let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0); + if status_line_active + && let Some(max_left) = max_left_width_for_right(hint_rect, right_width) + && left_width > max_left + && let Some(line) = 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); + } let can_show_left_and_context = - can_show_left_with_context(hint_rect, left_width, context_width); + can_show_left_with_context(hint_rect, left_width, right_width); let has_override = self.footer_flash_visible() || self.footer_hint_override.is_some(); let single_line_layout = if has_override { @@ -3149,8 +3218,8 @@ impl ChatComposer { // the context indicator on narrow widths. Some(single_line_footer_layout( hint_rect, - context_width, - self.collaboration_mode_indicator, + right_width, + left_mode_indicator, show_cycle_hint, show_shortcuts_hint, show_queue_hint, @@ -3161,7 +3230,7 @@ impl ChatComposer { | FooterMode::ShortcutOverlay => None, } }; - let show_context = if matches!( + let show_right = if matches!( footer_props.mode, FooterMode::EscHint | FooterMode::QuitShortcutReminder @@ -3178,15 +3247,31 @@ impl ChatComposer { if let Some((summary_left, _)) = single_line_layout { match summary_left { SummaryLeft::Default => { - render_footer_from_props( - hint_rect, - buf, - footer_props, - self.collaboration_mode_indicator, - show_cycle_hint, - show_shortcuts_hint, - show_queue_hint, - ); + if status_line_active { + if let Some(line) = truncated_status_line.clone() { + render_footer_line(hint_rect, buf, line); + } else { + render_footer_from_props( + hint_rect, + buf, + &footer_props, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ); + } + } else { + render_footer_from_props( + hint_rect, + buf, + &footer_props, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ); + } } SummaryLeft::Custom(line) => { render_footer_line(hint_rect, buf, line); @@ -3199,11 +3284,15 @@ impl ChatComposer { } } else if let Some(items) = self.footer_hint_override.as_ref() { render_footer_hint_items(hint_rect, buf, items); + } else if status_line_active { + if let Some(line) = truncated_status_line { + render_footer_line(hint_rect, buf, line); + } } else { render_footer_from_props( hint_rect, buf, - footer_props, + &footer_props, self.collaboration_mode_indicator, show_cycle_hint, show_shortcuts_hint, @@ -3211,8 +3300,8 @@ impl ChatComposer { ); } - if show_context { - render_context_right(hint_rect, buf, &context_line); + if show_right && let Some(line) = &right_line { + render_context_right(hint_rect, buf, line); } } } @@ -3488,7 +3577,7 @@ mod tests { ); setup(&mut composer); let footer_props = composer.footer_props(); - let footer_lines = footer_height(footer_props); + let footer_lines = footer_height(&footer_props); let footer_spacing = ChatComposer::footer_spacing(footer_lines); let height = footer_lines + footer_spacing + 8; let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 4893f2f6a0f..f6f61acf4b7 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -53,7 +53,7 @@ use ratatui::widgets::Widget; /// (`render_footer_from_props` or the single-line collapse logic). The footer /// treats these values as authoritative and does not attempt to infer missing /// state (for example, it does not query whether a task is running). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub(crate) struct FooterProps { pub(crate) mode: FooterMode, pub(crate) esc_backtrack_hint: bool, @@ -68,6 +68,8 @@ pub(crate) struct FooterProps { pub(crate) quit_shortcut_key: KeyBinding, pub(crate) context_window_percent: Option, pub(crate) context_window_used_tokens: Option, + pub(crate) status_line_value: Option>, + pub(crate) status_line_enabled: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -168,7 +170,7 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { } } -pub(crate) fn footer_height(props: FooterProps) -> u16 { +pub(crate) fn footer_height(props: &FooterProps) -> u16 { let show_shortcuts_hint = match props.mode { FooterMode::ComposerEmpty => true, FooterMode::QuitShortcutReminder @@ -206,7 +208,7 @@ pub(crate) fn render_footer_line(area: Rect, buf: &mut Buffer, line: Line<'stati pub(crate) fn render_footer_from_props( area: Rect, buf: &mut Buffer, - props: FooterProps, + props: &FooterProps, collaboration_mode_indicator: Option, show_cycle_hint: bool, show_shortcuts_hint: bool, @@ -448,6 +450,13 @@ pub(crate) fn single_line_footer_layout( (SummaryLeft::None, true) } +pub(crate) fn mode_indicator_line( + indicator: Option, + show_cycle_hint: bool, +) -> Option> { + indicator.map(|indicator| Line::from(vec![indicator.styled_span(show_cycle_hint)])) +} + fn right_aligned_x(area: Rect, content_width: u16) -> Option { if area.is_empty() { return None; @@ -471,6 +480,20 @@ fn right_aligned_x(area: Rect, content_width: u16) -> Option { ) } +pub(crate) fn max_left_width_for_right(area: Rect, right_width: u16) -> Option { + let context_x = right_aligned_x(area, right_width)?; + let left_start = area.x + FOOTER_INDENT_COLS as u16; + + // minimal one column gap between left and right + let gap = FOOTER_CONTEXT_GAP_COLS; + + if context_x <= left_start + gap { + return Some(0); + } + + Some(context_x.saturating_sub(left_start + gap)) +} + pub(crate) fn can_show_left_with_context(area: Rect, left_width: u16, context_width: u16) -> bool { let Some(context_x) = right_aligned_x(area, context_width) else { return true; @@ -534,12 +557,22 @@ pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(S /// fallback decisions live in `single_line_footer_layout`; this function only /// formats the chosen/default content. fn footer_from_props_lines( - props: FooterProps, + props: &FooterProps, collaboration_mode_indicator: Option, show_cycle_hint: bool, show_shortcuts_hint: bool, show_queue_hint: bool, ) -> Vec> { + // If status line content is present, show it for base modes. + if props.status_line_enabled + && let Some(status_line) = &props.status_line_value + && matches!( + props.mode, + FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft + ) + { + return vec![status_line.clone().dim()]; + } match props.mode { FooterMode::QuitShortcutReminder => { vec![quit_shortcut_reminder_line(props.quit_shortcut_key)] @@ -580,7 +613,7 @@ fn footer_from_props_lines( } pub(crate) fn footer_line_width( - props: FooterProps, + props: &FooterProps, collaboration_mode_indicator: Option, show_cycle_hint: bool, show_shortcuts_hint: bool, @@ -957,31 +990,27 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ #[cfg(test)] mod tests { use super::*; + use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow; + use crate::test_backend::VT100Backend; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::Terminal; + use ratatui::backend::Backend; use ratatui::backend::TestBackend; fn snapshot_footer(name: &str, props: FooterProps) { - snapshot_footer_with_mode_indicator(name, 80, props, None); + snapshot_footer_with_mode_indicator(name, 80, &props, None); } - fn snapshot_footer_with_mode_indicator( - name: &str, - width: u16, - props: FooterProps, + fn draw_footer_frame( + terminal: &mut Terminal, + height: u16, + props: &FooterProps, collaboration_mode_indicator: Option, ) { - let height = footer_height(props).max(1); - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal .draw(|f| { let area = Rect::new(0, 0, f.area().width, height); - let context_line = context_window_line( - props.context_window_percent, - props.context_window_used_tokens, - ); - let context_width = context_line.width() as u16; let show_cycle_hint = !props.is_task_running; let show_shortcuts_hint = match props.mode { FooterMode::ComposerEmpty => true, @@ -997,53 +1026,118 @@ mod tests { | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, }; - let left_width = footer_line_width( - props, - collaboration_mode_indicator, - show_cycle_hint, - show_shortcuts_hint, - show_queue_hint, - ); + let left_mode_indicator = if props.status_line_enabled { + None + } else { + collaboration_mode_indicator + }; + let available_width = area.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize; + let mut truncated_status_line = if props.status_line_enabled + && matches!( + props.mode, + FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft + ) { + props + .status_line_value + .as_ref() + .map(|line| line.clone().dim()) + .map(|line| truncate_line_with_ellipsis_if_overflow(line, available_width)) + } else { + None + }; + let mut left_width = if props.status_line_enabled { + truncated_status_line + .as_ref() + .map(|line| line.width() as u16) + .unwrap_or(0) + } else { + footer_line_width( + props, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ) + }; + let right_line = if props.status_line_enabled { + let full = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint); + let compact = mode_indicator_line(collaboration_mode_indicator, false); + let full_width = full.as_ref().map(|line| line.width() as u16).unwrap_or(0); + if can_show_left_with_context(area, left_width, full_width) { + full + } else { + compact + } + } else { + Some(context_window_line( + props.context_window_percent, + props.context_window_used_tokens, + )) + }; + let right_width = right_line + .as_ref() + .map(|line| line.width() as u16) + .unwrap_or(0); + if props.status_line_enabled + && let Some(max_left) = max_left_width_for_right(area, right_width) + && left_width > max_left + && let Some(line) = props + .status_line_value + .as_ref() + .map(|line| line.clone().dim()) + .map(|line| { + truncate_line_with_ellipsis_if_overflow(line, max_left as usize) + }) + { + left_width = line.width() as u16; + truncated_status_line = Some(line); + } let can_show_left_and_context = - can_show_left_with_context(area, left_width, context_width); + can_show_left_with_context(area, left_width, right_width); if matches!( props.mode, FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft ) { let (summary_left, show_context) = single_line_footer_layout( area, - context_width, - collaboration_mode_indicator, + right_width, + left_mode_indicator, show_cycle_hint, show_shortcuts_hint, show_queue_hint, ); match summary_left { SummaryLeft::Default => { - render_footer_from_props( - area, - f.buffer_mut(), - props, - collaboration_mode_indicator, - show_cycle_hint, - show_shortcuts_hint, - show_queue_hint, - ); + if props.status_line_enabled { + if let Some(line) = truncated_status_line.clone() { + render_footer_line(area, f.buffer_mut(), line); + } + } else { + render_footer_from_props( + area, + f.buffer_mut(), + props, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ); + } } SummaryLeft::Custom(line) => { render_footer_line(area, f.buffer_mut(), line); } SummaryLeft::None => {} } - if show_context { - render_context_right(area, f.buffer_mut(), &context_line); + if show_context && let Some(line) = &right_line { + render_context_right(area, f.buffer_mut(), line); } } else { render_footer_from_props( area, f.buffer_mut(), props, - collaboration_mode_indicator, + left_mode_indicator, show_cycle_hint, show_shortcuts_hint, show_queue_hint, @@ -1055,15 +1149,37 @@ mod tests { | FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay ); - if show_context { - render_context_right(area, f.buffer_mut(), &context_line); + if show_context && let Some(line) = &right_line { + render_context_right(area, f.buffer_mut(), line); } } }) .unwrap(); + } + + fn snapshot_footer_with_mode_indicator( + name: &str, + width: u16, + props: &FooterProps, + collaboration_mode_indicator: Option, + ) { + let height = footer_height(props).max(1); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator); assert_snapshot!(name, terminal.backend()); } + fn render_footer_with_mode_indicator( + width: u16, + props: &FooterProps, + collaboration_mode_indicator: Option, + ) -> String { + let height = footer_height(props).max(1); + let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal"); + draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator); + terminal.backend().vt100().screen().contents() + } + #[test] fn footer_snapshots() { snapshot_footer( @@ -1079,6 +1195,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, }, ); @@ -1095,6 +1213,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, }, ); @@ -1111,6 +1231,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, }, ); @@ -1127,6 +1249,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, }, ); @@ -1143,6 +1267,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, }, ); @@ -1159,6 +1285,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, }, ); @@ -1175,6 +1303,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, }, ); @@ -1191,6 +1321,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: Some(72), context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, }, ); @@ -1207,6 +1339,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: Some(123_456), + status_line_value: None, + status_line_enabled: false, }, ); @@ -1223,6 +1357,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, }, ); @@ -1239,6 +1375,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, }, ); @@ -1253,19 +1391,21 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, }; snapshot_footer_with_mode_indicator( "footer_mode_indicator_wide", 120, - props, + &props, Some(CollaborationModeIndicator::Plan), ); snapshot_footer_with_mode_indicator( "footer_mode_indicator_narrow_overlap_hides", 50, - props, + &props, Some(CollaborationModeIndicator::Plan), ); @@ -1280,14 +1420,161 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), context_window_percent: None, context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, }; snapshot_footer_with_mode_indicator( "footer_mode_indicator_running_hides_hint", 120, - props, + &props, Some(CollaborationModeIndicator::Plan), ); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + steer_enabled: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: None, + context_window_used_tokens: None, + status_line_value: Some(Line::from("Status line content".to_string())), + status_line_enabled: true, + }; + + snapshot_footer("footer_status_line_overrides_shortcuts", props); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + steer_enabled: false, + collaboration_modes_enabled: true, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: Some(50), + context_window_used_tokens: None, + status_line_value: None, // command timed out / empty + status_line_enabled: true, + }; + + snapshot_footer_with_mode_indicator( + "footer_status_line_enabled_mode_right", + 120, + &props, + Some(CollaborationModeIndicator::Plan), + ); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + steer_enabled: false, + collaboration_modes_enabled: true, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: Some(50), + context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, + }; + + snapshot_footer_with_mode_indicator( + "footer_status_line_disabled_context_right", + 120, + &props, + Some(CollaborationModeIndicator::Plan), + ); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + steer_enabled: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: Some(50), + context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: true, + }; + + // has status line and no collaboration mode + snapshot_footer_with_mode_indicator( + "footer_status_line_enabled_no_mode_right", + 120, + &props, + None, + ); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + steer_enabled: false, + collaboration_modes_enabled: true, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: Some(50), + context_window_used_tokens: None, + status_line_value: Some(Line::from( + "Status line content that should truncate before the mode indicator".to_string(), + )), + status_line_enabled: true, + }; + + snapshot_footer_with_mode_indicator( + "footer_status_line_truncated_with_gap", + 40, + &props, + Some(CollaborationModeIndicator::Plan), + ); + } + + #[test] + fn footer_status_line_truncates_to_keep_mode_indicator() { + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + steer_enabled: false, + collaboration_modes_enabled: true, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: Some(50), + context_window_used_tokens: None, + status_line_value: Some(Line::from( + "Status line content that is definitely too long to fit alongside the mode label" + .to_string(), + )), + status_line_enabled: true, + }; + + let screen = + render_footer_with_mode_indicator(80, &props, Some(CollaborationModeIndicator::Plan)); + let collapsed = screen.split_whitespace().collect::>().join(" "); + assert!( + collapsed.contains("Plan mode"), + "mode indicator should remain visible" + ); + assert!( + !collapsed.contains("shift+tab to cycle"), + "compact mode indicator should be used when space is tight" + ); + assert!( + screen.contains('…'), + "status line should be truncated with ellipsis to keep mode indicator" + ); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b251beb80e7..b9c77fc72d1 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -36,11 +36,14 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::text::Line; use std::time::Duration; mod app_link_view; mod approval_overlay; +mod multi_select_picker; mod request_user_input; +mod status_line_setup; pub(crate) use app_link_view::AppLinkView; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; @@ -74,6 +77,8 @@ pub(crate) use feedback_view::feedback_selection_params; 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::StatusLineSetupView; mod paste_burst; pub mod popup_consts; mod queued_user_messages; @@ -862,6 +867,16 @@ impl BottomPane { RenderableItem::Owned(Box::new(flex2)) } } + + pub(crate) fn set_status_line(&mut self, status_line: Option>) { + self.composer.set_status_line(status_line); + self.request_redraw(); + } + + pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) { + self.composer.set_status_line_enabled(enabled); + self.request_redraw(); + } } impl Renderable for BottomPane { diff --git a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs new file mode 100644 index 00000000000..0fb027a57a7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs @@ -0,0 +1,795 @@ +//! Multi-select picker widget for selecting multiple items from a list. +//! +//! This module provides a fuzzy-searchable, scrollable picker that allows users +//! to toggle multiple items on/off. It supports: +//! +//! - **Fuzzy search**: Type to filter items by name +//! - **Toggle selection**: Space to toggle items on/off +//! - **Reordering**: Optional left/right arrow support to reorder items +//! - **Live preview**: Optional callback to show a preview of current selections +//! - **Callbacks**: Hooks for change, confirm, and cancel events +//! +//! # Example +//! +//! ```ignore +//! let picker = MultiSelectPicker::new( +//! "Select Items".to_string(), +//! Some("Choose which items to enable".to_string()), +//! 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 }, +//! ]) +//! .on_confirm(|selected_ids, tx| { /* handle confirmation */ }) +//! .build(); +//! ``` + +use codex_common::fuzzy_match::fuzzy_match; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::Widget; + +use super::selection_popup_common::GenericDisplayRow; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS; +use crate::bottom_pane::scroll_state::ScrollState; +use crate::bottom_pane::selection_popup_common::render_rows_single_line; +use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow; +use crate::key_hint; +use crate::render::Insets; +use crate::render::RectExt; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::style::user_message_style; +use crate::text_formatting::truncate_text; + +/// Maximum display length for item names before truncation. +const ITEM_NAME_TRUNCATE_LEN: usize = 21; + +/// Placeholder text shown in the search input when empty. +const SEARCH_PLACEHOLDER: &str = "Type to search"; + +/// Prefix displayed before the search query (mimics a command prompt). +const SEARCH_PROMPT_PREFIX: &str = "> "; + +/// Direction for reordering items in the list. +enum Direction { + Up, + Down, +} + +/// Callback invoked when any item's state changes (toggled or reordered). +/// Receives the full list of items and the event sender. +pub type ChangeCallBack = Box; + +/// Callback invoked when the user confirms their selection (presses Enter). +/// Receives a list of IDs for all enabled items. +pub type ConfirmCallback = Box; + +/// Callback invoked when the user cancels the picker (presses Escape). +pub type CancelCallback = Box; + +/// Callback to generate an optional preview line based on current item states. +/// Returns `None` to hide the preview area. +pub type PreviewCallback = Box Option> + Send + Sync>; + +/// A single selectable item in the multi-select picker. +/// +/// 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, + + /// Display name shown in the picker list. Will be truncated if too long. + pub name: String, + + /// Optional description shown alongside the name (dimmed). + pub description: Option, + + /// Whether this item is currently selected/enabled. + pub enabled: bool, +} + +/// A multi-select picker widget with fuzzy search and optional reordering. +/// +/// The picker displays a scrollable list of items with checkboxes. Users can: +/// - Type to fuzzy-search and filter the list +/// - Use Up/Down (or Ctrl+P/Ctrl+N) to navigate +/// - Press Space to toggle the selected item +/// - Press Enter to confirm and close +/// - Press Escape to cancel and close +/// - Use Left/Right arrows to reorder items (if ordering is enabled) +/// +/// Create instances using the builder pattern via [`MultiSelectPicker::new`]. +pub(crate) struct MultiSelectPicker { + /// All items in the picker (unfiltered). + items: Vec, + + /// Scroll and selection state for the visible list. + state: ScrollState, + + /// Whether the picker has been closed (confirmed or cancelled). + pub(crate) complete: bool, + + /// Channel for sending application events. + app_event_tx: AppEventSender, + + /// Header widget displaying title and subtitle. + header: Box, + + /// Footer line showing keyboard hints. + footer_hint: Line<'static>, + + /// Current search/filter query entered by the user. + search_query: String, + + /// Indices into `items` that match the current filter, in display order. + filtered_indices: Vec, + + /// Whether left/right arrow reordering is enabled. + ordering_enabled: bool, + + /// Optional callback to generate a preview line from current item states. + preview_builder: Option, + + /// Cached preview line (updated on item changes). + preview_line: Option>, + + /// Callback invoked when items change (toggle or reorder). + on_change: Option, + + /// Callback invoked when the user confirms their selection. + on_confirm: Option, + + /// Callback invoked when the user cancels the picker. + on_cancel: Option, +} + +impl MultiSelectPicker { + /// Creates a new builder for constructing a `MultiSelectPicker`. + /// + /// # Arguments + /// + /// * `title` - The main title displayed at the top of the picker + /// * `subtitle` - Optional subtitle displayed below the title (dimmed) + /// * `app_event_tx` - Event sender for dispatching application events + pub fn builder( + title: String, + subtitle: Option, + app_event_tx: AppEventSender, + ) -> MultiSelectPickerBuilder { + MultiSelectPickerBuilder::new(title, subtitle, app_event_tx) + } + + /// Applies the current search query to filter and sort items. + /// + /// Updates `filtered_indices` to contain only matching items, sorted by + /// fuzzy match score. Attempts to preserve the current selection if it + /// still matches the filter. + fn apply_filter(&mut self) { + // Filter + sort while preserving the current selection when possible. + let previously_selected = self + .state + .selected_idx + .and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied()); + + let filter = self.search_query.trim(); + if filter.is_empty() { + self.filtered_indices = (0..self.items.len()).collect(); + } else { + let mut matches: Vec<(usize, i32)> = Vec::new(); + for (idx, item) in self.items.iter().enumerate() { + let display_name = item.name.as_str(); + if let Some((_indices, score)) = match_item(filter, display_name, &item.name) { + matches.push((idx, score)); + } + } + + matches.sort_by(|a, b| { + a.1.cmp(&b.1).then_with(|| { + let an = self.items[a.0].name.as_str(); + let bn = self.items[b.0].name.as_str(); + an.cmp(bn) + }) + }); + + self.filtered_indices = matches.into_iter().map(|(idx, _score)| idx).collect(); + } + + let len = self.filtered_indices.len(); + self.state.selected_idx = previously_selected + .and_then(|actual_idx| { + self.filtered_indices + .iter() + .position(|idx| *idx == actual_idx) + }) + .or_else(|| (len > 0).then_some(0)); + + let visible = Self::max_visible_rows(len); + self.state.clamp_selection(len); + self.state.ensure_visible(len, visible); + } + + /// Returns the number of items visible after filtering. + fn visible_len(&self) -> usize { + self.filtered_indices.len() + } + + /// Returns the maximum number of rows that can be displayed at once. + fn max_visible_rows(len: usize) -> usize { + MAX_POPUP_ROWS.min(len.max(1)) + } + + /// Calculates the width available for row content (accounts for borders). + fn rows_width(total_width: u16) -> u16 { + total_width.saturating_sub(2) + } + + /// 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) + } + + /// 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 { + 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() + } + + /// Moves the selection cursor up, wrapping to the bottom if at the top. + fn move_up(&mut self) { + let len = self.visible_len(); + self.state.move_up_wrap(len); + let visible = Self::max_visible_rows(len); + self.state.ensure_visible(len, visible); + } + + /// Moves the selection cursor down, wrapping to the top if at the bottom. + fn move_down(&mut self) { + let len = self.visible_len(); + self.state.move_down_wrap(len); + let visible = Self::max_visible_rows(len); + self.state.ensure_visible(len, visible); + } + + /// Toggles the enabled state of the currently selected item. + /// + /// Updates the preview line and invokes the `on_change` callback if set. + fn toggle_selected(&mut self) { + let Some(idx) = self.state.selected_idx else { + return; + }; + let Some(actual_idx) = self.filtered_indices.get(idx).copied() else { + return; + }; + let Some(item) = self.items.get_mut(actual_idx) else { + return; + }; + + item.enabled = !item.enabled; + self.update_preview_line(); + if let Some(on_change) = &self.on_change { + on_change(&self.items, &self.app_event_tx); + } + } + + /// Confirms the current selection and closes the picker. + /// + /// Collects the IDs of all enabled items and passes them to the + /// `on_confirm` callback. Does nothing if already complete. + fn confirm_selection(&mut self) { + if self.complete { + return; + } + self.complete = true; + + if let Some(on_confirm) = &self.on_confirm { + let selected_ids: Vec = self + .items + .iter() + .filter(|item| item.enabled) + .map(|item| item.id.clone()) + .collect(); + on_confirm(&selected_ids, &self.app_event_tx); + } + } + + /// Moves the currently selected item up or down in the list. + /// + /// Only works when: + /// - The search query is empty (reordering is disabled during filtering) + /// - Ordering is enabled via [`MultiSelectPickerBuilder::enable_ordering`] + /// + /// Updates the preview line and invokes the `on_change` callback. + fn move_selected_item(&mut self, direction: Direction) { + if !self.search_query.is_empty() { + return; + } + + let Some(visible_idx) = self.state.selected_idx else { + return; + }; + let Some(actual_idx) = self.filtered_indices.get(visible_idx).copied() else { + return; + }; + + let len = self.items.len(); + if len == 0 { + 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, + }; + + // move item in underlying list + self.items.swap(actual_idx, new_idx); + + self.update_preview_line(); + if let Some(on_change) = &self.on_change { + on_change(&self.items, &self.app_event_tx); + } + + // rebuild filtered indices to keep search/filter consistent + self.apply_filter(); + + // restore selection to moved item + let moved_idx = new_idx; + if let Some(new_visible_idx) = self + .filtered_indices + .iter() + .position(|idx| *idx == moved_idx) + { + self.state.selected_idx = Some(new_visible_idx); + } + } + + /// Regenerates the preview line using the preview callback. + /// + /// Called after any item state change (toggle or reorder). + fn update_preview_line(&mut self) { + self.preview_line = self + .preview_builder + .as_ref() + .and_then(|builder| builder(&self.items)); + } + + /// Closes the picker without confirming, invoking the `on_cancel` callback. + /// + /// Does nothing if already complete. + pub fn close(&mut self) { + if self.complete { + return; + } + self.complete = true; + if let Some(on_cancel) = &self.on_cancel { + on_cancel(&self.app_event_tx); + } + } +} + +impl BottomPaneView for MultiSelectPicker { + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.close(); + CancellationEvent::Handled + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { code: KeyCode::Left, .. } if self.ordering_enabled => { + self.move_selected_item(Direction::Up); + } + KeyEvent { code: KeyCode::Right, .. } if self.ordering_enabled => { + self.move_selected_item(Direction::Down); + } + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{0010}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^P */ => self.move_up(), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{000e}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^N */ => self.move_down(), + KeyEvent { + code: KeyCode::Backspace, + .. + } => { + self.search_query.pop(); + self.apply_filter(); + } + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } => self.toggle_selected(), + KeyEvent { + code: KeyCode::Enter, + .. + } => self.confirm_selection(), + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.close(); + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + self.search_query.push(c); + self.apply_filter(); + } + _ => {} + } + } +} + +impl Renderable for MultiSelectPicker { + fn desired_height(&self, width: u16) -> u16 { + let rows = self.build_rows(); + let rows_height = self.rows_height(&rows); + let preview_height = if self.preview_line.is_some() { 1 } else { 0 }; + + let mut height = self.header.desired_height(width.saturating_sub(4)); + height = height.saturating_add(rows_height + 3); + height = height.saturating_add(2); + height.saturating_add(1 + preview_height) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + // Reserve the footer line for the key-hint row. + let preview_height = if self.preview_line.is_some() { 1 } else { 0 }; + let footer_height = 1 + preview_height; + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_height)]).areas(area); + + Block::default() + .style(user_message_style()) + .render(content_area, buf); + + let header_height = self + .header + .desired_height(content_area.width.saturating_sub(4)); + let rows = self.build_rows(); + let rows_width = Self::rows_width(content_area.width); + let rows_height = self.rows_height(&rows); + let [header_area, _, search_area, list_area] = Layout::vertical([ + Constraint::Max(header_height), + Constraint::Max(1), + Constraint::Length(2), + Constraint::Length(rows_height), + ]) + .areas(content_area.inset(Insets::vh(1, 2))); + + self.header.render(header_area, buf); + + // Render the search prompt as two lines to mimic the composer. + if search_area.height >= 2 { + let [placeholder_area, input_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(search_area); + Line::from(SEARCH_PLACEHOLDER.dim()).render(placeholder_area, buf); + let line = if self.search_query.is_empty() { + Line::from(vec![SEARCH_PROMPT_PREFIX.dim()]) + } else { + Line::from(vec![ + SEARCH_PROMPT_PREFIX.dim(), + self.search_query.clone().into(), + ]) + }; + line.render(input_area, buf); + } else if search_area.height > 0 { + let query_span = if self.search_query.is_empty() { + SEARCH_PLACEHOLDER.dim() + } else { + self.search_query.clone().into() + }; + Line::from(query_span).render(search_area, buf); + } + + if list_area.height > 0 { + let render_area = Rect { + x: list_area.x.saturating_sub(2), + y: list_area.y, + width: rows_width.max(1), + height: list_area.height, + }; + render_rows_single_line( + render_area, + buf, + &rows, + &self.state, + render_area.height as usize, + "no matches", + ); + } + + let hint_area = if let Some(preview_line) = &self.preview_line { + let [preview_area, hint_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(footer_area); + let preview_area = Rect { + x: preview_area.x + 2, + y: preview_area.y, + width: preview_area.width.saturating_sub(2), + height: preview_area.height, + }; + let max_preview_width = preview_area.width.saturating_sub(2) as usize; + let preview_line = + truncate_line_with_ellipsis_if_overflow(preview_line.clone(), max_preview_width); + preview_line.render(preview_area, buf); + hint_area + } else { + footer_area + }; + let hint_area = Rect { + x: hint_area.x + 2, + y: hint_area.y, + width: hint_area.width.saturating_sub(2), + height: hint_area.height, + }; + self.footer_hint.clone().dim().render(hint_area, buf); + } +} + +/// Builder for constructing a [`MultiSelectPicker`] with a fluent API. +/// +/// # Example +/// +/// ```ignore +/// let picker = MultiSelectPicker::new("Title".into(), None, tx) +/// .items(items) +/// .enable_ordering() +/// .on_preview(|items| Some(Line::from("Preview"))) +/// .on_confirm(|ids, tx| { /* handle */ }) +/// .on_cancel(|tx| { /* handle */ }) +/// .build(); +/// ``` +pub(crate) struct MultiSelectPickerBuilder { + title: String, + subtitle: Option, + instructions: Vec>, + items: Vec, + ordering_enabled: bool, + app_event_tx: AppEventSender, + preview_builder: Option, + on_change: Option, + on_confirm: Option, + on_cancel: Option, +} + +impl MultiSelectPickerBuilder { + /// Creates a new builder with the given title, optional subtitle, and event sender. + pub fn new(title: String, subtitle: Option, app_event_tx: AppEventSender) -> Self { + Self { + title, + subtitle, + instructions: Vec::new(), + items: Vec::new(), + ordering_enabled: false, + app_event_tx, + preview_builder: None, + on_change: None, + on_confirm: None, + on_cancel: None, + } + } + + /// Sets the list of selectable items. + pub fn items(mut self, items: Vec) -> Self { + self.items = items; + self + } + + /// Sets custom instruction spans for the footer hint line. + /// + /// If not set, default instructions are shown (Space to toggle, Enter to + /// confirm, Escape to close). + pub fn instructions(mut self, instructions: Vec>) -> Self { + self.instructions = instructions; + self + } + + /// Enables left/right arrow keys for reordering items. + /// + /// Reordering is only active when the search query is empty. + pub fn enable_ordering(mut self) -> Self { + self.ordering_enabled = true; + self + } + + /// Sets a callback to generate a preview line from the current item states. + /// + /// The callback receives all items and should return a [`Line`] to display, + /// or `None` to hide the preview area. + pub fn on_preview(mut self, callback: F) -> Self + where + F: Fn(&[MultiSelectItem]) -> Option> + Send + Sync + 'static, + { + self.preview_builder = Some(Box::new(callback)); + self + } + + /// Sets a callback invoked whenever an item's state changes. + /// + /// This includes both toggles and reordering operations. + #[allow(dead_code)] + pub fn on_change(mut self, callback: F) -> Self + where + F: Fn(&[MultiSelectItem], &AppEventSender) + Send + Sync + 'static, + { + self.on_change = Some(Box::new(callback)); + self + } + + /// Sets a callback invoked when the user confirms their selection (Enter). + /// + /// The callback receives a list of IDs for all enabled items. + pub fn on_confirm(mut self, callback: F) -> Self + where + F: Fn(&[String], &AppEventSender) + Send + Sync + 'static, + { + self.on_confirm = Some(Box::new(callback)); + self + } + + /// Sets a callback invoked when the user cancels the picker (Escape). + pub fn on_cancel(mut self, callback: F) -> Self + where + F: Fn(&AppEventSender) + Send + Sync + 'static, + { + self.on_cancel = Some(Box::new(callback)); + self + } + + /// Builds the [`MultiSelectPicker`] with all configured options. + /// + /// Initializes the filter to show all items and generates the initial + /// preview line if a preview callback was set. + pub fn build(self) -> MultiSelectPicker { + let mut header = ColumnRenderable::new(); + header.push(Line::from(self.title.bold())); + + if let Some(subtitle) = self.subtitle { + header.push(Line::from(subtitle.dim())); + } + + let instructions = if self.instructions.is_empty() { + vec![ + "Press ".into(), + key_hint::plain(KeyCode::Char(' ')).into(), + " to toggle; ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to confirm and close; ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to close".into(), + ] + } else { + self.instructions + }; + + let mut view = MultiSelectPicker { + items: self.items, + state: ScrollState::new(), + complete: false, + app_event_tx: self.app_event_tx, + header: Box::new(header), + footer_hint: Line::from(instructions), + ordering_enabled: self.ordering_enabled, + search_query: String::new(), + filtered_indices: Vec::new(), + preview_builder: self.preview_builder, + preview_line: None, + on_change: self.on_change, + on_confirm: self.on_confirm, + on_cancel: self.on_cancel, + }; + view.apply_filter(); + view.update_preview_line(); + view + } +} + +/// Performs fuzzy matching on an item against a filter string. +/// +/// Tries to match against the display name first, then falls back to name if different. Returns +/// the matching character indices (if matched on display name) and a score for sorting. +/// +/// # Arguments +/// +/// * `filter` - The search query to match against +/// * `display_name` - The primary name to match (shown to user) +/// * `name` - A secondary/canonical name to try if display name doesn't match +/// +/// # Returns +/// +/// * `Some((Some(indices), score))` - Matched on display name with highlight indices +/// * `Some((None, score))` - Matched on skill name only (no highlights for display) +/// * `None` - No match +pub(crate) fn match_item( + filter: &str, + display_name: &str, + name: &str, +) -> Option<(Option>, i32)> { + if let Some((indices, score)) = fuzzy_match(display_name, filter) { + return Some((Some(indices), score)); + } + if display_name != name + && let Some((_indices, score)) = fuzzy_match(name, filter) + { + return Some((None, score)); + } + None +} diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index f8e1ad89020..3f827dc41f8 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -106,15 +106,20 @@ fn line_width(line: &Line<'_>) -> usize { .sum() } -fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> { +pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> { if max_width == 0 { return Line::from(Vec::>::new()); } + let Line { + style, + alignment, + spans, + } = line; let mut used = 0usize; let mut spans_out: Vec> = Vec::new(); - for span in line.spans { + for span in spans { let text = span.content.into_owned(); let style = span.style; let span_width = UnicodeWidthStr::width(text.as_str()); @@ -151,10 +156,17 @@ fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static break; } - Line::from(spans_out) + Line { + style, + alignment, + spans: spans_out, + } } -fn truncate_line_with_ellipsis_if_overflow(line: Line<'static>, max_width: usize) -> Line<'static> { +pub(crate) fn truncate_line_with_ellipsis_if_overflow( + line: Line<'static>, + max_width: usize, +) -> Line<'static> { if max_width == 0 { return Line::from(Vec::>::new()); } @@ -165,10 +177,18 @@ fn truncate_line_with_ellipsis_if_overflow(line: Line<'static>, max_width: usize } let truncated = truncate_line_to_width(line, max_width.saturating_sub(1)); - let mut spans = truncated.spans; + let Line { + style, + alignment, + mut spans, + } = truncated; let ellipsis_style = spans.last().map(|span| span.style).unwrap_or_default(); spans.push(Span::styled("…", ellipsis_style)); - Line::from(spans) + Line { + style, + alignment, + spans, + } } /// Computes the shared start column used for descriptions in selection rows. diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_disabled_context_right.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_disabled_context_right.snap new file mode 100644 index 00000000000..b86792ac777 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_disabled_context_right.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ? for shortcuts · Plan mode (shift+tab to cycle) 50% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_right.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_right.snap new file mode 100644 index 00000000000..2da49eeb640 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_right.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Plan mode (shift+tab to cycle) " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_no_mode_right.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_no_mode_right.snap new file mode 100644 index 00000000000..68138916136 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_no_mode_right.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_context.snap new file mode 100644 index 00000000000..d3958253e31 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_context.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Italic text " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_shortcuts.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_shortcuts.snap new file mode 100644 index 00000000000..bb0e2d33b94 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_shortcuts.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Status line content " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_truncated_with_gap.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_truncated_with_gap.snap new file mode 100644 index 00000000000..cef1531fd6e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_truncated_with_gap.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Status line content that … Plan mode " diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs new file mode 100644 index 00000000000..29bcf7b9c9a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -0,0 +1,278 @@ +//! Status line configuration view for customizing the TUI status bar. +//! +//! This module provides an interactive picker for selecting which items appear +//! in the status line at the bottom of the terminal. Users can: +//! +//! - **Select items**: Toggle which information is displayed +//! - **Reorder items**: Use left/right arrows to change display order +//! - **Preview changes**: See a live preview of the configured status line +//! +//! # Available Status Line Items +//! +//! - Model information (name, reasoning level) +//! - Directory paths (current dir, project root) +//! - Git information (branch name) +//! - Context usage (remaining %, used %, window size) +//! - Usage limits (5-hour, weekly) +//! - Session info (ID, tokens used) +//! - Application version + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::Line; +use std::collections::HashSet; +use strum::IntoEnumIterator; +use strum_macros::Display; +use strum_macros::EnumIter; +use strum_macros::EnumString; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +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::render::renderable::Renderable; + +/// Available items that can be displayed in the status line. +/// +/// Each variant represents a piece of information that can be shown at the +/// bottom of the TUI. Items are serialized to kebab-case for configuration +/// storage (e.g., `ModelWithReasoning` becomes `model-with-reasoning`). +/// +/// Some items are conditionally displayed based on availability: +/// - 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)] +#[strum(serialize_all = "kebab_case")] +pub(crate) enum StatusLineItem { + /// The current model name. + ModelName, + + /// Model name with reasoning level suffix. + ModelWithReasoning, + + /// Current working directory path. + CurrentDir, + + /// Project root directory (if detected). + ProjectRoot, + + /// Current git branch name (if in a repository). + GitBranch, + + /// Percentage of context window remaining. + ContextRemaining, + + /// Percentage of context window used. + ContextUsed, + + /// Remaining usage on the 5-hour rate limit. + FiveHourLimit, + + /// Remaining usage on the weekly rate limit. + WeeklyLimit, + + /// Codex application version. + CodexVersion, + + /// Total context window size in tokens. + ContextWindowSize, + + /// Total tokens used in the current session. + UsedTokens, + + /// Total input tokens consumed. + TotalInputTokens, + + /// Total output tokens generated. + TotalOutputTokens, + + /// Full session UUID. + SessionId, +} + +impl StatusLineItem { + /// User-visible description shown in the popup. + pub(crate) fn description(&self) -> &'static str { + match self { + StatusLineItem::ModelName => "Current model name", + StatusLineItem::ModelWithReasoning => "Current model name with reasoning level", + StatusLineItem::CurrentDir => "Current working directory", + StatusLineItem::ProjectRoot => "Project root directory (omitted when unavailable)", + StatusLineItem::GitBranch => "Current Git branch (omitted when unavailable)", + StatusLineItem::ContextRemaining => { + "Percentage of context window remaining (omitted when unknown)" + } + StatusLineItem::ContextUsed => { + "Percentage of context window used (omitted when unknown)" + } + StatusLineItem::FiveHourLimit => { + "Remaining usage on 5-hour usage limit (omitted when unavailable)" + } + StatusLineItem::WeeklyLimit => { + "Remaining usage on weekly usage limit (omitted when unavailable)" + } + StatusLineItem::CodexVersion => "Codex application version", + StatusLineItem::ContextWindowSize => { + "Total context window size in tokens (omitted when unknown)" + } + StatusLineItem::UsedTokens => "Total tokens used in session (omitted when zero)", + StatusLineItem::TotalInputTokens => "Total input tokens used in session", + StatusLineItem::TotalOutputTokens => "Total output tokens used in session", + StatusLineItem::SessionId => { + "Current session identifier (omitted until session starts)" + } + } + } + + /// Returns an example rendering of this item for the preview. + /// + /// These are placeholder values used to show users what each item looks + /// like in the status line before they confirm their selection. + pub(crate) fn render(&self) -> &'static str { + match self { + StatusLineItem::ModelName => "gpt-5.2-codex", + StatusLineItem::ModelWithReasoning => "gpt-5.2-codex medium", + StatusLineItem::CurrentDir => "~/project/path", + StatusLineItem::ProjectRoot => "~/project", + StatusLineItem::GitBranch => "feat/awesome-feature", + StatusLineItem::ContextRemaining => "18% left", + StatusLineItem::ContextUsed => "82% used", + StatusLineItem::FiveHourLimit => "5h 100%", + StatusLineItem::WeeklyLimit => "weekly 98%", + StatusLineItem::CodexVersion => "v0.93.0", + StatusLineItem::ContextWindowSize => "258K window", + StatusLineItem::UsedTokens => "27.3K used", + StatusLineItem::TotalInputTokens => "17,588 in", + StatusLineItem::TotalOutputTokens => "265 out", + StatusLineItem::SessionId => "019c19bd-ceb6-73b0-adc8-8ec0397b85cf", + } + } +} + +/// Interactive view for configuring which items appear in the status line. +/// +/// Wraps a [`MultiSelectPicker`] with status-line-specific behavior: +/// - Pre-populates items from current configuration +/// - Shows a live preview of the configured status line +/// - Emits [`AppEvent::StatusLineSetup`] on confirmation +/// - Emits [`AppEvent::StatusLineSetupCancelled`] on cancellation +pub(crate) struct StatusLineSetupView { + /// The underlying multi-select picker widget. + picker: MultiSelectPicker, +} + +impl StatusLineSetupView { + /// Creates a new status line setup view. + /// + /// # Arguments + /// + /// * `status_line_items` - Currently configured item IDs (in display order), + /// or `None` to start with all items disabled + /// * `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]>, app_event_tx: AppEventSender) -> Self { + let mut used_ids = HashSet::new(); + let mut items = Vec::new(); + + if let Some(selected_items) = status_line_items.as_ref() { + for id in *selected_items { + let Ok(item) = id.parse::() else { + continue; + }; + let item_id = item.to_string(); + if !used_ids.insert(item_id.clone()) { + continue; + } + items.push(Self::status_line_select_item(item, true)); + } + } + + for item in StatusLineItem::iter() { + let item_id = item.to_string(); + if used_ids.contains(&item_id) { + continue; + } + items.push(Self::status_line_select_item(item, false)); + } + + Self { + picker: MultiSelectPicker::builder( + "Configure Status Line".to_string(), + Some("Select which items to display in the status line.".to_string()), + app_event_tx, + ) + .instructions(vec![ + "Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel." + .into(), + ]) + .items(items) + .enable_ordering() + .on_preview(|items| { + let preview = items + .iter() + .filter(|item| item.enabled) + .filter_map(|item| item.id.parse::().ok()) + .map(|item| item.render()) + .collect::>() + .join(" · "); + if preview.is_empty() { + None + } else { + Some(Line::from(preview)) + } + }) + .on_confirm(|ids, app_event| { + let items = ids + .iter() + .map(|id| id.parse::()) + .collect::, _>>() + .unwrap_or_default(); + app_event.send(AppEvent::StatusLineSetup { items }); + }) + .on_cancel(|app_event| { + app_event.send(AppEvent::StatusLineSetupCancelled); + }) + .build(), + } + } + + /// Converts a [`StatusLineItem`] into a [`MultiSelectItem`] for the picker. + fn status_line_select_item(item: StatusLineItem, enabled: bool) -> MultiSelectItem { + MultiSelectItem { + id: item.to_string(), + name: item.to_string(), + description: Some(item.description().to_string()), + enabled, + } + } +} + +impl BottomPaneView for StatusLineSetupView { + fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) { + self.picker.handle_key_event(key_event); + } + + fn is_complete(&self) -> bool { + self.picker.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.picker.close(); + CancellationEvent::Handled + } +} + +impl Renderable for StatusLineSetupView { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.picker.render(area, buf) + } + + fn desired_height(&self, width: u16) -> u16 { + self.picker.desired_height(width) + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f29403f68c4..9096527e1c7 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -26,18 +26,29 @@ use std::collections::VecDeque; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; +use crate::bottom_pane::StatusLineItem; +use crate::bottom_pane::StatusLineSetupView; +use crate::status::RateLimitWindowDisplay; +use crate::status::format_directory_display; +use crate::status::format_tokens_compact; +use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; +use codex_app_server_protocol::ConfigLayerSource; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_core::config::Config; use codex_core::config::ConstraintResult; use codex_core::config::types::Notifications; +use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::features::FEATURES; use codex_core::features::Feature; use codex_core::git_info::current_branch_name; +use codex_core::git_info::get_git_repo_root; use codex_core::git_info::local_git_branches; use codex_core::models_manager::manager::ModelsManager; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; @@ -389,6 +400,8 @@ pub(crate) struct ChatWidgetInit { pub(crate) is_first_run: bool, pub(crate) feedback_audience: FeedbackAudience, pub(crate) model: Option, + // Shared latch so we only warn once about invalid status-line item IDs. + pub(crate) status_line_invalid_items_warned: Arc, pub(crate) otel_manager: OtelManager, } @@ -580,6 +593,18 @@ pub(crate) struct ChatWidget { feedback_audience: FeedbackAudience, // Current session rollout path (if known) current_rollout_path: Option, + // Current working directory (if known) + current_cwd: Option, + // Shared latch so we only warn once about invalid status-line item IDs. + status_line_invalid_items_warned: Arc, + // Cached git branch name for the status line (None if unknown). + status_line_branch: Option, + // CWD used to resolve the cached branch; change resets branch state. + status_line_branch_cwd: Option, + // True while an async branch lookup is in flight. + status_line_branch_pending: bool, + // True once we've attempted a branch lookup for the current CWD. + status_line_branch_lookup_complete: bool, external_editor_state: ExternalEditorState, } @@ -791,6 +816,120 @@ impl ChatWidget { self.set_status(header, None); } + /// Sets the currently rendered footer status-line value and schedules a redraw. + pub(crate) fn set_status_line(&mut self, status_line: Option>) { + self.bottom_pane.set_status_line(status_line); + self.request_redraw(); + } + + /// Recomputes footer status-line content from config and current runtime state. + /// + /// This method is the status-line orchestrator: it parses configured item identifiers, + /// warns once per session about invalid items, updates whether status-line mode is enabled, + /// schedules async git-branch lookup when needed, and renders only values that are currently + /// available. + /// + /// The omission behavior is intentional. If selected items are unavailable (for example before + /// a session id exists or before branch lookup completes), those items are skipped without + /// placeholders so the line remains compact and stable. + pub(crate) fn refresh_status_line(&mut self) { + let (items, invalid_items) = self.status_line_items_with_invalids(); + if self.thread_id.is_some() + && !invalid_items.is_empty() + && self + .status_line_invalid_items_warned + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + let label = if invalid_items.len() == 1 { + "item" + } else { + "items" + }; + let message = format!( + "Ignored invalid status line {label}: {}.", + proper_join(invalid_items.as_slice()) + ); + self.on_warning(message); + } + if !items.contains(&StatusLineItem::GitBranch) { + self.status_line_branch = None; + self.status_line_branch_pending = false; + self.status_line_branch_lookup_complete = false; + } + let enabled = !items.is_empty(); + self.bottom_pane.set_status_line_enabled(enabled); + if !enabled { + self.set_status_line(None); + return; + } + + let cwd = self.status_line_cwd().to_path_buf(); + self.sync_status_line_branch_state(&cwd); + + if items.contains(&StatusLineItem::GitBranch) && !self.status_line_branch_lookup_complete { + self.request_status_line_branch(cwd); + } + + let mut parts = Vec::new(); + for item in items { + if let Some(value) = self.status_line_value_for_item(&item) { + parts.push(value); + } + } + + let line = if parts.is_empty() { + None + } else { + Some(Line::from(parts.join(" · "))) + }; + self.set_status_line(line); + } + + /// Records that status-line setup was canceled. + /// + /// Cancellation is intentionally side-effect free for config state; the existing configuration + /// remains active and no persistence is attempted. + pub(crate) fn cancel_status_line_setup(&self) { + tracing::info!("Status line setup canceled by user"); + } + + /// Applies status-line item selection from the setup view to in-memory config. + /// + /// An empty selection is normalized to `None` so the status line is fully disabled and the + /// behavior matches an unset `tui.status_line` config value. + pub(crate) fn setup_status_line(&mut self, items: Vec) { + tracing::info!("status line setup confirmed with items: {items:#?}"); + let ids = items.iter().map(ToString::to_string).collect::>(); + self.config.tui_status_line = if ids.is_empty() { None } else { Some(ids) }; + self.refresh_status_line(); + } + + /// Stores async git-branch lookup results for the current status-line cwd. + /// + /// Results are dropped when they target an out-of-date cwd to avoid rendering stale branch + /// names after directory changes. + pub(crate) fn set_status_line_branch(&mut self, cwd: PathBuf, branch: Option) { + if self.status_line_branch_cwd.as_ref() != Some(&cwd) { + self.status_line_branch_pending = false; + return; + } + self.status_line_branch = branch; + self.status_line_branch_pending = false; + self.status_line_branch_lookup_complete = true; + } + + /// Forces a new git-branch lookup when `GitBranch` is part of the configured status line. + fn request_status_line_branch_refresh(&mut self) { + let (items, _) = self.status_line_items_with_invalids(); + if items.is_empty() || !items.contains(&StatusLineItem::GitBranch) { + return; + } + let cwd = self.status_line_cwd().to_path_buf(); + self.sync_status_line_branch_state(&cwd); + self.request_status_line_branch(cwd); + } + fn restore_retry_status_header_if_present(&mut self) { if let Some(header) = self.retry_status_header.take() { self.set_status_header(header); @@ -807,6 +946,7 @@ impl ChatWidget { self.thread_name = event.thread_name.clone(); self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); + self.current_cwd = Some(event.cwd.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); @@ -1065,6 +1205,7 @@ impl ChatWidget { } self.needs_final_message_separator = false; self.had_work_activity = false; + self.request_status_line_branch_refresh(); } // Mark task stopped and request redraw now that all content is in history. self.agent_turn_running = false; @@ -1278,6 +1419,7 @@ impl ChatWidget { } else { self.rate_limit_snapshot = None; } + self.refresh_status_line(); } /// Finalize any active exec as failed and stop/clear agent-turn UI state. /// @@ -1297,6 +1439,7 @@ impl ChatWidget { self.adaptive_chunking.reset(); self.stream_controller = None; self.plan_stream_controller = None; + self.request_status_line_branch_refresh(); self.maybe_show_pending_rate_limit_prompt(); } @@ -1800,6 +1943,7 @@ impl ChatWidget { fn on_turn_diff(&mut self, unified_diff: String) { debug!("TurnDiffEvent: {unified_diff}"); + self.refresh_status_line(); } fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) { @@ -2226,6 +2370,7 @@ impl ChatWidget { is_first_run, feedback_audience, model, + status_line_invalid_items_warned, otel_manager, } = common; let model = model.filter(|m| !m.trim().is_empty()); @@ -2258,6 +2403,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); + let current_cwd = Some(config.cwd.clone()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -2329,6 +2475,12 @@ impl ChatWidget { feedback, feedback_audience, current_rollout_path: None, + current_cwd, + status_line_invalid_items_warned, + status_line_branch: None, + status_line_branch_cwd: None, + status_line_branch_pending: false, + status_line_branch_lookup_complete: false, external_editor_state: ExternalEditorState::Closed, }; @@ -2336,6 +2488,13 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_status_line_enabled( + widget + .config + .tui_status_line + .as_ref() + .is_some_and(|items| !items.is_empty()), + ); widget.bottom_pane.set_collaboration_modes_enabled( widget.config.features.enabled(Feature::CollaborationModes), ); @@ -2373,6 +2532,7 @@ impl ChatWidget { is_first_run, feedback_audience, model, + status_line_invalid_items_warned, otel_manager, } = common; let model = model.filter(|m| !m.trim().is_empty()); @@ -2403,6 +2563,7 @@ impl ChatWidget { }; let active_cell = Some(Self::placeholder_session_header_cell(&config)); + let current_cwd = Some(config.cwd.clone()); let mut widget = Self { app_event_tx: app_event_tx.clone(), @@ -2475,6 +2636,12 @@ impl ChatWidget { feedback, feedback_audience, current_rollout_path: None, + current_cwd, + status_line_invalid_items_warned, + status_line_branch: None, + status_line_branch_cwd: None, + status_line_branch_pending: false, + status_line_branch_lookup_complete: false, external_editor_state: ExternalEditorState::Closed, }; @@ -2482,6 +2649,13 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_status_line_enabled( + widget + .config + .tui_status_line + .as_ref() + .is_some_and(|items| !items.is_empty()), + ); widget.bottom_pane.set_collaboration_modes_enabled( widget.config.features.enabled(Feature::CollaborationModes), ); @@ -2505,10 +2679,11 @@ impl ChatWidget { auth_manager, models_manager, feedback, + is_first_run: _, feedback_audience, model, + status_line_invalid_items_warned, otel_manager, - .. } = common; let model = model.filter(|m| !m.trim().is_empty()); let mut rng = rand::rng(); @@ -2525,6 +2700,7 @@ impl ChatWidget { .and_then(|mask| mask.model.clone()) .unwrap_or(header_model); + let current_cwd = Some(session_configured.cwd.clone()); let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); @@ -2610,6 +2786,12 @@ impl ChatWidget { feedback, feedback_audience, current_rollout_path: None, + current_cwd, + status_line_invalid_items_warned, + status_line_branch: None, + status_line_branch_cwd: None, + status_line_branch_pending: false, + status_line_branch_lookup_complete: false, external_editor_state: ExternalEditorState::Closed, }; @@ -2617,6 +2799,13 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_status_line_enabled( + widget + .config + .tui_status_line + .as_ref() + .is_some_and(|items| !items.is_empty()), + ); widget.bottom_pane.set_collaboration_modes_enabled( widget.config.features.enabled(Feature::CollaborationModes), ); @@ -3009,6 +3198,9 @@ impl ChatWidget { SlashCommand::DebugConfig => { self.add_debug_config_output(); } + SlashCommand::Statusline => { + self.open_status_line_setup(); + } SlashCommand::Ps => { self.add_ps_output(); } @@ -3788,6 +3980,229 @@ impl ChatWidget { self.add_to_history(crate::debug_config::new_debug_config_output(&self.config)); } + fn open_status_line_setup(&mut self) { + let view = StatusLineSetupView::new( + self.config.tui_status_line.as_deref(), + self.app_event_tx.clone(), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + /// Parses configured status-line ids into known items and collects unknown ids. + /// + /// Unknown ids are deduplicated in insertion order for warning messages. + fn status_line_items_with_invalids(&self) -> (Vec, Vec) { + let mut invalid = Vec::new(); + let mut invalid_seen = HashSet::new(); + let mut items = Vec::new(); + let Some(config_items) = self.config.tui_status_line.as_ref() else { + return (items, invalid); + }; + for id in config_items { + match id.parse::() { + Ok(item) => items.push(item), + Err(_) => { + if invalid_seen.insert(id.clone()) { + invalid.push(format!(r#""{id}""#)); + } + } + } + } + (items, invalid) + } + + fn status_line_cwd(&self) -> &Path { + self.current_cwd.as_ref().unwrap_or(&self.config.cwd) + } + + fn status_line_project_root(&self) -> Option { + let cwd = self.status_line_cwd(); + if let Some(repo_root) = get_git_repo_root(cwd) { + return Some(repo_root); + } + + self.config + .config_layer_stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .iter() + .find_map(|layer| match &layer.name { + ConfigLayerSource::Project { dot_codex_folder } => { + dot_codex_folder.as_path().parent().map(Path::to_path_buf) + } + _ => None, + }) + } + + fn status_line_project_root_name(&self) -> Option { + self.status_line_project_root().map(|root| { + root.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| format_directory_display(&root, None)) + }) + } + + /// Resets git-branch cache state when the status-line cwd changes. + /// + /// The branch cache is keyed by cwd because branch lookup is performed relative to that path. + /// Keeping stale branch values across cwd changes would surface incorrect repository context. + fn sync_status_line_branch_state(&mut self, cwd: &Path) { + if self + .status_line_branch_cwd + .as_ref() + .is_some_and(|path| path == cwd) + { + return; + } + self.status_line_branch_cwd = Some(cwd.to_path_buf()); + self.status_line_branch = None; + self.status_line_branch_pending = false; + self.status_line_branch_lookup_complete = false; + } + + /// Starts an async git-branch lookup unless one is already running. + /// + /// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject + /// stale completions after directory changes. + fn request_status_line_branch(&mut self, cwd: PathBuf) { + if self.status_line_branch_pending { + return; + } + self.status_line_branch_pending = true; + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let branch = current_branch_name(&cwd).await; + tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch }); + }); + } + + /// Resolves a display string for one configured status-line item. + /// + /// 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. + fn status_line_value_for_item(&self, item: &StatusLineItem) -> Option { + match item { + StatusLineItem::ModelName => Some(self.model_display_name().to_string()), + StatusLineItem::ModelWithReasoning => { + let label = + Self::status_line_reasoning_effort_label(self.effective_reasoning_effort()); + Some(format!("{} {label}", self.model_display_name())) + } + StatusLineItem::CurrentDir => { + Some(format_directory_display(self.status_line_cwd(), None)) + } + StatusLineItem::ProjectRoot => self.status_line_project_root_name(), + StatusLineItem::GitBranch => self.status_line_branch.clone(), + StatusLineItem::UsedTokens => { + let usage = self.status_line_total_usage(); + let total = usage.tokens_in_context_window(); + if total <= 0 { + None + } else { + Some(format!("{} used", format_tokens_compact(total))) + } + } + StatusLineItem::ContextRemaining => self + .status_line_context_remaining_percent() + .map(|remaining| format!("{remaining}% left")), + StatusLineItem::ContextUsed => self + .status_line_context_used_percent() + .map(|used| format!("{used}% used")), + StatusLineItem::FiveHourLimit => { + let window = self + .rate_limit_snapshot + .as_ref() + .and_then(|s| s.primary.as_ref()); + let label = window + .and_then(|window| window.window_minutes) + .map(get_limits_duration) + .unwrap_or_else(|| "5h".to_string()); + self.status_line_limit_display(window, &label) + } + StatusLineItem::WeeklyLimit => { + let window = self + .rate_limit_snapshot + .as_ref() + .and_then(|s| s.secondary.as_ref()); + let label = window + .and_then(|window| window.window_minutes) + .map(get_limits_duration) + .unwrap_or_else(|| "weekly".to_string()); + self.status_line_limit_display(window, &label) + } + StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()), + StatusLineItem::ContextWindowSize => self + .status_line_context_window_size() + .map(|cws| format!("{} window", format_tokens_compact(cws))), + StatusLineItem::TotalInputTokens => Some(format!( + "{} in", + format_tokens_compact(self.status_line_total_usage().input_tokens) + )), + StatusLineItem::TotalOutputTokens => Some(format!( + "{} out", + format_tokens_compact(self.status_line_total_usage().output_tokens) + )), + StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()), + } + } + + fn status_line_context_window_size(&self) -> Option { + self.token_info + .as_ref() + .and_then(|info| info.model_context_window) + .or(self.config.model_context_window) + } + + fn status_line_context_remaining_percent(&self) -> Option { + let Some(context_window) = self.status_line_context_window_size() else { + return Some(100); + }; + let default_usage = TokenUsage::default(); + let usage = self + .token_info + .as_ref() + .map(|info| &info.last_token_usage) + .unwrap_or(&default_usage); + Some( + usage + .percent_of_context_window_remaining(context_window) + .clamp(0, 100), + ) + } + + fn status_line_context_used_percent(&self) -> Option { + let remaining = self.status_line_context_remaining_percent().unwrap_or(100); + Some((100 - remaining).clamp(0, 100)) + } + + fn status_line_total_usage(&self) -> TokenUsage { + self.token_info + .as_ref() + .map(|info| info.total_token_usage.clone()) + .unwrap_or_default() + } + + fn status_line_limit_display( + &self, + window: Option<&RateLimitWindowDisplay>, + label: &str, + ) -> Option { + let window = window?; + let remaining = (100.0f64 - window.used_percent).clamp(0.0f64, 100.0f64); + Some(format!("{label} {remaining:.0}%")) + } + + fn status_line_reasoning_effort_label(effort: Option) -> &'static str { + match effort { + Some(ReasoningEffortConfig::Minimal) => "minimal", + Some(ReasoningEffortConfig::Low) => "low", + Some(ReasoningEffortConfig::Medium) => "medium", + Some(ReasoningEffortConfig::High) => "high", + Some(ReasoningEffortConfig::XHigh) => "xhigh", + None | Some(ReasoningEffortConfig::None) => "default", + } + } + pub(crate) fn add_ps_output(&mut self) { let processes = self .unified_exec_processes diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index dba8bf82e14..8ae17df0460 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -766,6 +766,7 @@ async fn helpers_are_available_and_do_not_panic() { is_first_run: true, feedback_audience: FeedbackAudience::External, model: Some(resolved_model), + status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), otel_manager, }; let mut w = ChatWidget::new(init, thread_manager); @@ -894,6 +895,12 @@ async fn make_chatwidget_manual( feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, current_rollout_path: None, + current_cwd: None, + status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), + status_line_branch: None, + status_line_branch_cwd: None, + status_line_branch_pending: false, + status_line_branch_lookup_complete: false, external_editor_state: ExternalEditorState::Closed, }; widget.set_model(&resolved_model); @@ -2510,6 +2517,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() { is_first_run: true, feedback_audience: FeedbackAudience::External, model: Some(resolved_model.clone()), + status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), otel_manager, }; @@ -2555,6 +2563,7 @@ async fn experimental_mode_plan_applies_on_startup() { is_first_run: true, feedback_audience: FeedbackAudience::External, model: Some(resolved_model.clone()), + status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), otel_manager, }; @@ -4780,6 +4789,83 @@ async fn warning_event_adds_warning_history_cell() { ); } +#[tokio::test] +async fn status_line_invalid_items_warn_once() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.tui_status_line = Some(vec![ + "model_name".to_string(), + "bogus_item".to_string(), + "lines_changed".to_string(), + "bogus_item".to_string(), + ]); + chat.thread_id = Some(ThreadId::new()); + + chat.refresh_status_line(); + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one warning history cell"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("bogus_item"), + "warning cell missing invalid item content: {rendered}" + ); + + chat.refresh_status_line(); + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected invalid status line warning to emit only once" + ); +} + +#[tokio::test] +async fn status_line_branch_state_resets_when_git_branch_disabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.status_line_branch = Some("main".to_string()); + chat.status_line_branch_pending = true; + chat.status_line_branch_lookup_complete = true; + chat.config.tui_status_line = Some(vec!["model_name".to_string()]); + + chat.refresh_status_line(); + + assert_eq!(chat.status_line_branch, None); + assert!(!chat.status_line_branch_pending); + assert!(!chat.status_line_branch_lookup_complete); +} + +#[tokio::test] +async fn status_line_branch_refreshes_after_turn_complete() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.tui_status_line = Some(vec!["git-branch".to_string()]); + chat.status_line_branch_lookup_complete = true; + chat.status_line_branch_pending = false; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + }), + }); + + assert!(chat.status_line_branch_pending); +} + +#[tokio::test] +async fn status_line_branch_refreshes_after_interrupt() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.tui_status_line = Some(vec!["git-branch".to_string()]); + chat.status_line_branch_lookup_complete = true; + chat.status_line_branch_pending = false; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + assert!(chat.status_line_branch_pending); +} + #[tokio::test] async fn stream_recovery_restores_previous_status_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 505ebc8edcf..9683df53baa 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -325,7 +325,7 @@ pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { chosen.display().to_string() } -fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) { +pub(crate) fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) { if let Ok(patch) = diffy::Patch::from_str(diff) { patch .hunks() diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index e4ac5dcacf4..6bb41b4696d 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -34,6 +34,7 @@ pub enum SlashCommand { Mention, Status, DebugConfig, + Statusline, Mcp, Apps, Logout, @@ -65,6 +66,7 @@ impl SlashCommand { SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", + SlashCommand::Statusline => "configure which items appear in the status line", SlashCommand::Ps => "list background terminals", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Personality => "choose a communication style for Codex", @@ -131,6 +133,7 @@ impl SlashCommand { SlashCommand::TestApproval => true, SlashCommand::Collab => true, SlashCommand::Agent => true, + SlashCommand::Statusline => false, } } diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs index 89c8daefdcf..4f46af1cbc0 100644 --- a/codex-rs/tui/src/status/mod.rs +++ b/codex-rs/tui/src/status/mod.rs @@ -1,3 +1,11 @@ +//! Status output formatting and display adapters for the TUI. +//! +//! This module turns protocol-level snapshots into stable display structures used by `/status` +//! output and footer/status-line helpers, while keeping rendering concerns out of transport-facing +//! code. +//! +//! `rate_limits` is the main integration point for status-line usage-limit items: it converts raw +//! window snapshots into local-time labels and classifies data as available, stale, or missing. mod account; mod card; mod format; @@ -5,8 +13,10 @@ mod helpers; mod rate_limits; pub(crate) use card::new_status_output; +pub(crate) use helpers::format_directory_display; pub(crate) use helpers::format_tokens_compact; pub(crate) use rate_limits::RateLimitSnapshotDisplay; +pub(crate) use rate_limits::RateLimitWindowDisplay; pub(crate) use rate_limits::rate_limit_snapshot_display; #[cfg(test)] diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 3fae3ac295c..830fe158b2e 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -1,3 +1,10 @@ +//! Rate-limit and credits display shaping for status surfaces. +//! +//! This module maps `RateLimitSnapshot` protocol payloads into display-oriented rows that the TUI +//! can render in `/status` and status-line contexts without duplicating formatting logic. +//! +//! The key contract is that time-sensitive values are interpreted relative to a caller-provided +//! capture timestamp so stale detection and reset labels remain coherent for a given draw cycle. use crate::chatwidget::get_limits_duration; use crate::text_formatting::capitalize_first; @@ -16,42 +23,58 @@ const STATUS_LIMIT_BAR_EMPTY: &str = "░"; #[derive(Debug, Clone)] pub(crate) struct StatusRateLimitRow { + /// Human-readable row label, such as `"5h limit"` or `"Credits"`. pub label: String, + /// Value payload for the row. pub value: StatusRateLimitValue, } +/// Display value variants for a single rate-limit row. #[derive(Debug, Clone)] pub(crate) enum StatusRateLimitValue { + /// Percent-based usage window with optional reset timestamp text. Window { + /// Percent of the window that has been consumed. percent_used: f64, + /// Localized reset string, or `None` when unknown. resets_at: Option, }, + /// Plain text value used for non-window rows. Text(String), } +/// Availability state for rate-limit data shown in status output. #[derive(Debug, Clone)] pub(crate) enum StatusRateLimitData { + /// Snapshot data is recent enough for normal rendering. Available(Vec), + /// Snapshot data exists but is older than the staleness threshold. Stale(Vec), + /// No snapshot data is currently available. Missing, } +/// Maximum age before a snapshot is considered stale in status output. pub(crate) const RATE_LIMIT_STALE_THRESHOLD_MINUTES: i64 = 15; +/// Display-friendly representation of one usage window from a snapshot. #[derive(Debug, Clone)] pub(crate) struct RateLimitWindowDisplay { + /// Percent used for the window. pub used_percent: f64, + /// Human-readable local reset time. pub resets_at: Option, + /// Window length in minutes when provided by the server. pub window_minutes: Option, } impl RateLimitWindowDisplay { fn from_window(window: &RateLimitWindow, captured_at: DateTime) -> Self { - let resets_at = window + let resets_at_utc = window .resets_at .and_then(|seconds| DateTime::::from_timestamp(seconds, 0)) - .map(|dt| dt.with_timezone(&Local)) - .map(|dt| format_reset_timestamp(dt, captured_at)); + .map(|dt| dt.with_timezone(&Local)); + let resets_at = resets_at_utc.map(|dt| format_reset_timestamp(dt, captured_at)); Self { used_percent: window.used_percent, @@ -63,19 +86,31 @@ impl RateLimitWindowDisplay { #[derive(Debug, Clone)] pub(crate) struct RateLimitSnapshotDisplay { + /// Local timestamp representing when this display snapshot was captured. pub captured_at: DateTime, + /// Primary usage window (typically short duration). pub primary: Option, + /// Secondary usage window (typically weekly). pub secondary: Option, + /// Optional credits metadata when available. pub credits: Option, } +/// Display-ready credits state extracted from protocol snapshots. #[derive(Debug, Clone)] pub(crate) struct CreditsSnapshotDisplay { + /// Whether credits tracking is enabled for the account. pub has_credits: bool, + /// Whether the account has unlimited credits. pub unlimited: bool, + /// Raw balance text as provided by the backend. pub balance: Option, } +/// Converts a protocol snapshot into UI-friendly display data. +/// +/// Pass the timestamp from the same observation point as `snapshot`; supplying a significantly +/// older or newer `captured_at` can produce misleading reset labels and stale classification. pub(crate) fn rate_limit_snapshot_display( snapshot: &RateLimitSnapshot, captured_at: DateTime, @@ -104,6 +139,10 @@ impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay { } } +/// Builds display rows from a snapshot and marks stale data by capture age. +/// +/// Callers should pass `Local::now()` for `now` at render time; using a cached timestamp can make +/// fresh data appear stale or prevent stale warnings from appearing. pub(crate) fn compose_rate_limit_data( snapshot: Option<&RateLimitSnapshotDisplay>, now: DateTime, @@ -163,6 +202,10 @@ pub(crate) fn compose_rate_limit_data( } } +/// Renders a fixed-width progress bar from remaining percentage. +/// +/// This function expects a remaining value in the `0..=100` range and clamps out-of-range input. +/// Passing a used percentage by mistake will invert the bar and mislead users. pub(crate) fn render_status_limit_progress_bar(percent_remaining: f64) -> String { let ratio = (percent_remaining / 100.0).clamp(0.0, 1.0); let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize; @@ -175,6 +218,7 @@ pub(crate) fn render_status_limit_progress_bar(percent_remaining: f64) -> String ) } +/// Formats a compact textual summary from remaining percentage. pub(crate) fn format_status_limit_summary(percent_remaining: f64) -> String { format!("{percent_remaining:.0}% left") } diff --git a/codex-rs/tui/src/text_formatting.rs b/codex-rs/tui/src/text_formatting.rs index f747b2fef2a..4869645dc00 100644 --- a/codex-rs/tui/src/text_formatting.rs +++ b/codex-rs/tui/src/text_formatting.rs @@ -327,6 +327,33 @@ pub(crate) fn center_truncate_path(path: &str, max_width: usize) -> String { front_truncate(path, max_width) } +/// Join a list of strings with proper English punctuation. +/// Examples: +/// - [] -> "" +/// - ["apple"] -> "apple" +/// - ["apple", "banana"] -> "apple and banana" +/// - ["apple", "banana", "cherry"] -> "apple, banana and cherry" +pub(crate) fn proper_join>(items: &[T]) -> String { + match items.len() { + 0 => String::new(), + 1 => items[0].as_ref().to_string(), + 2 => format!("{} and {}", items[0].as_ref(), items[1].as_ref()), + _ => { + let last = items[items.len() - 1].as_ref(); + let mut result = String::new(); + + for (i, item) in items.iter().take(items.len() - 1).enumerate() { + if i > 0 { + result.push_str(", "); + } + result.push_str(item.as_ref()); + } + + format!("{result} and {last}") + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -534,4 +561,20 @@ mod tests { assert_eq!(format_json_compact("null").unwrap(), "null"); assert_eq!(format_json_compact(r#""string""#).unwrap(), r#""string""#); } + + #[test] + fn test_proper_join() { + let empty: Vec = vec![]; + assert_eq!(proper_join(&empty), ""); + assert_eq!(proper_join(&["apple"]), "apple"); + assert_eq!(proper_join(&["apple", "banana"]), "apple and banana"); + assert_eq!( + proper_join(&["apple", "banana", "cherry"]), + "apple, banana and cherry" + ); + assert_eq!( + proper_join(&["apple", "banana", "cherry", "date"]), + "apple, banana, cherry and date" + ); + } }