mirror of
https://github.com/openai/codex.git
synced 2026-03-14 02:33:48 +00:00
Compare commits
1 Commits
sayan/pyth
...
codex/titl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc92e90650 |
@@ -1585,6 +1585,14 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"terminal_title": {
|
||||
"default": null,
|
||||
"description": "Ordered list of terminal title item identifiers.\n\nWhen set, the TUI renders the selected items into the terminal window/tab title. When unset, the TUI defaults to: `spinner` and `project`.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"theme": {
|
||||
"default": null,
|
||||
"description": "Syntax highlighting theme name (kebab-case).\n\nWhen set, overrides automatic light/dark theme detection. Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.",
|
||||
|
||||
@@ -234,6 +234,7 @@ fn config_toml_deserializes_model_availability_nux() {
|
||||
show_tooltips: true,
|
||||
alternate_screen: AltScreenMode::default(),
|
||||
status_line: None,
|
||||
terminal_title: None,
|
||||
theme: None,
|
||||
model_availability_nux: ModelAvailabilityNuxConfig {
|
||||
shown_count: HashMap::from([
|
||||
@@ -918,6 +919,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
|
||||
show_tooltips: true,
|
||||
alternate_screen: AltScreenMode::Auto,
|
||||
status_line: None,
|
||||
terminal_title: None,
|
||||
theme: None,
|
||||
model_availability_nux: ModelAvailabilityNuxConfig::default(),
|
||||
}
|
||||
@@ -4160,6 +4162,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
feedback_enabled: true,
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
},
|
||||
@@ -4296,6 +4299,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
feedback_enabled: true,
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
@@ -4430,6 +4434,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
feedback_enabled: true,
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
@@ -4550,6 +4555,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
feedback_enabled: true,
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ pub enum ConfigEdit {
|
||||
ClearPath { segments: Vec<String> },
|
||||
}
|
||||
|
||||
/// Produces a config edit that sets `[tui] theme = "<name>"`.
|
||||
/// Produces a config edit that sets `[tui].theme = "<name>"`.
|
||||
pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec!["tui".to_string(), "theme".to_string()],
|
||||
@@ -67,11 +67,12 @@ pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces a config edit that sets `[tui].status_line` to an explicit ordered list.
|
||||
///
|
||||
/// The array is written even when it is empty so "hide the status line" stays
|
||||
/// distinct from "unset, so use defaults".
|
||||
pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
|
||||
let mut array = toml_edit::Array::new();
|
||||
for item in items {
|
||||
array.push(item.clone());
|
||||
}
|
||||
let array = items.iter().cloned().collect::<toml_edit::Array>();
|
||||
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec!["tui".to_string(), "status_line".to_string()],
|
||||
@@ -79,6 +80,19 @@ pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces a config edit that sets `[tui].terminal_title` to an explicit ordered list.
|
||||
///
|
||||
/// The array is written even when it is empty so "disabled title updates" stays
|
||||
/// distinct from "unset, so use defaults".
|
||||
pub fn terminal_title_items_edit(items: &[String]) -> ConfigEdit {
|
||||
let array = items.iter().cloned().collect::<toml_edit::Array>();
|
||||
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec!["tui".to_string(), "terminal_title".to_string()],
|
||||
value: TomlItem::Value(array.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model_availability_nux_count_edits(shown_count: &HashMap<String, u32>) -> Vec<ConfigEdit> {
|
||||
let mut shown_count_entries: Vec<_> = shown_count.iter().collect();
|
||||
shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
|
||||
|
||||
@@ -318,6 +318,11 @@ pub struct Config {
|
||||
/// `current-dir`.
|
||||
pub tui_status_line: Option<Vec<String>>,
|
||||
|
||||
/// Ordered list of terminal title item identifiers for the TUI.
|
||||
///
|
||||
/// When unset, the TUI defaults to: `project` and `spinner`.
|
||||
pub tui_terminal_title: Option<Vec<String>>,
|
||||
|
||||
/// Syntax highlighting theme override (kebab-case name).
|
||||
pub tui_theme: Option<String>,
|
||||
|
||||
@@ -2516,6 +2521,7 @@ impl Config {
|
||||
.map(|t| t.alternate_screen)
|
||||
.unwrap_or_default(),
|
||||
tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()),
|
||||
tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()),
|
||||
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
|
||||
otel: {
|
||||
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
|
||||
|
||||
@@ -726,6 +726,13 @@ pub struct Tui {
|
||||
#[serde(default)]
|
||||
pub status_line: Option<Vec<String>>,
|
||||
|
||||
/// Ordered list of terminal title item identifiers.
|
||||
///
|
||||
/// When set, the TUI renders the selected items into the terminal window/tab title.
|
||||
/// When unset, the TUI defaults to: `spinner` and `project`.
|
||||
#[serde(default)]
|
||||
pub terminal_title: Option<Vec<String>>,
|
||||
|
||||
/// Syntax highlighting theme name (kebab-case).
|
||||
///
|
||||
/// When set, overrides automatic light/dark theme detection.
|
||||
|
||||
@@ -667,6 +667,8 @@ pub(crate) struct App {
|
||||
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>,
|
||||
// Shared across ChatWidget instances so invalid terminal-title config warnings only emit once.
|
||||
terminal_title_invalid_items_warned: Arc<AtomicBool>,
|
||||
|
||||
// Esc-backtracking state grouped
|
||||
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
|
||||
@@ -754,6 +756,7 @@ impl App {
|
||||
startup_tooltip_override: None,
|
||||
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
|
||||
session_telemetry: self.session_telemetry.clone(),
|
||||
terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1513,8 +1516,7 @@ impl App {
|
||||
let (tx, _rx) = unbounded_channel();
|
||||
tx
|
||||
};
|
||||
self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx);
|
||||
self.sync_active_agent_label();
|
||||
self.replace_chat_widget(ChatWidget::new_with_op_sender(init, codex_op_tx));
|
||||
|
||||
self.reset_for_thread_switch(tui)?;
|
||||
self.replay_thread_snapshot(snapshot, !is_replay_only);
|
||||
@@ -1554,6 +1556,16 @@ impl App {
|
||||
self.sync_active_agent_label();
|
||||
}
|
||||
|
||||
fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) {
|
||||
let previous_terminal_title = self.chat_widget.last_terminal_title.take();
|
||||
if chat_widget.last_terminal_title.is_none() {
|
||||
chat_widget.last_terminal_title = previous_terminal_title;
|
||||
}
|
||||
self.chat_widget = chat_widget;
|
||||
self.sync_active_agent_label();
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
|
||||
async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) {
|
||||
// Start a fresh in-memory session while preserving resumability via persisted rollout
|
||||
// history.
|
||||
@@ -1594,8 +1606,9 @@ impl App {
|
||||
startup_tooltip_override: None,
|
||||
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
|
||||
session_telemetry: self.session_telemetry.clone(),
|
||||
terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(),
|
||||
};
|
||||
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
||||
self.replace_chat_widget(ChatWidget::new(init, self.server.clone()));
|
||||
self.reset_thread_event_state();
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
|
||||
@@ -1684,7 +1697,7 @@ impl App {
|
||||
if resume_restored_queue {
|
||||
self.chat_widget.maybe_send_next_queued_input();
|
||||
}
|
||||
self.refresh_status_line();
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
|
||||
fn should_wait_for_initial_session(session_selection: &SessionSelection) -> bool {
|
||||
@@ -1804,6 +1817,7 @@ impl App {
|
||||
}
|
||||
|
||||
let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false));
|
||||
let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let enhanced_keys_supported = tui.enhanced_keys_supported();
|
||||
let wait_for_initial_session_configured =
|
||||
@@ -1833,6 +1847,8 @@ impl App {
|
||||
startup_tooltip_override,
|
||||
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
|
||||
session_telemetry: session_telemetry.clone(),
|
||||
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
|
||||
.clone(),
|
||||
};
|
||||
ChatWidget::new(init, thread_manager.clone())
|
||||
}
|
||||
@@ -1869,6 +1885,8 @@ impl App {
|
||||
startup_tooltip_override: None,
|
||||
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
|
||||
session_telemetry: session_telemetry.clone(),
|
||||
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
|
||||
.clone(),
|
||||
};
|
||||
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured)
|
||||
}
|
||||
@@ -1907,6 +1925,8 @@ impl App {
|
||||
startup_tooltip_override: None,
|
||||
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
|
||||
session_telemetry: session_telemetry.clone(),
|
||||
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
|
||||
.clone(),
|
||||
};
|
||||
ChatWidget::new_from_existing(init, forked.thread, forked.session_configured)
|
||||
}
|
||||
@@ -1939,6 +1959,7 @@ impl App {
|
||||
has_emitted_history_lines: false,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
|
||||
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: feedback.clone(),
|
||||
@@ -2083,7 +2104,7 @@ impl App {
|
||||
if matches!(event, TuiEvent::Draw) {
|
||||
let size = tui.terminal.size()?;
|
||||
if size != tui.terminal.last_known_screen_size {
|
||||
self.refresh_status_line();
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2205,11 +2226,11 @@ impl App {
|
||||
tui,
|
||||
self.config.clone(),
|
||||
);
|
||||
self.chat_widget = ChatWidget::new_from_existing(
|
||||
self.replace_chat_widget(ChatWidget::new_from_existing(
|
||||
init,
|
||||
resumed.thread,
|
||||
resumed.session_configured,
|
||||
);
|
||||
));
|
||||
self.reset_thread_event_state();
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> =
|
||||
@@ -2270,11 +2291,11 @@ impl App {
|
||||
tui,
|
||||
self.config.clone(),
|
||||
);
|
||||
self.chat_widget = ChatWidget::new_from_existing(
|
||||
self.replace_chat_widget(ChatWidget::new_from_existing(
|
||||
init,
|
||||
forked.thread,
|
||||
forked.session_configured,
|
||||
);
|
||||
));
|
||||
self.reset_thread_event_state();
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> =
|
||||
@@ -2447,15 +2468,15 @@ impl App {
|
||||
}
|
||||
AppEvent::UpdateReasoningEffort(effort) => {
|
||||
self.on_update_reasoning_effort(effort);
|
||||
self.refresh_status_line();
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
AppEvent::UpdateModel(model) => {
|
||||
self.chat_widget.set_model(&model);
|
||||
self.refresh_status_line();
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
AppEvent::UpdateCollaborationMode(mask) => {
|
||||
self.chat_widget.set_collaboration_mask(mask);
|
||||
self.refresh_status_line();
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
AppEvent::UpdatePersonality(personality) => {
|
||||
self.on_update_personality(personality);
|
||||
@@ -2894,7 +2915,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
AppEvent::PersistServiceTierSelection { service_tier } => {
|
||||
self.refresh_status_line();
|
||||
self.refresh_status_surfaces();
|
||||
let profile = self.active_profile.as_deref();
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_profile(profile)
|
||||
@@ -3059,7 +3080,7 @@ impl App {
|
||||
AppEvent::UpdatePlanModeReasoningEffort(effort) => {
|
||||
self.config.plan_mode_reasoning_effort = effort;
|
||||
self.chat_widget.set_plan_mode_reasoning_effort(effort);
|
||||
self.refresh_status_line();
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
AppEvent::PersistFullAccessWarningAcknowledged => {
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
@@ -3373,11 +3394,38 @@ impl App {
|
||||
}
|
||||
AppEvent::StatusLineBranchUpdated { cwd, branch } => {
|
||||
self.chat_widget.set_status_line_branch(cwd, branch);
|
||||
self.refresh_status_line();
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
AppEvent::StatusLineSetupCancelled => {
|
||||
self.chat_widget.cancel_status_line_setup();
|
||||
}
|
||||
AppEvent::TerminalTitleSetup { items } => {
|
||||
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
||||
let edit = codex_core::config::edit::terminal_title_items_edit(&ids);
|
||||
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_edits([edit])
|
||||
.apply()
|
||||
.await;
|
||||
match apply_result {
|
||||
Ok(()) => {
|
||||
self.config.tui_terminal_title = Some(ids.clone());
|
||||
self.chat_widget.setup_terminal_title(items);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(error = %err, "failed to persist terminal title items; keeping previous selection");
|
||||
self.chat_widget.revert_terminal_title_setup_preview();
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to save terminal title items: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::TerminalTitleSetupPreview { items } => {
|
||||
self.chat_widget.preview_terminal_title(items);
|
||||
}
|
||||
AppEvent::TerminalTitleSetupCancelled => {
|
||||
self.chat_widget.cancel_terminal_title_setup();
|
||||
}
|
||||
AppEvent::SyntaxThemeSelected { name } => {
|
||||
let edit = codex_core::config::edit::syntax_theme_edit(&name);
|
||||
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
@@ -3452,7 +3500,7 @@ impl App {
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
|
||||
if needs_refresh {
|
||||
self.refresh_status_line();
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3832,8 +3880,8 @@ impl App {
|
||||
};
|
||||
}
|
||||
|
||||
fn refresh_status_line(&mut self) {
|
||||
self.chat_widget.refresh_status_line();
|
||||
fn refresh_status_surfaces(&mut self) {
|
||||
self.chat_widget.refresh_status_surfaces();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -3865,12 +3913,21 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for App {
|
||||
fn drop(&mut self) {
|
||||
if let Err(err) = self.chat_widget.clear_managed_terminal_title() {
|
||||
tracing::debug!(error = %err, "failed to clear terminal title on app drop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_backtrack::BacktrackSelection;
|
||||
use crate::app_backtrack::BacktrackState;
|
||||
use crate::app_backtrack::user_count;
|
||||
use crate::bottom_pane::TerminalTitleItem;
|
||||
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
|
||||
use crate::chatwidget::tests::set_chatgpt_auth;
|
||||
use crate::file_search::FileSearchManager;
|
||||
@@ -4631,6 +4688,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_chat_widget_preserves_terminal_title_cache_for_empty_replacement_title() {
|
||||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
app.chat_widget.last_terminal_title = Some("my-project | Ready".to_string());
|
||||
|
||||
let (mut replacement, _app_event_tx, _rx, _new_op_rx) =
|
||||
make_chatwidget_manual_with_sender().await;
|
||||
replacement.setup_terminal_title(Vec::new());
|
||||
|
||||
app.replace_chat_widget(replacement);
|
||||
|
||||
assert_eq!(app.chat_widget.last_terminal_title, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_chat_widget_keeps_replacement_terminal_title_cache_when_present() {
|
||||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
app.chat_widget.last_terminal_title = Some("old-project | Ready".to_string());
|
||||
|
||||
let (mut replacement, _app_event_tx, _rx, _new_op_rx) =
|
||||
make_chatwidget_manual_with_sender().await;
|
||||
replacement.setup_terminal_title(vec![TerminalTitleItem::AppName]);
|
||||
replacement.last_terminal_title = Some("codex".to_string());
|
||||
|
||||
app.replace_chat_widget(replacement);
|
||||
|
||||
assert_eq!(
|
||||
app.chat_widget.last_terminal_title,
|
||||
Some("codex".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_thread_snapshot_restores_pending_pastes_for_submit() {
|
||||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
@@ -5604,6 +5693,7 @@ mod tests {
|
||||
enhanced_keys_supported: false,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
@@ -5664,6 +5754,7 @@ mod tests {
|
||||
enhanced_keys_supported: false,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
|
||||
@@ -20,6 +20,7 @@ use codex_utils_approval_presets::ApprovalPreset;
|
||||
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::bottom_pane::StatusLineItem;
|
||||
use crate::bottom_pane::TerminalTitleItem;
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
use codex_core::features::Feature;
|
||||
@@ -447,6 +448,16 @@ pub(crate) enum AppEvent {
|
||||
},
|
||||
/// Dismiss the status-line setup UI without changing config.
|
||||
StatusLineSetupCancelled,
|
||||
/// Apply a user-confirmed terminal-title item ordering/selection.
|
||||
TerminalTitleSetup {
|
||||
items: Vec<TerminalTitleItem>,
|
||||
},
|
||||
/// Apply a temporary terminal-title preview while the setup UI is open.
|
||||
TerminalTitleSetupPreview {
|
||||
items: Vec<TerminalTitleItem>,
|
||||
},
|
||||
/// Dismiss the terminal-title setup UI without changing config.
|
||||
TerminalTitleSetupCancelled,
|
||||
|
||||
/// Apply a user-confirmed syntax theme selection.
|
||||
SyntaxThemeSelected {
|
||||
|
||||
@@ -49,6 +49,7 @@ mod request_user_input;
|
||||
mod status_line_setup;
|
||||
pub(crate) use app_link_view::AppLinkElicitationTarget;
|
||||
pub(crate) use app_link_view::AppLinkSuggestionType;
|
||||
mod title_setup;
|
||||
pub(crate) use app_link_view::AppLinkView;
|
||||
pub(crate) use app_link_view::AppLinkViewParams;
|
||||
pub(crate) use approval_overlay::ApprovalOverlay;
|
||||
@@ -100,6 +101,8 @@ pub(crate) use skills_toggle_view::SkillsToggleView;
|
||||
pub(crate) use status_line_setup::StatusLineItem;
|
||||
pub(crate) use status_line_setup::StatusLinePreviewData;
|
||||
pub(crate) use status_line_setup::StatusLineSetupView;
|
||||
pub(crate) use title_setup::TerminalTitleItem;
|
||||
pub(crate) use title_setup::TerminalTitleSetupView;
|
||||
mod paste_burst;
|
||||
mod pending_input_preview;
|
||||
mod pending_thread_approvals;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/title_setup.rs
|
||||
expression: "render_lines(&view, 84)"
|
||||
---
|
||||
|
||||
Configure Terminal Title
|
||||
Select which items to display in the terminal title.
|
||||
|
||||
Type to search
|
||||
>
|
||||
› [x] project Project name (falls back to current directory name)
|
||||
[x] spinner Animated task spinner (omitted while idle or when animations…
|
||||
[x] status Compact session status text (Ready, Working, Thinking)
|
||||
[x] thread Current thread title (omitted until available)
|
||||
[ ] app-name Codex app name
|
||||
[ ] git-branch Current Git branch (omitted when unavailable)
|
||||
[ ] model Current model name
|
||||
[ ] task-progress Latest task progress from update_plan (omitted until availab…
|
||||
|
||||
my-project ⠋ Working | Investigate flaky test
|
||||
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
|
||||
298
codex-rs/tui/src/bottom_pane/title_setup.rs
Normal file
298
codex-rs/tui/src/bottom_pane/title_setup.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
//! 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 title
|
||||
|
||||
use 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::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 terminal title.
|
||||
#[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.
|
||||
Project,
|
||||
/// Animated task spinner while active.
|
||||
Spinner,
|
||||
/// Compact runtime status text.
|
||||
Status,
|
||||
/// Current thread title (if available).
|
||||
Thread,
|
||||
/// Current git branch (if available).
|
||||
GitBranch,
|
||||
/// Current model name.
|
||||
Model,
|
||||
/// 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::Spinner => {
|
||||
"Animated task spinner (omitted while idle or when animations are off)"
|
||||
}
|
||||
TerminalTitleItem::Status => "Compact session status text (Ready, Working, Thinking)",
|
||||
TerminalTitleItem::Thread => "Current thread title (omitted until available)",
|
||||
TerminalTitleItem::GitBranch => "Current Git branch (omitted when unavailable)",
|
||||
TerminalTitleItem::Model => "Current model name",
|
||||
TerminalTitleItem::TaskProgress => {
|
||||
"Latest task progress from update_plan (omitted until available)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Example text used when previewing the title picker.
|
||||
///
|
||||
/// These are illustrative sample values, not live data from the current
|
||||
/// session.
|
||||
pub(crate) fn preview_example(self) -> &'static str {
|
||||
match self {
|
||||
TerminalTitleItem::AppName => "codex",
|
||||
TerminalTitleItem::Project => "my-project",
|
||||
TerminalTitleItem::Spinner => "⠋",
|
||||
TerminalTitleItem::Status => "Working",
|
||||
TerminalTitleItem::Thread => "Investigate flaky test",
|
||||
TerminalTitleItem::GitBranch => "feat/awesome-feature",
|
||||
TerminalTitleItem::Model => "gpt-5.2-codex",
|
||||
TerminalTitleItem::TaskProgress => "Tasks 2/5",
|
||||
}
|
||||
}
|
||||
|
||||
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(_) => " | ",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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]>, app_event_tx: AppEventSender) -> 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, true))
|
||||
.chain(
|
||||
TerminalTitleItem::iter()
|
||||
.filter(|item| !selected_set.contains(item))
|
||||
.map(|item| Self::title_select_item(item, false)),
|
||||
)
|
||||
.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,
|
||||
)
|
||||
.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 items = parse_terminal_title_items(
|
||||
items
|
||||
.iter()
|
||||
.filter(|item| item.enabled)
|
||||
.map(|item| item.id.as_str()),
|
||||
)?;
|
||||
let mut preview = String::new();
|
||||
let mut previous = None;
|
||||
for item in items.iter().copied() {
|
||||
preview.push_str(item.separator_from_previous(previous));
|
||||
preview.push_str(item.preview_example());
|
||||
previous = Some(item);
|
||||
}
|
||||
if preview.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Line::from(preview))
|
||||
}
|
||||
})
|
||||
.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) -> MultiSelectItem {
|
||||
MultiSelectItem {
|
||||
id: item.to_string(),
|
||||
name: item.to_string(),
|
||||
description: Some(item.description().to_string()),
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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".to_string(),
|
||||
"spinner".to_string(),
|
||||
"status".to_string(),
|
||||
"thread".to_string(),
|
||||
];
|
||||
let view = TerminalTitleSetupView::new(Some(&selected), tx);
|
||||
assert_snapshot!("terminal_title_setup_basic", render_lines(&view, 84));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_terminal_title_items_preserves_order() {
|
||||
let items =
|
||||
parse_terminal_title_items(["project", "spinner", "status", "thread"].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);
|
||||
}
|
||||
}
|
||||
@@ -44,10 +44,15 @@ use crate::audio_device::list_realtime_audio_device_names;
|
||||
use crate::bottom_pane::StatusLineItem;
|
||||
use crate::bottom_pane::StatusLinePreviewData;
|
||||
use crate::bottom_pane::StatusLineSetupView;
|
||||
use crate::bottom_pane::TerminalTitleItem;
|
||||
use crate::bottom_pane::TerminalTitleSetupView;
|
||||
use crate::status::RateLimitWindowDisplay;
|
||||
use crate::status::format_directory_display;
|
||||
use crate::status::format_tokens_compact;
|
||||
use crate::status::rate_limit_snapshot_display_for_limit;
|
||||
use crate::terminal_title::SetTerminalTitleResult;
|
||||
use crate::terminal_title::clear_terminal_title;
|
||||
use crate::terminal_title::set_terminal_title;
|
||||
use crate::text_formatting::proper_join;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
@@ -166,6 +171,7 @@ use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
use tracing::warn;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
|
||||
const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?";
|
||||
@@ -281,6 +287,11 @@ use self::skills::find_skill_mentions_with_tool_mentions;
|
||||
mod realtime;
|
||||
use self::realtime::RealtimeConversationUiState;
|
||||
use self::realtime::RenderedUserMessageEvent;
|
||||
mod status_surfaces;
|
||||
use self::status_surfaces::CachedProjectRootName;
|
||||
#[cfg(test)]
|
||||
use self::status_surfaces::TERMINAL_TITLE_SPINNER_INTERVAL;
|
||||
use self::status_surfaces::TerminalTitleStatusKind;
|
||||
use crate::mention_codec::LinkedMention;
|
||||
use crate::mention_codec::encode_history_mentions;
|
||||
use crate::streaming::chunking::AdaptiveChunkingPolicy;
|
||||
@@ -297,6 +308,7 @@ use codex_file_search::FileMatch;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
@@ -481,6 +493,8 @@ pub(crate) struct ChatWidgetInit {
|
||||
pub(crate) startup_tooltip_override: Option<String>,
|
||||
// Shared latch so we only warn once about invalid status-line item IDs.
|
||||
pub(crate) status_line_invalid_items_warned: Arc<AtomicBool>,
|
||||
// Shared latch so we only warn once about invalid terminal-title item IDs.
|
||||
pub(crate) terminal_title_invalid_items_warned: Arc<AtomicBool>,
|
||||
pub(crate) session_telemetry: SessionTelemetry,
|
||||
}
|
||||
|
||||
@@ -612,6 +626,8 @@ pub(crate) struct ChatWidget {
|
||||
full_reasoning_buffer: String,
|
||||
// Current status header shown in the status indicator.
|
||||
current_status_header: String,
|
||||
// Semantic status used for terminal-title status rendering (avoid string matching on headers).
|
||||
terminal_title_status_kind: TerminalTitleStatusKind,
|
||||
// Previous status header to restore after a transient stream retry.
|
||||
retry_status_header: Option<String>,
|
||||
// Set when commentary output completes; once stream queues go idle we restore the status row.
|
||||
@@ -674,6 +690,8 @@ pub(crate) struct ChatWidget {
|
||||
// later steer. This is cleared when the user submits a steer so the plan popup only appears
|
||||
// if a newer proposed plan arrives afterward.
|
||||
saw_plan_item_this_turn: bool,
|
||||
// Latest `update_plan` checklist task counts for terminal-title rendering.
|
||||
last_plan_progress: Option<(usize, usize)>,
|
||||
// Incremental buffer for streamed plan content.
|
||||
plan_delta_buffer: String,
|
||||
// True while a plan item is streaming.
|
||||
@@ -697,6 +715,21 @@ pub(crate) struct ChatWidget {
|
||||
session_network_proxy: Option<codex_protocol::protocol::SessionNetworkProxyRuntime>,
|
||||
// Shared latch so we only warn once about invalid status-line item IDs.
|
||||
status_line_invalid_items_warned: Arc<AtomicBool>,
|
||||
// Shared latch so we only warn once about invalid terminal-title item IDs.
|
||||
terminal_title_invalid_items_warned: Arc<AtomicBool>,
|
||||
// Last terminal title emitted, to avoid writing duplicate OSC updates.
|
||||
//
|
||||
// App carries this cache across ChatWidget replacement so the next widget can
|
||||
// clear a stale title when its own configuration renders no title content.
|
||||
pub(crate) last_terminal_title: Option<String>,
|
||||
// Original terminal-title config captured when opening the setup UI so live preview can be
|
||||
// rolled back on cancel.
|
||||
terminal_title_setup_original_items: Option<Option<Vec<String>>>,
|
||||
// Baseline instant used to animate spinner-prefixed title statuses.
|
||||
terminal_title_animation_origin: Instant,
|
||||
// Cached project root display name for the current cwd; avoids walking parent directories on
|
||||
// frequent title/status refreshes.
|
||||
status_line_project_root_name_cache: Option<CachedProjectRootName>,
|
||||
// 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.
|
||||
@@ -992,12 +1025,15 @@ impl ChatWidget {
|
||||
fn update_task_running_state(&mut self) {
|
||||
self.bottom_pane
|
||||
.set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some());
|
||||
self.refresh_terminal_title();
|
||||
}
|
||||
|
||||
fn restore_reasoning_status_header(&mut self) {
|
||||
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
|
||||
self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
|
||||
self.set_status_header(header);
|
||||
} else if self.bottom_pane.is_task_running() {
|
||||
self.terminal_title_status_kind = TerminalTitleStatusKind::Working;
|
||||
self.set_status_header(String::from("Working"));
|
||||
}
|
||||
}
|
||||
@@ -1066,6 +1102,22 @@ impl ChatWidget {
|
||||
self.current_status_header = header.clone();
|
||||
self.bottom_pane
|
||||
.update_status(header, details, details_capitalization, details_max_lines);
|
||||
let title_uses_status = self
|
||||
.config
|
||||
.tui_terminal_title
|
||||
.as_ref()
|
||||
.is_some_and(|items| items.iter().any(|item| item == "status"));
|
||||
let title_uses_spinner = self
|
||||
.config
|
||||
.tui_terminal_title
|
||||
.as_ref()
|
||||
.is_none_or(|items| items.iter().any(|item| item == "spinner"));
|
||||
if title_uses_status
|
||||
|| (title_uses_spinner
|
||||
&& self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing)
|
||||
{
|
||||
self.refresh_terminal_title();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience wrapper around [`Self::set_status`];
|
||||
@@ -1092,70 +1144,6 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_active_agent_label(active_agent_label);
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -1171,7 +1159,45 @@ impl ChatWidget {
|
||||
tracing::info!("status line setup confirmed with items: {items:#?}");
|
||||
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
||||
self.config.tui_status_line = Some(ids);
|
||||
self.refresh_status_line();
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
|
||||
/// Applies a temporary terminal-title selection while the setup UI is open.
|
||||
pub(crate) fn preview_terminal_title(&mut self, items: Vec<TerminalTitleItem>) {
|
||||
if self.terminal_title_setup_original_items.is_none() {
|
||||
self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone());
|
||||
}
|
||||
|
||||
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
||||
self.config.tui_terminal_title = Some(ids);
|
||||
self.refresh_terminal_title();
|
||||
}
|
||||
|
||||
/// Restores the terminal title selection captured before opening the setup UI.
|
||||
pub(crate) fn revert_terminal_title_setup_preview(&mut self) {
|
||||
let Some(original_items) = self.terminal_title_setup_original_items.take() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.config.tui_terminal_title = original_items;
|
||||
self.refresh_terminal_title();
|
||||
}
|
||||
|
||||
/// Records that terminal-title setup was canceled and rolls back live preview changes.
|
||||
pub(crate) fn cancel_terminal_title_setup(&mut self) {
|
||||
tracing::info!("Terminal title setup canceled by user");
|
||||
self.revert_terminal_title_setup_preview();
|
||||
}
|
||||
|
||||
/// Applies terminal-title item selection from the setup view to in-memory config.
|
||||
///
|
||||
/// An empty selection persists as an explicit empty list (disables title updates).
|
||||
pub(crate) fn setup_terminal_title(&mut self, items: Vec<TerminalTitleItem>) {
|
||||
tracing::info!("terminal title setup confirmed with items: {items:#?}");
|
||||
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
||||
self.terminal_title_setup_original_items = None;
|
||||
self.config.tui_terminal_title = Some(ids);
|
||||
self.refresh_terminal_title();
|
||||
}
|
||||
|
||||
/// Stores async git-branch lookup results for the current status-line cwd.
|
||||
@@ -1188,17 +1214,6 @@ impl ChatWidget {
|
||||
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 collect_runtime_metrics_delta(&mut self) {
|
||||
if let Some(delta) = self.session_telemetry.runtime_metrics_summary() {
|
||||
self.apply_runtime_metrics_delta(delta);
|
||||
@@ -1263,6 +1278,7 @@ impl ChatWidget {
|
||||
self.config.permissions.sandbox_policy =
|
||||
Constrained::allow_only(event.sandbox_policy.clone());
|
||||
}
|
||||
self.status_line_project_root_name_cache = None;
|
||||
let initial_messages = event.initial_messages.clone();
|
||||
self.last_copyable_output = None;
|
||||
let forked_from_id = event.forked_from_id;
|
||||
@@ -1366,6 +1382,7 @@ impl ChatWidget {
|
||||
fn on_thread_name_updated(&mut self, event: codex_protocol::protocol::ThreadNameUpdatedEvent) {
|
||||
if self.thread_id == Some(event.thread_id) {
|
||||
self.thread_name = event.thread_name;
|
||||
self.refresh_terminal_title();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
@@ -1537,6 +1554,7 @@ impl ChatWidget {
|
||||
|
||||
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
|
||||
// Update the shimmer header to the extracted reasoning chunk header.
|
||||
self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
|
||||
self.set_status_header(header);
|
||||
} else {
|
||||
// Fallback while we don't yet have a bold header: leave existing header as-is.
|
||||
@@ -1573,6 +1591,7 @@ impl ChatWidget {
|
||||
self.turn_sleep_inhibitor.set_turn_running(true);
|
||||
self.saw_plan_update_this_turn = false;
|
||||
self.saw_plan_item_this_turn = false;
|
||||
self.last_plan_progress = None;
|
||||
self.plan_delta_buffer.clear();
|
||||
self.plan_item_active = false;
|
||||
self.adaptive_chunking.reset();
|
||||
@@ -1586,6 +1605,7 @@ impl ChatWidget {
|
||||
self.retry_status_header = None;
|
||||
self.pending_status_indicator_restore = false;
|
||||
self.bottom_pane.set_interrupt_hint_visible(true);
|
||||
self.terminal_title_status_kind = TerminalTitleStatusKind::Working;
|
||||
self.set_status_header(String::from("Working"));
|
||||
self.full_reasoning_buffer.clear();
|
||||
self.reasoning_buffer.clear();
|
||||
@@ -1921,7 +1941,7 @@ impl ChatWidget {
|
||||
} else {
|
||||
self.rate_limit_snapshots_by_limit_id.clear();
|
||||
}
|
||||
self.refresh_status_line();
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
/// Finalize any active exec as failed and stop/clear agent-turn UI state.
|
||||
///
|
||||
@@ -2228,6 +2248,17 @@ impl ChatWidget {
|
||||
|
||||
fn on_plan_update(&mut self, update: UpdatePlanArgs) {
|
||||
self.saw_plan_update_this_turn = true;
|
||||
let total = update.plan.len();
|
||||
let completed = update
|
||||
.plan
|
||||
.iter()
|
||||
.filter(|item| match &item.status {
|
||||
StepStatus::Completed => true,
|
||||
StepStatus::Pending | StepStatus::InProgress => false,
|
||||
})
|
||||
.count();
|
||||
self.last_plan_progress = (total > 0).then_some((completed, total));
|
||||
self.refresh_terminal_title();
|
||||
self.add_to_history(history_cell::new_plan_update(update));
|
||||
}
|
||||
|
||||
@@ -2324,6 +2355,7 @@ impl ChatWidget {
|
||||
// the transcript. Keep the header short so the interrupt hint remains visible.
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.bottom_pane.set_interrupt_hint_visible(true);
|
||||
self.terminal_title_status_kind = TerminalTitleStatusKind::WaitingForBackgroundTerminal;
|
||||
self.set_status(
|
||||
"Waiting for background terminal".to_string(),
|
||||
command_display.clone(),
|
||||
@@ -2574,7 +2606,7 @@ impl ChatWidget {
|
||||
|
||||
fn on_turn_diff(&mut self, unified_diff: String) {
|
||||
debug!("TurnDiffEvent: {unified_diff}");
|
||||
self.refresh_status_line();
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
|
||||
fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) {
|
||||
@@ -2587,6 +2619,7 @@ impl ChatWidget {
|
||||
debug!("BackgroundEvent: {message}");
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.bottom_pane.set_interrupt_hint_visible(true);
|
||||
self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
|
||||
self.set_status_header(message);
|
||||
}
|
||||
|
||||
@@ -2627,12 +2660,15 @@ impl ChatWidget {
|
||||
let message = event
|
||||
.message
|
||||
.unwrap_or_else(|| "Undo in progress...".to_string());
|
||||
self.terminal_title_status_kind = TerminalTitleStatusKind::Undoing;
|
||||
self.set_status_header(message);
|
||||
}
|
||||
|
||||
fn on_undo_completed(&mut self, event: UndoCompletedEvent) {
|
||||
let UndoCompletedEvent { success, message } = event;
|
||||
self.bottom_pane.hide_status_indicator();
|
||||
self.terminal_title_status_kind = TerminalTitleStatusKind::Working;
|
||||
self.refresh_terminal_title();
|
||||
let message = message.unwrap_or_else(|| {
|
||||
if success {
|
||||
"Undo completed successfully.".to_string()
|
||||
@@ -2652,6 +2688,7 @@ impl ChatWidget {
|
||||
self.retry_status_header = Some(self.current_status_header.clone());
|
||||
}
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
|
||||
self.set_status(
|
||||
message,
|
||||
additional_details,
|
||||
@@ -2662,6 +2699,9 @@ impl ChatWidget {
|
||||
|
||||
pub(crate) fn pre_draw_tick(&mut self) {
|
||||
self.bottom_pane.pre_draw_tick();
|
||||
if self.should_animate_terminal_title_spinner() {
|
||||
self.refresh_terminal_title();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle completion of an `AgentMessage` turn item.
|
||||
@@ -3184,6 +3224,7 @@ impl ChatWidget {
|
||||
model,
|
||||
startup_tooltip_override,
|
||||
status_line_invalid_items_warned,
|
||||
terminal_title_invalid_items_warned,
|
||||
session_telemetry,
|
||||
} = common;
|
||||
let model = model.filter(|m| !m.trim().is_empty());
|
||||
@@ -3274,6 +3315,7 @@ impl ChatWidget {
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
current_status_header: String::from("Working"),
|
||||
terminal_title_status_kind: TerminalTitleStatusKind::Working,
|
||||
retry_status_header: None,
|
||||
pending_status_indicator_restore: false,
|
||||
suppress_queue_autosend: false,
|
||||
@@ -3296,6 +3338,7 @@ impl ChatWidget {
|
||||
had_work_activity: false,
|
||||
saw_plan_update_this_turn: false,
|
||||
saw_plan_item_this_turn: false,
|
||||
last_plan_progress: None,
|
||||
plan_delta_buffer: String::new(),
|
||||
plan_item_active: false,
|
||||
last_separator_elapsed_secs: None,
|
||||
@@ -3307,6 +3350,11 @@ impl ChatWidget {
|
||||
current_cwd,
|
||||
session_network_proxy: None,
|
||||
status_line_invalid_items_warned,
|
||||
terminal_title_invalid_items_warned,
|
||||
last_terminal_title: None,
|
||||
terminal_title_setup_original_items: None,
|
||||
terminal_title_animation_origin: Instant::now(),
|
||||
status_line_project_root_name_cache: None,
|
||||
status_line_branch: None,
|
||||
status_line_branch_cwd: None,
|
||||
status_line_branch_pending: false,
|
||||
@@ -3349,6 +3397,8 @@ impl ChatWidget {
|
||||
.bottom_pane
|
||||
.set_connectors_enabled(widget.connectors_enabled());
|
||||
|
||||
widget.refresh_terminal_title();
|
||||
|
||||
widget
|
||||
}
|
||||
|
||||
@@ -3370,6 +3420,7 @@ impl ChatWidget {
|
||||
model,
|
||||
startup_tooltip_override,
|
||||
status_line_invalid_items_warned,
|
||||
terminal_title_invalid_items_warned,
|
||||
session_telemetry,
|
||||
} = common;
|
||||
let model = model.filter(|m| !m.trim().is_empty());
|
||||
@@ -3459,6 +3510,7 @@ impl ChatWidget {
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
current_status_header: String::from("Working"),
|
||||
terminal_title_status_kind: TerminalTitleStatusKind::Working,
|
||||
retry_status_header: None,
|
||||
pending_status_indicator_restore: false,
|
||||
suppress_queue_autosend: false,
|
||||
@@ -3467,6 +3519,7 @@ impl ChatWidget {
|
||||
forked_from: None,
|
||||
saw_plan_update_this_turn: false,
|
||||
saw_plan_item_this_turn: false,
|
||||
last_plan_progress: None,
|
||||
plan_delta_buffer: String::new(),
|
||||
plan_item_active: false,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
@@ -3492,6 +3545,11 @@ impl ChatWidget {
|
||||
current_cwd,
|
||||
session_network_proxy: None,
|
||||
status_line_invalid_items_warned,
|
||||
terminal_title_invalid_items_warned,
|
||||
last_terminal_title: None,
|
||||
terminal_title_setup_original_items: None,
|
||||
terminal_title_animation_origin: Instant::now(),
|
||||
status_line_project_root_name_cache: None,
|
||||
status_line_branch: None,
|
||||
status_line_branch_cwd: None,
|
||||
status_line_branch_pending: false,
|
||||
@@ -3523,6 +3581,8 @@ impl ChatWidget {
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_connectors_enabled(widget.connectors_enabled());
|
||||
widget.refresh_terminal_title();
|
||||
widget.refresh_terminal_title();
|
||||
|
||||
widget
|
||||
}
|
||||
@@ -3547,6 +3607,7 @@ impl ChatWidget {
|
||||
model,
|
||||
startup_tooltip_override: _,
|
||||
status_line_invalid_items_warned,
|
||||
terminal_title_invalid_items_warned,
|
||||
session_telemetry,
|
||||
} = common;
|
||||
let model = model.filter(|m| !m.trim().is_empty());
|
||||
@@ -3636,6 +3697,7 @@ impl ChatWidget {
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
current_status_header: String::from("Working"),
|
||||
terminal_title_status_kind: TerminalTitleStatusKind::Working,
|
||||
retry_status_header: None,
|
||||
pending_status_indicator_restore: false,
|
||||
suppress_queue_autosend: false,
|
||||
@@ -3658,6 +3720,7 @@ impl ChatWidget {
|
||||
had_work_activity: false,
|
||||
saw_plan_update_this_turn: false,
|
||||
saw_plan_item_this_turn: false,
|
||||
last_plan_progress: None,
|
||||
plan_delta_buffer: String::new(),
|
||||
plan_item_active: false,
|
||||
last_separator_elapsed_secs: None,
|
||||
@@ -3669,6 +3732,11 @@ impl ChatWidget {
|
||||
current_cwd,
|
||||
session_network_proxy: None,
|
||||
status_line_invalid_items_warned,
|
||||
terminal_title_invalid_items_warned,
|
||||
last_terminal_title: None,
|
||||
terminal_title_setup_original_items: None,
|
||||
terminal_title_animation_origin: Instant::now(),
|
||||
status_line_project_root_name_cache: None,
|
||||
status_line_branch: None,
|
||||
status_line_branch_cwd: None,
|
||||
status_line_branch_pending: false,
|
||||
@@ -3709,6 +3777,8 @@ impl ChatWidget {
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_connectors_enabled(widget.connectors_enabled());
|
||||
widget.refresh_terminal_title();
|
||||
widget.refresh_terminal_title();
|
||||
|
||||
widget
|
||||
}
|
||||
@@ -4203,6 +4273,9 @@ impl ChatWidget {
|
||||
SlashCommand::DebugConfig => {
|
||||
self.add_debug_config_output();
|
||||
}
|
||||
SlashCommand::Title => {
|
||||
self.open_terminal_title_setup();
|
||||
}
|
||||
SlashCommand::Statusline => {
|
||||
self.open_status_line_setup();
|
||||
}
|
||||
@@ -5381,182 +5454,14 @@ impl ChatWidget {
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
for id in self.configured_status_line_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 configured_status_line_items(&self) -> Vec<String> {
|
||||
self.config.tui_status_line.clone().unwrap_or_else(|| {
|
||||
DEFAULT_STATUS_LINE_ITEMS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
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());
|
||||
let fast_label = if self
|
||||
.should_show_fast_status(self.current_model(), self.config.service_tier)
|
||||
{
|
||||
" fast"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
Some(format!("{} {label}{fast_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_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.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_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.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()),
|
||||
StatusLineItem::FastMode => Some(
|
||||
if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
|
||||
"Fast on".to_string()
|
||||
} else {
|
||||
"Fast off".to_string()
|
||||
},
|
||||
),
|
||||
}
|
||||
fn open_terminal_title_setup(&mut self) {
|
||||
let configured_terminal_title_items = self.configured_terminal_title_items();
|
||||
self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone());
|
||||
let view = TerminalTitleSetupView::new(
|
||||
Some(configured_terminal_title_items.as_slice()),
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
fn status_line_context_window_size(&self) -> Option<i64> {
|
||||
@@ -7694,6 +7599,7 @@ impl ChatWidget {
|
||||
self.session_header.set_model(effective.model());
|
||||
// Keep composer paste affordances aligned with the currently effective model.
|
||||
self.sync_image_paste_enabled();
|
||||
self.refresh_terminal_title();
|
||||
}
|
||||
|
||||
fn model_display_name(&self) -> &str {
|
||||
@@ -8767,8 +8673,8 @@ fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool {
|
||||
|
||||
impl Drop for ChatWidget {
|
||||
fn drop(&mut self) {
|
||||
self.reset_realtime_conversation_state();
|
||||
self.stop_rate_limit_poller();
|
||||
self.reset_realtime_conversation_state();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
650
codex-rs/tui/src/chatwidget/status_surfaces.rs
Normal file
650
codex-rs/tui/src/chatwidget/status_surfaces.rs
Normal file
@@ -0,0 +1,650 @@
|
||||
//! Status-line and terminal-title rendering helpers for `ChatWidget`.
|
||||
//!
|
||||
//! Keeping this logic in a focused submodule makes the additive title/status
|
||||
//! behavior easier to review without paging through the rest of `chatwidget.rs`.
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["spinner", "project"];
|
||||
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
|
||||
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
/// Compact runtime states that can be rendered into the terminal title.
|
||||
///
|
||||
/// This is intentionally smaller than the full status-header vocabulary. The
|
||||
/// title needs short, stable labels, so callers map richer lifecycle events
|
||||
/// onto one of these buckets before rendering.
|
||||
pub(super) enum TerminalTitleStatusKind {
|
||||
Working,
|
||||
WaitingForBackgroundTerminal,
|
||||
Undoing,
|
||||
#[default]
|
||||
Thinking,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Parsed status-surface configuration for one refresh pass.
|
||||
///
|
||||
/// The status line and terminal title share some expensive or stateful inputs
|
||||
/// (notably git branch lookup and invalid-item warnings). This snapshot lets one
|
||||
/// refresh pass compute those shared concerns once, then render both surfaces
|
||||
/// from the same selection set.
|
||||
struct StatusSurfaceSelections {
|
||||
status_line_items: Vec<StatusLineItem>,
|
||||
invalid_status_line_items: Vec<String>,
|
||||
terminal_title_items: Vec<TerminalTitleItem>,
|
||||
invalid_terminal_title_items: Vec<String>,
|
||||
}
|
||||
|
||||
impl StatusSurfaceSelections {
|
||||
fn uses_git_branch(&self) -> bool {
|
||||
self.status_line_items.contains(&StatusLineItem::GitBranch)
|
||||
|| self
|
||||
.terminal_title_items
|
||||
.contains(&TerminalTitleItem::GitBranch)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
/// Cached project-root display name keyed by the cwd used for the last lookup.
|
||||
///
|
||||
/// Terminal-title refreshes can happen very frequently, so the title path avoids
|
||||
/// repeatedly walking up the filesystem to rediscover the same project root name
|
||||
/// while the working directory is unchanged.
|
||||
pub(super) struct CachedProjectRootName {
|
||||
pub(super) cwd: PathBuf,
|
||||
pub(super) root_name: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
fn status_surface_selections(&self) -> StatusSurfaceSelections {
|
||||
let (status_line_items, invalid_status_line_items) = self.status_line_items_with_invalids();
|
||||
let (terminal_title_items, invalid_terminal_title_items) =
|
||||
self.terminal_title_items_with_invalids();
|
||||
StatusSurfaceSelections {
|
||||
status_line_items,
|
||||
invalid_status_line_items,
|
||||
terminal_title_items,
|
||||
invalid_terminal_title_items,
|
||||
}
|
||||
}
|
||||
|
||||
fn warn_invalid_status_line_items_once(&mut self, invalid_items: &[String]) {
|
||||
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)
|
||||
);
|
||||
self.on_warning(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn warn_invalid_terminal_title_items_once(&mut self, invalid_items: &[String]) {
|
||||
if self.thread_id.is_some()
|
||||
&& !invalid_items.is_empty()
|
||||
&& self
|
||||
.terminal_title_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 terminal title {label}: {}.",
|
||||
proper_join(invalid_items)
|
||||
);
|
||||
self.on_warning(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_status_surface_shared_state(&mut self, selections: &StatusSurfaceSelections) {
|
||||
if !selections.uses_git_branch() {
|
||||
self.status_line_branch = None;
|
||||
self.status_line_branch_pending = false;
|
||||
self.status_line_branch_lookup_complete = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let cwd = self.status_line_cwd().to_path_buf();
|
||||
self.sync_status_line_branch_state(&cwd);
|
||||
if !self.status_line_branch_lookup_complete {
|
||||
self.request_status_line_branch(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) {
|
||||
let enabled = !selections.status_line_items.is_empty();
|
||||
self.bottom_pane.set_status_line_enabled(enabled);
|
||||
if !enabled {
|
||||
self.set_status_line(None);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
for item in &selections.status_line_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);
|
||||
}
|
||||
|
||||
/// Clears the terminal title Codex most recently wrote, if any.
|
||||
///
|
||||
/// This does not attempt to restore the shell or terminal's previous title;
|
||||
/// it only clears the managed title and updates the cache after a successful
|
||||
/// OSC write.
|
||||
pub(crate) fn clear_managed_terminal_title(&mut self) -> std::io::Result<()> {
|
||||
if self.last_terminal_title.is_some() {
|
||||
clear_terminal_title()?;
|
||||
self.last_terminal_title = None;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renders and applies the terminal title for one parsed selection snapshot.
|
||||
///
|
||||
/// Empty selections clear the managed title. Non-empty selections render the
|
||||
/// current values in configured order, skip unavailable segments, and cache
|
||||
/// the last successfully written title so redundant OSC writes are avoided.
|
||||
/// When the `spinner` item is present in an animated running state, this also
|
||||
/// schedules the next frame so the spinner keeps advancing.
|
||||
fn refresh_terminal_title_from_selections(&mut self, selections: &StatusSurfaceSelections) {
|
||||
if selections.terminal_title_items.is_empty() {
|
||||
if let Err(err) = self.clear_managed_terminal_title() {
|
||||
tracing::debug!(error = %err, "failed to clear terminal title");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let mut previous = None;
|
||||
let title = selections
|
||||
.terminal_title_items
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|item| {
|
||||
self.terminal_title_value_for_item(item, now)
|
||||
.map(|value| (item, value))
|
||||
})
|
||||
.fold(String::new(), |mut title, (item, value)| {
|
||||
title.push_str(item.separator_from_previous(previous));
|
||||
title.push_str(&value);
|
||||
previous = Some(item);
|
||||
title
|
||||
});
|
||||
let title = (!title.is_empty()).then_some(title);
|
||||
let should_animate_spinner =
|
||||
self.should_animate_terminal_title_spinner_with_selections(selections);
|
||||
if self.last_terminal_title == title {
|
||||
if should_animate_spinner {
|
||||
self.frame_requester
|
||||
.schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL);
|
||||
}
|
||||
return;
|
||||
}
|
||||
match title {
|
||||
Some(title) => match set_terminal_title(&title) {
|
||||
Ok(SetTerminalTitleResult::Applied) => {
|
||||
self.last_terminal_title = Some(title);
|
||||
}
|
||||
Ok(SetTerminalTitleResult::NoVisibleContent) => {
|
||||
if let Err(err) = self.clear_managed_terminal_title() {
|
||||
tracing::debug!(error = %err, "failed to clear terminal title");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(error = %err, "failed to set terminal title");
|
||||
}
|
||||
},
|
||||
None => {
|
||||
if let Err(err) = self.clear_managed_terminal_title() {
|
||||
tracing::debug!(error = %err, "failed to clear terminal title");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_animate_spinner {
|
||||
self.frame_requester
|
||||
.schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recomputes both status surfaces from one shared config snapshot.
|
||||
///
|
||||
/// This is the common refresh entrypoint for the footer status line and the
|
||||
/// terminal title. It parses both configurations once, emits invalid-item
|
||||
/// warnings once, synchronizes shared cached state (such as git-branch
|
||||
/// lookup), then renders each surface from that shared snapshot.
|
||||
pub(crate) fn refresh_status_surfaces(&mut self) {
|
||||
let selections = self.status_surface_selections();
|
||||
self.warn_invalid_status_line_items_once(&selections.invalid_status_line_items);
|
||||
self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items);
|
||||
self.sync_status_surface_shared_state(&selections);
|
||||
self.refresh_status_line_from_selections(&selections);
|
||||
self.refresh_terminal_title_from_selections(&selections);
|
||||
}
|
||||
|
||||
/// Recomputes and emits the terminal title from config and runtime state.
|
||||
pub(crate) fn refresh_terminal_title(&mut self) {
|
||||
let selections = self.status_surface_selections();
|
||||
self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items);
|
||||
self.sync_status_surface_shared_state(&selections);
|
||||
self.refresh_terminal_title_from_selections(&selections);
|
||||
}
|
||||
|
||||
pub(super) fn request_status_line_branch_refresh(&mut self) {
|
||||
let selections = self.status_surface_selections();
|
||||
if !selections.uses_git_branch() {
|
||||
return;
|
||||
}
|
||||
let cwd = self.status_line_cwd().to_path_buf();
|
||||
self.sync_status_line_branch_state(&cwd);
|
||||
self.request_status_line_branch(cwd);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
for id in self.configured_status_line_items() {
|
||||
match id.parse::<StatusLineItem>() {
|
||||
Ok(item) => items.push(item),
|
||||
Err(_) => {
|
||||
if invalid_seen.insert(id.clone()) {
|
||||
invalid.push(format!(r#""{id}""#));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(items, invalid)
|
||||
}
|
||||
|
||||
pub(super) fn configured_status_line_items(&self) -> Vec<String> {
|
||||
self.config.tui_status_line.clone().unwrap_or_else(|| {
|
||||
DEFAULT_STATUS_LINE_ITEMS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses configured terminal-title ids into known items and collects unknown ids.
|
||||
///
|
||||
/// Unknown ids are deduplicated in insertion order for warning messages.
|
||||
fn terminal_title_items_with_invalids(&self) -> (Vec<TerminalTitleItem>, Vec<String>) {
|
||||
let mut invalid = Vec::new();
|
||||
let mut invalid_seen = HashSet::new();
|
||||
let mut items = Vec::new();
|
||||
for id in self.configured_terminal_title_items() {
|
||||
match id.parse::<TerminalTitleItem>() {
|
||||
Ok(item) => items.push(item),
|
||||
Err(_) => {
|
||||
if invalid_seen.insert(id.clone()) {
|
||||
invalid.push(format!(r#""{id}""#));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(items, invalid)
|
||||
}
|
||||
|
||||
/// Returns the configured terminal-title ids, or the default ordering when unset.
|
||||
pub(super) fn configured_terminal_title_items(&self) -> Vec<String> {
|
||||
self.config.tui_terminal_title.clone().unwrap_or_else(|| {
|
||||
DEFAULT_TERMINAL_TITLE_ITEMS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn status_line_cwd(&self) -> &Path {
|
||||
self.current_cwd.as_ref().unwrap_or(&self.config.cwd)
|
||||
}
|
||||
|
||||
/// Resolves the project root associated with `cwd`.
|
||||
///
|
||||
/// Git repository root wins when available. Otherwise we fall back to the
|
||||
/// nearest project config layer so non-git projects can still surface a
|
||||
/// stable project label.
|
||||
fn status_line_project_root_for_cwd(&self, cwd: &Path) -> Option<PathBuf> {
|
||||
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_for_cwd(&self, cwd: &Path) -> Option<String> {
|
||||
self.status_line_project_root_for_cwd(cwd).map(|root| {
|
||||
root.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| format_directory_display(&root, None))
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a cached project-root display name for the active cwd.
|
||||
fn status_line_project_root_name(&mut self) -> Option<String> {
|
||||
let cwd = self.status_line_cwd().to_path_buf();
|
||||
if let Some(cache) = &self.status_line_project_root_name_cache
|
||||
&& cache.cwd == cwd
|
||||
{
|
||||
return cache.root_name.clone();
|
||||
}
|
||||
|
||||
let root_name = self.status_line_project_root_name_for_cwd(&cwd);
|
||||
self.status_line_project_root_name_cache = Some(CachedProjectRootName {
|
||||
cwd,
|
||||
root_name: root_name.clone(),
|
||||
});
|
||||
root_name
|
||||
}
|
||||
|
||||
/// Produces the terminal-title `project` value.
|
||||
///
|
||||
/// This prefers the cached project-root name and falls back to the current
|
||||
/// directory name when no project root can be inferred.
|
||||
fn terminal_title_project_name(&mut self) -> Option<String> {
|
||||
let project = self.status_line_project_root_name().or_else(|| {
|
||||
let cwd = self.status_line_cwd();
|
||||
Some(
|
||||
cwd.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| format_directory_display(cwd, None)),
|
||||
)
|
||||
})?;
|
||||
Some(Self::truncate_terminal_title_part(project, 24))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(super) fn status_line_value_for_item(&mut 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());
|
||||
let fast_label = if self
|
||||
.should_show_fast_status(self.current_model(), self.config.service_tier)
|
||||
{
|
||||
" fast"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
Some(format!("{} {label}{fast_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_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.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_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.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()),
|
||||
StatusLineItem::FastMode => Some(
|
||||
if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
|
||||
"Fast on".to_string()
|
||||
} else {
|
||||
"Fast off".to_string()
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves one configured terminal-title item into a displayable segment.
|
||||
///
|
||||
/// Returning `None` means "omit this segment for now" so callers can keep
|
||||
/// the configured order while hiding values that are not yet available.
|
||||
fn terminal_title_value_for_item(
|
||||
&mut self,
|
||||
item: TerminalTitleItem,
|
||||
now: Instant,
|
||||
) -> Option<String> {
|
||||
match item {
|
||||
TerminalTitleItem::AppName => Some("codex".to_string()),
|
||||
TerminalTitleItem::Project => self.terminal_title_project_name(),
|
||||
TerminalTitleItem::Spinner => self.terminal_title_spinner_text_at(now),
|
||||
TerminalTitleItem::Status => Some(self.terminal_title_status_text()),
|
||||
TerminalTitleItem::Thread => self.thread_name.as_ref().and_then(|name| {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Self::truncate_terminal_title_part(trimmed.to_string(), 48))
|
||||
}
|
||||
}),
|
||||
TerminalTitleItem::GitBranch => self
|
||||
.status_line_branch
|
||||
.as_ref()
|
||||
.map(|branch| Self::truncate_terminal_title_part(branch.clone(), 32)),
|
||||
TerminalTitleItem::Model => Some(Self::truncate_terminal_title_part(
|
||||
self.model_display_name().to_string(),
|
||||
32,
|
||||
)),
|
||||
TerminalTitleItem::TaskProgress => self.terminal_title_task_progress(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the compact runtime status label used by the terminal title.
|
||||
///
|
||||
/// Startup takes precedence over normal task states, and idle state renders
|
||||
/// as `Ready` regardless of the last active status bucket.
|
||||
pub(super) fn terminal_title_status_text(&self) -> String {
|
||||
if self.mcp_startup_status.is_some() {
|
||||
return "Starting".to_string();
|
||||
}
|
||||
|
||||
match self.terminal_title_status_kind {
|
||||
TerminalTitleStatusKind::Working if !self.bottom_pane.is_task_running() => {
|
||||
"Ready".to_string()
|
||||
}
|
||||
TerminalTitleStatusKind::WaitingForBackgroundTerminal
|
||||
if !self.bottom_pane.is_task_running() =>
|
||||
{
|
||||
"Ready".to_string()
|
||||
}
|
||||
TerminalTitleStatusKind::Thinking if !self.bottom_pane.is_task_running() => {
|
||||
"Ready".to_string()
|
||||
}
|
||||
TerminalTitleStatusKind::Working => "Working".to_string(),
|
||||
TerminalTitleStatusKind::WaitingForBackgroundTerminal => "Waiting".to_string(),
|
||||
TerminalTitleStatusKind::Undoing => "Undoing".to_string(),
|
||||
TerminalTitleStatusKind::Thinking => "Thinking".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn terminal_title_spinner_text_at(&self, now: Instant) -> Option<String> {
|
||||
if !self.config.animations {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !self.terminal_title_has_active_progress() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(self.terminal_title_spinner_frame_at(now).to_string())
|
||||
}
|
||||
|
||||
fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str {
|
||||
let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin);
|
||||
let frame_index =
|
||||
(elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize;
|
||||
TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()]
|
||||
}
|
||||
|
||||
fn terminal_title_uses_spinner(&self) -> bool {
|
||||
self.config
|
||||
.tui_terminal_title
|
||||
.as_ref()
|
||||
.is_none_or(|items| items.iter().any(|item| item == "spinner"))
|
||||
}
|
||||
|
||||
fn terminal_title_has_active_progress(&self) -> bool {
|
||||
self.mcp_startup_status.is_some()
|
||||
|| self.bottom_pane.is_task_running()
|
||||
|| self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing
|
||||
}
|
||||
|
||||
pub(super) fn should_animate_terminal_title_spinner(&self) -> bool {
|
||||
self.config.animations
|
||||
&& self.terminal_title_uses_spinner()
|
||||
&& self.terminal_title_has_active_progress()
|
||||
}
|
||||
|
||||
fn should_animate_terminal_title_spinner_with_selections(
|
||||
&self,
|
||||
selections: &StatusSurfaceSelections,
|
||||
) -> bool {
|
||||
self.config.animations
|
||||
&& selections
|
||||
.terminal_title_items
|
||||
.contains(&TerminalTitleItem::Spinner)
|
||||
&& self.terminal_title_has_active_progress()
|
||||
}
|
||||
|
||||
/// Formats the last `update_plan` progress snapshot for terminal-title display.
|
||||
pub(super) fn terminal_title_task_progress(&self) -> Option<String> {
|
||||
let (completed, total) = self.last_plan_progress?;
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(format!("Tasks {completed}/{total}"))
|
||||
}
|
||||
|
||||
/// Truncates a title segment by grapheme cluster and appends `...` when needed.
|
||||
pub(super) fn truncate_terminal_title_part(value: String, max_chars: usize) -> String {
|
||||
if max_chars == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut graphemes = value.graphemes(true);
|
||||
let head: String = graphemes.by_ref().take(max_chars).collect();
|
||||
if graphemes.next().is_none() || max_chars <= 3 {
|
||||
return head;
|
||||
}
|
||||
|
||||
let mut truncated = head.graphemes(true).take(max_chars - 3).collect::<String>();
|
||||
truncated.push_str("...");
|
||||
truncated
|
||||
}
|
||||
}
|
||||
@@ -1742,6 +1742,7 @@ async fn helpers_are_available_and_do_not_panic() {
|
||||
model: Some(resolved_model),
|
||||
startup_tooltip_override: None,
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
session_telemetry,
|
||||
};
|
||||
let mut w = ChatWidget::new(init, thread_manager);
|
||||
@@ -1860,6 +1861,7 @@ async fn make_chatwidget_manual(
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
current_status_header: String::from("Working"),
|
||||
terminal_title_status_kind: TerminalTitleStatusKind::Working,
|
||||
retry_status_header: None,
|
||||
pending_status_indicator_restore: false,
|
||||
suppress_queue_autosend: false,
|
||||
@@ -1883,6 +1885,7 @@ async fn make_chatwidget_manual(
|
||||
had_work_activity: false,
|
||||
saw_plan_update_this_turn: false,
|
||||
saw_plan_item_this_turn: false,
|
||||
last_plan_progress: None,
|
||||
plan_delta_buffer: String::new(),
|
||||
plan_item_active: false,
|
||||
last_separator_elapsed_secs: None,
|
||||
@@ -1894,6 +1897,11 @@ async fn make_chatwidget_manual(
|
||||
current_cwd: None,
|
||||
session_network_proxy: None,
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
last_terminal_title: None,
|
||||
terminal_title_setup_original_items: None,
|
||||
terminal_title_animation_origin: Instant::now(),
|
||||
status_line_project_root_name_cache: None,
|
||||
status_line_branch: None,
|
||||
status_line_branch_cwd: None,
|
||||
status_line_branch_pending: false,
|
||||
@@ -5599,6 +5607,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() {
|
||||
model: Some(resolved_model.clone()),
|
||||
startup_tooltip_override: None,
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
session_telemetry,
|
||||
};
|
||||
|
||||
@@ -5649,6 +5658,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() {
|
||||
model: Some(resolved_model.clone()),
|
||||
startup_tooltip_override: None,
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
session_telemetry,
|
||||
};
|
||||
|
||||
@@ -6204,6 +6214,50 @@ async fn undo_started_hides_interrupt_hint() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn undo_completed_clears_terminal_title_undo_state() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.animations = true;
|
||||
chat.config.tui_terminal_title = Some(vec!["spinner".to_string(), "status".to_string()]);
|
||||
chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-undo".to_string(),
|
||||
msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }),
|
||||
});
|
||||
|
||||
assert_eq!(chat.last_terminal_title, Some("⠋ Undoing".to_string()));
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-undo".to_string(),
|
||||
msg: EventMsg::UndoCompleted(UndoCompletedEvent {
|
||||
success: true,
|
||||
message: None,
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(chat.last_terminal_title, Some("Ready".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn undo_started_refreshes_default_spinner_project_title() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.animations = true;
|
||||
chat.refresh_terminal_title();
|
||||
let project = chat
|
||||
.last_terminal_title
|
||||
.clone()
|
||||
.expect("default title should include a project name");
|
||||
chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-undo".to_string(),
|
||||
msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }),
|
||||
});
|
||||
|
||||
assert_eq!(chat.last_terminal_title, Some(format!("⠋ {project}")));
|
||||
}
|
||||
|
||||
/// The commit picker shows only commit subjects (no timestamps).
|
||||
#[tokio::test]
|
||||
async fn review_commit_picker_shows_subjects_without_timestamps() {
|
||||
@@ -9690,16 +9744,20 @@ async fn status_line_invalid_items_warn_once() {
|
||||
]);
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
|
||||
chat.refresh_status_line();
|
||||
chat.refresh_status_surfaces();
|
||||
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"),
|
||||
rendered.contains(r#""bogus_item""#),
|
||||
"warning cell missing invalid item content: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains(r#"\"bogus_item\""#),
|
||||
"warning cell should render plain quotes, not escaped quotes: {rendered}"
|
||||
);
|
||||
|
||||
chat.refresh_status_line();
|
||||
chat.refresh_status_surfaces();
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
cells.is_empty(),
|
||||
@@ -9707,6 +9765,257 @@ async fn status_line_invalid_items_warn_once() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_title_invalid_items_warn_once() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.tui_terminal_title = Some(vec![
|
||||
"status".to_string(),
|
||||
"bogus_item".to_string(),
|
||||
"bogus_item".to_string(),
|
||||
]);
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
|
||||
chat.refresh_status_surfaces();
|
||||
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(r#""bogus_item""#),
|
||||
"warning cell missing invalid item content: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains(r#"\"bogus_item\""#),
|
||||
"warning cell should render plain quotes, not escaped quotes: {rendered}"
|
||||
);
|
||||
|
||||
chat.refresh_status_surfaces();
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
cells.is_empty(),
|
||||
"expected invalid terminal title warning to emit only once"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_title_setup_cancel_reverts_live_preview() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
let original = chat.config.tui_terminal_title.clone();
|
||||
|
||||
chat.open_terminal_title_setup();
|
||||
chat.preview_terminal_title(vec![TerminalTitleItem::Thread, TerminalTitleItem::Status]);
|
||||
|
||||
assert_eq!(
|
||||
chat.config.tui_terminal_title,
|
||||
Some(vec!["thread".to_string(), "status".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
chat.terminal_title_setup_original_items,
|
||||
Some(original.clone())
|
||||
);
|
||||
|
||||
chat.cancel_terminal_title_setup();
|
||||
|
||||
assert_eq!(chat.config.tui_terminal_title, original);
|
||||
assert_eq!(chat.terminal_title_setup_original_items, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_title_status_uses_waiting_label_for_background_terminal_when_animations_disabled()
|
||||
{
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.animations = false;
|
||||
chat.on_task_started();
|
||||
terminal_interaction(&mut chat, "call-1", "proc-1", "");
|
||||
|
||||
assert_eq!(chat.terminal_title_status_text(), "Waiting");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_title_status_uses_plain_labels_for_transient_states_when_animations_disabled() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.animations = false;
|
||||
|
||||
chat.mcp_startup_status = Some(std::collections::HashMap::new());
|
||||
assert_eq!(chat.terminal_title_status_text(), "Starting");
|
||||
|
||||
chat.mcp_startup_status = None;
|
||||
chat.on_task_started();
|
||||
assert_eq!(chat.terminal_title_status_text(), "Working");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "undo-1".to_string(),
|
||||
msg: EventMsg::UndoStarted(UndoStartedEvent {
|
||||
message: Some("Undoing changes".to_string()),
|
||||
}),
|
||||
});
|
||||
assert_eq!(chat.terminal_title_status_text(), "Undoing");
|
||||
|
||||
chat.on_agent_reasoning_delta("**Planning**\nmore".to_string());
|
||||
assert_eq!(chat.terminal_title_status_text(), "Thinking");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_terminal_title_items_are_spinner_then_project() {
|
||||
let (chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
assert_eq!(
|
||||
chat.configured_terminal_title_items(),
|
||||
vec!["spinner".to_string(), "project".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_title_can_render_app_name_item() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.tui_terminal_title = Some(vec!["app-name".to_string()]);
|
||||
|
||||
chat.refresh_terminal_title();
|
||||
|
||||
assert_eq!(chat.last_terminal_title, Some("codex".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_terminal_title_refreshes_when_spinner_state_changes() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.animations = true;
|
||||
|
||||
chat.config.tui_terminal_title = None;
|
||||
let cwd = chat
|
||||
.current_cwd
|
||||
.clone()
|
||||
.unwrap_or_else(|| chat.config.cwd.clone());
|
||||
let project = get_git_repo_root(&cwd)
|
||||
.map(|root| {
|
||||
root.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| format_directory_display(&root, None))
|
||||
})
|
||||
.or_else(|| {
|
||||
chat.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| {
|
||||
path.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| format_directory_display(path, None))
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
cwd.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| format_directory_display(&cwd, None))
|
||||
});
|
||||
chat.last_terminal_title = Some(project.clone());
|
||||
chat.bottom_pane.set_task_running(true);
|
||||
chat.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
|
||||
chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1);
|
||||
|
||||
chat.refresh_terminal_title();
|
||||
|
||||
assert_eq!(chat.last_terminal_title, Some(format!("⠋ {project}")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_title_spinner_item_renders_when_animations_enabled() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.bottom_pane.set_task_running(true);
|
||||
chat.terminal_title_status_kind = TerminalTitleStatusKind::Working;
|
||||
chat.terminal_title_animation_origin = Instant::now();
|
||||
|
||||
assert_eq!(
|
||||
chat.terminal_title_spinner_text_at(chat.terminal_title_animation_origin),
|
||||
Some("⠋".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
chat.terminal_title_spinner_text_at(
|
||||
chat.terminal_title_animation_origin + TERMINAL_TITLE_SPINNER_INTERVAL,
|
||||
),
|
||||
Some("⠙".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_title_uses_spaces_around_spinner_item() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.animations = true;
|
||||
chat.config.tui_terminal_title = Some(vec![
|
||||
"project".to_string(),
|
||||
"spinner".to_string(),
|
||||
"status".to_string(),
|
||||
"thread".to_string(),
|
||||
]);
|
||||
chat.thread_name = Some("Investigate flaky test".to_string());
|
||||
chat.bottom_pane.set_task_running(true);
|
||||
chat.terminal_title_status_kind = TerminalTitleStatusKind::Working;
|
||||
chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1);
|
||||
|
||||
chat.refresh_terminal_title();
|
||||
|
||||
let title = chat
|
||||
.last_terminal_title
|
||||
.clone()
|
||||
.expect("expected terminal title");
|
||||
assert!(title.contains(" ⠋ Working | "));
|
||||
assert!(!title.contains("| ⠋"));
|
||||
assert!(!title.contains("⠋ |"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_title_shows_spinner_and_undoing_without_task_running() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.animations = true;
|
||||
chat.config.tui_terminal_title = Some(vec!["spinner".to_string(), "status".to_string()]);
|
||||
chat.terminal_title_status_kind = TerminalTitleStatusKind::Undoing;
|
||||
chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1);
|
||||
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
|
||||
chat.refresh_terminal_title();
|
||||
|
||||
assert_eq!(chat.last_terminal_title, Some("⠋ Undoing".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_title_reschedules_spinner_when_title_text_is_unchanged() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
let (frame_requester, mut frame_schedule_rx) = FrameRequester::test_observable();
|
||||
chat.frame_requester = frame_requester;
|
||||
chat.config.animations = true;
|
||||
chat.config.tui_terminal_title = Some(vec!["spinner".to_string()]);
|
||||
chat.bottom_pane.set_task_running(true);
|
||||
chat.terminal_title_status_kind = TerminalTitleStatusKind::Working;
|
||||
chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1);
|
||||
chat.last_terminal_title = Some("⠋".to_string());
|
||||
|
||||
chat.refresh_terminal_title();
|
||||
|
||||
assert!(frame_schedule_rx.try_recv().is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn on_task_started_resets_terminal_title_task_progress() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.last_plan_progress = Some((2, 5));
|
||||
|
||||
chat.on_task_started();
|
||||
|
||||
assert_eq!(chat.last_plan_progress, None);
|
||||
assert_eq!(chat.terminal_title_task_progress(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_part_truncation_preserves_grapheme_clusters() {
|
||||
let value = "ab👩💻cdefg".to_string();
|
||||
let truncated = ChatWidget::truncate_terminal_title_part(value, 7);
|
||||
assert_eq!(truncated, "ab👩💻c...");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_line_branch_state_resets_when_git_branch_disabled() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
@@ -9715,7 +10024,7 @@ async fn status_line_branch_state_resets_when_git_branch_disabled() {
|
||||
chat.status_line_branch_lookup_complete = true;
|
||||
chat.config.tui_status_line = Some(vec!["model_name".to_string()]);
|
||||
|
||||
chat.refresh_status_line();
|
||||
chat.refresh_status_surfaces();
|
||||
|
||||
assert_eq!(chat.status_line_branch, None);
|
||||
assert!(!chat.status_line_branch_pending);
|
||||
@@ -9740,6 +10049,25 @@ async fn status_line_branch_refreshes_after_turn_complete() {
|
||||
assert!(chat.status_line_branch_pending);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_line_branch_refreshes_after_turn_complete_when_terminal_title_uses_git_branch() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.tui_status_line = Some(Vec::new());
|
||||
chat.config.tui_terminal_title = 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 {
|
||||
turn_id: "turn-1".to_string(),
|
||||
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;
|
||||
@@ -9763,11 +10091,11 @@ async fn status_line_fast_mode_renders_on_and_off() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]);
|
||||
|
||||
chat.refresh_status_line();
|
||||
chat.refresh_status_surfaces();
|
||||
assert_eq!(status_line_text(&chat), Some("Fast off".to_string()));
|
||||
|
||||
chat.set_service_tier(Some(ServiceTier::Fast));
|
||||
chat.refresh_status_line();
|
||||
chat.refresh_status_surfaces();
|
||||
assert_eq!(status_line_text(&chat), Some("Fast on".to_string()));
|
||||
}
|
||||
|
||||
@@ -9780,7 +10108,7 @@ async fn status_line_fast_mode_footer_snapshot() {
|
||||
chat.show_welcome_banner = false;
|
||||
chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]);
|
||||
chat.set_service_tier(Some(ServiceTier::Fast));
|
||||
chat.refresh_status_line();
|
||||
chat.refresh_status_surfaces();
|
||||
|
||||
let width = 80;
|
||||
let height = chat.desired_height(width);
|
||||
@@ -9803,7 +10131,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() {
|
||||
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
|
||||
chat.set_service_tier(Some(ServiceTier::Fast));
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.refresh_status_line();
|
||||
chat.refresh_status_surfaces();
|
||||
|
||||
assert_eq!(
|
||||
status_line_text(&chat),
|
||||
@@ -9811,7 +10139,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() {
|
||||
);
|
||||
|
||||
chat.set_model("gpt-5.3-codex");
|
||||
chat.refresh_status_line();
|
||||
chat.refresh_status_surfaces();
|
||||
|
||||
assert_eq!(
|
||||
status_line_text(&chat),
|
||||
@@ -9835,7 +10163,7 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() {
|
||||
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
|
||||
chat.set_service_tier(Some(ServiceTier::Fast));
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.refresh_status_line();
|
||||
chat.refresh_status_surfaces();
|
||||
|
||||
let width = 80;
|
||||
let height = chat.desired_height(width);
|
||||
|
||||
@@ -110,6 +110,7 @@ mod status_indicator_widget;
|
||||
mod streaming;
|
||||
mod style;
|
||||
mod terminal_palette;
|
||||
mod terminal_title;
|
||||
mod text_formatting;
|
||||
mod theme_picker;
|
||||
mod tooltips;
|
||||
|
||||
@@ -38,6 +38,7 @@ pub enum SlashCommand {
|
||||
Mention,
|
||||
Status,
|
||||
DebugConfig,
|
||||
Title,
|
||||
Statusline,
|
||||
Theme,
|
||||
Mcp,
|
||||
@@ -83,6 +84,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::Title => "configure which items appear in the terminal title",
|
||||
SlashCommand::Statusline => "configure which items appear in the status line",
|
||||
SlashCommand::Theme => "choose a syntax highlighting theme",
|
||||
SlashCommand::Ps => "list background terminals",
|
||||
@@ -175,6 +177,7 @@ impl SlashCommand {
|
||||
SlashCommand::Agent | SlashCommand::MultiAgents => true,
|
||||
SlashCommand::Statusline => false,
|
||||
SlashCommand::Theme => false,
|
||||
SlashCommand::Title => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
205
codex-rs/tui/src/terminal_title.rs
Normal file
205
codex-rs/tui/src/terminal_title.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! Terminal-title output helpers for the TUI.
|
||||
//!
|
||||
//! This module owns the low-level OSC title write path and the sanitization
|
||||
//! that happens immediately before we emit it. It is intentionally narrow:
|
||||
//! callers decide when the title should change and whether an empty title means
|
||||
//! "leave the old title alone" or "clear the title Codex last wrote".
|
||||
//! This module does not attempt to read or restore the terminal's previous
|
||||
//! title because that is not portable across terminals.
|
||||
//!
|
||||
//! Sanitization is necessary because title content is assembled from untrusted
|
||||
//! text sources such as model output, thread names, project paths, and config.
|
||||
//! Before we place that text inside an OSC sequence, we strip:
|
||||
//! - control characters that could terminate or reshape the escape sequence
|
||||
//! - bidi/invisible formatting codepoints that can visually reorder or hide
|
||||
//! text (the same family of issues discussed in Trojan Source writeups)
|
||||
//! - redundant whitespace that would make titles noisy or hard to scan
|
||||
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::stdout;
|
||||
|
||||
use crossterm::Command;
|
||||
use ratatui::crossterm::execute;
|
||||
|
||||
const MAX_TERMINAL_TITLE_CHARS: usize = 240;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) enum SetTerminalTitleResult {
|
||||
/// A sanitized title was written, or stdout is not a terminal so no write was needed.
|
||||
Applied,
|
||||
/// Sanitization removed every visible character, so no title was emitted.
|
||||
///
|
||||
/// This is distinct from clearing the title. Callers decide whether an
|
||||
/// empty post-sanitization value should result in no-op behavior, clearing
|
||||
/// the title Codex manages, or some other fallback.
|
||||
NoVisibleContent,
|
||||
}
|
||||
|
||||
/// Writes a sanitized OSC window-title sequence to stdout.
|
||||
///
|
||||
/// The input is treated as untrusted display text: control characters,
|
||||
/// invisible formatting characters, and redundant whitespace are removed before
|
||||
/// the title is emitted. If sanitization removes all visible content, the
|
||||
/// function returns [`SetTerminalTitleResult::NoVisibleContent`] instead of
|
||||
/// clearing the title because clearing and restoring are policy decisions for
|
||||
/// higher-level callers. Mechanically, sanitization collapses whitespace runs
|
||||
/// to single spaces, drops disallowed codepoints, and bounds the result to
|
||||
/// [`MAX_TERMINAL_TITLE_CHARS`] visible characters before writing OSC 0.
|
||||
pub(crate) fn set_terminal_title(title: &str) -> io::Result<SetTerminalTitleResult> {
|
||||
if !stdout().is_terminal() {
|
||||
return Ok(SetTerminalTitleResult::Applied);
|
||||
}
|
||||
|
||||
let title = sanitize_terminal_title(title);
|
||||
if title.is_empty() {
|
||||
return Ok(SetTerminalTitleResult::NoVisibleContent);
|
||||
}
|
||||
|
||||
execute!(stdout(), SetWindowTitle(title))?;
|
||||
Ok(SetTerminalTitleResult::Applied)
|
||||
}
|
||||
|
||||
/// Clears the current terminal title by writing an empty OSC title payload.
|
||||
///
|
||||
/// This clears the visible title; it does not restore whatever title the shell
|
||||
/// or a previous program may have set before Codex started managing the title.
|
||||
pub(crate) fn clear_terminal_title() -> io::Result<()> {
|
||||
if !stdout().is_terminal() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
execute!(stdout(), SetWindowTitle(String::new()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SetWindowTitle(String);
|
||||
|
||||
impl Command for SetWindowTitle {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
// xterm/ctlseqs documents OSC 0/2 title sequences with ST (ESC \) termination.
|
||||
// Most terminals also accept BEL for compatibility, but ST is the canonical form.
|
||||
write!(f, "\x1b]0;{}\x1b\\", self.0)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> io::Result<()> {
|
||||
Err(std::io::Error::other(
|
||||
"tried to execute SetWindowTitle using WinAPI; use ANSI instead",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_ansi_code_supported(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalizes untrusted title text into a single bounded display line.
|
||||
///
|
||||
/// This removes terminal control characters, strips invisible/bidi formatting
|
||||
/// characters, collapses any whitespace run into a single ASCII space, and
|
||||
/// truncates after [`MAX_TERMINAL_TITLE_CHARS`] emitted characters.
|
||||
fn sanitize_terminal_title(title: &str) -> String {
|
||||
let mut sanitized = String::new();
|
||||
let mut chars_written = 0;
|
||||
let mut pending_space = false;
|
||||
|
||||
for ch in title.chars() {
|
||||
if ch.is_whitespace() {
|
||||
pending_space = !sanitized.is_empty();
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_disallowed_terminal_title_char(ch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if pending_space && chars_written < MAX_TERMINAL_TITLE_CHARS {
|
||||
sanitized.push(' ');
|
||||
chars_written += 1;
|
||||
pending_space = false;
|
||||
}
|
||||
|
||||
if chars_written >= MAX_TERMINAL_TITLE_CHARS {
|
||||
break;
|
||||
}
|
||||
|
||||
sanitized.push(ch);
|
||||
chars_written += 1;
|
||||
}
|
||||
|
||||
sanitized
|
||||
}
|
||||
|
||||
/// Returns whether `ch` should be dropped from terminal-title output.
|
||||
///
|
||||
/// This includes both plain control characters and a curated set of invisible
|
||||
/// formatting codepoints. The bidi entries here cover the Trojan-Source-style
|
||||
/// text-reordering controls that can make a title render misleadingly relative
|
||||
/// to its underlying byte sequence.
|
||||
fn is_disallowed_terminal_title_char(ch: char) -> bool {
|
||||
if ch.is_control() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strip Trojan-Source-related bidi controls plus common non-rendering
|
||||
// formatting characters so title text cannot smuggle terminal control
|
||||
// semantics or visually misleading content.
|
||||
matches!(
|
||||
ch,
|
||||
'\u{00AD}'
|
||||
| '\u{034F}'
|
||||
| '\u{061C}'
|
||||
| '\u{180E}'
|
||||
| '\u{200B}'..='\u{200F}'
|
||||
| '\u{202A}'..='\u{202E}'
|
||||
| '\u{2060}'..='\u{206F}'
|
||||
| '\u{FE00}'..='\u{FE0F}'
|
||||
| '\u{FEFF}'
|
||||
| '\u{FFF9}'..='\u{FFFB}'
|
||||
| '\u{1BCA0}'..='\u{1BCA3}'
|
||||
| '\u{E0100}'..='\u{E01EF}'
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MAX_TERMINAL_TITLE_CHARS;
|
||||
use super::SetWindowTitle;
|
||||
use super::sanitize_terminal_title;
|
||||
use crossterm::Command;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn sanitizes_terminal_title() {
|
||||
let sanitized =
|
||||
sanitize_terminal_title(" Project\t|\nWorking\x1b\x07\u{009D}\u{009C} | Thread ");
|
||||
assert_eq!(sanitized, "Project | Working | Thread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_invisible_format_chars_from_terminal_title() {
|
||||
let sanitized = sanitize_terminal_title(
|
||||
"Pro\u{202E}j\u{2066}e\u{200F}c\u{061C}t\u{200B} \u{FEFF}T\u{2060}itle",
|
||||
);
|
||||
assert_eq!(sanitized, "Project Title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_terminal_title() {
|
||||
let input = "a".repeat(MAX_TERMINAL_TITLE_CHARS + 10);
|
||||
let sanitized = sanitize_terminal_title(&input);
|
||||
assert_eq!(sanitized.len(), MAX_TERMINAL_TITLE_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_osc_title_with_string_terminator() {
|
||||
let mut out = String::new();
|
||||
SetWindowTitle("hello".to_string())
|
||||
.write_ansi(&mut out)
|
||||
.expect("encode terminal title");
|
||||
assert_eq!(out, "\x1b]0;hello\x1b\\");
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,17 @@ impl FrameRequester {
|
||||
frame_schedule_tx: tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a requester and expose its raw schedule queue for assertions.
|
||||
pub(crate) fn test_observable() -> (Self, mpsc::UnboundedReceiver<Instant>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
(
|
||||
FrameRequester {
|
||||
frame_schedule_tx: tx,
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A scheduler for coalescing frame draw requests and notifying the TUI event loop.
|
||||
|
||||
Reference in New Issue
Block a user