Codex Handbook
tui/src/bottom_pane/title_setup.rs 581 lines
//! Terminal title configuration view for customizing the terminal window/tab title.//!//! This module provides an interactive picker for selecting which items appear//! in the terminal title. Users can://!//! - Select items//! - Reorder items//! - Preview the rendered titleuse itertools::Itertools;use ratatui::buffer::Buffer;use ratatui::layout::Rect;use ratatui::text::Line;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::ACTION_REQUIRED_PREVIEW_PREFIX;use crate::bottom_pane::CancellationEvent;use crate::bottom_pane::bottom_pane_view::BottomPaneView;use crate::bottom_pane::build_action_required_title_text;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;/// Available items that can be displayed in the terminal title.////// Variants serialize to kebab-case identifiers (e.g. `AppName` -> `"app-name"`)/// via strum. These identifiers are persisted in user config files, so renaming/// or removing a variant is a breaking config change.#[derive(EnumIter, EnumString, Display, Debug, Clone, Copy, Eq, PartialEq, Hash)]#[strum(serialize_all = "kebab-case")]pub(crate) enum TerminalTitleItem {    /// Codex app name.    AppName,    /// Project root name, or a compact cwd fallback.    #[strum(to_string = "project-name", serialize = "project")]    Project,    /// Current working directory path.    CurrentDir,    /// Terminal-title activity indicator while active.    #[strum(to_string = "activity", serialize = "spinner")]    Spinner,    /// Compact runtime run-state text.    #[strum(to_string = "run-state", serialize = "status")]    Status,    /// Current thread title (if available).    #[strum(to_string = "thread-title", serialize = "thread")]    Thread,    /// Current git branch (if available).    GitBranch,    /// Percentage of context window remaining.    ContextRemaining,    /// Percentage of context window used.    #[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 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,    /// Current model name.    #[strum(to_string = "model", serialize = "model-name")]    Model,    /// Current model name with reasoning level.    ModelWithReasoning,    /// Current reasoning level.    Reasoning,    /// Latest checklist task progress from `update_plan` (if available).    TaskProgress,}impl TerminalTitleItem {    pub(crate) fn description(self) -> &'static str {        match self {            TerminalTitleItem::AppName => "Codex app name",            TerminalTitleItem::Project => "Project name (falls back to current directory name)",            TerminalTitleItem::CurrentDir => "Current working directory",            TerminalTitleItem::Spinner => {                "Spinner while working, action-required message while blocked."            }            TerminalTitleItem::Status => {                "Compact session run-state text (Ready, Working, Thinking)"            }            TerminalTitleItem::Thread => "Current thread title, or thread identifier when unnamed",            TerminalTitleItem::GitBranch => "Current Git branch (omitted when unavailable)",            TerminalTitleItem::ContextRemaining => {                "Percentage of context window remaining (omitted when unknown)"            }            TerminalTitleItem::ContextUsed => {                "Percentage of context window used (omitted when unknown)"            }            TerminalTitleItem::FiveHourLimit => {                "Remaining usage on the primary usage limit (omitted when unavailable)"            }            TerminalTitleItem::WeeklyLimit => {                "Remaining usage on the secondary usage limit (omitted when unavailable)"            }            TerminalTitleItem::CodexVersion => "Codex application version",            TerminalTitleItem::UsedTokens => "Total tokens used in session (omitted when zero)",            TerminalTitleItem::TotalInputTokens => "Total input tokens used in session",            TerminalTitleItem::TotalOutputTokens => "Total output tokens used in session",            TerminalTitleItem::SessionId => {                "Current thread identifier (omitted until thread starts)"            }            TerminalTitleItem::FastMode => "Whether Fast mode is currently active",            TerminalTitleItem::Model => "Current model name",            TerminalTitleItem::ModelWithReasoning => "Current model name with reasoning level",            TerminalTitleItem::Reasoning => "Current reasoning level",            TerminalTitleItem::TaskProgress => {                "Latest task progress from update_plan (omitted until available)"            }        }    }    pub(crate) fn preview_item(self) -> Option<StatusSurfacePreviewItem> {        match self {            TerminalTitleItem::AppName => Some(StatusSurfacePreviewItem::AppName),            TerminalTitleItem::Project => Some(StatusSurfacePreviewItem::ProjectName),            TerminalTitleItem::CurrentDir => Some(StatusSurfacePreviewItem::CurrentDir),            TerminalTitleItem::Spinner => None,            TerminalTitleItem::Status => Some(StatusSurfacePreviewItem::Status),            TerminalTitleItem::Thread => Some(StatusSurfacePreviewItem::ThreadTitle),            TerminalTitleItem::GitBranch => Some(StatusSurfacePreviewItem::GitBranch),            TerminalTitleItem::ContextRemaining => Some(StatusSurfacePreviewItem::ContextRemaining),            TerminalTitleItem::ContextUsed => Some(StatusSurfacePreviewItem::ContextUsed),            TerminalTitleItem::FiveHourLimit => Some(StatusSurfacePreviewItem::FiveHourLimit),            TerminalTitleItem::WeeklyLimit => Some(StatusSurfacePreviewItem::WeeklyLimit),            TerminalTitleItem::CodexVersion => Some(StatusSurfacePreviewItem::CodexVersion),            TerminalTitleItem::UsedTokens => Some(StatusSurfacePreviewItem::UsedTokens),            TerminalTitleItem::TotalInputTokens => Some(StatusSurfacePreviewItem::TotalInputTokens),            TerminalTitleItem::TotalOutputTokens => {                Some(StatusSurfacePreviewItem::TotalOutputTokens)            }            TerminalTitleItem::SessionId => Some(StatusSurfacePreviewItem::SessionId),            TerminalTitleItem::FastMode => Some(StatusSurfacePreviewItem::FastMode),            TerminalTitleItem::Model => Some(StatusSurfacePreviewItem::Model),            TerminalTitleItem::ModelWithReasoning => {                Some(StatusSurfacePreviewItem::ModelWithReasoning)            }            TerminalTitleItem::Reasoning => Some(StatusSurfacePreviewItem::Reasoning),            TerminalTitleItem::TaskProgress => Some(StatusSurfacePreviewItem::TaskProgress),        }    }    /// Returns the separator to place before this item in a rendered title.    ///    /// The activity indicator gets a plain space on either side so it reads as    /// `my-project <activity> Working` rather than `my-project | <activity> | Working`.    /// All other adjacent items are joined with ` | `.    pub(crate) fn separator_from_previous(self, previous: Option<Self>) -> &'static str {        match previous {            None => "",            Some(previous)                if previous == TerminalTitleItem::Spinner || self == TerminalTitleItem::Spinner =>            {                " "            }            Some(_) => " | ",        }    }}pub(crate) fn preview_line_for_title_items(    items: &[TerminalTitleItem],    preview_data: &StatusSurfacePreviewData,) -> Option<Line<'static>> {    if items.contains(&TerminalTitleItem::Spinner) {        let preview = build_action_required_title_text(            ACTION_REQUIRED_PREVIEW_PREFIX,            items.iter().copied(),            &[],            |item| {                item.preview_item()                    .and_then(|preview_item| preview_data.value_for(preview_item))                    .map(str::to_owned)            },        );        return Some(Line::from(preview));    }    let mut previous = None;    let preview = items        .iter()        .copied()        .fold(String::new(), |mut preview, item| {            let Some(value) = item                .preview_item()                .and_then(|preview_item| preview_data.value_for(preview_item))            else {                return preview;            };            preview.push_str(item.separator_from_previous(previous));            preview.push_str(value);            previous = Some(item);            preview        });    if preview.is_empty() {        None    } else {        Some(Line::from(preview))    }}fn parse_terminal_title_items<T>(ids: impl Iterator<Item = T>) -> Option<Vec<TerminalTitleItem>>where    T: AsRef<str>,{    // Treat parsing as all-or-nothing so preview/confirm callbacks never emit    // a partially interpreted ordering. Invalid ids are ignored when building    // the picker, but once the user is interacting with the picker we only want    // to persist or preview a fully valid selection.    ids.map(|id| id.as_ref().parse::<TerminalTitleItem>())        .collect::<Result<Vec<_>, _>>()        .ok()}/// Interactive view for configuring terminal-title items.pub(crate) struct TerminalTitleSetupView {    picker: MultiSelectPicker,}impl TerminalTitleSetupView {    /// Creates the terminal-title picker, preserving the configured item order first.    ///    /// Unknown configured ids are skipped here instead of surfaced inline. The    /// main TUI still warns about them when rendering the actual title, but the    /// picker itself only exposes the selectable items it can meaningfully    /// preview and persist.    pub(crate) fn new(        title_items: Option<&[String]>,        preview_data: StatusSurfacePreviewData,        app_event_tx: AppEventSender,        list_keymap: ListKeymap,    ) -> Self {        let selected_items = title_items            .into_iter()            .flatten()            .filter_map(|id| id.parse::<TerminalTitleItem>().ok())            .unique()            .collect_vec();        let selected_set = selected_items            .iter()            .copied()            .collect::<std::collections::HashSet<_>>();        let items = selected_items            .into_iter()            .map(|item| Self::title_select_item(item, /*enabled*/ true, &preview_data))            .chain(                TerminalTitleItem::iter()                    .filter(|item| !selected_set.contains(item))                    .map(|item| {                        Self::title_select_item(item, /*enabled*/ false, &preview_data)                    }),            )            .collect();        Self {            picker: MultiSelectPicker::builder(                "Configure Terminal Title".to_string(),                Some("Select which items to display in the terminal title.".to_string()),                app_event_tx,            )            .list_keymap(list_keymap)            .items(items)            .enable_ordering()            .on_preview(move |items| {                let items = parse_terminal_title_items(                    items                        .iter()                        .filter(|item| item.enabled)                        .map(|item| item.id.as_str()),                )?;                preview_line_for_title_items(&items, &preview_data)            })            .on_change(|items, app_event| {                let Some(items) = parse_terminal_title_items(                    items                        .iter()                        .filter(|item| item.enabled)                        .map(|item| item.id.as_str()),                ) else {                    return;                };                app_event.send(AppEvent::TerminalTitleSetupPreview { items });            })            .on_confirm(|ids, app_event| {                let Some(items) = parse_terminal_title_items(ids.iter().map(String::as_str)) else {                    return;                };                app_event.send(AppEvent::TerminalTitleSetup { items });            })            .on_cancel(|app_event| {                app_event.send(AppEvent::TerminalTitleSetupCancelled);            })            .build(),        }    }    fn title_select_item(        item: TerminalTitleItem,        enabled: bool,        preview_data: &StatusSurfacePreviewData,    ) -> MultiSelectItem {        let default_name = item.to_string();        let default_description = item.description();        let (name, description) = match item.preview_item() {            Some(                preview_item @ (StatusSurfacePreviewItem::FiveHourLimit                | StatusSurfacePreviewItem::WeeklyLimit),            ) => (                preview_data.rate_limit_item_name(preview_item, &default_name),                preview_data.rate_limit_item_description(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 TerminalTitleSetupView {    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 TerminalTitleSetupView {    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 insta::assert_snapshot;    use pretty_assertions::assert_eq;    use tokio::sync::mpsc::unbounded_channel;    fn render_lines(view: &TerminalTitleSetupView, width: u16) -> String {        let height = view.desired_height(width);        let area = Rect::new(0, 0, width, height);        let mut buf = Buffer::empty(area);        view.render(area, &mut buf);        let lines: Vec<String> = (0..area.height)            .map(|row| {                let mut line = String::new();                for col in 0..area.width {                    let symbol = buf[(area.x + col, area.y + row)].symbol();                    if symbol.is_empty() {                        line.push(' ');                    } else {                        line.push_str(symbol);                    }                }                line            })            .collect();        lines.join("\n")    }    #[test]    fn renders_title_setup_popup() {        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();        let tx = AppEventSender::new(tx_raw);        let selected = [            "project-name".to_string(),            "activity".to_string(),            "run-state".to_string(),            "thread-title".to_string(),        ];        let view = TerminalTitleSetupView::new(            Some(&selected),            StatusSurfacePreviewData::default(),            tx,            crate::keymap::RuntimeKeymap::defaults().list,        );        assert_snapshot!(            "terminal_title_setup_basic",            render_lines(&view, /*width*/ 84)        );    }    #[test]    fn parse_terminal_title_items_preserves_order() {        let items = parse_terminal_title_items(            ["project-name", "activity", "run-state", "thread-title"].into_iter(),        );        assert_eq!(            items,            Some(vec![                TerminalTitleItem::Project,                TerminalTitleItem::Spinner,                TerminalTitleItem::Status,                TerminalTitleItem::Thread,            ])        );    }    #[test]    fn parse_terminal_title_items_rejects_invalid_ids() {        let items = parse_terminal_title_items(["project", "not-a-title-item"].into_iter());        assert_eq!(items, None);    }    #[test]    fn activity_is_canonical_and_accepts_spinner_legacy_id() {        assert_eq!(TerminalTitleItem::Spinner.to_string(), "activity");        assert_eq!(            "activity".parse::<TerminalTitleItem>(),            Ok(TerminalTitleItem::Spinner)        );        assert_eq!(            "spinner".parse::<TerminalTitleItem>(),            Ok(TerminalTitleItem::Spinner)        );    }    #[test]    fn project_name_is_canonical_and_accepts_project_legacy_id() {        assert_eq!(TerminalTitleItem::Project.to_string(), "project-name");        assert_eq!(            "project-name".parse::<TerminalTitleItem>(),            Ok(TerminalTitleItem::Project)        );        assert_eq!(            "project".parse::<TerminalTitleItem>(),            Ok(TerminalTitleItem::Project)        );    }    #[test]    fn thread_title_is_canonical_and_accepts_thread_legacy_id() {        assert_eq!(TerminalTitleItem::Thread.to_string(), "thread-title");        assert_eq!(            "thread-title".parse::<TerminalTitleItem>(),            Ok(TerminalTitleItem::Thread)        );        assert_eq!(            "thread".parse::<TerminalTitleItem>(),            Ok(TerminalTitleItem::Thread)        );    }    #[test]    fn model_is_canonical_and_accepts_model_name_legacy_id() {        assert_eq!(TerminalTitleItem::Model.to_string(), "model");        assert_eq!(            "model".parse::<TerminalTitleItem>(),            Ok(TerminalTitleItem::Model)        );        assert_eq!(            "model-name".parse::<TerminalTitleItem>(),            Ok(TerminalTitleItem::Model)        );    }    #[test]    fn run_state_is_canonical_and_accepts_status_legacy_id() {        assert_eq!(TerminalTitleItem::Status.to_string(), "run-state");        assert_eq!(            "run-state".parse::<TerminalTitleItem>(),            Ok(TerminalTitleItem::Status)        );        assert_eq!(            "status".parse::<TerminalTitleItem>(),            Ok(TerminalTitleItem::Status)        );    }    #[test]    fn model_with_reasoning_has_distinct_id() {        assert_eq!(            TerminalTitleItem::ModelWithReasoning.to_string(),            "model-with-reasoning"        );        assert_eq!(            "model-with-reasoning".parse::<TerminalTitleItem>(),            Ok(TerminalTitleItem::ModelWithReasoning)        );    }    #[test]    fn reasoning_is_selectable_id() {        assert_eq!(TerminalTitleItem::Reasoning.to_string(), "reasoning");        assert_eq!(            "reasoning".parse::<TerminalTitleItem>(),            Ok(TerminalTitleItem::Reasoning)        );    }    #[test]    fn parse_terminal_title_items_accepts_kebab_case_variants() {        let items = parse_terminal_title_items(            [                "app-name",                "context-remaining",                "context-used",                "five-hour-limit",                "git-branch",                "activity",                "current-dir",                "project-name",                "model",                "model-with-reasoning",                "reasoning",                "weekly-limit",                "codex-version",                "used-tokens",                "total-input-tokens",                "total-output-tokens",                "session-id",                "fast-mode",            ]            .into_iter(),        );        assert_eq!(            items,            Some(vec![                TerminalTitleItem::AppName,                TerminalTitleItem::ContextRemaining,                TerminalTitleItem::ContextUsed,                TerminalTitleItem::FiveHourLimit,                TerminalTitleItem::GitBranch,                TerminalTitleItem::Spinner,                TerminalTitleItem::CurrentDir,                TerminalTitleItem::Project,                TerminalTitleItem::Model,                TerminalTitleItem::ModelWithReasoning,                TerminalTitleItem::Reasoning,                TerminalTitleItem::WeeklyLimit,                TerminalTitleItem::CodexVersion,                TerminalTitleItem::UsedTokens,                TerminalTitleItem::TotalInputTokens,                TerminalTitleItem::TotalOutputTokens,                TerminalTitleItem::SessionId,                TerminalTitleItem::FastMode,            ])        );    }}