Compare commits

..

16 Commits

Author SHA1 Message Date
Yaroslav Volovich
781b749b89 fix(title): preserve replacement widget terminal title 2026-03-02 17:50:25 +00:00
Yaroslav Volovich
bec7ab604d fix(title): satisfy clippy on default refresh 2026-03-02 15:25:17 +00:00
Yaroslav Volovich
1341dd21cb fix(title): repair rebase fallout and default refresh 2026-03-02 15:11:24 +00:00
Yaroslav Volovich
1d69c16eca docs(tui): clarify terminal title behavior 2026-03-02 15:02:21 +00:00
Yaroslav Volovich
65f947fa9d fix(title): address remaining jif-oai review feedback 2026-03-02 15:02:21 +00:00
Yaroslav Volovich
2b85dfe3e5 codex: sync config schema fixture on PR #12334 2026-03-02 15:02:21 +00:00
Yaroslav Volovich
535d129f1a fix(tui): tighten terminal title refresh and caching 2026-03-02 15:02:21 +00:00
Yaroslav Volovich
da170042aa fix(title): address review feedback after rebase 2026-03-02 15:02:21 +00:00
Yaroslav Volovich
6906ead735 fix(tui): refresh git branch for title-only terminal title 2026-03-02 15:02:21 +00:00
Yaroslav Volovich
4d39c8bfa0 fix(tui): clear title when sanitization strips content 2026-03-02 15:02:21 +00:00
Yaroslav Volovich
a03a23d31f fix(tui): retry terminal title writes after io errors 2026-03-02 15:02:21 +00:00
Yaroslav Volovich
3018bcd094 fix(tui): use neutral /title preview examples 2026-03-02 15:02:21 +00:00
Yaroslav Volovich
45c14ff361 fix(tui): add ellipses to transient title states 2026-03-02 15:02:21 +00:00
Yaroslav Volovich
0556074389 fix(tui): live preview /title and revert on cancel 2026-03-02 15:02:21 +00:00
Yaroslav Volovich
7b1e10d0f7 fix(app-server): stabilize running thread resume status 2026-03-02 15:02:21 +00:00
Yaroslav Volovich
e156c40d66 tui: add secure /title terminal title config 2026-03-02 15:02:21 +00:00
25 changed files with 1326 additions and 317 deletions

View File

@@ -207,13 +207,12 @@ tmp_path.replace(payload_path)
let notify_script = notify_script
.to_str()
.expect("notify script path should be valid UTF-8");
let notify_command = if cfg!(windows) { "python" } else { "python3" };
create_config_toml_with_extra(
codex_home.path(),
&server.uri(),
"never",
&format!(
"notify = [\"{notify_command}\", {}]",
"notify = [\"python3\", {}]",
toml_basic_string(notify_script)
),
)?;
@@ -262,12 +261,7 @@ tmp_path.replace(payload_path)
)
.await??;
let notify_timeout = if cfg!(windows) {
Duration::from_secs(15)
} else {
Duration::from_secs(5)
};
fs_wait::wait_for_path_exists(&notify_file, notify_timeout).await?;
fs_wait::wait_for_path_exists(&notify_file, Duration::from_secs(5)).await?;
let payload_raw = tokio::fs::read_to_string(&notify_file).await?;
let payload: Value = serde_json::from_str(&payload_raw)?;
assert_eq!(payload["client"], "xcode");

View File

@@ -616,19 +616,11 @@
"additionalProperties": false,
"description": "Memories settings loaded from config.toml.",
"properties": {
"consolidation_model": {
"description": "Model used for memory consolidation.",
"type": "string"
},
"extract_model": {
"description": "Model used for thread summarisation.",
"type": "string"
},
"generate_memories": {
"description": "When `false`, newly created threads are stored with `memory_mode = \"disabled\"` in the state DB.",
"type": "boolean"
},
"max_raw_memories_for_consolidation": {
"max_raw_memories_for_global": {
"description": "Maximum number of recent raw memories retained for global consolidation.",
"format": "uint",
"minimum": 0.0,
@@ -659,6 +651,14 @@
"description": "When `true`, web searches and MCP tool calls mark the thread `memory_mode` as `\"polluted\"`.",
"type": "boolean"
},
"phase_1_model": {
"description": "Model used for thread summarisation.",
"type": "string"
},
"phase_2_model": {
"description": "Model used for memory consolidation.",
"type": "string"
},
"use_memories": {
"description": "When `false`, skip injecting memory usage instructions into developer prompts.",
"type": "boolean"
@@ -1491,6 +1491,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: `project` and `status`.",
"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.",

View File

@@ -7,7 +7,6 @@ use crate::find_thread_path_by_id_str;
use crate::rollout::RolloutRecorder;
use crate::session_prefix::format_subagent_context_line;
use crate::session_prefix::format_subagent_notification_message;
use crate::shell_snapshot::ShellSnapshot;
use crate::state_db;
use crate::thread_manager::ThreadManagerState;
use codex_protocol::ThreadId;
@@ -84,9 +83,6 @@ impl AgentControl {
) -> CodexResult<ThreadId> {
let state = self.upgrade()?;
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let inherited_shell_snapshot = self
.inherited_shell_snapshot_for_source(&state, session_source.as_ref())
.await;
let session_source = match session_source {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
@@ -165,7 +161,6 @@ impl AgentControl {
self.clone(),
session_source,
false,
inherited_shell_snapshot,
)
.await?
} else {
@@ -176,7 +171,6 @@ impl AgentControl {
session_source,
false,
None,
inherited_shell_snapshot,
)
.await?
}
@@ -241,9 +235,6 @@ impl AgentControl {
other => other,
};
let notification_source = session_source.clone();
let inherited_shell_snapshot = self
.inherited_shell_snapshot_for_source(&state, Some(&session_source))
.await;
let rollout_path =
find_thread_path_by_id_str(config.codex_home.as_path(), &thread_id.to_string())
.await?
@@ -255,7 +246,6 @@ impl AgentControl {
rollout_path,
self.clone(),
session_source,
inherited_shell_snapshot,
)
.await?;
reservation.commit(resumed_thread.thread_id);
@@ -441,22 +431,6 @@ impl AgentControl {
.upgrade()
.ok_or_else(|| CodexErr::UnsupportedOperation("thread manager dropped".to_string()))
}
async fn inherited_shell_snapshot_for_source(
&self,
state: &Arc<ThreadManagerState>,
session_source: Option<&SessionSource>,
) -> Option<Arc<ShellSnapshot>> {
let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
})) = session_source
else {
return None;
};
let parent_thread = state.get_thread(*parent_thread_id).await.ok()?;
parent_thread.codex.session.user_shell().shell_snapshot()
}
}
#[cfg(test)]
mod tests {

View File

@@ -345,7 +345,6 @@ impl Codex {
dynamic_tools: Vec<DynamicToolSpec>,
persist_extended_history: bool,
metrics_service_name: Option<String>,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
) -> CodexResult<CodexSpawnOk> {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
@@ -476,7 +475,6 @@ impl Codex {
session_source,
dynamic_tools,
persist_extended_history,
inherited_shell_snapshot,
};
// Generate a unique ID for the lifetime of this Codex session.
@@ -867,7 +865,6 @@ pub(crate) struct SessionConfiguration {
session_source: SessionSource,
dynamic_tools: Vec<DynamicToolSpec>,
persist_extended_history: bool,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
}
impl SessionConfiguration {
@@ -1386,19 +1383,13 @@ impl Session {
};
// Create the mutable state for the Session.
let shell_snapshot_tx = if config.features.enabled(Feature::ShellSnapshot) {
if let Some(snapshot) = session_configuration.inherited_shell_snapshot.clone() {
let (tx, rx) = watch::channel(Some(snapshot));
default_shell.shell_snapshot = rx;
tx
} else {
ShellSnapshot::start_snapshotting(
config.codex_home.clone(),
conversation_id,
session_configuration.cwd.clone(),
&mut default_shell,
otel_manager.clone(),
)
}
ShellSnapshot::start_snapshotting(
config.codex_home.clone(),
conversation_id,
session_configuration.cwd.clone(),
&mut default_shell,
otel_manager.clone(),
)
} else {
let (tx, rx) = watch::channel(None);
default_shell.shell_snapshot = rx;
@@ -1987,7 +1978,6 @@ impl Session {
previous_cwd: &Path,
next_cwd: &Path,
codex_home: &Path,
session_source: &SessionSource,
) {
if previous_cwd == next_cwd {
return;
@@ -1997,13 +1987,6 @@ impl Session {
return;
}
if matches!(
session_source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
) {
return;
}
ShellSnapshot::refresh_snapshot(
codex_home.to_path_buf(),
self.conversation_id,
@@ -2025,16 +2008,10 @@ impl Session {
let previous_cwd = state.session_configuration.cwd.clone();
let next_cwd = updated.cwd.clone();
let codex_home = updated.codex_home.clone();
let session_source = updated.session_source.clone();
state.session_configuration = updated;
drop(state);
self.maybe_refresh_shell_snapshot_for_cwd(
&previous_cwd,
&next_cwd,
&codex_home,
&session_source,
);
self.maybe_refresh_shell_snapshot_for_cwd(&previous_cwd, &next_cwd, &codex_home);
Ok(())
}
@@ -2050,13 +2027,7 @@ impl Session {
sub_id: String,
updates: SessionSettingsUpdate,
) -> ConstraintResult<Arc<TurnContext>> {
let (
session_configuration,
sandbox_policy_changed,
previous_cwd,
codex_home,
session_source,
) = {
let (session_configuration, sandbox_policy_changed, previous_cwd, codex_home) = {
let mut state = self.state.lock().await;
match state.session_configuration.clone().apply(&updates) {
Ok(next) => {
@@ -2064,15 +2035,8 @@ impl Session {
let sandbox_policy_changed =
state.session_configuration.sandbox_policy != next.sandbox_policy;
let codex_home = next.codex_home.clone();
let session_source = next.session_source.clone();
state.session_configuration = next.clone();
(
next,
sandbox_policy_changed,
previous_cwd,
codex_home,
session_source,
)
(next, sandbox_policy_changed, previous_cwd, codex_home)
}
Err(err) => {
drop(state);
@@ -2093,7 +2057,6 @@ impl Session {
&previous_cwd,
&session_configuration.cwd,
&codex_home,
&session_source,
);
Ok(self
@@ -7704,7 +7667,6 @@ mod tests {
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
};
let mut state = SessionState::new(session_configuration);
@@ -7798,7 +7760,6 @@ mod tests {
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
};
let mut state = SessionState::new(session_configuration);
@@ -8111,7 +8072,6 @@ mod tests {
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
}
}
@@ -8166,7 +8126,6 @@ mod tests {
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
};
let (tx_event, _rx_event) = async_channel::unbounded();
@@ -8257,7 +8216,6 @@ mod tests {
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
@@ -8425,7 +8383,6 @@ mod tests {
session_source: SessionSource::Exec,
dynamic_tools,
persist_extended_history: false,
inherited_shell_snapshot: None,
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline_for_tests(

View File

@@ -62,7 +62,6 @@ pub(crate) async fn run_codex_thread_interactive(
Vec::new(),
false,
None,
None,
)
.await?;
let codex = Arc::new(codex);

View File

@@ -56,7 +56,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()],
@@ -64,6 +64,10 @@ 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 {
@@ -76,6 +80,22 @@ 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 mut array = toml_edit::Array::new();
for item in items {
array.push(item.clone());
}
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));

View File

@@ -297,6 +297,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 `status`.
pub tui_terminal_title: Option<Vec<String>>,
/// Syntax highlighting theme override (kebab-case name).
pub tui_theme: Option<String>,
@@ -2233,6 +2238,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();
@@ -2508,13 +2514,13 @@ persistence = "none"
no_memories_if_mcp_or_web_search = true
generate_memories = false
use_memories = false
max_raw_memories_for_consolidation = 512
max_raw_memories_for_global = 512
max_unused_days = 21
max_rollout_age_days = 42
max_rollouts_per_startup = 9
min_rollout_idle_hours = 24
extract_model = "gpt-5-mini"
consolidation_model = "gpt-5"
phase_1_model = "gpt-5-mini"
phase_2_model = "gpt-5"
"#;
let memories_cfg =
toml::from_str::<ConfigToml>(memories).expect("TOML deserialization should succeed");
@@ -2523,13 +2529,13 @@ consolidation_model = "gpt-5"
no_memories_if_mcp_or_web_search: Some(true),
generate_memories: Some(false),
use_memories: Some(false),
max_raw_memories_for_consolidation: Some(512),
max_raw_memories_for_global: Some(512),
max_unused_days: Some(21),
max_rollout_age_days: Some(42),
max_rollouts_per_startup: Some(9),
min_rollout_idle_hours: Some(24),
extract_model: Some("gpt-5-mini".to_string()),
consolidation_model: Some("gpt-5".to_string()),
phase_1_model: Some("gpt-5-mini".to_string()),
phase_2_model: Some("gpt-5".to_string()),
}),
memories_cfg.memories
);
@@ -2546,13 +2552,13 @@ consolidation_model = "gpt-5"
no_memories_if_mcp_or_web_search: true,
generate_memories: false,
use_memories: false,
max_raw_memories_for_consolidation: 512,
max_raw_memories_for_global: 512,
max_unused_days: 21,
max_rollout_age_days: 42,
max_rollouts_per_startup: 9,
min_rollout_idle_hours: 24,
extract_model: Some("gpt-5-mini".to_string()),
consolidation_model: Some("gpt-5".to_string()),
phase_1_model: Some("gpt-5-mini".to_string()),
phase_2_model: Some("gpt-5".to_string()),
}
);
}
@@ -2576,6 +2582,7 @@ consolidation_model = "gpt-5"
show_tooltips: true,
alternate_screen: AltScreenMode::default(),
status_line: None,
terminal_title: None,
theme: None,
model_availability_nux: ModelAvailabilityNuxConfig {
shown_count: HashMap::from([
@@ -2735,6 +2742,7 @@ theme = "dracula"
show_tooltips: true,
alternate_screen: AltScreenMode::Auto,
status_line: None,
terminal_title: None,
theme: None,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
}
@@ -4953,6 +4961,7 @@ model_verbosity = "high"
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_terminal_title: None,
tui_theme: None,
otel: OtelConfig::default(),
},
@@ -5081,6 +5090,7 @@ model_verbosity = "high"
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_terminal_title: None,
tui_theme: None,
otel: OtelConfig::default(),
};
@@ -5207,6 +5217,7 @@ model_verbosity = "high"
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_terminal_title: None,
tui_theme: None,
otel: OtelConfig::default(),
};
@@ -5319,6 +5330,7 @@ model_verbosity = "high"
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_terminal_title: None,
tui_theme: None,
otel: OtelConfig::default(),
};

View File

@@ -26,7 +26,7 @@ pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 16;
pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 30;
pub const DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS: i64 = 6;
pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 256;
pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL: usize = 256;
pub const DEFAULT_MEMORIES_MAX_UNUSED_DAYS: i64 = 30;
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
@@ -378,7 +378,7 @@ pub struct MemoriesToml {
/// When `false`, skip injecting memory usage instructions into developer prompts.
pub use_memories: Option<bool>,
/// Maximum number of recent raw memories retained for global consolidation.
pub max_raw_memories_for_consolidation: Option<usize>,
pub max_raw_memories_for_global: Option<usize>,
/// Maximum number of days since a memory was last used before it becomes ineligible for phase 2 selection.
pub max_unused_days: Option<i64>,
/// Maximum age of the threads used for memories.
@@ -388,9 +388,9 @@ pub struct MemoriesToml {
/// Minimum idle time between last thread activity and memory creation (hours). > 12h recommended.
pub min_rollout_idle_hours: Option<i64>,
/// Model used for thread summarisation.
pub extract_model: Option<String>,
pub phase_1_model: Option<String>,
/// Model used for memory consolidation.
pub consolidation_model: Option<String>,
pub phase_2_model: Option<String>,
}
/// Effective memories settings after defaults are applied.
@@ -399,13 +399,13 @@ pub struct MemoriesConfig {
pub no_memories_if_mcp_or_web_search: bool,
pub generate_memories: bool,
pub use_memories: bool,
pub max_raw_memories_for_consolidation: usize,
pub max_raw_memories_for_global: usize,
pub max_unused_days: i64,
pub max_rollout_age_days: i64,
pub max_rollouts_per_startup: usize,
pub min_rollout_idle_hours: i64,
pub extract_model: Option<String>,
pub consolidation_model: Option<String>,
pub phase_1_model: Option<String>,
pub phase_2_model: Option<String>,
}
impl Default for MemoriesConfig {
@@ -414,13 +414,13 @@ impl Default for MemoriesConfig {
no_memories_if_mcp_or_web_search: false,
generate_memories: true,
use_memories: true,
max_raw_memories_for_consolidation: DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
max_raw_memories_for_global: DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
max_unused_days: DEFAULT_MEMORIES_MAX_UNUSED_DAYS,
max_rollout_age_days: DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS,
max_rollouts_per_startup: DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP,
min_rollout_idle_hours: DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS,
extract_model: None,
consolidation_model: None,
phase_1_model: None,
phase_2_model: None,
}
}
}
@@ -434,9 +434,9 @@ impl From<MemoriesToml> for MemoriesConfig {
.unwrap_or(defaults.no_memories_if_mcp_or_web_search),
generate_memories: toml.generate_memories.unwrap_or(defaults.generate_memories),
use_memories: toml.use_memories.unwrap_or(defaults.use_memories),
max_raw_memories_for_consolidation: toml
.max_raw_memories_for_consolidation
.unwrap_or(defaults.max_raw_memories_for_consolidation)
max_raw_memories_for_global: toml
.max_raw_memories_for_global
.unwrap_or(defaults.max_raw_memories_for_global)
.min(4096),
max_unused_days: toml
.max_unused_days
@@ -454,8 +454,8 @@ impl From<MemoriesToml> for MemoriesConfig {
.min_rollout_idle_hours
.unwrap_or(defaults.min_rollout_idle_hours)
.clamp(1, 48),
extract_model: toml.extract_model,
consolidation_model: toml.consolidation_model,
phase_1_model: toml.phase_1_model,
phase_2_model: toml.phase_2_model,
}
}
}
@@ -725,6 +725,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: `project` and `status`.
#[serde(default)]
pub terminal_title: Option<Vec<String>>,
/// Syntax highlighting theme name (kebab-case).
///
/// When set, overrides automatic light/dark theme detection.

View File

@@ -193,7 +193,7 @@ async fn claim_startup_jobs(
async fn build_request_context(session: &Arc<Session>, config: &Config) -> RequestContext {
let model_name = config
.memories
.extract_model
.phase_1_model
.clone()
.unwrap_or(phase_one::MODEL.to_string());
let model = session

View File

@@ -52,7 +52,7 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
return;
};
let root = memory_root(&config.codex_home);
let max_raw_memories = config.memories.max_raw_memories_for_consolidation;
let max_raw_memories = config.memories.max_raw_memories_for_global;
let max_unused_days = config.memories.max_unused_days;
// 1. Claim the job.
@@ -294,7 +294,7 @@ mod agent {
agent_config.model = Some(
config
.memories
.consolidation_model
.phase_2_model
.clone()
.unwrap_or(phase_two::MODEL.to_string()),
);

View File

@@ -13,21 +13,21 @@ use crate::memories::rollout_summaries_dir;
pub(super) async fn rebuild_raw_memories_file_from_memories(
root: &Path,
memories: &[Stage1Output],
max_raw_memories_for_consolidation: usize,
max_raw_memories_for_global: usize,
) -> std::io::Result<()> {
ensure_layout(root).await?;
rebuild_raw_memories_file(root, memories, max_raw_memories_for_consolidation).await
rebuild_raw_memories_file(root, memories, max_raw_memories_for_global).await
}
/// Syncs canonical rollout summary files from DB-backed stage-1 output rows.
pub(super) async fn sync_rollout_summaries_from_memories(
root: &Path,
memories: &[Stage1Output],
max_raw_memories_for_consolidation: usize,
max_raw_memories_for_global: usize,
) -> std::io::Result<()> {
ensure_layout(root).await?;
let retained = retained_memories(memories, max_raw_memories_for_consolidation);
let retained = retained_memories(memories, max_raw_memories_for_global);
let keep = retained
.iter()
.map(rollout_summary_file_stem)
@@ -62,9 +62,9 @@ pub(super) async fn sync_rollout_summaries_from_memories(
async fn rebuild_raw_memories_file(
root: &Path,
memories: &[Stage1Output],
max_raw_memories_for_consolidation: usize,
max_raw_memories_for_global: usize,
) -> std::io::Result<()> {
let retained = retained_memories(memories, max_raw_memories_for_consolidation);
let retained = retained_memories(memories, max_raw_memories_for_global);
let mut body = String::from("# Raw Memories\n\n");
if retained.is_empty() {
@@ -155,9 +155,9 @@ async fn write_rollout_summary_for_thread(
fn retained_memories(
memories: &[Stage1Output],
max_raw_memories_for_consolidation: usize,
max_raw_memories_for_global: usize,
) -> &[Stage1Output] {
&memories[..memories.len().min(max_raw_memories_for_consolidation)]
&memories[..memories.len().min(max_raw_memories_for_global)]
}
fn raw_memories_format_error(err: std::fmt::Error) -> std::io::Error {

View File

@@ -1,6 +1,6 @@
use super::storage::rebuild_raw_memories_file_from_memories;
use super::storage::sync_rollout_summaries_from_memories;
use crate::config::types::DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION;
use crate::config::types::DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL;
use crate::memories::ensure_layout;
use crate::memories::memory_root;
use crate::memories::raw_memories_file;
@@ -95,14 +95,14 @@ async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only
sync_rollout_summaries_from_memories(
&root,
&memories,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
)
.await
.expect("sync rollout summaries");
rebuild_raw_memories_file_from_memories(
&root,
&memories,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
)
.await
.expect("rebuild raw memories");
@@ -201,7 +201,7 @@ async fn sync_rollout_summaries_uses_timestamp_hash_and_sanitized_slug_filename(
sync_rollout_summaries_from_memories(
&root,
&memories,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
)
.await
.expect("sync rollout summaries");
@@ -304,14 +304,14 @@ task_outcome: success
sync_rollout_summaries_from_memories(
&root,
&memories,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
)
.await
.expect("sync rollout summaries");
rebuild_raw_memories_file_from_memories(
&root,
&memories,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
)
.await
.expect("rebuild raw memories");

View File

@@ -20,7 +20,6 @@ use crate::protocol::EventMsg;
use crate::protocol::SessionConfiguredEvent;
use crate::rollout::RolloutRecorder;
use crate::rollout::truncation;
use crate::shell_snapshot::ShellSnapshot;
use crate::skills::SkillsManager;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationModeMask;
@@ -480,7 +479,6 @@ impl ThreadManagerState {
self.session_source.clone(),
false,
None,
None,
)
.await
}
@@ -492,7 +490,6 @@ impl ThreadManagerState {
session_source: SessionSource,
persist_extended_history: bool,
metrics_service_name: Option<String>,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
) -> CodexResult<NewThread> {
self.spawn_thread_with_source(
config,
@@ -503,7 +500,6 @@ impl ThreadManagerState {
Vec::new(),
persist_extended_history,
metrics_service_name,
inherited_shell_snapshot,
)
.await
}
@@ -514,7 +510,6 @@ impl ThreadManagerState {
rollout_path: PathBuf,
agent_control: AgentControl,
session_source: SessionSource,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
) -> CodexResult<NewThread> {
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
self.spawn_thread_with_source(
@@ -526,7 +521,6 @@ impl ThreadManagerState {
Vec::new(),
false,
None,
inherited_shell_snapshot,
)
.await
}
@@ -538,7 +532,6 @@ impl ThreadManagerState {
agent_control: AgentControl,
session_source: SessionSource,
persist_extended_history: bool,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
) -> CodexResult<NewThread> {
self.spawn_thread_with_source(
config,
@@ -549,7 +542,6 @@ impl ThreadManagerState {
Vec::new(),
persist_extended_history,
None,
inherited_shell_snapshot,
)
.await
}
@@ -575,7 +567,6 @@ impl ThreadManagerState {
dynamic_tools,
persist_extended_history,
metrics_service_name,
None,
)
.await
}
@@ -591,7 +582,6 @@ impl ThreadManagerState {
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
persist_extended_history: bool,
metrics_service_name: Option<String>,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
) -> CodexResult<NewThread> {
let watch_registration = self
.file_watcher
@@ -612,7 +602,6 @@ impl ThreadManagerState {
dynamic_tools,
persist_extended_history,
metrics_service_name,
inherited_shell_snapshot,
)
.await?;
self.finalize_thread_spawn(codex, thread_id, watch_registration)

View File

@@ -168,7 +168,7 @@ async fn web_search_pollution_moves_selected_thread_into_removed_phase2_inputs()
let mut initial_builder = test_codex().with_home(home.clone()).with_config(|config| {
config.features.enable(Feature::Sqlite);
config.features.enable(Feature::MemoryTool);
config.memories.max_raw_memories_for_consolidation = 1;
config.memories.max_raw_memories_for_global = 1;
config.memories.no_memories_if_mcp_or_web_search = true;
});
let initial = initial_builder.build(&server).await?;
@@ -234,7 +234,7 @@ async fn web_search_pollution_moves_selected_thread_into_removed_phase2_inputs()
let mut resumed_builder = test_codex().with_home(home.clone()).with_config(|config| {
config.features.enable(Feature::Sqlite);
config.features.enable(Feature::MemoryTool);
config.memories.max_raw_memories_for_consolidation = 1;
config.memories.max_raw_memories_for_global = 1;
config.memories.no_memories_if_mcp_or_web_search = true;
});
let resumed = resumed_builder
@@ -313,7 +313,7 @@ async fn build_test_codex(server: &wiremock::MockServer, home: Arc<TempDir>) ->
let mut builder = test_codex().with_home(home).with_config(|config| {
config.features.enable(Feature::Sqlite);
config.features.enable(Feature::MemoryTool);
config.memories.max_raw_memories_for_consolidation = 1;
config.memories.max_raw_memories_for_global = 1;
});
builder.build(server).await
}

View File

@@ -653,6 +653,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,
@@ -739,6 +741,7 @@ impl App {
model: Some(self.chat_widget.current_model().to_string()),
startup_tooltip_override: None,
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(),
otel_manager: self.otel_manager.clone(),
}
}
@@ -1246,8 +1249,8 @@ impl App {
.collect();
self.chat_widget.show_selection_view(SelectionViewParams {
title: Some("Multi-agents".to_string()),
subtitle: Some("Select an agent to watch".to_string()),
title: Some("Agents".to_string()),
subtitle: Some("Select a thread to focus".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
initial_selected_idx,
@@ -1323,7 +1326,7 @@ impl App {
let (tx, _rx) = unbounded_channel();
tx
};
self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx);
self.replace_chat_widget(ChatWidget::new_with_op_sender(init, codex_op_tx));
self.reset_for_thread_switch(tui)?;
self.replay_thread_snapshot(snapshot);
@@ -1362,6 +1365,13 @@ impl App {
self.chat_widget.set_pending_thread_approvals(Vec::new());
}
fn replace_chat_widget(&mut self, chat_widget: ChatWidget) {
// The replacement widget applies its own title before assignment.
// Disarm cleanup on the old widget so its Drop impl does not clear that new title.
self.chat_widget.skip_terminal_title_cleanup_on_drop();
self.chat_widget = chat_widget;
}
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.
@@ -1390,9 +1400,10 @@ impl App {
model: Some(model),
startup_tooltip_override: None,
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(),
otel_manager: self.otel_manager.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()];
@@ -1464,7 +1475,7 @@ impl App {
for event in snapshot.events {
self.handle_codex_event_replay(event);
}
self.refresh_status_line();
self.refresh_status_surfaces();
}
fn should_wait_for_initial_session(session_selection: &SessionSelection) -> bool {
@@ -1581,6 +1592,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 =
@@ -1609,6 +1621,8 @@ impl App {
model: Some(model.clone()),
startup_tooltip_override,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
otel_manager: otel_manager.clone(),
};
ChatWidget::new(init, thread_manager.clone())
@@ -1644,6 +1658,8 @@ impl App {
model: config.model.clone(),
startup_tooltip_override: None,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
otel_manager: otel_manager.clone(),
};
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured)
@@ -1681,6 +1697,8 @@ impl App {
model: config.model.clone(),
startup_tooltip_override: None,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
otel_manager: otel_manager.clone(),
};
ChatWidget::new_from_existing(init, forked.thread, forked.session_configured)
@@ -1714,6 +1732,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(),
@@ -1858,7 +1877,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();
}
}
@@ -1981,11 +2000,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>> =
@@ -2041,11 +2060,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>> =
@@ -2215,15 +2234,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);
@@ -2846,7 +2865,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)
@@ -3136,11 +3155,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)
@@ -3215,7 +3261,7 @@ impl App {
self.chat_widget.handle_codex_event(event);
if needs_refresh {
self.refresh_status_line();
self.refresh_status_surfaces();
}
}
@@ -3556,8 +3602,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")]
@@ -4374,6 +4420,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(),
@@ -4434,6 +4481,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(),
@@ -5364,6 +5412,18 @@ mod tests {
assert_eq!(op_rx.try_recv(), Ok(Op::Shutdown));
}
#[tokio::test]
async fn replace_chat_widget_keeps_drop_cleanup_armed_on_new_widget() {
let mut app = make_test_app().await;
let (replacement, _app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
assert!(app.chat_widget.clears_terminal_title_on_drop());
app.replace_chat_widget(replacement);
assert!(app.chat_widget.clears_terminal_title_on_drop());
}
#[tokio::test]
async fn clear_only_ui_reset_preserves_chat_session_state() {
let mut app = make_test_app().await;

View File

@@ -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;
@@ -441,6 +442,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 {

View File

@@ -342,7 +342,7 @@ mod tests {
CommandItem::UserPrompt(_) => None,
})
.collect();
assert_eq!(cmds, vec!["model", "mention", "mcp", "multi-agents"]);
assert_eq!(cmds, vec!["model", "mention", "mcp"]);
}
#[test]

View File

@@ -45,6 +45,7 @@ mod approval_overlay;
mod multi_select_picker;
mod request_user_input;
mod status_line_setup;
mod title_setup;
pub(crate) use app_link_view::AppLinkView;
pub(crate) use app_link_view::AppLinkViewParams;
pub(crate) use approval_overlay::ApprovalOverlay;
@@ -92,6 +93,8 @@ pub(crate) use skills_toggle_view::SkillsToggleItem;
pub(crate) use skills_toggle_view::SkillsToggleView;
pub(crate) use status_line_setup::StatusLineItem;
pub(crate) use status_line_setup::StatusLineSetupView;
pub(crate) use title_setup::TerminalTitleItem;
pub(crate) use title_setup::TerminalTitleSetupView;
mod paste_burst;
mod pending_thread_approvals;
pub mod popup_consts;
@@ -129,7 +132,6 @@ pub(crate) enum CancellationEvent {
NotHandled,
}
use crate::bottom_pane::prompt_args::parse_slash_name;
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::ChatComposerConfig;
pub(crate) use chat_composer::InputResult;
@@ -399,20 +401,11 @@ impl BottomPane {
self.request_redraw();
InputResult::None
} else {
let is_agent_command = self
.composer_text()
.lines()
.next()
.and_then(parse_slash_name)
.is_some_and(|(name, _, _)| name == "agent");
// If a task is running and a status line is visible, allow Esc to
// send an interrupt even while the composer has focus.
// When a popup is active, prefer dismissing it over interrupting the task.
if key_event.code == KeyCode::Esc
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& self.is_task_running
&& !is_agent_command
&& !self.composer.popup_active()
&& let Some(status) = &self.status
{
@@ -1603,90 +1596,6 @@ mod tests {
assert_eq!(pane.composer_text(), "/");
}
#[test]
fn esc_with_agent_command_without_popup_does_not_interrupt_task() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
pane.set_task_running(true);
// Repro: `/agent ` hides the popup (cursor past command name). Esc should
// keep editing command text instead of interrupting the running task.
pane.insert_str("/agent ");
assert!(
!pane.composer.popup_active(),
"expected command popup to be hidden after entering `/agent `"
);
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
while let Ok(ev) = rx.try_recv() {
assert!(
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
"expected Esc to not send Op::Interrupt while typing `/agent`"
);
}
assert_eq!(pane.composer_text(), "/agent ");
}
#[test]
fn esc_release_after_dismissing_agent_picker_does_not_interrupt_task() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.show_selection_view(SelectionViewParams {
title: Some("Agents".to_string()),
items: vec![SelectionItem {
name: "Main".to_string(),
..Default::default()
}],
..Default::default()
});
pane.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Esc,
KeyModifiers::NONE,
KeyEventKind::Press,
));
pane.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Esc,
KeyModifiers::NONE,
KeyEventKind::Release,
));
while let Ok(ev) = rx.try_recv() {
assert!(
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
"expected Esc release after dismissing agent picker to not interrupt"
);
}
assert!(
pane.no_modal_or_popup_active(),
"expected Esc press to dismiss the agent picker"
);
}
#[test]
fn esc_interrupts_running_task_when_no_popup() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();

View File

@@ -0,0 +1,19 @@
---
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] status Compact session status (Ready, Working, Thinking, ...)
[x] thread Current thread title (omitted until available)
[ ] 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.

View File

@@ -0,0 +1,273 @@
//! 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 std::collections::HashSet;
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, Eq, PartialEq)]
#[strum(serialize_all = "kebab_case")]
pub(crate) enum TerminalTitleItem {
/// Project root name, or a compact cwd fallback.
Project,
/// Compact runtime status (Ready, Working, Thinking, ...).
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).
#[strum(to_string = "task-progress")]
TaskProgress,
}
impl TerminalTitleItem {
pub(crate) fn description(&self) -> &'static str {
match self {
TerminalTitleItem::Project => "Project name (falls back to current directory name)",
TerminalTitleItem::Status => "Compact session status (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)"
}
}
}
pub(crate) fn render(&self) -> &'static str {
match self {
TerminalTitleItem::Project => "my-project",
TerminalTitleItem::Status => "Working...",
TerminalTitleItem::Thread => "Investigate flaky test",
TerminalTitleItem::GitBranch => "feat/awesome-feature",
TerminalTitleItem::Model => "gpt-5.2-codex",
TerminalTitleItem::TaskProgress => "Tasks 2/5",
}
}
}
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 mut used_ids = HashSet::new();
let mut items = Vec::new();
if let Some(selected_items) = title_items.as_ref() {
for id in *selected_items {
let Ok(item) = id.parse::<TerminalTitleItem>() else {
continue;
};
let item_id = item.to_string();
if !used_ids.insert(item_id.clone()) {
continue;
}
items.push(Self::title_select_item(item, true));
}
}
for item in TerminalTitleItem::iter() {
let item_id = item.to_string();
if used_ids.contains(&item_id) {
continue;
}
items.push(Self::title_select_item(item, false));
}
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 preview = items
.iter()
.map(TerminalTitleItem::render)
.collect::<Vec<_>>()
.join(" | ");
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(),
"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", "status", "thread"].into_iter());
assert_eq!(
items,
Some(vec![
TerminalTitleItem::Project,
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);
}
}

View File

@@ -42,10 +42,15 @@ use crate::app_event::RealtimeAudioDeviceKind;
use crate::audio_device::list_realtime_audio_device_names;
use crate::bottom_pane::StatusLineItem;
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;
@@ -158,6 +163,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?";
@@ -284,6 +290,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;
@@ -296,6 +303,7 @@ const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls";
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
const DEFAULT_STATUS_LINE_ITEMS: [&str; 3] =
["model-with-reasoning", "context-remaining", "current-dir"];
const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["project", "status"];
// Track information about an in-flight exec command.
struct RunningCommand {
command: Vec<String>,
@@ -467,6 +475,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) otel_manager: OtelManager,
}
@@ -596,6 +606,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.
@@ -647,6 +659,8 @@ pub(crate) struct ChatWidget {
saw_plan_update_this_turn: bool,
// Whether the current turn emitted a proposed plan item.
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.
@@ -670,6 +684,18 @@ 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.
last_terminal_title: Option<String>,
// Clears the terminal title on drop unless the widget is being intentionally replaced.
clear_terminal_title_on_drop: bool,
// 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>>>,
// 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.
@@ -857,6 +883,54 @@ enum ReplayKind {
ThreadSnapshot,
}
#[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.
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.
struct CachedProjectRootName {
cwd: PathBuf,
root_name: Option<String>,
}
impl ChatWidget {
fn realtime_conversation_enabled(&self) -> bool {
self.config.features.enabled(Feature::RealtimeConversation)
@@ -874,12 +948,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"));
}
}
@@ -948,6 +1025,14 @@ impl ChatWidget {
self.current_status_header = header.clone();
self.bottom_pane
.update_status(header, details, details_capitalization, details_max_lines);
if self
.config
.tui_terminal_title
.as_ref()
.is_none_or(|items| items.iter().any(|item| item == "status"))
{
self.refresh_terminal_title();
}
}
/// Convenience wrapper around [`Self::set_status`];
@@ -966,58 +1051,100 @@ impl ChatWidget {
self.bottom_pane.set_status_line(status_line);
}
/// Recomputes footer status-line content from config and current runtime state.
/// Recomputes status-line and terminal-title 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.
/// This method is the shared status-surface 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
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_none()
|| invalid_items.is_empty()
|| self
.status_line_invalid_items_warned
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
.is_err()
{
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);
return;
}
if !items.contains(&StatusLineItem::GitBranch) {
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_none()
|| invalid_items.is_empty()
|| self
.terminal_title_invalid_items_warned
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
.is_err()
{
return;
}
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 enabled = !items.is_empty();
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 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) {
for item in &selections.status_line_items {
if let Some(value) = self.status_line_value_for_item(item) {
parts.push(value);
}
}
@@ -1030,6 +1157,80 @@ impl ChatWidget {
self.set_status_line(line);
}
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(())
}
/// Prevents drop-time terminal-title cleanup when this widget is being replaced.
pub(crate) fn skip_terminal_title_cleanup_on_drop(&mut self) {
self.clear_terminal_title_on_drop = false;
}
#[cfg(test)]
pub(crate) fn clears_terminal_title_on_drop(&self) -> bool {
self.clear_terminal_title_on_drop
}
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 title = selections
.terminal_title_items
.iter()
.filter_map(|item| self.terminal_title_value_for_item(item))
.collect::<Vec<_>>()
.join(" | ");
let title = (!title.is_empty()).then_some(title);
if self.last_terminal_title == title {
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");
}
}
}
}
/// 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);
}
/// Records that status-line setup was canceled.
///
/// Cancellation is intentionally side-effect free for config state; the existing configuration
@@ -1045,7 +1246,53 @@ 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();
}
/// 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);
}
/// 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.
@@ -1062,10 +1309,11 @@ impl ChatWidget {
self.status_line_branch_lookup_complete = true;
}
/// Forces a new git-branch lookup when `GitBranch` is part of the configured status line.
/// Forces a new git-branch lookup when `GitBranch` is used by the status line or terminal
/// title.
fn request_status_line_branch_refresh(&mut self) {
let (items, _) = self.status_line_items_with_invalids();
if items.is_empty() || !items.contains(&StatusLineItem::GitBranch) {
let selections = self.status_surface_selections();
if !selections.uses_git_branch() {
return;
}
let cwd = self.status_line_cwd().to_path_buf();
@@ -1137,6 +1385,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;
@@ -1236,6 +1485,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();
}
}
@@ -1375,6 +1625,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.
@@ -1409,6 +1660,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();
@@ -1422,6 +1674,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();
@@ -1694,7 +1947,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.
///
@@ -1925,6 +2178,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));
}
@@ -2013,6 +2277,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(),
@@ -2244,7 +2509,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) {
@@ -2257,6 +2522,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);
}
@@ -2266,6 +2532,7 @@ 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);
}
@@ -2291,6 +2558,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,
@@ -2780,6 +3048,7 @@ impl ChatWidget {
model,
startup_tooltip_override,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
otel_manager,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
@@ -2868,6 +3137,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,
thread_id: None,
@@ -2887,6 +3157,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,
@@ -2898,6 +3169,11 @@ impl ChatWidget {
current_cwd,
session_network_proxy: None,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
last_terminal_title: None,
clear_terminal_title_on_drop: true,
terminal_title_setup_original_items: None,
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -2939,6 +3215,8 @@ impl ChatWidget {
.bottom_pane
.set_connectors_enabled(widget.config.features.enabled(Feature::Apps));
widget.refresh_terminal_title();
widget
}
@@ -2960,6 +3238,7 @@ impl ChatWidget {
model,
startup_tooltip_override,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
otel_manager,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
@@ -3047,6 +3326,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,
thread_id: None,
@@ -3054,6 +3334,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(),
@@ -3077,6 +3358,11 @@ impl ChatWidget {
current_cwd,
session_network_proxy: None,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
last_terminal_title: None,
clear_terminal_title_on_drop: true,
terminal_title_setup_original_items: None,
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -3104,6 +3390,7 @@ impl ChatWidget {
widget
.bottom_pane
.set_queued_message_edit_binding(widget.queued_message_edit_binding);
widget.refresh_terminal_title();
widget
}
@@ -3128,6 +3415,7 @@ impl ChatWidget {
model,
startup_tooltip_override: _,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
otel_manager,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
@@ -3215,6 +3503,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,
thread_id: None,
@@ -3234,6 +3523,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,
@@ -3245,6 +3535,11 @@ impl ChatWidget {
current_cwd,
session_network_proxy: None,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
last_terminal_title: None,
clear_terminal_title_on_drop: true,
terminal_title_setup_original_items: None,
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -3281,6 +3576,7 @@ impl ChatWidget {
),
);
widget.update_collaboration_mode_indicator();
widget.refresh_terminal_title();
widget
}
@@ -3609,7 +3905,7 @@ impl ChatWidget {
}
self.open_collaboration_modes_popup();
}
SlashCommand::Agent | SlashCommand::MultiAgents => {
SlashCommand::Agent => {
self.app_event_tx.send(AppEvent::OpenAgentPicker);
}
SlashCommand::Approvals => {
@@ -3748,6 +4044,9 @@ impl ChatWidget {
SlashCommand::DebugConfig => {
self.add_debug_config_output();
}
SlashCommand::Title => {
self.open_terminal_title_setup();
}
SlashCommand::Statusline => {
self.open_status_line_setup();
}
@@ -4759,6 +5058,16 @@ impl ChatWidget {
self.bottom_pane.show_selection_view(params);
}
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));
}
/// Parses configured status-line ids into known items and collects unknown ids.
///
/// Unknown ids are deduplicated in insertion order for warning messages.
@@ -4788,12 +5097,46 @@ impl ChatWidget {
})
}
/// 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.
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)
}
fn status_line_project_root(&self) -> Option<PathBuf> {
let cwd = self.status_line_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);
}
@@ -4810,14 +5153,47 @@ impl ChatWidget {
})
}
fn status_line_project_root_name(&self) -> Option<String> {
self.status_line_project_root().map(|root| {
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.
@@ -4857,7 +5233,7 @@ impl ChatWidget {
/// 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> {
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 => {
@@ -4923,6 +5299,81 @@ impl ChatWidget {
}
}
/// 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) -> Option<String> {
match item {
TerminalTitleItem::Project => self.terminal_title_project_name(),
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.
fn terminal_title_status_text(&self) -> String {
if self.mcp_startup_status.is_some() {
return "Starting...".to_string();
}
if !self.bottom_pane.is_task_running() {
return "Ready".to_string();
}
match self.terminal_title_status_kind {
TerminalTitleStatusKind::Working => "Working...".to_string(),
TerminalTitleStatusKind::WaitingForBackgroundTerminal => "Waiting...".to_string(),
TerminalTitleStatusKind::Undoing => "Undoing...".to_string(),
TerminalTitleStatusKind::Thinking => "Thinking...".to_string(),
}
}
/// Formats the last `update_plan` progress snapshot for terminal-title display.
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.
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
}
fn status_line_context_window_size(&self) -> Option<i64> {
self.token_info
.as_ref()
@@ -6991,6 +7442,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 {
@@ -7965,8 +8417,13 @@ fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool {
impl Drop for ChatWidget {
fn drop(&mut self) {
self.reset_realtime_conversation_state();
if self.clear_terminal_title_on_drop
&& let Err(err) = self.clear_managed_terminal_title()
{
tracing::debug!(error = %err, "failed to clear terminal title on drop");
}
self.stop_rate_limit_poller();
self.reset_realtime_conversation_state();
}
}

View File

@@ -1583,6 +1583,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)),
otel_manager,
};
let mut w = ChatWidget::new(init, thread_manager);
@@ -1699,6 +1700,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,
thread_id: None,
@@ -1719,6 +1721,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,
@@ -1730,6 +1733,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,
clear_terminal_title_on_drop: true,
terminal_title_setup_original_items: None,
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -4496,6 +4504,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)),
otel_manager,
};
@@ -4546,6 +4555,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)),
otel_manager,
};
@@ -8134,7 +8144,7 @@ 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]);
@@ -8143,7 +8153,7 @@ async fn status_line_invalid_items_warn_once() {
"warning cell missing invalid item content: {rendered}"
);
chat.refresh_status_line();
chat.refresh_status_surfaces();
let cells = drain_insert_history(&mut rx);
assert!(
cells.is_empty(),
@@ -8151,6 +8161,110 @@ async fn status_line_invalid_items_warn_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_ellipsis_for_background_terminal() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
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_ellipses_for_other_transient_states() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
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_refreshes_when_status_changes() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let project = chat
.terminal_title_project_name()
.expect("default title should include a project segment");
chat.config.tui_terminal_title = None;
chat.last_terminal_title = Some(format!("{project} | Ready"));
chat.bottom_pane.set_task_running(true);
chat.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
chat.set_status_header("Thinking".to_string());
assert_eq!(
chat.last_terminal_title,
Some(format!("{project} | Thinking..."))
);
}
#[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 skip_terminal_title_cleanup_on_drop_disarms_drop_cleanup() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
assert!(chat.clears_terminal_title_on_drop());
chat.skip_terminal_title_cleanup_on_drop();
assert!(!chat.clears_terminal_title_on_drop());
}
#[tokio::test]
async fn status_line_branch_state_resets_when_git_branch_disabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -8159,7 +8273,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);
@@ -8184,6 +8298,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;

View File

@@ -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;

View File

@@ -37,6 +37,7 @@ pub enum SlashCommand {
Mention,
Status,
DebugConfig,
Title,
Statusline,
Theme,
Mcp,
@@ -53,7 +54,6 @@ pub enum SlashCommand {
Realtime,
Settings,
TestApproval,
MultiAgents,
// Debugging commands.
#[strum(serialize = "debug-m-drop")]
MemoryDrop,
@@ -82,6 +82,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",
@@ -94,7 +95,7 @@ impl SlashCommand {
SlashCommand::Settings => "configure realtime microphone/speaker",
SlashCommand::Plan => "switch to Plan mode",
SlashCommand::Collab => "change collaboration mode (experimental)",
SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread",
SlashCommand::Agent => "switch the active agent thread",
SlashCommand::Approvals => "choose what Codex is allowed to do",
SlashCommand::Permissions => "choose what Codex is allowed to do",
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
@@ -168,9 +169,10 @@ impl SlashCommand {
SlashCommand::Realtime => true,
SlashCommand::Settings => true,
SlashCommand::Collab => true,
SlashCommand::Agent | SlashCommand::MultiAgents => true,
SlashCommand::Agent => true,
SlashCommand::Statusline => false,
SlashCommand::Theme => false,
SlashCommand::Title => false,
}
}

View File

@@ -0,0 +1,184 @@
//! 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.
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.
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
}
}
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
}
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\\");
}
}