feat(tui): add /statusline command for interactive status line configuration (#10546)

## Summary
- Adds a new `/statusline` command to configure TUI footer status line
- Introduces reusable `MultiSelectPicker` component with keyboard
navigation, optional ordering and toggle support
- Implement status line setup modal that persist configuration to
config.toml

  ## Status Line Items
  The following items can be displayed in the status line:
  - **Model**: Current model name (with optional reasoning level)
  - **Context**: Remaining/used context window percentage
  - **Rate Limits**: 5-day and weekly usage limits
  - **Git**: Current branch (with optimized lookups)
  - **Tokens**: Used tokens, input/output token counts
  - **Session**: Session ID (full or shortened prefix)
  - **Paths**: Current directory, project root
  - **Version**: Codex version

  ## Features
  - Live preview while configuring status line items
  - Fuzzy search filtering in the picker
  - Intelligent truncation when items don't fit
  - Items gracefully omit when data is unavailable
  - Configuration persists to `config.toml`
  - Validates and warns about invalid status line items

  ## Test plan
  - [x] Run `/statusline` and verify picker UI appears
  - [x] Toggle items on/off and verify live preview updates
  - [x] Confirm selection persists after restart
  - [x] Verify truncation behavior with many items selected
  - [x] Test git branch detection in and out of git repos

---------

Co-authored-by: Josh McKinney <joshka@openai.com>
This commit is contained in:
Felipe Coury
2026-02-05 13:50:21 -03:00
committed by GitHub
Unverified
parent 3b54fd7336
commit b0e5a6305b
25 changed files with 2324 additions and 83 deletions
+8
View File
@@ -1049,6 +1049,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"
+18
View File
@@ -55,6 +55,24 @@ pub enum ConfigEdit {
ClearPath { segments: Vec<String> },
}
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;
+9
View File
@@ -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<Vec<String>>,
/// 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.
@@ -1712,6 +1715,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);
@@ -1947,6 +1951,7 @@ persistence = "none"
show_tooltips: true,
experimental_mode: None,
alternate_screen: AltScreenMode::Auto,
status_line: None,
}
);
}
@@ -3897,6 +3902,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
@@ -3983,6 +3989,7 @@ model_verbosity = "high"
analytics_enabled: Some(true),
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
otel: OtelConfig::default(),
};
@@ -4084,6 +4091,7 @@ model_verbosity = "high"
analytics_enabled: Some(false),
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
otel: OtelConfig::default(),
};
@@ -4171,6 +4179,7 @@ model_verbosity = "high"
analytics_enabled: Some(true),
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
otel: OtelConfig::default(),
};
+6
View File
@@ -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<Vec<String>>,
}
const fn default_true() -> bool {
+72
View File
@@ -535,6 +535,8 @@ pub(crate) struct App {
/// Controls the animation thread that sends CommitTick events.
pub(crate) commit_anim_running: Arc<AtomicBool>,
// Shared across ChatWidget instances so invalid status-line config warnings only emit once.
status_line_invalid_items_warned: Arc<AtomicBool>,
// 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<AppRunControl> {
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());
@@ -1564,12 +1589,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);
@@ -2203,11 +2231,45 @@ impl App {
));
}
},
AppEvent::StatusLineSetup { items } => {
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
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;
@@ -2219,6 +2281,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) {
@@ -2473,6 +2539,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,
@@ -2629,6 +2699,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(),
@@ -2682,6 +2753,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(),
+13
View File
@@ -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<String>,
},
/// Apply a user-confirmed status-line item ordering/selection.
StatusLineSetup {
items: Vec<StatusLineItem>,
},
/// Dismiss the status-line setup UI without changing config.
StatusLineSetupCancelled,
}
/// The exit strategy requested by the UI layer.
+117 -28
View File
@@ -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<Line<'static>>,
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<Line<'static>>) {
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();
+331 -44
View File
@@ -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<i64>,
pub(crate) context_window_used_tokens: Option<i64>,
pub(crate) status_line_value: Option<Line<'static>>,
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<CollaborationModeIndicator>,
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<CollaborationModeIndicator>,
show_cycle_hint: bool,
) -> Option<Line<'static>> {
indicator.map(|indicator| Line::from(vec![indicator.styled_span(show_cycle_hint)]))
}
fn right_aligned_x(area: Rect, content_width: u16) -> Option<u16> {
if area.is_empty() {
return None;
@@ -471,6 +480,20 @@ fn right_aligned_x(area: Rect, content_width: u16) -> Option<u16> {
)
}
pub(crate) fn max_left_width_for_right(area: Rect, right_width: u16) -> Option<u16> {
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<CollaborationModeIndicator>,
show_cycle_hint: bool,
show_shortcuts_hint: bool,
show_queue_hint: bool,
) -> Vec<Line<'static>> {
// 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<CollaborationModeIndicator>,
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<B: Backend>(
terminal: &mut Terminal<B>,
height: u16,
props: &FooterProps,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
) {
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<CollaborationModeIndicator>,
) {
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<CollaborationModeIndicator>,
) -> 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::<Vec<_>>().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]
+15
View File
@@ -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<Line<'static>>) {
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 {
@@ -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<dyn Fn(&[MultiSelectItem], &AppEventSender) + Send + Sync>;
/// Callback invoked when the user confirms their selection (presses Enter).
/// Receives a list of IDs for all enabled items.
pub type ConfirmCallback = Box<dyn Fn(&[String], &AppEventSender) + Send + Sync>;
/// Callback invoked when the user cancels the picker (presses Escape).
pub type CancelCallback = Box<dyn Fn(&AppEventSender) + Send + Sync>;
/// Callback to generate an optional preview line based on current item states.
/// Returns `None` to hide the preview area.
pub type PreviewCallback = Box<dyn Fn(&[MultiSelectItem]) -> Option<Line<'static>> + 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<String>,
/// 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<MultiSelectItem>,
/// 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<dyn Renderable>,
/// 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<usize>,
/// Whether left/right arrow reordering is enabled.
ordering_enabled: bool,
/// Optional callback to generate a preview line from current item states.
preview_builder: Option<PreviewCallback>,
/// Cached preview line (updated on item changes).
preview_line: Option<Line<'static>>,
/// Callback invoked when items change (toggle or reorder).
on_change: Option<ChangeCallBack>,
/// Callback invoked when the user confirms their selection.
on_confirm: Option<ConfirmCallback>,
/// Callback invoked when the user cancels the picker.
on_cancel: Option<CancelCallback>,
}
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<String>,
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<GenericDisplayRow> {
self.filtered_indices
.iter()
.enumerate()
.filter_map(|(visible_idx, actual_idx)| {
self.items.get(*actual_idx).map(|item| {
let is_selected = self.state.selected_idx == Some(visible_idx);
let prefix = if is_selected { '' } else { ' ' };
let marker = if item.enabled { 'x' } else { ' ' };
let item_name = truncate_text(&item.name, ITEM_NAME_TRUNCATE_LEN);
let name = format!("{prefix} [{marker}] {item_name}");
GenericDisplayRow {
name,
description: item.description.clone(),
..Default::default()
}
})
})
.collect()
}
/// 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<String> = 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<String>,
instructions: Vec<Span<'static>>,
items: Vec<MultiSelectItem>,
ordering_enabled: bool,
app_event_tx: AppEventSender,
preview_builder: Option<PreviewCallback>,
on_change: Option<ChangeCallBack>,
on_confirm: Option<ConfirmCallback>,
on_cancel: Option<CancelCallback>,
}
impl MultiSelectPickerBuilder {
/// Creates a new builder with the given title, optional subtitle, and event sender.
pub fn new(title: String, subtitle: Option<String>, 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<MultiSelectItem>) -> 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<Span<'static>>) -> 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<F>(mut self, callback: F) -> Self
where
F: Fn(&[MultiSelectItem]) -> Option<Line<'static>> + 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<F>(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<F>(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<F>(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<Vec<usize>>, 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
}
@@ -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::<Span<'static>>::new());
}
let Line {
style,
alignment,
spans,
} = line;
let mut used = 0usize;
let mut spans_out: Vec<Span<'static>> = 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::<Span<'static>>::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.
@@ -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 "
@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" Plan mode (shift+tab to cycle) "
@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" "
@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" Italic text "
@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" Status line content "
@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" Status line content that … Plan mode "
@@ -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::<StatusLineItem>() 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::<StatusLineItem>().ok())
.map(|item| item.render())
.collect::<Vec<_>>()
.join(" · ");
if preview.is_empty() {
None
} else {
Some(Line::from(preview))
}
})
.on_confirm(|ids, app_event| {
let items = ids
.iter()
.map(|id| id.parse::<StatusLineItem>())
.collect::<Result<Vec<_>, _>>()
.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)
}
}
+416 -1
View File
@@ -26,19 +26,30 @@ 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::find_thread_name_by_id;
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;
@@ -390,6 +401,8 @@ pub(crate) struct ChatWidgetInit {
pub(crate) is_first_run: bool,
pub(crate) feedback_audience: FeedbackAudience,
pub(crate) model: Option<String>,
// Shared latch so we only warn once about invalid status-line item IDs.
pub(crate) status_line_invalid_items_warned: Arc<AtomicBool>,
pub(crate) otel_manager: OtelManager,
}
@@ -581,6 +594,18 @@ pub(crate) struct ChatWidget {
feedback_audience: FeedbackAudience,
// Current session rollout path (if known)
current_rollout_path: Option<PathBuf>,
// Current working directory (if known)
current_cwd: Option<PathBuf>,
// Shared latch so we only warn once about invalid status-line item IDs.
status_line_invalid_items_warned: Arc<AtomicBool>,
// Cached git branch name for the status line (None if unknown).
status_line_branch: Option<String>,
// CWD used to resolve the cached branch; change resets branch state.
status_line_branch_cwd: Option<PathBuf>,
// 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,
}
@@ -792,6 +817,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<Line<'static>>) {
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<StatusLineItem>) {
tracing::info!("status line setup confirmed with items: {items:#?}");
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
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<String>) {
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);
@@ -808,6 +947,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 forked_from_id = event.forked_from_id;
let model_for_header = event.model.clone();
@@ -1114,6 +1254,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;
@@ -1327,6 +1468,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.
///
@@ -1346,6 +1488,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();
}
@@ -1851,6 +1994,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) {
@@ -2284,6 +2428,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());
@@ -2316,6 +2461,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(),
@@ -2387,6 +2533,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,
};
@@ -2394,6 +2546,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),
);
@@ -2431,6 +2590,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());
@@ -2461,6 +2621,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(),
@@ -2533,6 +2694,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,
};
@@ -2540,6 +2707,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),
);
@@ -2563,10 +2737,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();
@@ -2583,6 +2758,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());
@@ -2668,6 +2844,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,
};
@@ -2675,6 +2857,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),
);
@@ -3067,6 +3256,9 @@ impl ChatWidget {
SlashCommand::DebugConfig => {
self.add_debug_config_output();
}
SlashCommand::Statusline => {
self.open_status_line_setup();
}
SlashCommand::Ps => {
self.add_ps_output();
}
@@ -3846,6 +4038,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<StatusLineItem>, Vec<String>) {
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::<StatusLineItem>() {
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<PathBuf> {
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<String> {
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<String> {
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<i64> {
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<i64> {
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<i64> {
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<String> {
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<ReasoningEffortConfig>) -> &'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
+86
View File
@@ -830,6 +830,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);
@@ -958,6 +959,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);
@@ -2637,6 +2644,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,
};
@@ -2682,6 +2690,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,
};
@@ -4907,6 +4916,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;
+1 -1
View File
@@ -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()
+3
View File
@@ -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,
}
}
+10
View File
@@ -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)]
+47 -3
View File
@@ -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<String>,
},
/// 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<StatusRateLimitRow>),
/// Snapshot data exists but is older than the staleness threshold.
Stale(Vec<StatusRateLimitRow>),
/// 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<String>,
/// Window length in minutes when provided by the server.
pub window_minutes: Option<i64>,
}
impl RateLimitWindowDisplay {
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self {
let resets_at = window
let resets_at_utc = window
.resets_at
.and_then(|seconds| DateTime::<Utc>::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<Local>,
/// Primary usage window (typically short duration).
pub primary: Option<RateLimitWindowDisplay>,
/// Secondary usage window (typically weekly).
pub secondary: Option<RateLimitWindowDisplay>,
/// Optional credits metadata when available.
pub credits: Option<CreditsSnapshotDisplay>,
}
/// 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<String>,
}
/// 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<Local>,
@@ -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<Local>,
@@ -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")
}
+43
View File
@@ -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<T: AsRef<str>>(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<String> = 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"
);
}
}