Codex Handbook
tui/src/bottom_pane/status_line_setup.rs 696 lines
//! 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)//! - Permissions profile//! - Approval mode//! - Context usage (remaining %, used %, window size)//! - Usage limits (primary, secondary)//! - Session info (thread title, thread ID, tokens used)//! - Application versionuse ratatui::buffer::Buffer;use ratatui::layout::Rect;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::bottom_pane::status_surface_preview::StatusSurfacePreviewData;use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewItem;use crate::keymap::ListKeymap;use crate::render::renderable::Renderable;const STATUS_LINE_USE_THEME_COLORS_ITEM_ID: &str = "status-line-use-theme-colors";/// Available items that can be displayed in the status line.////// Each variant represents a piece of information that can be shown at the/// 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/// - Thread ID only shows after a session has started#[derive(EnumIter, EnumString, Display, Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]#[strum(serialize_all = "kebab_case")]pub(crate) enum StatusLineItem {    /// The current model name.    #[strum(to_string = "model", serialize = "model-name")]    ModelName,    /// Model name with reasoning level suffix.    ModelWithReasoning,    /// Current reasoning level.    Reasoning,    /// Current working directory path.    CurrentDir,    /// Project root directory (if detected).    #[strum(        to_string = "project-name",        serialize = "project",        serialize = "project-root"    )]    ProjectRoot,    /// Current git branch name (if in a repository).    GitBranch,    /// Open pull request number for the current branch.    PullRequestNumber,    /// Committed branch diff stats relative to the default branch.    BranchChanges,    /// Compact runtime run-state text.    #[strum(to_string = "run-state", serialize = "status")]    Status,    /// Active permission profile or sandbox summary.    Permissions,    /// Active command approval mode.    #[strum(to_string = "approval-mode", serialize = "approval")]    ApprovalMode,    /// Percentage of context window remaining.    ContextRemaining,    /// Percentage of context window used.    ///    /// Also accepts the legacy `context-usage` config value.    #[strum(to_string = "context-used", serialize = "context-usage")]    ContextUsed,    /// Remaining usage on the primary rate limit.    FiveHourLimit,    /// Remaining usage on the secondary 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 thread UUID.    #[strum(to_string = "thread-id", serialize = "session-id")]    SessionId,    /// Whether Fast mode is currently active.    FastMode,    /// Whether raw scrollback mode is currently active.    RawOutput,    /// Current thread title (if set by user).    ThreadTitle,    /// Latest checklist task progress from `update_plan` (if available).    TaskProgress,}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::Reasoning => "Current reasoning level",            StatusLineItem::CurrentDir => "Current working directory",            StatusLineItem::ProjectRoot => "Project name (omitted when unavailable)",            StatusLineItem::GitBranch => "Current Git branch (omitted when unavailable)",            StatusLineItem::PullRequestNumber => {                "Open pull request number for the current branch (omitted when unavailable)"            }            StatusLineItem::BranchChanges => {                "Committed branch changes against the default branch (omitted when unavailable)"            }            StatusLineItem::Status => "Compact session run-state text (Ready, Working, Thinking)",            StatusLineItem::Permissions => "Active permission profile or sandbox mode",            StatusLineItem::ApprovalMode => "Active command approval mode",            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 the primary usage limit (omitted when unavailable)"            }            StatusLineItem::WeeklyLimit => {                "Remaining usage on the secondary 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 thread identifier (omitted until thread starts)",            StatusLineItem::FastMode => "Whether Fast mode is currently active",            StatusLineItem::RawOutput => "Whether raw scrollback mode is active",            StatusLineItem::ThreadTitle => {                "Current thread title, or thread identifier when unnamed"            }            StatusLineItem::TaskProgress => {                "Latest task progress from update_plan (omitted until available)"            }        }    }    pub(crate) fn preview_item(self) -> StatusSurfacePreviewItem {        match self {            StatusLineItem::ModelName => StatusSurfacePreviewItem::Model,            StatusLineItem::ModelWithReasoning => StatusSurfacePreviewItem::ModelWithReasoning,            StatusLineItem::Reasoning => StatusSurfacePreviewItem::Reasoning,            StatusLineItem::CurrentDir => StatusSurfacePreviewItem::CurrentDir,            StatusLineItem::ProjectRoot => StatusSurfacePreviewItem::ProjectRoot,            StatusLineItem::GitBranch => StatusSurfacePreviewItem::GitBranch,            StatusLineItem::PullRequestNumber => StatusSurfacePreviewItem::PullRequestNumber,            StatusLineItem::BranchChanges => StatusSurfacePreviewItem::BranchChanges,            StatusLineItem::Status => StatusSurfacePreviewItem::Status,            StatusLineItem::Permissions => StatusSurfacePreviewItem::Permissions,            StatusLineItem::ApprovalMode => StatusSurfacePreviewItem::ApprovalMode,            StatusLineItem::ContextRemaining => StatusSurfacePreviewItem::ContextRemaining,            StatusLineItem::ContextUsed => StatusSurfacePreviewItem::ContextUsed,            StatusLineItem::FiveHourLimit => StatusSurfacePreviewItem::FiveHourLimit,            StatusLineItem::WeeklyLimit => StatusSurfacePreviewItem::WeeklyLimit,            StatusLineItem::CodexVersion => StatusSurfacePreviewItem::CodexVersion,            StatusLineItem::ContextWindowSize => StatusSurfacePreviewItem::ContextWindowSize,            StatusLineItem::UsedTokens => StatusSurfacePreviewItem::UsedTokens,            StatusLineItem::TotalInputTokens => StatusSurfacePreviewItem::TotalInputTokens,            StatusLineItem::TotalOutputTokens => StatusSurfacePreviewItem::TotalOutputTokens,            StatusLineItem::SessionId => StatusSurfacePreviewItem::SessionId,            StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode,            StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput,            StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle,            StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress,        }    }}/// 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 cancellationpub(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    /// * `use_theme_colors` - Whether the preview and saved status line use colors from    ///   the active theme    /// * `app_event_tx` - Event sender for dispatching configuration changes    ///    /// Items from `status_line_items` are shown first (in order) and marked as    /// enabled. Remaining items are appended and marked as disabled.    pub(crate) fn new(        status_line_items: Option<&[String]>,        use_theme_colors: bool,        preview_data: StatusSurfacePreviewData,        app_event_tx: AppEventSender,        list_keymap: ListKeymap,    ) -> Self {        let mut used_ids = HashSet::new();        let mut items = vec![MultiSelectItem {            id: STATUS_LINE_USE_THEME_COLORS_ITEM_ID.to_string(),            name: "Use theme colors".to_string(),            description: Some("Apply colors from the active /theme".to_string()),            enabled: use_theme_colors,            orderable: false,            section_break_after: true,        }];        if let Some(selected_items) = status_line_items.as_ref() {            for id in *selected_items {                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,                    /*enabled*/ true,                    &preview_data,                ));            }        }        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,                /*enabled*/ false,                &preview_data,            ));        }        Self {            picker: MultiSelectPicker::builder(                "Configure Status Line".to_string(),                Some("Select which items to display in the status line.".to_string()),                app_event_tx,            )            .list_keymap(list_keymap)            .items(items)            .enable_ordering()            .on_preview(move |items| {                let use_theme_colors = items                    .iter()                    .find(|item| item.id == STATUS_LINE_USE_THEME_COLORS_ITEM_ID)                    .map(|item| item.enabled)                    .unwrap_or(true);                preview_data.status_line_for_items(                    items                        .iter()                        .filter(|item| item.enabled)                        .filter_map(|item| item.id.parse::<StatusLineItem>().ok()),                    use_theme_colors,                )            })            .on_confirm(|ids, app_event| {                let use_theme_colors = ids                    .iter()                    .any(|id| id == STATUS_LINE_USE_THEME_COLORS_ITEM_ID);                let items = ids                    .iter()                    .filter_map(|id| id.parse::<StatusLineItem>().ok())                    .collect::<Vec<_>>();                app_event.send(AppEvent::StatusLineSetup {                    items,                    use_theme_colors,                });            })            .on_cancel(|app_event| {                app_event.send(AppEvent::StatusLineSetupCancelled);            })            .build(),        }    }    /// Converts a [`StatusLineItem`] into a [`MultiSelectItem`] for the picker.    fn status_line_select_item(        item: StatusLineItem,        enabled: bool,        preview_data: &StatusSurfacePreviewData,    ) -> MultiSelectItem {        let default_name = item.to_string();        let default_description = item.description();        let (name, description) = match item {            StatusLineItem::FiveHourLimit | StatusLineItem::WeeklyLimit => (                preview_data.rate_limit_item_name(item.preview_item(), &default_name),                preview_data.rate_limit_item_description(item.preview_item(), default_description),            ),            _ => (default_name, default_description.to_string()),        };        MultiSelectItem {            id: item.to_string(),            name,            description: Some(description),            enabled,            orderable: true,            section_break_after: false,        }    }}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)    }}#[cfg(test)]mod tests {    use super::*;    use crate::app_event_sender::AppEventSender;    use insta::assert_snapshot;    use pretty_assertions::assert_eq;    use ratatui::buffer::Buffer;    use ratatui::layout::Rect;    use ratatui::text::Line;    use tokio::sync::mpsc::unbounded_channel;    use crate::app_event::AppEvent;    #[test]    fn context_used_accepts_context_usage_legacy_id() {        assert_eq!(StatusLineItem::ContextUsed.to_string(), "context-used");        assert_eq!(            "context-used".parse::<StatusLineItem>(),            Ok(StatusLineItem::ContextUsed)        );        assert_eq!(            "context-usage".parse::<StatusLineItem>(),            Ok(StatusLineItem::ContextUsed)        );    }    #[test]    fn context_remaining_is_selectable_id() {        assert_eq!(            "context-remaining".parse::<StatusLineItem>(),            Ok(StatusLineItem::ContextRemaining)        );        assert_eq!(            StatusLineItem::ContextRemaining.to_string(),            "context-remaining"        );    }    #[test]    fn project_name_is_canonical_and_accepts_legacy_ids() {        assert_eq!(StatusLineItem::ProjectRoot.to_string(), "project-name");        assert_eq!(            "project-name".parse::<StatusLineItem>(),            Ok(StatusLineItem::ProjectRoot)        );        assert_eq!(            "project".parse::<StatusLineItem>(),            Ok(StatusLineItem::ProjectRoot)        );        assert_eq!(            "project-root".parse::<StatusLineItem>(),            Ok(StatusLineItem::ProjectRoot)        );    }    #[test]    fn model_is_canonical_and_accepts_model_name_legacy_id() {        assert_eq!(StatusLineItem::ModelName.to_string(), "model");        assert_eq!(            "model".parse::<StatusLineItem>(),            Ok(StatusLineItem::ModelName)        );        assert_eq!(            "model-name".parse::<StatusLineItem>(),            Ok(StatusLineItem::ModelName)        );    }    #[test]    fn reasoning_is_selectable_id() {        assert_eq!(StatusLineItem::Reasoning.to_string(), "reasoning");        assert_eq!(            "reasoning".parse::<StatusLineItem>(),            Ok(StatusLineItem::Reasoning)        );    }    #[test]    fn run_state_is_canonical_and_accepts_status_legacy_id() {        assert_eq!(StatusLineItem::Status.to_string(), "run-state");        assert_eq!(            "run-state".parse::<StatusLineItem>(),            Ok(StatusLineItem::Status)        );        assert_eq!(            "status".parse::<StatusLineItem>(),            Ok(StatusLineItem::Status)        );    }    #[test]    fn git_summary_items_are_selectable_ids() {        assert_eq!(            "pull-request-number".parse::<StatusLineItem>(),            Ok(StatusLineItem::PullRequestNumber)        );        assert_eq!(            "branch-changes".parse::<StatusLineItem>(),            Ok(StatusLineItem::BranchChanges)        );    }    #[test]    fn parse_status_line_items_accepts_title_only_variants() {        let items = ["run-state", "task-progress"]            .into_iter()            .map(str::parse::<StatusLineItem>)            .collect::<Result<Vec<_>, _>>();        assert_eq!(            items,            Ok(vec![StatusLineItem::Status, StatusLineItem::TaskProgress,])        );    }    #[test]    fn preview_uses_runtime_values() {        let preview_data = StatusSurfacePreviewData::from_iter([            (                StatusLineItem::ModelName.preview_item(),                "gpt-5".to_string(),            ),            (                StatusLineItem::CurrentDir.preview_item(),                "/repo".to_string(),            ),        ]);        let items = [            MultiSelectItem {                id: StatusLineItem::ModelName.to_string(),                name: String::new(),                description: None,                enabled: true,                orderable: true,                section_break_after: false,            },            MultiSelectItem {                id: StatusLineItem::CurrentDir.to_string(),                name: String::new(),                description: None,                enabled: true,                orderable: true,                section_break_after: false,            },        ];        assert_eq!(            line_text(                preview_data.status_line_for_items(                    items                        .iter()                        .filter_map(|item| item.id.parse::<StatusLineItem>().ok()),                    /*use_theme_colors*/ true,                )            ),            Some("gpt-5 · /repo".to_string())        );    }    #[test]    fn preview_uses_placeholders_when_runtime_values_are_missing() {        let preview_data = StatusSurfacePreviewData::from_iter([(            StatusSurfacePreviewItem::Model,            "gpt-5".to_string(),        )]);        let items = [            MultiSelectItem {                id: StatusLineItem::ModelName.to_string(),                name: String::new(),                description: None,                enabled: true,                orderable: true,                section_break_after: false,            },            MultiSelectItem {                id: StatusLineItem::GitBranch.to_string(),                name: String::new(),                description: None,                enabled: true,                orderable: true,                section_break_after: false,            },        ];        assert_eq!(            line_text(                preview_data.status_line_for_items(                    items                        .iter()                        .filter_map(|item| item.id.parse::<StatusLineItem>().ok()),                    /*use_theme_colors*/ true,                )            ),            Some("gpt-5 · feat/awesome-feature".to_string())        );    }    #[test]    fn preview_includes_thread_title() {        let preview_data = StatusSurfacePreviewData::from_iter([            (                StatusLineItem::ModelName.preview_item(),                "gpt-5".to_string(),            ),            (                StatusLineItem::ThreadTitle.preview_item(),                "Roadmap cleanup".to_string(),            ),        ]);        let items = [            MultiSelectItem {                id: StatusLineItem::ModelName.to_string(),                name: String::new(),                description: None,                enabled: true,                orderable: true,                section_break_after: false,            },            MultiSelectItem {                id: StatusLineItem::ThreadTitle.to_string(),                name: String::new(),                description: None,                enabled: true,                orderable: true,                section_break_after: false,            },        ];        assert_eq!(            line_text(                preview_data.status_line_for_items(                    items                        .iter()                        .filter_map(|item| item.id.parse::<StatusLineItem>().ok()),                    /*use_theme_colors*/ true,                )            ),            Some("gpt-5 · Roadmap cleanup".to_string())        );    }    #[test]    fn setup_view_snapshot_uses_runtime_preview_values() {        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();        let view = StatusLineSetupView::new(            Some(&[                StatusLineItem::ModelName.to_string(),                StatusLineItem::CurrentDir.to_string(),                StatusLineItem::GitBranch.to_string(),            ]),            /*use_theme_colors*/ true,            StatusSurfacePreviewData::from_iter([                (                    StatusLineItem::ModelName.preview_item(),                    "gpt-5-codex".to_string(),                ),                (                    StatusLineItem::CurrentDir.preview_item(),                    "~/codex-rs".to_string(),                ),                (                    StatusLineItem::GitBranch.preview_item(),                    "jif/statusline-preview".to_string(),                ),                (                    StatusLineItem::WeeklyLimit.preview_item(),                    "weekly 82% left".to_string(),                ),            ]),            AppEventSender::new(tx_raw),            crate::keymap::RuntimeKeymap::defaults().list,        );        assert_snapshot!(render_lines(&view, /*width*/ 72));    }    fn render_lines(view: &StatusLineSetupView, width: u16) -> String {        let height = view.desired_height(width);        let area = Rect::new(0, 0, width, height);        let mut buf = Buffer::empty(area);        view.render(area, &mut buf);        (0..area.height)            .map(|row| {                let mut line = String::new();                for col in 0..area.width {                    let symbol = buf[(area.x + col, area.y + row)].symbol();                    if symbol.is_empty() {                        line.push(' ');                    } else {                        line.push_str(symbol);                    }                }                line            })            .collect::<Vec<_>>()            .join("\n")    }    fn line_text(line: Option<Line<'static>>) -> Option<String> {        line.map(|line| {            line.spans                .iter()                .map(|span| span.content.as_ref())                .collect::<String>()        })    }}