Compare commits

...

16 Commits

Author SHA1 Message Date
Yaroslav Volovich
be6d8f3bcf fix(tui): tighten terminal title refresh and caching 2026-02-23 16:52:18 +00:00
Yaroslav Volovich
9f62b3929c fix(title): address review feedback after rebase 2026-02-23 11:53:58 +00:00
Yaroslav Volovich
94a63753ee fix(tui): refresh git branch for title-only terminal title 2026-02-23 11:42:54 +00:00
Yaroslav Volovich
66e76b7afb fix(tui): clear title when sanitization strips content 2026-02-23 11:42:54 +00:00
Yaroslav Volovich
69f68e4bc3 fix(tui): retry terminal title writes after io errors 2026-02-23 11:42:54 +00:00
Yaroslav Volovich
ebdc4d9c86 fix(tui): use neutral /title preview examples 2026-02-23 11:42:54 +00:00
Yaroslav Volovich
fc0c2df63d fix(tui): add ellipses to transient title states 2026-02-23 11:42:54 +00:00
Yaroslav Volovich
5fe79defeb fix(tui): live preview /title and revert on cancel 2026-02-23 11:42:54 +00:00
Yaroslav Volovich
4985f2e6b8 fix(app-server): stabilize running thread resume status 2026-02-23 11:42:53 +00:00
Yaroslav Volovich
a9ec5398c7 tui: add secure /title terminal title config 2026-02-23 11:42:53 +00:00
jif-oai
6fbf19ef5f chore: phase 2 name (#12568) 2026-02-23 11:04:55 +00:00
jif-oai
2b9d0c385f chore: add doc to memories (#12565)
]
2026-02-23 10:52:58 +00:00
jif-oai
cfcbff4c48 chore: awaiter (#12562) 2026-02-23 10:28:24 +00:00
jif-oai
8e9312958d chore: nit name (#12559) 2026-02-23 08:49:41 +00:00
Michael Bolin
956f2f439e refactor: decouple MCP policy construction from escalate server (#12555)
## Why
The current escalate path in `codex-rs/exec-server` still had policy
creation coupled to MCP details, which makes it hard to reuse the shell
execution flow outside the MCP server. This change is part of a broader
goal to split MCP-specific behavior from shared escalation execution so
other handlers (for example a future `ShellCommandHandler`) can reuse it
without depending on MCP request context types.

## What changed
- Added a new `EscalationPolicyFactory` abstraction in `mcp.rs`:
  - `crate`-relative path: `codex-rs/exec-server/src/posix/mcp.rs`
-
https://github.com/openai/codex/blob/main/codex-rs/exec-server/src/posix/mcp.rs#L87-L107
- Made `run_escalate_server` in `mcp.rs` accept a policy factory instead
of constructing `McpEscalationPolicy` directly.
-
https://github.com/openai/codex/blob/main/codex-rs/exec-server/src/posix/mcp.rs#L178-L201
- Introduced `McpEscalationPolicyFactory` that stores MCP-only state
(`RequestContext`, `preserve_program_paths`) and implements the new
trait.
-
https://github.com/openai/codex/blob/main/codex-rs/exec-server/src/posix/mcp.rs#L100-L117
- Updated `shell()` to pass a `McpEscalationPolicyFactory` instance into
`run_escalate_server`, so the server remains the MCP-specific wiring
layer.
-
https://github.com/openai/codex/blob/main/codex-rs/exec-server/src/posix/mcp.rs#L163-L170

## Verification
- Build and test execution was not re-run in this pass; changes are
limited to `mcp.rs` and preserve the existing escalation flow semantics
by only extracting policy construction behind a factory.




---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/12555).
* #12556
* __->__ #12555
2026-02-23 00:31:29 -08:00
pakrym-oai
335a4e1cbc Return image content from view_image (#12553)
Responses API supports image content
2026-02-22 23:00:08 -08:00
22 changed files with 1305 additions and 144 deletions

View File

@@ -6333,12 +6333,7 @@ async fn handle_pending_thread_resume_request(
return;
}
};
has_in_progress_turn = has_in_progress_turn
|| thread
.turns
.iter()
.any(|turn| matches!(turn.status, TurnStatus::InProgress));
has_in_progress_turn = has_in_progress_turn || latest_turn_is_in_progress(&thread.turns);
let status = resolve_thread_status(
thread_watch_manager
@@ -6412,6 +6407,12 @@ fn merge_turn_history_with_active_turn(turns: &mut Vec<Turn>, active_turn: Turn)
turns.push(active_turn);
}
fn latest_turn_is_in_progress(turns: &[Turn]) -> bool {
turns
.last()
.is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress))
}
fn collect_resume_override_mismatches(
request: &ThreadResumeParams,
config_snapshot: &ThreadConfigSnapshot,
@@ -7351,6 +7352,28 @@ mod tests {
Ok(())
}
#[test]
fn latest_turn_is_in_progress_ignores_stale_historical_in_progress_turns() {
let turn = |id: &str, status: TurnStatus| Turn {
id: id.to_string(),
items: Vec::new(),
status,
error: None,
};
let turns = vec![
turn("old", TurnStatus::InProgress),
turn("new", TurnStatus::Completed),
];
assert!(!latest_turn_is_in_progress(&turns));
let turns = vec![
turn("old", TurnStatus::Completed),
turn("new", TurnStatus::InProgress),
];
assert!(latest_turn_is_in_progress(&turns));
}
#[tokio::test]
async fn removing_listeners_retains_thread_listener_when_last_subscriber_leaves() -> Result<()>
{

View File

@@ -1401,6 +1401,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.",
@@ -2068,4 +2076,4 @@
},
"title": "ConfigToml",
"type": "object"
}
}

View File

@@ -1,36 +1,35 @@
background_terminal_max_timeout = 3600000
model_reasoning_effort = "low"
developer_instructions="""You are a waiting agent.
You're name is Superman.
Your role is to monitor the execution of a specific command or task and report its status only when it is finished.
developer_instructions="""You are an awaiter.
Your role is to await the completion of a specific command or task and report its status only when it is finished.
Behavior rules:
1. When given a command or task identifier, you must:
- Execute or monitor it using the appropriate tool
- Continue waiting until the task reaches a terminal state.
- Execute or await it using the appropriate tool
- Continue awaiting until the task reaches a terminal state.
2. You must NOT:
- Modify the task.
- Interpret or optimize the task.
- Perform unrelated actions.
- Stop waiting unless explicitly instructed.
- Stop awaiting unless explicitly instructed.
3. Waiting behavior:
3. Awaiting behavior:
- If the task is still running, continue polling using tool calls.
- Use repeated tool calls if necessary.
- Do not hallucinate completion.
- Use long timeouts when waiting for something. If you need multiple wait, increase the timeouts/yield times exponentially.
- Use long timeouts when awaiting for something. If you need multiple awaits, increase the timeouts/yield times exponentially.
4. If asked for status:
- Return the current known status.
- Immediately resume waiting afterward.
- Immediately resume awaiting afterward.
5. Termination:
- Only exit waiting when:
- Only exit awaiting when:
- The task completes successfully, OR
- The task fails, OR
- You receive an explicit stop instruction.
You must behave deterministically and conservatively.
"""
"""

View File

@@ -187,16 +187,16 @@ Rules:
}
),
(
"monitor".to_string(),
"awaiter".to_string(),
AgentRoleConfig {
description: Some(r#"Use a `monitor` agent EVERY TIME you must run a command that might take some time.
description: Some(r#"Use an `awaiter` agent EVERY TIME you must run a command that might take some time.
This includes, but not only:
* testing
* monitoring of a long running process
* explicit ask to wait for something
When YOU wait for the `monitor` agent to be done, use the largest possible timeout."#.to_string()),
config_file: Some("monitor.toml".to_string().parse().unwrap_or_default()),
When YOU wait for the `awaiter` agent to be done, use the largest possible timeout."#.to_string()),
config_file: Some("awaiter.toml".to_string().parse().unwrap_or_default()),
}
)
])
@@ -207,10 +207,10 @@ When YOU wait for the `monitor` agent to be done, use the largest possible timeo
/// Resolves a built-in role `config_file` path to embedded content.
pub(super) fn config_file_contents(path: &Path) -> Option<&'static str> {
const EXPLORER: &str = include_str!("builtins/explorer.toml");
const MONITOR: &str = include_str!("builtins/monitor.toml");
const AWAITER: &str = include_str!("builtins/awaiter.toml");
match path.to_str()? {
"explorer.toml" => Some(EXPLORER),
"monitor.toml" => Some(MONITOR),
"awaiter.toml" => Some(AWAITER),
_ => None,
}
}

View File

@@ -75,6 +75,18 @@ pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
}
}
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()),
}
}
// TODO(jif) move to a dedicated file
mod document_helpers {
use crate::config::types::McpServerConfig;

View File

@@ -278,6 +278,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>,
@@ -2124,6 +2129,7 @@ impl Config {
.unwrap_or_default(),
tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()),
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()),
otel: {
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
@@ -2566,6 +2572,8 @@ theme = "dracula"
alternate_screen: AltScreenMode::Auto,
status_line: None,
theme: None,
terminal_title: None,
theme: None,
}
);
}
@@ -4675,6 +4683,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(),
},
@@ -4798,6 +4807,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(),
};
@@ -4919,6 +4929,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(),
};
@@ -5026,6 +5037,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

@@ -682,6 +682,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

@@ -0,0 +1,92 @@
# Memories Pipeline (Core)
This module runs a startup memory pipeline for eligible sessions.
## When it runs
The pipeline is triggered when a root session starts, and only if:
- the session is not ephemeral
- the memory feature is enabled
- the session is not a sub-agent session
- the state DB is available
It runs asynchronously in the background and executes two phases in order: Phase 1, then Phase 2.
## Phase 1: Rollout Extraction (per-thread)
Phase 1 finds recent eligible rollouts and extracts a structured memory from each one.
Eligible rollouts are selected from the state DB using startup claim rules. In practice this means
the pipeline only considers rollouts that are:
- from allowed interactive session sources
- within the configured age window
- idle long enough (to avoid summarizing still-active/fresh rollouts)
- not already owned by another in-flight phase-1 worker
- within startup scan/claim limits (bounded work per startup)
What it does:
- claims a bounded set of rollout jobs from the state DB (startup claim)
- filters rollout content down to memory-relevant response items
- sends each rollout to a model (in parallel, with a concurrency cap)
- expects structured output containing:
- a detailed `raw_memory`
- a compact `rollout_summary`
- an optional `rollout_slug`
- redacts secrets from the generated memory fields
- stores successful outputs back into the state DB as stage-1 outputs
Concurrency / coordination:
- Phase 1 runs multiple extraction jobs in parallel (with a fixed concurrency cap) so startup memory generation can process several rollouts at once.
- Each job is leased/claimed in the state DB before processing, which prevents duplicate work across concurrent workers/startups.
- Failed jobs are marked with retry backoff, so they are retried later instead of hot-looping.
Job outcomes:
- `succeeded` (memory produced)
- `succeeded_no_output` (valid run but nothing useful generated)
- `failed` (with retry backoff/lease handling in DB)
Phase 1 is the stage that turns individual rollouts into DB-backed memory records.
## Phase 2: Global Consolidation
Phase 2 consolidates the latest stage-1 outputs into the filesystem memory artifacts and then runs a dedicated consolidation agent.
What it does:
- claims a single global phase-2 job (so only one consolidation runs at a time)
- loads a bounded set of the most recent stage-1 outputs from the state DB (the per-rollout memories produced by Phase 1, used as the consolidation input set)
- computes a completion watermark from the claimed watermark + newest input timestamps
- syncs local memory artifacts under the memories root:
- `raw_memories.md` (merged raw memories, latest first)
- `rollout_summaries/` (one summary file per retained rollout)
- prunes stale rollout summaries that are no longer retained
- if there are no inputs, marks the job successful and exits
If there is input, it then:
- spawns an internal consolidation sub-agent
- runs it with no approvals, no network, and local write access only
- disables collab for that agent (to prevent recursive delegation)
- watches the agent status and heartbeats the global job lease while it runs
- marks the phase-2 job success/failure in the state DB when the agent finishes
Watermark behavior:
- The global phase-2 job claim includes an input watermark representing the latest input timestamp known when the job was claimed.
- Phase 2 recomputes a `new_watermark` using the max of:
- the claimed watermark
- the newest `source_updated_at` timestamp in the stage-1 inputs it actually loaded
- On success, Phase 2 stores that completion watermark in the DB.
- This lets later phase-2 runs know whether new stage-1 data arrived since the last successful consolidation (dirty vs not dirty), while also avoiding moving the watermark backwards.
In practice, this phase is responsible for refreshing the on-disk memory workspace and producing/updating the higher-level consolidated memory outputs.
## Why it is split into two phases
- Phase 1 scales across many rollouts and produces normalized per-rollout memory records.
- Phase 2 serializes global consolidation so the shared memory artifacts are updated safely and consistently.

View File

@@ -1,5 +1,6 @@
use async_trait::async_trait;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::openai_models::InputModality;
use serde::Deserialize;
use tokio::fs;
@@ -14,7 +15,6 @@ use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::local_image_content_items_with_label_number;
pub struct ViewImageHandler;
@@ -81,21 +81,20 @@ impl ToolHandler for ViewImageHandler {
}
let event_path = abs_path.clone();
let content: Vec<ContentItem> =
local_image_content_items_with_label_number(&abs_path, None);
let input = ResponseInputItem::Message {
role: "user".to_string(),
content,
};
session
.inject_response_items(vec![input])
.await
.map_err(|_| {
FunctionCallError::RespondToModel(
"unable to attach image (no active task)".to_string(),
)
})?;
let content = local_image_content_items_with_label_number(&abs_path, None)
.into_iter()
.map(|item| match item {
ContentItem::InputText { text } => {
FunctionCallOutputContentItem::InputText { text }
}
ContentItem::InputImage { image_url } => {
FunctionCallOutputContentItem::InputImage { image_url }
}
ContentItem::OutputText { text } => {
FunctionCallOutputContentItem::InputText { text }
}
})
.collect();
session
.send_event(
@@ -108,7 +107,7 @@ impl ToolHandler for ViewImageHandler {
.await;
Ok(ToolOutput::Function {
body: FunctionCallOutputBody::Text("attached local image path".to_string()),
body: FunctionCallOutputBody::ContentItems(content),
success: Some(true),
})
}

View File

@@ -237,40 +237,29 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
let req = mock.single_request();
let body = req.body_json();
let output_text = req
.function_call_output_content_and_success(call_id)
.and_then(|(content, _)| content)
.expect("output text present");
assert_eq!(output_text, "attached local image path");
assert!(
find_image_message(&body).is_none(),
"view_image tool should not inject a separate image message"
);
let image_message =
find_image_message(&body).expect("pending input image message not included in request");
let content_items = image_message
.get("content")
let function_output = req.function_call_output(call_id);
let output_items = function_output
.get("output")
.and_then(Value::as_array)
.expect("image message has content array");
.expect("function_call_output should be a content item array");
assert_eq!(
content_items.len(),
output_items.len(),
1,
"view_image should inject only the image content item (no tag/label text)"
"view_image should return only the image content item (no tag/label text)"
);
assert_eq!(
content_items[0].get("type").and_then(Value::as_str),
output_items[0].get("type").and_then(Value::as_str),
Some("input_image"),
"view_image should inject only an input_image content item"
"view_image should return only an input_image content item"
);
let image_url = image_message
.get("content")
.and_then(Value::as_array)
.and_then(|content| {
content.iter().find_map(|span| {
if span.get("type").and_then(Value::as_str) == Some("input_image") {
span.get("image_url").and_then(Value::as_str)
} else {
None
}
})
})
let image_url = output_items[0]
.get("image_url")
.and_then(Value::as_str)
.expect("image_url present");
let (prefix, encoded) = image_url
@@ -535,38 +524,36 @@ async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()>
request.inputs_of_type("input_image").is_empty(),
"non-image file should not produce an input_image message"
);
let function_output = request.function_call_output(call_id);
let output_items = function_output
.get("output")
.and_then(Value::as_array)
.expect("function_call_output should be a content item array");
assert_eq!(
output_items.len(),
1,
"non-image placeholder should be returned as a single content item"
);
assert_eq!(
output_items[0].get("type").and_then(Value::as_str),
Some("input_text"),
"non-image placeholder should be returned as input_text"
);
let placeholder = output_items[0]
.get("text")
.and_then(Value::as_str)
.expect("placeholder text present");
let placeholder = request
.inputs_of_type("message")
.iter()
.find_map(|item| {
let content = item.get("content").and_then(Value::as_array)?;
content.iter().find_map(|span| {
if span.get("type").and_then(Value::as_str) == Some("input_text") {
let text = span.get("text").and_then(Value::as_str)?;
if text.contains("Codex could not read the local image at")
&& text.contains("unsupported MIME type `application/json`")
{
return Some(text.to_string());
}
}
None
})
})
.expect("placeholder text found");
assert!(
placeholder.contains("Codex could not read the local image at")
&& placeholder.contains("unsupported MIME type `application/json`"),
"placeholder should describe the unsupported file type: {placeholder}"
);
assert!(
placeholder.contains(&abs_path.display().to_string()),
"placeholder should mention path: {placeholder}"
);
let output_text = mock
.single_request()
.function_call_output_content_and_success(call_id)
.and_then(|(content, _)| content)
.expect("output text present");
assert_eq!(output_text, "attached local image path");
Ok(())
}

View File

@@ -1,3 +1,4 @@
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@@ -30,6 +31,7 @@ use tokio::sync::RwLock;
use crate::posix::escalate_server::EscalateServer;
use crate::posix::escalate_server::{self};
use crate::posix::escalation_policy::EscalationPolicy;
use crate::posix::mcp_escalation_policy::McpEscalationPolicy;
use crate::posix::stopwatch::Stopwatch;
@@ -85,6 +87,30 @@ pub struct ExecTool {
sandbox_state: Arc<RwLock<Option<SandboxState>>>,
}
trait EscalationPolicyFactory {
type Policy: EscalationPolicy + Send + Sync + 'static;
fn create_policy(&self, policy: Arc<RwLock<Policy>>, stopwatch: Stopwatch) -> Self::Policy;
}
struct McpEscalationPolicyFactory {
context: RequestContext<RoleServer>,
preserve_program_paths: bool,
}
impl EscalationPolicyFactory for McpEscalationPolicyFactory {
type Policy = McpEscalationPolicy;
fn create_policy(&self, policy: Arc<RwLock<Policy>>, stopwatch: Stopwatch) -> Self::Policy {
McpEscalationPolicy::new(
policy,
self.context.clone(),
stopwatch,
self.preserve_program_paths,
)
}
}
#[tool_router]
impl ExecTool {
pub fn new(
@@ -115,8 +141,6 @@ impl ExecTool {
.timeout_ms
.unwrap_or(codex_core::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS),
);
let stopwatch = Stopwatch::new(effective_timeout);
let cancel_token = stopwatch.cancellation_token();
let sandbox_state =
self.sandbox_state
.read()
@@ -128,27 +152,68 @@ impl ExecTool {
sandbox_cwd: PathBuf::from(&params.workdir),
use_linux_sandbox_bwrap: false,
});
let escalate_server = EscalateServer::new(
self.bash_path.clone(),
self.execve_wrapper.clone(),
McpEscalationPolicy::new(
self.policy.clone(),
let result = run_escalate_server(
params,
sandbox_state,
&self.bash_path,
&self.execve_wrapper,
self.policy.clone(),
McpEscalationPolicyFactory {
context,
stopwatch.clone(),
self.preserve_program_paths,
),
);
let result = escalate_server
.exec(params, cancel_token, &sandbox_state)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
preserve_program_paths: self.preserve_program_paths,
},
effective_timeout,
)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::json(
ExecResult::from(result),
)?]))
}
}
/// Runs the escalate server to execute a shell command with potential
/// escalation of execve calls.
///
/// - `exec_params` defines the shell command to run
/// - `sandbox_state` is the sandbox to use to run the shell program
/// - `shell_program` is the path to the shell program to run (e.g. /bin/bash)
/// - `execve_wrapper` is the path to the execve wrapper binary to use for
/// handling execve calls from the shell program. This is likely a symlink to
/// Codex using a special name.
/// - `policy` is the exec policy to use for deciding whether to allow or deny
/// execve calls from the shell program.
/// - `escalation_policy_factory` is a factory for creating an
/// `EscalationPolicy` to use for deciding whether to allow, deny, or prompt
/// the user for execve calls from the shell program. We use a factory here
/// because the `EscalationPolicy` may need to capture request-specific
/// context (e.g. the MCP request context) that is not available at the time
/// we create the `ExecTool`.
/// - `effective_timeout` is the timeout to use for running the shell command.
/// Implementations are encouraged to excludeany time spent prompting the
/// user.
async fn run_escalate_server(
exec_params: ExecParams,
sandbox_state: SandboxState,
shell_program: impl AsRef<Path>,
execve_wrapper: impl AsRef<Path>,
policy: Arc<RwLock<Policy>>,
escalation_policy_factory: impl EscalationPolicyFactory,
effective_timeout: Duration,
) -> anyhow::Result<crate::posix::escalate_server::ExecResult> {
let stopwatch = Stopwatch::new(effective_timeout);
let cancel_token = stopwatch.cancellation_token();
let escalate_server = EscalateServer::new(
shell_program.as_ref().to_path_buf(),
execve_wrapper.as_ref().to_path_buf(),
escalation_policy_factory.create_policy(policy, stopwatch),
);
escalate_server
.exec(exec_params, cancel_token, &sandbox_state)
.await
}
#[derive(Default)]
pub struct CodexSandboxStateUpdateMethod;

View File

@@ -1961,6 +1961,9 @@ impl SessionSource {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) => {
agent_nickname.clone()
}
SessionSource::SubAgent(SubAgentSource::MemoryConsolidation) => {
Some("morpheus".to_string())
}
_ => None,
}
}
@@ -1970,6 +1973,9 @@ impl SessionSource {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => {
agent_role.clone()
}
SessionSource::SubAgent(SubAgentSource::MemoryConsolidation) => {
Some("memory builder".to_string())
}
_ => None,
}
}

View File

@@ -544,6 +544,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,
@@ -627,6 +629,7 @@ impl App {
feedback_audience: self.feedback_audience,
model: Some(self.chat_widget.current_model().to_string()),
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(),
otel_manager: self.otel_manager.clone(),
}
}
@@ -1167,6 +1170,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 =
@@ -1191,6 +1195,8 @@ impl App {
feedback_audience,
model: Some(model.clone()),
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())
@@ -1221,6 +1227,8 @@ impl App {
feedback_audience,
model: config.model.clone(),
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)
@@ -1252,6 +1260,8 @@ impl App {
feedback_audience,
model: config.model.clone(),
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)
@@ -1285,6 +1295,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(),
@@ -1505,6 +1516,9 @@ impl App {
feedback_audience: self.feedback_audience,
model: Some(model),
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());
@@ -2669,6 +2683,33 @@ impl App {
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)
@@ -3539,6 +3580,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(),
@@ -3597,6 +3639,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(),

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;
@@ -363,6 +364,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

@@ -43,6 +43,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;
@@ -90,6 +91,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;
pub mod popup_consts;
mod queued_user_messages;

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,229 @@
//! 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",
}
}
}
/// Interactive view for configuring terminal-title items.
pub(crate) struct TerminalTitleSetupView {
picker: MultiSelectPicker,
}
impl TerminalTitleSetupView {
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 preview = items
.iter()
.filter(|item| item.enabled)
.filter_map(|item| item.id.parse::<TerminalTitleItem>().ok())
.map(|item| item.render())
.collect::<Vec<_>>()
.join(" | ");
if preview.is_empty() {
None
} else {
Some(Line::from(preview))
}
})
.on_change(|items, app_event| {
let items = items
.iter()
.filter(|item| item.enabled)
.filter_map(|item| item.id.parse::<TerminalTitleItem>().ok())
.collect::<Vec<_>>();
app_event.send(AppEvent::TerminalTitleSetupPreview { items });
})
.on_confirm(|ids, app_event| {
let items = ids
.iter()
.map(|id| id.parse::<TerminalTitleItem>())
.collect::<Result<Vec<_>, _>>()
.unwrap_or_default();
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 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));
}
}

View File

@@ -39,10 +39,14 @@ use std::time::Instant;
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::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;
@@ -152,6 +156,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?";
@@ -272,6 +277,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;
@@ -284,6 +290,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>,
@@ -454,6 +461,8 @@ pub(crate) struct ChatWidgetInit {
pub(crate) model: Option<String>,
// Shared latch so we only warn once about invalid status-line item IDs.
pub(crate) status_line_invalid_items_warned: Arc<AtomicBool>,
// 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,
}
@@ -581,6 +590,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.
@@ -630,6 +641,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.
@@ -653,6 +666,16 @@ 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>,
// 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.
@@ -838,6 +861,38 @@ enum ReplayKind {
ThreadSnapshot,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum TerminalTitleStatusKind {
Working,
WaitingForBackgroundTerminal,
Undoing,
#[default]
Thinking,
}
#[derive(Debug)]
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)]
struct CachedProjectRootName {
cwd: PathBuf,
root_name: Option<String>,
}
impl ChatWidget {
/// Synchronize the bottom-pane "task running" indicator with the current lifecycles.
///
@@ -846,12 +901,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"));
}
}
@@ -913,6 +971,7 @@ impl ChatWidget {
fn set_status(&mut self, header: String, details: Option<String>) {
self.current_status_header = header.clone();
self.bottom_pane.update_status(header, details);
self.refresh_terminal_title();
}
/// Convenience wrapper around [`Self::set_status`];
@@ -936,48 +995,90 @@ impl ChatWidget {
/// 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);
}
}
@@ -990,6 +1091,64 @@ impl ChatWidget {
self.set_status_line(line);
}
fn refresh_terminal_title_from_selections(&mut self, selections: &StatusSurfaceSelections) {
if selections.terminal_title_items.is_empty() {
if self.last_terminal_title.is_some() {
match clear_terminal_title() {
Ok(()) => {
self.last_terminal_title = None;
}
Err(err) => {
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(()) => {
self.last_terminal_title = Some(title);
}
Err(err) => {
tracing::debug!(error = %err, "failed to set terminal title");
}
},
None => {
if self.last_terminal_title.is_some() {
match clear_terminal_title() {
Ok(()) => {
self.last_terminal_title = None;
}
Err(err) => {
tracing::debug!(error = %err, "failed to clear terminal title");
}
}
}
}
}
}
pub(crate) fn refresh_status_line(&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
@@ -1008,6 +1167,52 @@ impl ChatWidget {
self.refresh_status_line();
}
/// 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.
///
/// Results are dropped when they target an out-of-date cwd to avoid rendering stale branch
@@ -1022,10 +1227,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();
@@ -1076,6 +1282,7 @@ impl ChatWidget {
self.forked_from = event.forked_from_id;
self.current_rollout_path = event.rollout_path.clone();
self.current_cwd = Some(event.cwd.clone());
self.status_line_project_root_name_cache = None;
let initial_messages = event.initial_messages.clone();
let forked_from_id = event.forked_from_id;
let model_for_header = event.model.clone();
@@ -1168,6 +1375,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();
}
}
@@ -1304,6 +1512,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.
@@ -1338,6 +1547,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();
@@ -1351,6 +1561,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();
@@ -1849,6 +2060,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));
}
@@ -1941,6 +2163,7 @@ impl ChatWidget {
} else {
"Waiting for background terminal".to_string()
};
self.terminal_title_status_kind = TerminalTitleStatusKind::WaitingForBackgroundTerminal;
self.set_status_header(header);
match &mut self.unified_exec_wait_streak {
Some(wait) if wait.process_id == ev.process_id => {
@@ -2180,6 +2403,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);
}
@@ -2189,6 +2413,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);
}
@@ -2214,6 +2439,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);
}
@@ -2679,6 +2905,7 @@ impl ChatWidget {
feedback_audience,
model,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
otel_manager,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
@@ -2766,6 +2993,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,
@@ -2784,6 +3012,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,
@@ -2795,6 +3024,10 @@ impl ChatWidget {
current_cwd,
session_network_proxy: None,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
last_terminal_title: None,
terminal_title_setup_original_items: None,
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -2830,6 +3063,8 @@ impl ChatWidget {
.bottom_pane
.set_connectors_enabled(widget.config.features.enabled(Feature::Apps));
widget.refresh_terminal_title();
widget
}
@@ -2850,6 +3085,7 @@ impl ChatWidget {
feedback_audience,
model,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
otel_manager,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
@@ -2936,6 +3172,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,
@@ -2943,6 +3180,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(),
@@ -2965,6 +3203,10 @@ impl ChatWidget {
current_cwd,
session_network_proxy: None,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
last_terminal_title: None,
terminal_title_setup_original_items: None,
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -2986,6 +3228,7 @@ impl ChatWidget {
widget
.bottom_pane
.set_queued_message_edit_binding(widget.queued_message_edit_binding);
widget.refresh_terminal_title();
widget
}
@@ -3009,6 +3252,7 @@ impl ChatWidget {
feedback_audience,
model,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
otel_manager,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
@@ -3095,6 +3339,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,
@@ -3113,6 +3358,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,
@@ -3124,6 +3370,10 @@ impl ChatWidget {
current_cwd,
session_network_proxy: None,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
last_terminal_title: None,
terminal_title_setup_original_items: None,
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -3154,6 +3404,7 @@ impl ChatWidget {
),
);
widget.update_collaboration_mode_indicator();
widget.refresh_terminal_title();
widget
}
@@ -3547,6 +3798,9 @@ impl ChatWidget {
SlashCommand::DebugConfig => {
self.add_debug_config_output();
}
SlashCommand::Title => {
self.open_terminal_title_setup();
}
SlashCommand::Statusline => {
self.open_status_line_setup();
}
@@ -4516,6 +4770,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.
@@ -4545,12 +4809,40 @@ 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)
}
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();
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);
}
@@ -4567,14 +4859,42 @@ 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))
})
}
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
}
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.
@@ -4614,7 +4934,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 => {
@@ -4680,6 +5000,71 @@ impl ChatWidget {
}
}
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(),
}
}
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(),
}
}
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}"))
}
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()
@@ -6570,6 +6955,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 {
@@ -7506,6 +7892,11 @@ fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool {
impl Drop for ChatWidget {
fn drop(&mut self) {
if self.last_terminal_title.is_some()
&& let Err(err) = clear_terminal_title()
{
tracing::debug!(error = %err, "failed to clear terminal title on drop");
}
self.stop_rate_limit_poller();
}
}

View File

@@ -1523,6 +1523,7 @@ async fn helpers_are_available_and_do_not_panic() {
feedback_audience: FeedbackAudience::External,
model: Some(resolved_model),
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);
@@ -1633,6 +1634,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,
@@ -1652,6 +1654,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,
@@ -1663,6 +1666,10 @@ async fn make_chatwidget_manual(
current_cwd: None,
session_network_proxy: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
last_terminal_title: None,
terminal_title_setup_original_items: None,
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -4244,6 +4251,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() {
feedback_audience: FeedbackAudience::External,
model: Some(resolved_model.clone()),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
otel_manager,
};
@@ -4293,6 +4301,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() {
feedback_audience: FeedbackAudience::External,
model: Some(resolved_model.clone()),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
otel_manager,
};
@@ -7618,6 +7627,79 @@ 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 on_task_started_resets_terminal_title_task_progress() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.last_plan_progress = Some((2, 5));
chat.on_task_started();
assert_eq!(chat.last_plan_progress, None);
assert_eq!(chat.terminal_title_task_progress(), None);
}
#[test]
fn terminal_title_part_truncation_preserves_grapheme_clusters() {
let value = "ab👩💻cdefg".to_string();
let truncated = ChatWidget::truncate_terminal_title_part(value, 7);
assert_eq!(truncated, "ab👩💻c...");
}
#[tokio::test]
async fn status_line_branch_state_resets_when_git_branch_disabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -7651,6 +7733,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

@@ -104,6 +104,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

@@ -36,6 +36,7 @@ pub enum SlashCommand {
Mention,
Status,
DebugConfig,
Title,
Statusline,
Theme,
Mcp,
@@ -77,6 +78,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",
@@ -161,6 +163,7 @@ impl SlashCommand {
SlashCommand::Agent => true,
SlashCommand::Statusline => false,
SlashCommand::Theme => false,
SlashCommand::Title => false,
}
}

View File

@@ -0,0 +1,150 @@
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;
pub(crate) fn set_terminal_title(title: &str) -> io::Result<()> {
if !stdout().is_terminal() {
return Ok(());
}
let title = sanitize_terminal_title(title);
if title.is_empty() {
return clear_terminal_title();
}
execute!(stdout(), SetWindowTitle(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\\");
}
}