mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
3b54fd7336
commit
b0e5a6305b
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
@@ -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 "
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Plan mode (shift+tab to cycle) "
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Italic text "
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Status line content "
|
||||
+5
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user