Merge branch 'main' into jif/feedback-agents

This commit is contained in:
jif-oai
2026-03-26 18:08:52 +00:00
committed by GitHub
49 changed files with 2118 additions and 728 deletions
+1
View File
@@ -40,6 +40,7 @@ In the codex-rs folder where the rust code lives:
`codex-rs/tui/src/bottom_pane/mod.rs`, and similarly central orchestration modules.
- When extracting code from a large module, move the related tests and module/type docs toward
the new implementation so the invariants stay close to the code that owns them.
- When running Rust commands (e.g. `just fix` or `cargo test`) be patient with the command and never try to kill them using the PID. Rust lock can make the execution slow, this is expected.
Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests:
-10
View File
@@ -1846,7 +1846,6 @@ dependencies = [
"codex-shell-escalation",
"codex-state",
"codex-terminal-detection",
"codex-test-macros",
"codex-utils-absolute-path",
"codex-utils-cache",
"codex-utils-cargo-bin",
@@ -2625,15 +2624,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "codex-test-macros"
version = "0.0.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "codex-tui"
version = "0.0.0"
-2
View File
@@ -80,7 +80,6 @@ members = [
"state",
"terminal-detection",
"codex-experimental-api-macros",
"test-macros",
"package-manager",
"plugin",
]
@@ -150,7 +149,6 @@ codex-shell-escalation = { path = "shell-escalation" }
codex-skills = { path = "skills" }
codex-state = { path = "state" }
codex-stdio-to-uds = { path = "stdio-to-uds" }
codex-test-macros = { path = "test-macros" }
codex-terminal-detection = { path = "terminal-detection" }
codex-tui = { path = "tui" }
codex-tui-app-server = { path = "tui_app_server" }
-1
View File
@@ -148,7 +148,6 @@ codex-arg0 = { workspace = true }
codex-otel = { workspace = true, features = [
"disable-default-metrics-exporter",
] }
codex-test-macros = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
core_test_support = { workspace = true }
ctor = { workspace = true }
+1 -3
View File
@@ -1258,9 +1258,7 @@
"type": "object"
},
"RawMcpServerConfig": {
"additionalProperties": {
"$ref": "#/definitions/McpServerToolConfig"
},
"additionalProperties": false,
"properties": {
"args": {
"default": null,
+16 -7
View File
@@ -45,6 +45,7 @@ use tracing::warn;
const AGENT_NAMES: &str = include_str!("agent_names.txt");
const FORKED_SPAWN_AGENT_OUTPUT_MESSAGE: &str = "You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context.";
const ROOT_LAST_TASK_MESSAGE: &str = "Main thread";
#[derive(Clone, Debug, Default)]
pub(crate) struct SpawnAgentOptions {
@@ -651,12 +652,6 @@ impl AgentControl {
let agent_path = current_agent_path
.resolve(agent_reference)
.map_err(CodexErr::UnsupportedOperation)?;
if agent_path.is_root() {
return Err(CodexErr::UnsupportedOperation(
"root is not a spawned agent".to_string(),
));
}
if let Some(thread_id) = self.state.agent_id_for_path(&agent_path) {
return Ok(thread_id);
}
@@ -727,7 +722,21 @@ impl AgentControl {
let mut live_agents = self.state.live_agents();
live_agents.sort_by(compare_agent_metadata);
let mut agents = Vec::with_capacity(live_agents.len());
let root_path = AgentPath::root();
let mut agents = Vec::with_capacity(live_agents.len().saturating_add(1));
if resolved_prefix
.as_ref()
.is_none_or(|prefix| agent_matches_prefix(Some(&root_path), prefix))
&& let Some(root_thread_id) = self.state.agent_id_for_path(&root_path)
&& let Ok(root_thread) = state.get_thread(root_thread_id).await
{
agents.push(ListedAgent {
agent_name: root_path.to_string(),
agent_status: root_thread.agent_status().await,
last_task_message: Some(ROOT_LAST_TASK_MESSAGE.to_string()),
});
}
for metadata in live_agents {
let Some(thread_id) = metadata.agent_id else {
continue;
+5 -14
View File
@@ -1991,28 +1991,19 @@ approval_mode = "approve"
}
#[test]
fn mcp_servers_toml_parses_legacy_flattened_per_tool_approval_overrides() {
fn mcp_servers_toml_ignores_unknown_server_fields() {
let config = toml::from_str::<ConfigToml>(
r#"
[mcp_servers.docs]
command = "docs-server"
[mcp_servers.docs.search]
approval_mode = "approve"
trust_level = "trusted"
"#,
)
.expect("legacy TOML deserialization should succeed");
let tool = config
.mcp_servers
.get("docs")
.and_then(|server| server.tools.get("search"))
.expect("docs/search tool config exists");
.expect("unknown MCP server fields should be ignored");
assert_eq!(
tool,
&McpServerToolConfig {
approval_mode: Some(AppToolApproval::Approve),
}
config.mcp_servers.get("docs"),
Some(&stdio_mcp("docs-server"))
);
}
+1 -8
View File
@@ -163,10 +163,6 @@ pub(crate) struct RawMcpServerConfig {
pub _name: Option<String>,
#[serde(default)]
pub tools: Option<HashMap<String, McpServerToolConfig>>,
/// Legacy flattened per-tool approval settings accepted for backward compatibility.
#[serde(default)]
#[serde(flatten)]
pub legacy_tools: HashMap<String, McpServerToolConfig>,
}
impl<'de> Deserialize<'de> for McpServerConfig {
@@ -191,10 +187,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
let disabled_tools = raw.disabled_tools.clone();
let scopes = raw.scopes.clone();
let oauth_resource = raw.oauth_resource.clone();
let mut tools = raw.legacy_tools.clone();
if let Some(nested_tools) = raw.tools.clone() {
tools.extend(nested_tools);
}
let tools = raw.tools.clone().unwrap_or_default();
fn throw_if_set<E, T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), E>
where
+34
View File
@@ -243,6 +243,40 @@ fn deserialize_server_config_with_tool_filters() {
assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()]));
}
#[test]
fn deserialize_ignores_unknown_server_fields() {
let cfg: McpServerConfig = toml::from_str(
r#"
command = "echo"
trust_level = "trusted"
"#,
)
.expect("should ignore unknown server fields");
assert_eq!(
cfg,
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: "echo".to_string(),
args: vec![],
env: None,
env_vars: Vec::new(),
cwd: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
}
);
}
#[test]
fn deserialize_skill_config_with_name_selector() {
let cfg: SkillConfig = toml::from_str(
+104 -36
View File
@@ -38,6 +38,7 @@ use codex_analytics::AppInvocation;
use codex_analytics::InvocationType;
use codex_analytics::build_track_events_context;
use codex_features::Feature;
use codex_otel::sanitize_metric_tag_value;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::openai_models::InputModality;
use codex_protocol::protocol::AskForApproval;
@@ -61,6 +62,9 @@ use tracing::Span;
use tracing::field::Empty;
use url::Url;
const MCP_CALL_COUNT_METRIC: &str = "codex.mcp.call";
const MCP_CALL_DURATION_METRIC: &str = "codex.mcp.call.duration_ms";
/// Handles the specified tool call dispatches the appropriate
/// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`.
pub(crate) async fn handle_mcp_tool_call(
@@ -128,7 +132,7 @@ pub(crate) async fn handle_mcp_tool_call(
.await;
let status = if result.is_ok() { "ok" } else { "error" };
turn_context.session_telemetry.counter(
"codex.mcp.call",
MCP_CALL_COUNT_METRIC,
/*inc*/ 1,
&[("status", status)],
);
@@ -166,7 +170,7 @@ pub(crate) async fn handle_mcp_tool_call(
)
.await
{
let result = match decision {
let (result, call_duration) = match decision {
McpToolApprovalDecision::Accept
| McpToolApprovalDecision::AcceptForSession
| McpToolApprovalDecision::AcceptAndRemember => {
@@ -206,10 +210,11 @@ pub(crate) async fn handle_mcp_tool_call(
if let Err(error) = &result {
tracing::warn!("MCP tool call error: {error:?}");
}
let duration = start.elapsed();
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: call_id.clone(),
invocation,
duration: start.elapsed(),
duration,
result: result.clone(),
});
notify_mcp_tool_call_event(
@@ -225,50 +230,62 @@ pub(crate) async fn handle_mcp_tool_call(
&tool_name,
)
.await;
result
(result, Some(duration))
}
McpToolApprovalDecision::Decline => {
let message = "user rejected MCP tool call".to_string();
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
message,
/*already_started*/ true,
(
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
message,
/*already_started*/ true,
)
.await,
None,
)
.await
}
McpToolApprovalDecision::Cancel => {
let message = "user cancelled MCP tool call".to_string();
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
message,
/*already_started*/ true,
(
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
message,
/*already_started*/ true,
)
.await,
None,
)
.await
}
McpToolApprovalDecision::BlockedBySafetyMonitor(message) => {
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
message,
/*already_started*/ true,
(
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
message,
/*already_started*/ true,
)
.await,
None,
)
.await
}
};
let status = if result.is_ok() { "ok" } else { "error" };
turn_context.session_telemetry.counter(
"codex.mcp.call",
/*inc*/ 1,
&[("status", status)],
emit_mcp_call_metrics(
turn_context.as_ref(),
status,
&tool_name,
connector_id.as_deref(),
connector_name.as_deref(),
call_duration,
);
return CallToolResult::from_result(result);
@@ -306,10 +323,11 @@ pub(crate) async fn handle_mcp_tool_call(
if let Err(error) = &result {
tracing::warn!("MCP tool call error: {error:?}");
}
let duration = start.elapsed();
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: call_id.clone(),
invocation,
duration: start.elapsed(),
duration,
result: result.clone(),
});
@@ -322,13 +340,63 @@ pub(crate) async fn handle_mcp_tool_call(
maybe_track_codex_app_used(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await;
let status = if result.is_ok() { "ok" } else { "error" };
turn_context
.session_telemetry
.counter("codex.mcp.call", /*inc*/ 1, &[("status", status)]);
emit_mcp_call_metrics(
turn_context.as_ref(),
status,
&tool_name,
connector_id.as_deref(),
connector_name.as_deref(),
Some(duration),
);
CallToolResult::from_result(result)
}
fn emit_mcp_call_metrics(
turn_context: &TurnContext,
status: &str,
tool_name: &str,
connector_id: Option<&str>,
connector_name: Option<&str>,
duration: Option<Duration>,
) {
let tags = mcp_call_metric_tags(status, tool_name, connector_id, connector_name);
let tag_refs: Vec<(&str, &str)> = tags
.iter()
.map(|(key, value)| (*key, value.as_str()))
.collect();
turn_context
.session_telemetry
.counter(MCP_CALL_COUNT_METRIC, /*inc*/ 1, &tag_refs);
if let Some(duration) = duration {
turn_context.session_telemetry.record_duration(
MCP_CALL_DURATION_METRIC,
duration,
&tag_refs,
);
}
}
fn mcp_call_metric_tags(
status: &str,
tool_name: &str,
connector_id: Option<&str>,
connector_name: Option<&str>,
) -> Vec<(&'static str, String)> {
let mut tags = vec![
("status", sanitize_metric_tag_value(status)),
("tool", sanitize_metric_tag_value(tool_name)),
];
if let Some(connector_id) = connector_id.filter(|connector_id| !connector_id.is_empty()) {
tags.push(("connector_id", sanitize_metric_tag_value(connector_id)));
}
if let Some(connector_name) = connector_name.filter(|connector_name| !connector_name.is_empty())
{
tags.push(("connector_name", sanitize_metric_tag_value(connector_name)));
}
tags
}
fn mcp_tool_call_span(
session: &Session,
turn_context: &TurnContext,
+2 -3
View File
@@ -16,10 +16,9 @@ const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[
"slack@openai-curated",
"gmail@openai-curated",
"google-calendar@openai-curated",
"google-docs@openai-curated",
"google-drive@openai-curated",
"google-sheets@openai-curated",
"google-slides@openai-curated",
"linear@openai-curated",
"figma@openai-curated",
];
pub(crate) fn list_tool_suggest_discoverable_plugins(
@@ -281,7 +281,7 @@ pub(crate) fn apply_spawn_agent_runtime_overrides(
}
pub(crate) fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) {
if child_depth >= config.agent_max_depth {
if child_depth >= config.agent_max_depth && !config.features.enabled(Feature::MultiAgentV2) {
let _ = config.features.disable(Feature::SpawnCsv);
let _ = config.features.disable(Feature::Collab);
}
@@ -430,6 +430,81 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
}));
}
#[tokio::test]
async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = (*turn.config).clone();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
turn.config = Arc::new(config);
let child_path = AgentPath::try_from("/root/worker").expect("agent path");
let child_thread_id = session
.services
.agent_control
.spawn_agent_with_metadata(
(*turn.config).clone(),
vec![UserInput::Text {
text: "inspect this repo".to_string(),
text_elements: Vec::new(),
}],
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(child_path.clone()),
agent_nickname: None,
agent_role: None,
})),
crate::agent::control::SpawnAgentOptions::default(),
)
.await
.expect("worker spawn should succeed")
.thread_id;
session.conversation_id = child_thread_id;
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(child_path.clone()),
agent_nickname: None,
agent_role: None,
});
SendMessageHandlerV2
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"send_message",
function_payload(json!({
"target": "/root",
"items": [{"type": "text", "text": "done"}]
})),
))
.await
.expect("send_message should accept the root agent path");
assert!(manager.captured_ops().iter().any(|(id, op)| {
*id == root.thread_id
&& matches!(
op,
Op::InterAgentCommunication { communication }
if communication.author == child_path
&& communication.recipient == AgentPath::root()
&& communication.other_recipients.is_empty()
&& communication.content == "done"
&& !communication.trigger_turn
)
}));
}
#[tokio::test]
async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_message() {
let (mut session, mut turn) = make_session_and_context().await;
@@ -496,11 +571,26 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa
let result: ListAgentsResult =
serde_json::from_str(&content).expect("list_agents result should be json");
assert_eq!(result.agents.len(), 1);
assert_eq!(result.agents[0].agent_name, "/root/worker");
assert_eq!(result.agents[0].agent_status, json!({"completed": "done"}));
let agent_names = result
.agents
.iter()
.map(|agent| agent.agent_name.as_str())
.collect::<Vec<_>>();
assert_eq!(agent_names, vec!["/root", "/root/worker"]);
let root_agent = result
.agents
.iter()
.find(|agent| agent.agent_name == "/root")
.expect("root agent should be listed");
assert_eq!(root_agent.last_task_message.as_deref(), Some("Main thread"));
let worker = result
.agents
.iter()
.find(|agent| agent.agent_name == "/root/worker")
.expect("worker agent should be listed");
assert_eq!(worker.agent_status, json!({"completed": "done"}));
assert_eq!(
result.agents[0].last_task_message.as_deref(),
worker.last_task_message.as_deref(),
Some("inspect this repo")
);
assert_eq!(success, Some(true));
@@ -647,7 +737,12 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() {
let result: ListAgentsResult =
serde_json::from_str(&content).expect("list_agents result should be json");
assert!(result.agents.is_empty());
assert_eq!(result.agents.len(), 1);
assert_eq!(result.agents[0].agent_name, "/root");
assert_eq!(
result.agents[0].last_task_message.as_deref(),
Some("Main thread")
);
}
#[tokio::test]
@@ -2036,6 +2131,54 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() {
);
}
#[tokio::test]
async fn multi_agent_v2_close_agent_rejects_root_target_and_id() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = (*turn.config).clone();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
let root_path_error = CloseAgentHandlerV2
.handle(invocation(
session.clone(),
turn.clone(),
"close_agent",
function_payload(json!({"target": "/root"})),
))
.await
.expect_err("close_agent should reject the root path");
assert_eq!(
root_path_error,
FunctionCallError::RespondToModel("root is not a spawned agent".to_string())
);
let root_id_error = CloseAgentHandlerV2
.handle(invocation(
session,
turn,
"close_agent",
function_payload(json!({"target": root.thread_id.to_string()})),
))
.await
.expect_err("close_agent should reject the root thread id");
assert_eq!(
root_id_error,
FunctionCallError::RespondToModel("root is not a spawned agent".to_string())
);
}
#[tokio::test]
async fn close_agent_submits_shutdown_and_returns_previous_status() {
let (mut session, turn) = make_session_and_context().await;
@@ -30,6 +30,15 @@ impl ToolHandler for Handler {
.agent_control
.get_agent_metadata(agent_id)
.unwrap_or_default();
if receiver_agent
.agent_path
.as_ref()
.is_some_and(AgentPath::is_root)
{
return Err(FunctionCallError::RespondToModel(
"root is not a spawned agent".to_string(),
));
}
session
.send_event(
&turn,
@@ -603,7 +603,7 @@ impl UnifiedExecProcessManager {
let started = environment
.get_exec_backend()
.start(codex_exec_server::ExecParams {
process_id: exec_server_process_id(process_id),
process_id: exec_server_process_id(process_id).into(),
argv: env.command.clone(),
cwd: env.cwd.clone(),
env: env.env.clone(),
+9 -3
View File
@@ -792,7 +792,9 @@ impl TestCodexHarness {
}
pub async fn submit(&self, prompt: &str) -> Result<()> {
self.test.submit_turn(prompt).await
// Box the submit-and-wait path so callers do not inline the full turn
// future into their own async state.
Box::pin(self.test.submit_turn(prompt)).await
}
pub async fn submit_with_policy(
@@ -844,13 +846,17 @@ impl TestCodexHarness {
call_id: &str,
output_type: ApplyPatchModelOutput,
) -> String {
// Box the awaited output helpers so callers do not inline request
// capture and response parsing into their own async state.
match output_type {
ApplyPatchModelOutput::Freeform => self.custom_tool_call_output(call_id).await,
ApplyPatchModelOutput::Freeform => {
Box::pin(self.custom_tool_call_output(call_id)).await
}
ApplyPatchModelOutput::Function
| ApplyPatchModelOutput::Shell
| ApplyPatchModelOutput::ShellViaHeredoc
| ApplyPatchModelOutput::ShellCommandViaHeredoc => {
self.function_call_stdout(call_id).await
Box::pin(self.function_call_stdout(call_id)).await
}
}
}
+33 -32
View File
@@ -3,7 +3,6 @@
use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_test_macros::large_stack_test;
use core_test_support::responses::ev_apply_patch_call;
use core_test_support::responses::ev_apply_patch_custom_tool_call;
use core_test_support::responses::ev_shell_command_call;
@@ -53,7 +52,9 @@ async fn apply_patch_harness_with(
let builder = configure(test_codex()).with_config(|config| {
config.include_apply_patch_tool = true;
});
TestCodexHarness::with_builder(builder).await
// Box harness construction so apply_patch_cli tests do not inline the
// full test-thread startup path into each test future.
Box::pin(TestCodexHarness::with_builder(builder)).await
}
pub async fn mount_apply_patch(
@@ -90,7 +91,7 @@ fn apply_patch_responses(
}
#[cfg(target_os = "linux")]
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_cli_uses_codex_self_exe_with_linux_sandbox_helper_alias() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -136,7 +137,7 @@ async fn apply_patch_cli_uses_codex_self_exe_with_linux_sandbox_helper_alias() -
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -183,7 +184,7 @@ D delete.txt
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -210,7 +211,7 @@ async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) ->
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -239,7 +240,7 @@ async fn apply_patch_cli_moves_file_to_new_directory(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -267,7 +268,7 @@ async fn apply_patch_cli_updates_file_appends_trailing_newline(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -293,7 +294,7 @@ async fn apply_patch_cli_insert_only_hunk_modifies_file(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -324,7 +325,7 @@ async fn apply_patch_cli_move_overwrites_existing_destination(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -387,7 +388,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -413,7 +414,7 @@ async fn apply_patch_cli_add_overwrites_existing_file(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -445,7 +446,7 @@ async fn apply_patch_cli_rejects_invalid_hunk_header(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -479,7 +480,7 @@ async fn apply_patch_cli_reports_missing_context(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -515,7 +516,7 @@ async fn apply_patch_cli_reports_missing_target_file(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -552,7 +553,7 @@ async fn apply_patch_cli_delete_missing_file_reports_error(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -577,7 +578,7 @@ async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -604,7 +605,7 @@ async fn apply_patch_cli_delete_directory_reports_verification_error(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -658,7 +659,7 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -713,7 +714,7 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -750,7 +751,7 @@ async fn apply_patch_cli_verification_failure_has_no_side_effects(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -788,7 +789,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() ->
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -928,7 +929,7 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1013,7 +1014,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1091,7 +1092,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() ->
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_function_accepts_lenient_heredoc_wrapped_patch(
@@ -1114,7 +1115,7 @@ async fn apply_patch_function_accepts_lenient_heredoc_wrapped_patch(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -1137,7 +1138,7 @@ async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput)
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -1172,7 +1173,7 @@ async fn apply_patch_cli_missing_second_chunk_context_rejected(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -1233,7 +1234,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -1301,7 +1302,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change(
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1371,7 +1372,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()>
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1463,7 +1464,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result
Ok(())
}
#[large_stack_test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
+24 -18
View File
@@ -113,7 +113,7 @@ struct Inner {
// process on the connection. Keep a local process_id -> session registry so
// we can turn those connection-global notifications into process wakeups
// without making notifications the source of truth for output delivery.
sessions: ArcSwap<HashMap<String, Arc<SessionState>>>,
sessions: ArcSwap<HashMap<ProcessId, Arc<SessionState>>>,
// ArcSwap makes reads cheap on the hot notification path, but writes still
// need serialization so concurrent register/remove operations do not
// overwrite each other's copy-on-write updates.
@@ -225,7 +225,7 @@ impl ExecServerClient {
pub async fn write(
&self,
process_id: &str,
process_id: &ProcessId,
chunk: Vec<u8>,
) -> Result<WriteResponse, ExecServerError> {
self.inner
@@ -233,7 +233,7 @@ impl ExecServerClient {
.call(
EXEC_WRITE_METHOD,
&WriteParams {
process_id: process_id.to_string(),
process_id: process_id.clone(),
chunk: chunk.into(),
},
)
@@ -241,13 +241,16 @@ impl ExecServerClient {
.map_err(Into::into)
}
pub async fn terminate(&self, process_id: &str) -> Result<TerminateResponse, ExecServerError> {
pub async fn terminate(
&self,
process_id: &ProcessId,
) -> Result<TerminateResponse, ExecServerError> {
self.inner
.client
.call(
EXEC_TERMINATE_METHOD,
&TerminateParams {
process_id: process_id.to_string(),
process_id: process_id.clone(),
},
)
.await
@@ -330,7 +333,7 @@ impl ExecServerClient {
pub(crate) async fn register_session(
&self,
process_id: &str,
process_id: &ProcessId,
) -> Result<Session, ExecServerError> {
let state = Arc::new(SessionState::new());
self.inner
@@ -338,12 +341,12 @@ impl ExecServerClient {
.await?;
Ok(Session {
client: self.clone(),
process_id: process_id.to_string().into(),
process_id: process_id.clone(),
state,
})
}
pub(crate) async fn unregister_session(&self, process_id: &str) {
pub(crate) async fn unregister_session(&self, process_id: &ProcessId) {
self.inner.remove_session(process_id).await;
}
@@ -487,7 +490,7 @@ impl Session {
match self
.client
.read(ReadParams {
process_id: self.process_id.to_string(),
process_id: self.process_id.clone(),
after_seq,
max_bytes,
wait_ms,
@@ -519,13 +522,13 @@ impl Session {
}
impl Inner {
fn get_session(&self, process_id: &str) -> Option<Arc<SessionState>> {
fn get_session(&self, process_id: &ProcessId) -> Option<Arc<SessionState>> {
self.sessions.load().get(process_id).cloned()
}
async fn insert_session(
&self,
process_id: &str,
process_id: &ProcessId,
session: Arc<SessionState>,
) -> Result<(), ExecServerError> {
let _sessions_write_guard = self.sessions_write_lock.lock().await;
@@ -536,12 +539,12 @@ impl Inner {
)));
}
let mut next_sessions = sessions.as_ref().clone();
next_sessions.insert(process_id.to_string(), session);
next_sessions.insert(process_id.clone(), session);
self.sessions.store(Arc::new(next_sessions));
Ok(())
}
async fn remove_session(&self, process_id: &str) -> Option<Arc<SessionState>> {
async fn remove_session(&self, process_id: &ProcessId) -> Option<Arc<SessionState>> {
let _sessions_write_guard = self.sessions_write_lock.lock().await;
let sessions = self.sessions.load();
let session = sessions.get(process_id).cloned();
@@ -552,7 +555,7 @@ impl Inner {
session
}
async fn take_all_sessions(&self) -> HashMap<String, Arc<SessionState>> {
async fn take_all_sessions(&self) -> HashMap<ProcessId, Arc<SessionState>> {
let _sessions_write_guard = self.sessions_write_lock.lock().await;
let sessions = self.sessions.load();
let drained_sessions = sessions.as_ref().clone();
@@ -640,6 +643,7 @@ mod tests {
use super::ExecServerClient;
use super::ExecServerClientConnectOptions;
use crate::ProcessId;
use crate::connection::JsonRpcConnection;
use crate::protocol::EXEC_EXITED_METHOD;
use crate::protocol::EXEC_OUTPUT_DELTA_METHOD;
@@ -718,12 +722,14 @@ mod tests {
.await
.expect("client should connect");
let noisy_process_id = ProcessId::from("noisy");
let quiet_process_id = ProcessId::from("quiet");
let _noisy_session = client
.register_session("noisy")
.register_session(&noisy_process_id)
.await
.expect("noisy session should register");
let quiet_session = client
.register_session("quiet")
.register_session(&quiet_process_id)
.await
.expect("quiet session should register");
let mut quiet_wake_rx = quiet_session.subscribe_wake();
@@ -734,7 +740,7 @@ mod tests {
method: EXEC_OUTPUT_DELTA_METHOD.to_string(),
params: Some(
serde_json::to_value(ExecOutputDeltaNotification {
process_id: "noisy".to_string(),
process_id: noisy_process_id.clone(),
seq,
stream: ExecOutputStream::Stdout,
chunk: b"x".to_vec().into(),
@@ -751,7 +757,7 @@ mod tests {
method: EXEC_EXITED_METHOD.to_string(),
params: Some(
serde_json::to_value(ExecExitedNotification {
process_id: "quiet".to_string(),
process_id: quiet_process_id,
seq: 1,
exit_code: 17,
})
+2 -1
View File
@@ -159,6 +159,7 @@ mod tests {
use super::Environment;
use super::EnvironmentManager;
use crate::ProcessId;
use pretty_assertions::assert_eq;
#[tokio::test]
@@ -195,7 +196,7 @@ mod tests {
let response = environment
.get_exec_backend()
.start(crate::ExecParams {
process_id: "default-env-proc".to_string(),
process_id: ProcessId::from("default-env-proc"),
argv: vec!["true".to_string()],
cwd: std::env::current_dir().expect("read current dir"),
env: Default::default(),
+2 -1
View File
@@ -6,6 +6,7 @@ mod file_system;
mod local_file_system;
mod local_process;
mod process;
mod process_id;
mod protocol;
mod remote_file_system;
mod remote_process;
@@ -43,8 +44,8 @@ pub use file_system::ReadDirectoryEntry;
pub use file_system::RemoveOptions;
pub use process::ExecBackend;
pub use process::ExecProcess;
pub use process::ProcessId;
pub use process::StartedExecProcess;
pub use process_id::ProcessId;
pub use protocol::ExecClosedNotification;
pub use protocol::ExecExitedNotification;
pub use protocol::ExecOutputDeltaNotification;
+12 -12
View File
@@ -75,7 +75,7 @@ enum ProcessEntry {
struct Inner {
notifications: RpcNotificationSender,
processes: Mutex<HashMap<String, ProcessEntry>>,
processes: Mutex<HashMap<ProcessId, ProcessEntry>>,
initialize_requested: AtomicBool,
initialized: AtomicBool,
}
@@ -420,7 +420,7 @@ impl ExecBackend for LocalProcess {
.map_err(map_handler_error)?;
Ok(StartedExecProcess {
process: Arc::new(LocalExecProcess {
process_id: response.process_id.into(),
process_id: response.process_id,
backend: self.clone(),
wake_tx,
}),
@@ -461,13 +461,13 @@ impl ExecProcess for LocalExecProcess {
impl LocalProcess {
async fn read(
&self,
process_id: &str,
process_id: &ProcessId,
after_seq: Option<u64>,
max_bytes: Option<usize>,
wait_ms: Option<u64>,
) -> Result<ReadResponse, ExecServerError> {
self.exec_read(ReadParams {
process_id: process_id.to_string(),
process_id: process_id.clone(),
after_seq,
max_bytes,
wait_ms,
@@ -478,20 +478,20 @@ impl LocalProcess {
async fn write(
&self,
process_id: &str,
process_id: &ProcessId,
chunk: Vec<u8>,
) -> Result<WriteResponse, ExecServerError> {
self.exec_write(WriteParams {
process_id: process_id.to_string(),
process_id: process_id.clone(),
chunk: chunk.into(),
})
.await
.map_err(map_handler_error)
}
async fn terminate(&self, process_id: &str) -> Result<(), ExecServerError> {
async fn terminate(&self, process_id: &ProcessId) -> Result<(), ExecServerError> {
self.terminate_process(TerminateParams {
process_id: process_id.to_string(),
process_id: process_id.clone(),
})
.await
.map_err(map_handler_error)?;
@@ -507,7 +507,7 @@ fn map_handler_error(error: JSONRPCErrorError) -> ExecServerError {
}
async fn stream_output(
process_id: String,
process_id: ProcessId,
stream: ExecOutputStream,
mut receiver: tokio::sync::mpsc::Receiver<Vec<u8>>,
inner: Arc<Inner>,
@@ -560,7 +560,7 @@ async fn stream_output(
}
async fn watch_exit(
process_id: String,
process_id: ProcessId,
exit_rx: tokio::sync::oneshot::Receiver<i32>,
inner: Arc<Inner>,
output_notify: Arc<Notify>,
@@ -605,7 +605,7 @@ async fn watch_exit(
}
}
async fn finish_output_stream(process_id: String, inner: Arc<Inner>) {
async fn finish_output_stream(process_id: ProcessId, inner: Arc<Inner>) {
{
let mut processes = inner.processes.lock().await;
let Some(ProcessEntry::Running(process)) = processes.get_mut(&process_id) else {
@@ -620,7 +620,7 @@ async fn finish_output_stream(process_id: String, inner: Arc<Inner>) {
maybe_emit_closed(process_id, inner).await;
}
async fn maybe_emit_closed(process_id: String, inner: Arc<Inner>) {
async fn maybe_emit_closed(process_id: ProcessId, inner: Arc<Inner>) {
let notification = {
let mut processes = inner.processes.lock().await;
let Some(ProcessEntry::Running(process)) = processes.get_mut(&process_id) else {
+1 -41
View File
@@ -1,58 +1,18 @@
use std::fmt;
use std::ops::Deref;
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::watch;
use crate::ExecServerError;
use crate::ProcessId;
use crate::protocol::ExecParams;
use crate::protocol::ReadResponse;
use crate::protocol::WriteResponse;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProcessId(String);
pub struct StartedExecProcess {
pub process: Arc<dyn ExecProcess>,
}
impl ProcessId {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl Deref for ProcessId {
type Target = str;
fn deref(&self) -> &Self::Target {
self.as_str()
}
}
impl AsRef<str> for ProcessId {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for ProcessId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl From<String> for ProcessId {
fn from(value: String) -> Self {
Self(value)
}
}
#[async_trait]
pub trait ExecProcess: Send + Sync {
fn process_id(&self) -> &ProcessId;
+74
View File
@@ -0,0 +1,74 @@
use std::borrow::Borrow;
use std::fmt;
use std::ops::Deref;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ProcessId(String);
impl ProcessId {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl Deref for ProcessId {
type Target = str;
fn deref(&self) -> &Self::Target {
self.as_str()
}
}
impl Borrow<str> for ProcessId {
fn borrow(&self) -> &str {
self.as_str()
}
}
impl AsRef<str> for ProcessId {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for ProcessId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl From<String> for ProcessId {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for ProcessId {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
impl From<&String> for ProcessId {
fn from(value: &String) -> Self {
Self(value.clone())
}
}
impl From<ProcessId> for String {
fn from(value: ProcessId) -> Self {
value.0
}
}
+10 -8
View File
@@ -5,6 +5,8 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use serde::Deserialize;
use serde::Serialize;
use crate::ProcessId;
pub const INITIALIZE_METHOD: &str = "initialize";
pub const INITIALIZED_METHOD: &str = "initialized";
pub const EXEC_METHOD: &str = "process/start";
@@ -53,7 +55,7 @@ pub struct InitializeResponse {}
pub struct ExecParams {
/// Client-chosen logical process handle scoped to this connection/session.
/// This is a protocol key, not an OS pid.
pub process_id: String,
pub process_id: ProcessId,
pub argv: Vec<String>,
pub cwd: PathBuf,
pub env: HashMap<String, String>,
@@ -64,13 +66,13 @@ pub struct ExecParams {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecResponse {
pub process_id: String,
pub process_id: ProcessId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadParams {
pub process_id: String,
pub process_id: ProcessId,
pub after_seq: Option<u64>,
pub max_bytes: Option<usize>,
pub wait_ms: Option<u64>,
@@ -98,7 +100,7 @@ pub struct ReadResponse {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteParams {
pub process_id: String,
pub process_id: ProcessId,
pub chunk: ByteChunk,
}
@@ -120,7 +122,7 @@ pub struct WriteResponse {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TerminateParams {
pub process_id: String,
pub process_id: ProcessId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -140,7 +142,7 @@ pub enum ExecOutputStream {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecOutputDeltaNotification {
pub process_id: String,
pub process_id: ProcessId,
pub seq: u64,
pub stream: ExecOutputStream,
pub chunk: ByteChunk,
@@ -149,7 +151,7 @@ pub struct ExecOutputDeltaNotification {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecExitedNotification {
pub process_id: String,
pub process_id: ProcessId,
pub seq: u64,
pub exit_code: i32,
}
@@ -157,7 +159,7 @@ pub struct ExecExitedNotification {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecClosedNotification {
pub process_id: String,
pub process_id: ProcessId,
pub seq: u64,
}
@@ -6,6 +6,7 @@ use pretty_assertions::assert_eq;
use tokio::sync::mpsc;
use super::ExecServerHandler;
use crate::ProcessId;
use crate::protocol::ExecParams;
use crate::protocol::InitializeResponse;
use crate::protocol::TerminateParams;
@@ -18,7 +19,7 @@ fn exec_params(process_id: &str) -> ExecParams {
env.insert("PATH".to_string(), path.to_string_lossy().into_owned());
}
ExecParams {
process_id: process_id.to_string(),
process_id: ProcessId::from(process_id),
argv: vec![
"bash".to_string(),
"-lc".to_string(),
@@ -84,7 +85,7 @@ async fn terminate_reports_false_after_process_exit() {
loop {
let response = handler
.terminate(TerminateParams {
process_id: "proc-1".to_string(),
process_id: ProcessId::from("proc-1"),
})
.await
.expect("terminate response");
+6 -5
View File
@@ -9,6 +9,7 @@ use codex_exec_server::Environment;
use codex_exec_server::ExecBackend;
use codex_exec_server::ExecParams;
use codex_exec_server::ExecProcess;
use codex_exec_server::ProcessId;
use codex_exec_server::ReadResponse;
use codex_exec_server::StartedExecProcess;
use pretty_assertions::assert_eq;
@@ -47,7 +48,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
let session = context
.backend
.start(ExecParams {
process_id: "proc-1".to_string(),
process_id: ProcessId::from("proc-1"),
argv: vec!["true".to_string()],
cwd: std::env::current_dir()?,
env: Default::default(),
@@ -119,7 +120,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
let session = context
.backend
.start(ExecParams {
process_id: process_id.clone(),
process_id: process_id.clone().into(),
argv: vec![
"/bin/sh".to_string(),
"-c".to_string(),
@@ -148,7 +149,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> {
let session = context
.backend
.start(ExecParams {
process_id: process_id.clone(),
process_id: process_id.clone().into(),
argv: vec![
"/usr/bin/python3".to_string(),
"-c".to_string(),
@@ -184,7 +185,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe(
let session = context
.backend
.start(ExecParams {
process_id: "proc-queued".to_string(),
process_id: ProcessId::from("proc-queued"),
argv: vec![
"/bin/sh".to_string(),
"-c".to_string(),
@@ -214,7 +215,7 @@ async fn remote_exec_process_reports_transport_disconnect() -> Result<()> {
let session = context
.backend
.start(ExecParams {
process_id: "proc-disconnect".to_string(),
process_id: ProcessId::from("proc-disconnect"),
argv: vec![
"/bin/sh".to_string(),
"-c".to_string(),
+2 -1
View File
@@ -6,6 +6,7 @@ use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_exec_server::ExecResponse;
use codex_exec_server::InitializeParams;
use codex_exec_server::ProcessId;
use common::exec_server::exec_server;
use pretty_assertions::assert_eq;
@@ -62,7 +63,7 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> {
assert_eq!(
process_start_response,
ExecResponse {
process_id: "proc-1".to_string()
process_id: ProcessId::from("proc-1")
}
);
+2 -6
View File
@@ -822,12 +822,8 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::TuiAppServer,
key: "tui_app_server",
stage: Stage::Experimental {
name: "App-server TUI",
menu_description: "Use the app-server-backed TUI implementation.",
announcement: "",
},
default_enabled: false,
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::PreventIdleSleep,
+10
View File
@@ -367,6 +367,16 @@ mod tests {
assert_eq!(false, set.is_match("example.com"));
}
#[test]
fn compile_globset_supports_mid_label_wildcards() {
let set = compile_denylist_globset(&["region*.v2.argotunnel.com".to_string()]).unwrap();
assert_eq!(true, set.is_match("region1.v2.argotunnel.com"));
assert_eq!(true, set.is_match("region.v2.argotunnel.com"));
assert_eq!(false, set.is_match("xregion1.v2.argotunnel.com"));
assert_eq!(false, set.is_match("foo.region1.v2.argotunnel.com"));
}
#[test]
fn compile_globset_normalizes_apex_and_subdomains() {
let set = compile_denylist_globset(&["**.Example.COM.".to_string()]).unwrap();
@@ -53,6 +53,29 @@ fn is_git_global_option_with_inline_value(arg: &str) -> bool {
) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2)
}
/// Git global options that can redirect config, repository, or helper lookup
/// and therefore must never be auto-approved as "safe".
pub(crate) fn git_global_option_requires_prompt(arg: &str) -> bool {
matches!(
arg,
"-c" | "--config-env"
| "--exec-path"
| "--git-dir"
| "--namespace"
| "--super-prefix"
| "--work-tree"
) || matches!(
arg,
s if (s.starts_with("-c") && s.len() > 2)
|| s.starts_with("--config-env=")
|| s.starts_with("--exec-path=")
|| s.starts_with("--git-dir=")
|| s.starts_with("--namespace=")
|| s.starts_with("--super-prefix=")
|| s.starts_with("--work-tree=")
)
}
pub(crate) fn executable_name_lookup_key(raw: &str) -> Option<String> {
#[cfg(windows)]
{
@@ -4,6 +4,7 @@ use crate::command_safety::is_dangerous_command::executable_name_lookup_key;
// may appear before it (e.g., `-C`, `-c`, `--git-dir`).
// Implemented in `is_dangerous_command` and shared here.
use crate::command_safety::is_dangerous_command::find_git_subcommand;
use crate::command_safety::is_dangerous_command::git_global_option_requires_prompt;
use crate::command_safety::windows_safe_commands::is_safe_command_windows;
pub fn is_known_safe_command(command: &[String]) -> bool {
@@ -134,10 +135,10 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool {
// Git
Some("git") => {
// Global config overrides like `-c core.pager=...` can force git
// to execute arbitrary external commands. With no sandboxing, we
// should always prompt in those cases.
if git_has_config_override_global_option(command) {
// Global options that redirect config, repository, or helper
// lookup can make otherwise read-only git commands execute
// attacker-controlled code, so they must never be auto-approved.
if git_has_unsafe_global_option(command) {
return false;
}
@@ -208,12 +209,12 @@ fn git_branch_is_read_only(branch_args: &[String]) -> bool {
saw_read_only_flag
}
fn git_has_config_override_global_option(command: &[String]) -> bool {
command.iter().map(String::as_str).any(|arg| {
matches!(arg, "-c" | "--config-env")
|| (arg.starts_with("-c") && arg.len() > 2)
|| arg.starts_with("--config-env=")
})
fn git_has_unsafe_global_option(command: &[String]) -> bool {
command
.iter()
.skip(1)
.map(String::as_str)
.any(git_global_option_requires_prompt)
}
fn git_subcommand_args_are_read_only(args: &[String]) -> bool {
@@ -356,7 +357,7 @@ mod tests {
}
#[test]
fn git_output_and_config_override_flags_are_not_safe() {
fn git_output_flags_are_not_safe() {
assert!(!is_known_safe_command(&vec_str(&[
"git",
"log",
@@ -376,6 +377,10 @@ mod tests {
"--output=/tmp/git-show-out-test",
"HEAD",
])));
}
#[test]
fn git_global_override_flags_are_not_safe() {
assert!(!is_known_safe_command(&vec_str(&[
"git",
"-c",
@@ -389,6 +394,32 @@ mod tests {
"-ccore.pager=cat",
"status",
])));
for args in [
vec_str(&["git", "--config-env", "core.pager=PAGER", "show", "HEAD"]),
vec_str(&["git", "--config-env=core.pager=PAGER", "show", "HEAD"]),
vec_str(&["git", "--git-dir", ".evil-git", "diff", "HEAD~1..HEAD"]),
vec_str(&["git", "--git-dir=.evil-git", "diff", "HEAD~1..HEAD"]),
vec_str(&["git", "--work-tree", ".", "status"]),
vec_str(&["git", "--work-tree=.", "status"]),
vec_str(&["git", "--exec-path", ".git/helpers", "show", "HEAD"]),
vec_str(&["git", "--exec-path=.git/helpers", "show", "HEAD"]),
vec_str(&["git", "--namespace", "attacker", "show", "HEAD"]),
vec_str(&["git", "--namespace=attacker", "show", "HEAD"]),
vec_str(&["git", "--super-prefix", "attacker/", "show", "HEAD"]),
vec_str(&["git", "--super-prefix=attacker/", "show", "HEAD"]),
] {
assert!(
!is_known_safe_command(&args),
"expected {args:?} to require approval due to unsafe git global option",
);
}
assert!(!is_known_safe_command(&vec_str(&[
"bash",
"-lc",
"git --git-dir=.evil-git diff HEAD~1..HEAD",
])));
}
#[test]
@@ -1,3 +1,4 @@
use crate::command_safety::is_dangerous_command::git_global_option_requires_prompt;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use serde::Deserialize;
@@ -305,33 +306,17 @@ fn is_safe_ripgrep(words: &[String]) -> bool {
fn is_safe_git_command(words: &[String]) -> bool {
const SAFE_SUBCOMMANDS: &[&str] = &["status", "log", "show", "diff", "cat-file"];
let mut iter = words.iter().skip(1);
while let Some(arg) = iter.next() {
for arg in words.iter().skip(1) {
let arg_lc = arg.to_ascii_lowercase();
if arg.starts_with('-') {
if arg.eq_ignore_ascii_case("-c") || arg.eq_ignore_ascii_case("--config") {
if iter.next().is_none() {
// Examples rejected here: "pwsh -Command 'git -c'" and "pwsh -Command 'git --config'".
return false;
}
continue;
}
if arg_lc.starts_with("-c=")
if git_global_option_requires_prompt(&arg_lc)
|| arg.eq_ignore_ascii_case("--config")
|| arg_lc.starts_with("--config=")
|| arg_lc.starts_with("--git-dir=")
|| arg_lc.starts_with("--work-tree=")
{
continue;
}
if arg.eq_ignore_ascii_case("--git-dir") || arg.eq_ignore_ascii_case("--work-tree") {
if iter.next().is_none() {
// Examples rejected here: "pwsh -Command 'git --git-dir'" and "pwsh -Command 'git --work-tree'".
return false;
}
continue;
// Examples rejected here: "pwsh -Command 'git --git-dir=.evil-git diff'" and
// "pwsh -Command 'git -c core.pager=cat show HEAD:foo.rs'".
return false;
}
continue;
@@ -435,14 +420,6 @@ mod tests {
"Get-Content foo.rs | Select-Object -Skip 200".to_string()
]));
assert!(is_safe_command_windows(&[
pwsh.clone(),
"-NoLogo".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"git -c core.pager=cat show HEAD:foo.rs".to_string()
]));
assert!(is_safe_command_windows(&[
pwsh.clone(),
"-Command".to_string(),
@@ -462,6 +439,41 @@ mod tests {
]));
}
#[test]
fn rejects_git_global_override_options() {
let Some(pwsh) = try_find_pwsh_executable_blocking() else {
return;
};
let pwsh: String = pwsh.as_path().to_str().unwrap().into();
for script in [
"git -c core.pager=cat show HEAD:foo.rs",
"git --config-env core.pager=PAGER show HEAD:foo.rs",
"git --config-env=core.pager=PAGER show HEAD:foo.rs",
"git --git-dir .evil-git diff HEAD~1..HEAD",
"git --git-dir=.evil-git diff HEAD~1..HEAD",
"git --work-tree . status",
"git --work-tree=. status",
"git --exec-path .git/helpers show HEAD:foo.rs",
"git --exec-path=.git/helpers show HEAD:foo.rs",
"git --namespace attacker show HEAD:foo.rs",
"git --namespace=attacker show HEAD:foo.rs",
"git --super-prefix attacker/ show HEAD:foo.rs",
"git --super-prefix=attacker/ show HEAD:foo.rs",
] {
assert!(
!is_safe_command_windows(&[
pwsh.clone(),
"-NoLogo".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
script.to_string(),
]),
"expected {script:?} to require approval due to unsafe git global option",
);
}
}
#[test]
fn rejects_powershell_commands_with_side_effects() {
assert!(!is_safe_command_windows(&vec_str(&[
-7
View File
@@ -1,7 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "test-macros",
crate_name = "codex_test_macros",
proc_macro = True,
)
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "codex-test-macros"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
[lints]
workspace = true
-155
View File
@@ -1,155 +0,0 @@
use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::Attribute;
use syn::ItemFn;
use syn::parse::Nothing;
use syn::parse_macro_input;
use syn::parse_quote;
const LARGE_STACK_TEST_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024;
/// Run a test body on a dedicated thread with a larger stack.
///
/// For async tests, this macro creates a Tokio multi-thread runtime with two
/// worker threads and blocks on the original async body inside the large-stack
/// thread.
#[proc_macro_attribute]
pub fn large_stack_test(attr: TokenStream, item: TokenStream) -> TokenStream {
parse_macro_input!(attr as Nothing);
let item = parse_macro_input!(item as ItemFn);
expand_large_stack_test(item).into()
}
fn expand_large_stack_test(mut item: ItemFn) -> TokenStream2 {
let attrs = filtered_attributes(&item.attrs);
item.attrs = attrs;
let is_async = item.sig.asyncness.take().is_some();
let name = &item.sig.ident;
let body = &item.block;
let thread_body = if is_async {
quote! {
{
let runtime = ::tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap_or_else(|error| {
panic!("failed to build tokio runtime for large-stack test: {error}")
});
runtime.block_on(async move #body)
}
}
} else {
quote! { #body }
};
*item.block = parse_quote!({
let handle = ::std::thread::Builder::new()
.name(::std::string::String::from(::std::stringify!(#name)))
.stack_size(#LARGE_STACK_TEST_STACK_SIZE_BYTES)
.spawn(move || #thread_body)
.unwrap_or_else(|error| {
panic!("failed to spawn large-stack test thread: {error}")
});
match handle.join() {
Ok(result) => result,
Err(payload) => ::std::panic::resume_unwind(payload),
}
});
quote! { #item }
}
fn filtered_attributes(attrs: &[Attribute]) -> Vec<Attribute> {
let mut filtered = Vec::with_capacity(attrs.len() + 1);
let mut has_test_attr = false;
for attr in attrs {
if is_tokio_test_attr(attr) {
continue;
}
if is_test_attr(attr) || is_test_case_attr(attr) {
has_test_attr = true;
}
filtered.push(attr.clone());
}
if !has_test_attr {
filtered.push(parse_quote!(#[test]));
}
filtered
}
fn is_test_attr(attr: &Attribute) -> bool {
attr.path().is_ident("test")
}
fn is_test_case_attr(attr: &Attribute) -> bool {
attr.path().is_ident("test_case")
}
fn is_tokio_test_attr(attr: &Attribute) -> bool {
let mut segments = attr.path().segments.iter();
matches!(
(segments.next(), segments.next(), segments.next()),
(Some(first), Some(second), None) if first.ident == "tokio" && second.ident == "test"
)
}
#[cfg(test)]
mod tests {
use super::expand_large_stack_test;
use syn::ItemFn;
use syn::parse_quote;
fn has_attr(item: &ItemFn, name: &str) -> bool {
item.attrs.iter().any(|attr| attr.path().is_ident(name))
}
#[test]
fn adds_test_attribute_when_missing() {
let item: ItemFn = parse_quote! {
fn sample() {}
};
let expanded_tokens = expand_large_stack_test(item);
let expanded: ItemFn = match syn::parse2(expanded_tokens) {
Ok(expanded) => expanded,
Err(error) => panic!("failed to parse expanded function: {error}"),
};
assert!(has_attr(&expanded, "test"));
let body = quote::quote!(#expanded).to_string();
assert!(body.contains("stack_size"));
}
#[test]
fn removes_tokio_test_and_keeps_test_case() {
let item: ItemFn = parse_quote! {
#[test_case(1)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sample(value: usize) -> anyhow::Result<()> {
let _ = value;
Ok(())
}
};
let expanded_tokens = expand_large_stack_test(item);
let expanded: ItemFn = match syn::parse2(expanded_tokens) {
Ok(expanded) => expanded,
Err(error) => panic!("failed to parse expanded function: {error}"),
};
assert!(has_attr(&expanded, "test_case"));
assert!(!has_attr(&expanded, "test"));
let body = quote::quote!(#expanded).to_string();
assert!(body.contains("tokio :: runtime :: Builder"));
assert!(!body.contains("tokio :: test"));
}
}
@@ -112,6 +112,8 @@ trust_level = "trusted"
"resume".to_string(),
"--last".to_string(),
"--no-alt-screen".to_string(),
"--disable".to_string(),
"tui_app_server".to_string(),
"-C".to_string(),
repo_root.display().to_string(),
"-c".to_string(),
+58
View File
@@ -948,6 +948,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,
@@ -1053,6 +1055,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(),
session_telemetry: self.session_telemetry.clone(),
}
}
@@ -2728,6 +2731,14 @@ impl App {
/// This helper copies every known nickname/role from `AgentNavigationState` into the
/// replacement widget so that replayed collab items render agent names immediately.
fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) {
// Transfer the last-written terminal title to the replacement widget
// so it knows what OSC title is currently displayed. Without this, the
// new widget would redundantly clear and rewrite the same title, causing
// a visible flicker in some terminals.
let previous_terminal_title = self.chat_widget.last_terminal_title.take();
if chat_widget.last_terminal_title.is_none() {
chat_widget.last_terminal_title = previous_terminal_title;
}
for (thread_id, entry) in self.agent_navigation.ordered_threads() {
chat_widget.set_collab_agent_metadata(
thread_id,
@@ -3195,6 +3206,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 =
@@ -3226,6 +3238,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(),
session_telemetry: session_telemetry.clone(),
};
(ChatWidget::new_with_app_event(init), Some(started))
@@ -3259,6 +3273,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(),
session_telemetry: session_telemetry.clone(),
};
(ChatWidget::new_with_app_event(init), Some(resumed))
@@ -3297,6 +3313,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(),
session_telemetry: session_telemetry.clone(),
};
(ChatWidget::new_with_app_event(init), Some(forked))
@@ -3332,6 +3350,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(),
@@ -5007,6 +5026,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)
@@ -5649,6 +5695,14 @@ fn mcp_inventory_maps_from_statuses(statuses: Vec<McpServerStatus>) -> McpInvent
(tools, resources, resource_templates, auth_statuses)
}
impl Drop for App {
fn drop(&mut self) {
if let Err(err) = self.chat_widget.clear_managed_terminal_title() {
tracing::debug!(error = %err, "failed to clear terminal title on app drop");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -5978,6 +6032,7 @@ mod tests {
model: Some(model),
startup_tooltip_override: None,
status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(),
session_telemetry: app.session_telemetry.clone(),
});
@@ -8122,6 +8177,7 @@ guardian_approval = true
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(),
@@ -8175,6 +8231,7 @@ guardian_approval = true
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(),
@@ -9306,6 +9363,7 @@ guardian_approval = true
model: Some(app.chat_widget.current_model().to_string()),
startup_tooltip_override: None,
status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(),
session_telemetry: app.session_telemetry.clone(),
});
app.replace_chat_widget(replacement);
+12
View File
@@ -28,6 +28,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::config::types::ApprovalsReviewer;
@@ -547,6 +548,17 @@ 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 {
name: String,
@@ -1,3 +1,5 @@
use crate::bottom_pane::FeedbackAudience;
use crate::status::StatusAccountDisplay;
use codex_app_server_client::AppServerClient;
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
@@ -84,10 +86,6 @@ use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::debug;
use crate::bottom_pane::FeedbackAudience;
use crate::status::StatusAccountDisplay;
pub(crate) struct AppServerBootstrap {
pub(crate) account_auth_mode: Option<AuthMode>,
@@ -258,8 +256,8 @@ impl AppServerSession {
.await
{
Ok(rate_limits) => app_server_rate_limit_snapshots_to_core(rate_limits),
Err(error) => {
debug!(error = ?error, "failed to fetch rate limits during TUI bootstrap");
Err(err) => {
tracing::warn!("account/rateLimits/read failed during TUI bootstrap: {err}");
Vec::new()
}
}
@@ -47,6 +47,7 @@ mod mcp_server_elicitation;
mod multi_select_picker;
mod request_user_input;
mod status_line_setup;
mod title_setup;
pub(crate) use app_link_view::AppLinkElicitationTarget;
pub(crate) use app_link_view::AppLinkSuggestionType;
pub(crate) use app_link_view::AppLinkView;
@@ -100,6 +101,8 @@ pub(crate) use skills_toggle_view::SkillsToggleView;
pub(crate) use status_line_setup::StatusLineItem;
pub(crate) use status_line_setup::StatusLinePreviewData;
pub(crate) use status_line_setup::StatusLineSetupView;
pub(crate) use title_setup::TerminalTitleItem;
pub(crate) use title_setup::TerminalTitleSetupView;
mod paste_burst;
mod pending_input_preview;
mod pending_thread_approvals;
@@ -0,0 +1,21 @@
---
source: tui_app_server/src/bottom_pane/title_setup.rs
expression: "render_lines(&view, 84)"
---
Configure Terminal Title
Select which items to display in the terminal title.
Type to search
>
› [x] project Project name (falls back to current directory name)
[x] spinner Animated task spinner (omitted while idle or when animations…
[x] status Compact session status text (Ready, Working, Thinking)
[x] thread Current thread title (omitted until available)
[ ] app-name Codex app name
[ ] git-branch Current Git branch (omitted when unavailable)
[ ] model Current model name
[ ] task-progress Latest task progress from update_plan (omitted until availab…
my-project ⠋ Working | Investigate flaky test
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
@@ -0,0 +1,319 @@
//! Terminal title configuration view for customizing the terminal window/tab title.
//!
//! This module provides an interactive picker for selecting which items appear
//! in the terminal title. Users can:
//!
//! - Select items
//! - Reorder items
//! - Preview the rendered title
use itertools::Itertools;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use strum::IntoEnumIterator;
use strum_macros::Display;
use strum_macros::EnumIter;
use strum_macros::EnumString;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::multi_select_picker::MultiSelectItem;
use crate::bottom_pane::multi_select_picker::MultiSelectPicker;
use crate::render::renderable::Renderable;
/// Available items that can be displayed in the terminal title.
///
/// Variants serialize to kebab-case identifiers (e.g. `AppName` -> `"app-name"`)
/// via strum. These identifiers are persisted in user config files, so renaming
/// or removing a variant is a breaking config change.
#[derive(EnumIter, EnumString, Display, Debug, Clone, Copy, Eq, PartialEq, Hash)]
#[strum(serialize_all = "kebab-case")]
pub(crate) enum TerminalTitleItem {
/// Codex app name.
AppName,
/// Project root name, or a compact cwd fallback.
Project,
/// Animated task spinner while active.
Spinner,
/// Compact runtime status text.
Status,
/// Current thread title (if available).
Thread,
/// Current git branch (if available).
GitBranch,
/// Current model name.
Model,
/// Latest checklist task progress from `update_plan` (if available).
TaskProgress,
}
impl TerminalTitleItem {
pub(crate) fn description(self) -> &'static str {
match self {
TerminalTitleItem::AppName => "Codex app name",
TerminalTitleItem::Project => "Project name (falls back to current directory name)",
TerminalTitleItem::Spinner => {
"Animated task spinner (omitted while idle or when animations are off)"
}
TerminalTitleItem::Status => "Compact session status text (Ready, Working, Thinking)",
TerminalTitleItem::Thread => "Current thread title (omitted until available)",
TerminalTitleItem::GitBranch => "Current Git branch (omitted when unavailable)",
TerminalTitleItem::Model => "Current model name",
TerminalTitleItem::TaskProgress => {
"Latest task progress from update_plan (omitted until available)"
}
}
}
/// Example text used when previewing the title picker.
///
/// These are illustrative sample values, not live data from the current
/// session.
pub(crate) fn preview_example(self) -> &'static str {
match self {
TerminalTitleItem::AppName => "codex",
TerminalTitleItem::Project => "my-project",
TerminalTitleItem::Spinner => "⠋",
TerminalTitleItem::Status => "Working",
TerminalTitleItem::Thread => "Investigate flaky test",
TerminalTitleItem::GitBranch => "feat/awesome-feature",
TerminalTitleItem::Model => "gpt-5.2-codex",
TerminalTitleItem::TaskProgress => "Tasks 2/5",
}
}
/// Returns the separator to place before this item in a rendered title.
///
/// The spinner gets a plain space on either side so it reads as
/// `my-project <spinner> Working` rather than `my-project | <spinner> | Working`.
/// All other adjacent items are joined with ` | `.
pub(crate) fn separator_from_previous(self, previous: Option<Self>) -> &'static str {
match previous {
None => "",
Some(previous)
if previous == TerminalTitleItem::Spinner || self == TerminalTitleItem::Spinner =>
{
" "
}
Some(_) => " | ",
}
}
}
fn parse_terminal_title_items<T>(ids: impl Iterator<Item = T>) -> Option<Vec<TerminalTitleItem>>
where
T: AsRef<str>,
{
// Treat parsing as all-or-nothing so preview/confirm callbacks never emit
// a partially interpreted ordering. Invalid ids are ignored when building
// the picker, but once the user is interacting with the picker we only want
// to persist or preview a fully valid selection.
ids.map(|id| id.as_ref().parse::<TerminalTitleItem>())
.collect::<Result<Vec<_>, _>>()
.ok()
}
/// Interactive view for configuring terminal-title items.
pub(crate) struct TerminalTitleSetupView {
picker: MultiSelectPicker,
}
impl TerminalTitleSetupView {
/// Creates the terminal-title picker, preserving the configured item order first.
///
/// Unknown configured ids are skipped here instead of surfaced inline. The
/// main TUI still warns about them when rendering the actual title, but the
/// picker itself only exposes the selectable items it can meaningfully
/// preview and persist.
pub(crate) fn new(title_items: Option<&[String]>, app_event_tx: AppEventSender) -> Self {
let selected_items = title_items
.into_iter()
.flatten()
.filter_map(|id| id.parse::<TerminalTitleItem>().ok())
.unique()
.collect_vec();
let selected_set = selected_items
.iter()
.copied()
.collect::<std::collections::HashSet<_>>();
let items = selected_items
.into_iter()
.map(|item| Self::title_select_item(item, /*enabled*/ true))
.chain(
TerminalTitleItem::iter()
.filter(|item| !selected_set.contains(item))
.map(|item| Self::title_select_item(item, /*enabled*/ false)),
)
.collect();
Self {
picker: MultiSelectPicker::builder(
"Configure Terminal Title".to_string(),
Some("Select which items to display in the terminal title.".to_string()),
app_event_tx,
)
.instructions(vec![
"Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel."
.into(),
])
.items(items)
.enable_ordering()
.on_preview(|items| {
let items = parse_terminal_title_items(
items
.iter()
.filter(|item| item.enabled)
.map(|item| item.id.as_str()),
)?;
let mut preview = String::new();
let mut previous = None;
for item in items.iter().copied() {
preview.push_str(item.separator_from_previous(previous));
preview.push_str(item.preview_example());
previous = Some(item);
}
if preview.is_empty() {
None
} else {
Some(Line::from(preview))
}
})
.on_change(|items, app_event| {
let Some(items) = parse_terminal_title_items(
items
.iter()
.filter(|item| item.enabled)
.map(|item| item.id.as_str()),
) else {
return;
};
app_event.send(AppEvent::TerminalTitleSetupPreview { items });
})
.on_confirm(|ids, app_event| {
let Some(items) = parse_terminal_title_items(ids.iter().map(String::as_str)) else {
return;
};
app_event.send(AppEvent::TerminalTitleSetup { items });
})
.on_cancel(|app_event| {
app_event.send(AppEvent::TerminalTitleSetupCancelled);
})
.build(),
}
}
fn title_select_item(item: TerminalTitleItem, enabled: bool) -> MultiSelectItem {
MultiSelectItem {
id: item.to_string(),
name: item.to_string(),
description: Some(item.description().to_string()),
enabled,
}
}
}
impl BottomPaneView for TerminalTitleSetupView {
fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) {
self.picker.handle_key_event(key_event);
}
fn is_complete(&self) -> bool {
self.picker.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.picker.close();
CancellationEvent::Handled
}
}
impl Renderable for TerminalTitleSetupView {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.picker.render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.picker.desired_height(width)
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
fn render_lines(view: &TerminalTitleSetupView, width: u16) -> String {
let height = view.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let lines: Vec<String> = (0..area.height)
.map(|row| {
let mut line = String::new();
for col in 0..area.width {
let symbol = buf[(area.x + col, area.y + row)].symbol();
if symbol.is_empty() {
line.push(' ');
} else {
line.push_str(symbol);
}
}
line
})
.collect();
lines.join("\n")
}
#[test]
fn renders_title_setup_popup() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let selected = [
"project".to_string(),
"spinner".to_string(),
"status".to_string(),
"thread".to_string(),
];
let view = TerminalTitleSetupView::new(Some(&selected), tx);
assert_snapshot!("terminal_title_setup_basic", render_lines(&view, 84));
}
#[test]
fn parse_terminal_title_items_preserves_order() {
let items =
parse_terminal_title_items(["project", "spinner", "status", "thread"].into_iter());
assert_eq!(
items,
Some(vec![
TerminalTitleItem::Project,
TerminalTitleItem::Spinner,
TerminalTitleItem::Status,
TerminalTitleItem::Thread,
])
);
}
#[test]
fn parse_terminal_title_items_rejects_invalid_ids() {
let items = parse_terminal_title_items(["project", "not-a-title-item"].into_iter());
assert_eq!(items, None);
}
#[test]
fn parse_terminal_title_items_accepts_kebab_case_variants() {
let items = parse_terminal_title_items(["app-name", "git-branch"].into_iter());
assert_eq!(
items,
Some(vec![
TerminalTitleItem::AppName,
TerminalTitleItem::GitBranch,
])
);
}
}
+177 -282
View File
@@ -48,6 +48,8 @@ use crate::audio_device::list_realtime_audio_device_names;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::StatusLinePreviewData;
use crate::bottom_pane::StatusLineSetupView;
use crate::bottom_pane::TerminalTitleItem;
use crate::bottom_pane::TerminalTitleSetupView;
use crate::mention_codec::LinkedMention;
use crate::mention_codec::encode_history_mentions;
use crate::model_catalog::ModelCatalog;
@@ -57,6 +59,9 @@ use crate::status::StatusAccountDisplay;
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::AppSummary;
@@ -350,6 +355,9 @@ use self::plugins::PluginsCacheState;
mod realtime;
use self::realtime::RealtimeConversationUiState;
use self::realtime::RenderedUserMessageEvent;
mod status_surfaces;
use self::status_surfaces::CachedProjectRootName;
use self::status_surfaces::TerminalTitleStatusKind;
use crate::streaming::chunking::AdaptiveChunkingPolicy;
use crate::streaming::commit_tick::CommitTickScope;
use crate::streaming::commit_tick::run_commit_tick;
@@ -361,12 +369,14 @@ 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;
use codex_utils_approval_presets::ApprovalPreset;
use codex_utils_approval_presets::builtin_approval_presets;
use strum::IntoEnumIterator;
use unicode_segmentation::UnicodeSegmentation;
const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally";
const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls";
@@ -547,6 +557,8 @@ pub(crate) struct ChatWidgetInit {
pub(crate) startup_tooltip_override: Option<String>,
// Shared latch so we only warn once about invalid status-line item IDs.
pub(crate) status_line_invalid_items_warned: Arc<AtomicBool>,
// Shared latch so we only warn once about invalid terminal-title item IDs.
pub(crate) terminal_title_invalid_items_warned: Arc<AtomicBool>,
pub(crate) session_telemetry: SessionTelemetry,
}
@@ -801,6 +813,8 @@ pub(crate) struct ChatWidget {
// Guardian review keeps its own pending set so it can derive a single
// footer summary from one or more in-flight review events.
pending_guardian_review_status: PendingGuardianReviewStatus,
// Semantic status used for terminal-title status rendering.
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.
@@ -850,7 +864,6 @@ pub(crate) struct ChatWidget {
quit_shortcut_key: Option<KeyBinding>,
// Simple review mode flag; used to adjust layout and banners.
is_review_mode: bool,
#[cfg(test)]
// Snapshot of token usage to restore after review mode exits.
pre_review_token_info: Option<Option<TokenUsageInfo>>,
// Whether the next streamed assistant content should be preceded by a final message separator.
@@ -870,6 +883,8 @@ pub(crate) struct ChatWidget {
// later steer. This is cleared when the user submits a steer so the plan popup only appears
// if a newer proposed plan arrives afterward.
saw_plan_item_this_turn: bool,
// Latest `update_plan` checklist task counts for terminal-title rendering.
last_plan_progress: Option<(usize, usize)>,
// Incremental buffer for streamed plan content.
plan_delta_buffer: String,
// True while a plan item is streaming.
@@ -893,6 +908,22 @@ 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.
pub(crate) last_terminal_title: Option<String>,
// Original terminal-title config captured when the setup UI opens.
//
// The outer `Option` tracks whether a setup session is active (`Some`)
// or not (`None`). The inner `Option<Vec<String>>` mirrors the shape
// of `config.tui_terminal_title` (which is `None` when using defaults).
// On cancel or persist-failure the inner value is restored to config;
// on confirm the outer is set to `None` to end the session.
terminal_title_setup_original_items: Option<Option<Vec<String>>>,
// Baseline instant used to animate spinner-prefixed title statuses.
terminal_title_animation_origin: Instant,
// Cached project-root display name keyed by cwd for status/title rendering.
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.
@@ -1585,12 +1616,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"));
}
}
@@ -1683,6 +1717,22 @@ impl ChatWidget {
StatusDetailsCapitalization::Preserve,
details_max_lines,
);
let title_uses_status = self
.config
.tui_terminal_title
.as_ref()
.is_some_and(|items| items.iter().any(|item| item == "status"));
let title_uses_spinner = self
.config
.tui_terminal_title
.as_ref()
.is_none_or(|items| items.iter().any(|item| item == "spinner"));
if title_uses_status
|| (title_uses_spinner
&& self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing)
{
self.refresh_terminal_title();
}
}
/// Convenience wrapper around [`Self::set_status`];
@@ -1720,57 +1770,7 @@ impl ChatWidget {
/// a session id exists or before branch lookup completes), those items are skipped without
/// placeholders so the line remains compact and stable.
pub(crate) fn refresh_status_line(&mut self) {
let (items, invalid_items) = self.status_line_items_with_invalids();
if self.thread_id.is_some()
&& !invalid_items.is_empty()
&& self
.status_line_invalid_items_warned
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
let label = if invalid_items.len() == 1 {
"item"
} else {
"items"
};
let message = format!(
"Ignored invalid status line {label}: {}.",
proper_join(invalid_items.as_slice())
);
self.on_warning(message);
}
if !items.contains(&StatusLineItem::GitBranch) {
self.status_line_branch = None;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = false;
}
let enabled = !items.is_empty();
self.bottom_pane.set_status_line_enabled(enabled);
if !enabled {
self.set_status_line(/*status_line*/ None);
return;
}
let cwd = self.status_line_cwd().to_path_buf();
self.sync_status_line_branch_state(&cwd);
if items.contains(&StatusLineItem::GitBranch) && !self.status_line_branch_lookup_complete {
self.request_status_line_branch(cwd);
}
let mut parts = Vec::new();
for item in items {
if let Some(value) = self.status_line_value_for_item(&item) {
parts.push(value);
}
}
let line = if parts.is_empty() {
None
} else {
Some(Line::from(parts.join(" · ")))
};
self.set_status_line(line);
self.refresh_status_surfaces();
}
/// Records that status-line setup was canceled.
@@ -1791,6 +1791,46 @@ impl ChatWidget {
self.refresh_status_line();
}
/// 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 config that was active before the setup UI
/// opened, undoing any preview changes. No-op if no setup session is active.
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();
}
/// Dismisses the terminal-title setup UI and reverts to the pre-setup config.
pub(crate) fn cancel_terminal_title_setup(&mut self) {
tracing::info!("Terminal title setup canceled by user");
self.revert_terminal_title_setup_preview();
}
/// Commits a confirmed terminal-title selection, ending the setup session.
///
/// After this call, `revert_terminal_title_setup_preview` becomes a no-op
/// because the original config snapshot is discarded.
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
@@ -1803,17 +1843,7 @@ impl ChatWidget {
self.status_line_branch = branch;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = true;
}
/// Forces a new git-branch lookup when `GitBranch` is part of the configured status line.
fn request_status_line_branch_refresh(&mut self) {
let (items, _) = self.status_line_items_with_invalids();
if items.is_empty() || !items.contains(&StatusLineItem::GitBranch) {
return;
}
let cwd = self.status_line_cwd().to_path_buf();
self.sync_status_line_branch_state(&cwd);
self.request_status_line_branch(cwd);
self.refresh_status_surfaces();
}
fn collect_runtime_metrics_delta(&mut self) {
@@ -1886,6 +1916,7 @@ impl ChatWidget {
Constrained::allow_only(event.sandbox_policy.clone());
}
self.config.approvals_reviewer = event.approvals_reviewer;
self.status_line_project_root_name_cache = None;
self.last_copyable_output = None;
let forked_from_id = event.forked_from_id;
let model_for_header = event.model.clone();
@@ -1900,6 +1931,7 @@ impl ChatWidget {
mask.reasoning_effort = Some(event.reasoning_effort);
}
self.refresh_model_display();
self.refresh_status_surfaces();
self.sync_fast_command_enabled();
self.sync_personality_command_enabled();
self.sync_plugins_command_enabled();
@@ -2006,6 +2038,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();
}
}
@@ -2163,6 +2196,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.
@@ -2200,6 +2234,7 @@ impl ChatWidget {
.set_turn_running(/*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();
@@ -2214,6 +2249,7 @@ impl ChatWidget {
self.pending_status_indicator_restore = false;
self.bottom_pane
.set_interrupt_hint_visible(/*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();
@@ -2509,7 +2545,6 @@ impl ChatWidget {
Some(info.total_token_usage.tokens_in_context_window())
}
#[cfg(test)]
fn restore_pre_review_token_info(&mut self) {
if let Some(saved) = self.pre_review_token_info.take() {
match saved {
@@ -2950,6 +2985,7 @@ impl ChatWidget {
self.update_task_running_state();
if restored_task_running && !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
self.refresh_terminal_title();
}
self.refresh_pending_input_preview();
self.request_redraw();
@@ -2961,6 +2997,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));
}
@@ -3279,6 +3326,7 @@ impl ChatWidget {
self.bottom_pane.ensure_status_indicator();
self.bottom_pane
.set_interrupt_hint_visible(/*visible*/ true);
self.terminal_title_status_kind = TerminalTitleStatusKind::WaitingForBackgroundTerminal;
self.set_status(
"Waiting for background terminal".to_string(),
command_display.clone(),
@@ -3740,6 +3788,7 @@ impl ChatWidget {
self.bottom_pane.ensure_status_indicator();
self.bottom_pane
.set_interrupt_hint_visible(/*visible*/ true);
self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
self.set_status_header(message);
}
@@ -3782,6 +3831,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);
}
@@ -3789,6 +3839,8 @@ impl ChatWidget {
fn on_undo_completed(&mut self, event: UndoCompletedEvent) {
let UndoCompletedEvent { success, message } = event;
self.bottom_pane.hide_status_indicator();
self.terminal_title_status_kind = TerminalTitleStatusKind::Working;
self.refresh_terminal_title();
let message = message.unwrap_or_else(|| {
if success {
"Undo completed successfully.".to_string()
@@ -3808,6 +3860,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,
@@ -3818,6 +3871,9 @@ impl ChatWidget {
pub(crate) fn pre_draw_tick(&mut self) {
self.bottom_pane.pre_draw_tick();
if self.should_animate_terminal_title_spinner() {
self.refresh_terminal_title();
}
}
/// Handle completion of an `AgentMessage` turn item.
@@ -4354,6 +4410,7 @@ impl ChatWidget {
model,
startup_tooltip_override,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
session_telemetry,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
@@ -4448,6 +4505,7 @@ impl ChatWidget {
full_reasoning_buffer: String::new(),
current_status: StatusIndicatorState::working(),
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
terminal_title_status_kind: TerminalTitleStatusKind::Working,
retry_status_header: None,
pending_status_indicator_restore: false,
suppress_queue_autosend: false,
@@ -4467,12 +4525,12 @@ impl ChatWidget {
quit_shortcut_expires_at: None,
quit_shortcut_key: None,
is_review_mode: false,
#[cfg(test)]
pre_review_token_info: None,
needs_final_message_separator: false,
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,
@@ -4484,6 +4542,11 @@ impl ChatWidget {
current_cwd,
session_network_proxy: None,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
last_terminal_title: None,
terminal_title_setup_original_items: None,
terminal_title_animation_origin: Instant::now(),
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -4528,6 +4591,7 @@ impl ChatWidget {
widget
.bottom_pane
.set_connectors_enabled(widget.connectors_enabled());
widget.refresh_status_surfaces();
widget
}
@@ -5036,6 +5100,9 @@ impl ChatWidget {
SlashCommand::DebugConfig => {
self.add_debug_config_output();
}
SlashCommand::Title => {
self.open_terminal_title_setup();
}
SlashCommand::Statusline => {
self.open_status_line_setup();
}
@@ -5962,17 +6029,12 @@ impl ChatWidget {
});
}
ThreadItem::EnteredReviewMode { review, .. } => {
self.add_to_history(history_cell::new_review_status_line(format!(
">> Code review started: {review} <<"
)));
if !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
if from_replay {
self.enter_review_mode_with_hint(review, /*from_replay*/ true);
}
self.is_review_mode = true;
}
ThreadItem::ExitedReviewMode { review, .. } => {
self.on_agent_message(review);
self.is_review_mode = false;
ThreadItem::ExitedReviewMode { .. } => {
self.exit_review_mode_after_item();
}
ThreadItem::ContextCompaction { .. } => {
self.on_agent_message("Context compacted".to_owned());
@@ -6097,7 +6159,7 @@ impl ChatWidget {
self.handle_turn_completed_notification(notification, replay_kind);
}
ServerNotification::ItemStarted(notification) => {
self.handle_item_started_notification(notification);
self.handle_item_started_notification(notification, replay_kind.is_some());
}
ServerNotification::ItemCompleted(notification) => {
self.handle_item_completed_notification(notification, replay_kind);
@@ -6381,7 +6443,11 @@ impl ChatWidget {
}
}
fn handle_item_started_notification(&mut self, notification: ItemStartedNotification) {
fn handle_item_started_notification(
&mut self,
notification: ItemStartedNotification,
from_replay: bool,
) {
match notification.item {
ThreadItem::CommandExecution {
id,
@@ -6458,10 +6524,9 @@ impl ChatWidget {
agents_states,
}),
ThreadItem::EnteredReviewMode { review, .. } => {
self.add_to_history(history_cell::new_review_status_line(format!(
">> Code review started: {review} <<"
)));
self.is_review_mode = true;
if !from_replay {
self.enter_review_mode_with_hint(review, /*from_replay*/ false);
}
}
_ => {}
}
@@ -6861,28 +6926,41 @@ impl ChatWidget {
}
}
#[cfg(test)]
fn on_entered_review_mode(&mut self, review: ReviewRequest, from_replay: bool) {
// Enter review mode and emit a concise banner
fn enter_review_mode_with_hint(&mut self, hint: String, from_replay: bool) {
if self.pre_review_token_info.is_none() {
self.pre_review_token_info = Some(self.token_info.clone());
}
// Avoid toggling running state for replayed history events on resume.
if !from_replay && !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
}
self.is_review_mode = true;
let hint = review
.user_facing_hint
.unwrap_or_else(|| codex_core::review_prompts::user_facing_hint(&review.target));
let banner = format!(">> Code review started: {hint} <<");
self.add_to_history(history_cell::new_review_status_line(banner));
self.request_redraw();
}
fn exit_review_mode_after_item(&mut self) {
self.flush_answer_stream_with_separator();
self.flush_interrupt_queue();
self.flush_active_cell();
self.is_review_mode = false;
self.restore_pre_review_token_info();
self.add_to_history(history_cell::new_review_status_line(
"<< Code review finished >>".to_string(),
));
self.request_redraw();
}
#[cfg(test)]
fn on_entered_review_mode(&mut self, review: ReviewRequest, from_replay: bool) {
let hint = review
.user_facing_hint
.unwrap_or_else(|| codex_core::review_prompts::user_facing_hint(&review.target));
self.enter_review_mode_with_hint(hint, from_replay);
}
#[cfg(test)]
fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) {
// Leave review mode; if output is present, flush pending stream + show results.
if let Some(output) = review.review_output {
self.flush_answer_stream_with_separator();
self.flush_interrupt_queue();
@@ -6911,14 +6989,7 @@ impl ChatWidget {
}
// Final message is rendered as part of the AgentMessage.
}
self.is_review_mode = false;
self.restore_pre_review_token_info();
// Append a finishing banner at the end of this turn.
self.add_to_history(history_cell::new_review_status_line(
"<< Code review finished >>".to_string(),
));
self.request_redraw();
self.exit_review_mode_after_item();
}
fn on_user_message_event(&mut self, event: UserMessageEvent) {
@@ -7101,6 +7172,16 @@ impl ChatWidget {
self.bottom_pane.show_view(Box::new(view));
}
fn open_terminal_title_setup(&mut self) {
let configured_terminal_title_items = self.configured_terminal_title_items();
self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone());
let view = TerminalTitleSetupView::new(
Some(configured_terminal_title_items.as_slice()),
self.app_event_tx.clone(),
);
self.bottom_pane.show_view(Box::new(view));
}
fn open_theme_picker(&mut self) {
let codex_home = codex_core::config::find_codex_home().ok();
let terminal_width = self
@@ -7115,192 +7196,6 @@ impl ChatWidget {
self.bottom_pane.show_selection_view(params);
}
/// Parses configured status-line ids into known items and collects unknown ids.
///
/// Unknown ids are deduplicated in insertion order for warning messages.
fn status_line_items_with_invalids(&self) -> (Vec<StatusLineItem>, Vec<String>) {
let mut invalid = Vec::new();
let mut invalid_seen = HashSet::new();
let mut items = Vec::new();
for id in self.configured_status_line_items() {
match id.parse::<StatusLineItem>() {
Ok(item) => items.push(item),
Err(_) => {
if invalid_seen.insert(id.clone()) {
invalid.push(format!(r#""{id}""#));
}
}
}
}
(items, invalid)
}
fn configured_status_line_items(&self) -> Vec<String> {
self.config.tui_status_line.clone().unwrap_or_else(|| {
DEFAULT_STATUS_LINE_ITEMS
.iter()
.map(ToString::to_string)
.collect()
})
}
fn status_line_cwd(&self) -> &Path {
self.current_cwd
.as_deref()
.unwrap_or(self.config.cwd.as_path())
}
fn status_line_project_root(&self) -> Option<PathBuf> {
let cwd = self.status_line_cwd();
if let Some(repo_root) = get_git_repo_root(cwd) {
return Some(repo_root);
}
self.config
.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.iter()
.find_map(|layer| match &layer.name {
ConfigLayerSource::Project { dot_codex_folder } => {
dot_codex_folder.as_path().parent().map(Path::to_path_buf)
}
_ => None,
})
}
fn status_line_project_root_name(&self) -> Option<String> {
self.status_line_project_root().map(|root| {
root.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| format_directory_display(&root, /*max_width*/ None))
})
}
/// Resets git-branch cache state when the status-line cwd changes.
///
/// The branch cache is keyed by cwd because branch lookup is performed relative to that path.
/// Keeping stale branch values across cwd changes would surface incorrect repository context.
fn sync_status_line_branch_state(&mut self, cwd: &Path) {
if self
.status_line_branch_cwd
.as_ref()
.is_some_and(|path| path == cwd)
{
return;
}
self.status_line_branch_cwd = Some(cwd.to_path_buf());
self.status_line_branch = None;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = false;
}
/// Starts an async git-branch lookup unless one is already running.
///
/// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject
/// stale completions after directory changes.
fn request_status_line_branch(&mut self, cwd: PathBuf) {
if self.status_line_branch_pending {
return;
}
self.status_line_branch_pending = true;
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let branch = current_branch_name(&cwd).await;
tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch });
});
}
/// Resolves a display string for one configured status-line item.
///
/// Returning `None` means "omit this item for now", not "configuration error". Callers rely on
/// this to keep partially available status lines readable while waiting for session, token, or
/// git metadata.
fn status_line_value_for_item(&self, item: &StatusLineItem) -> Option<String> {
match item {
StatusLineItem::ModelName => Some(self.model_display_name().to_string()),
StatusLineItem::ModelWithReasoning => {
let label =
Self::status_line_reasoning_effort_label(self.effective_reasoning_effort());
let fast_label = if self
.should_show_fast_status(self.current_model(), self.config.service_tier)
{
" fast"
} else {
""
};
Some(format!("{} {label}{fast_label}", self.model_display_name()))
}
StatusLineItem::CurrentDir => {
Some(format_directory_display(
self.status_line_cwd(),
/*max_width*/ None,
))
}
StatusLineItem::ProjectRoot => self.status_line_project_root_name(),
StatusLineItem::GitBranch => self.status_line_branch.clone(),
StatusLineItem::UsedTokens => {
let usage = self.status_line_total_usage();
let total = usage.tokens_in_context_window();
if total <= 0 {
None
} else {
Some(format!("{} used", format_tokens_compact(total)))
}
}
StatusLineItem::ContextRemaining => self
.status_line_context_remaining_percent()
.map(|remaining| format!("{remaining}% left")),
StatusLineItem::ContextUsed => self
.status_line_context_used_percent()
.map(|used| format!("{used}% used")),
StatusLineItem::FiveHourLimit => {
let window = self
.rate_limit_snapshots_by_limit_id
.get("codex")
.and_then(|s| s.primary.as_ref());
let label = window
.and_then(|window| window.window_minutes)
.map(get_limits_duration)
.unwrap_or_else(|| "5h".to_string());
self.status_line_limit_display(window, &label)
}
StatusLineItem::WeeklyLimit => {
let window = self
.rate_limit_snapshots_by_limit_id
.get("codex")
.and_then(|s| s.secondary.as_ref());
let label = window
.and_then(|window| window.window_minutes)
.map(get_limits_duration)
.unwrap_or_else(|| "weekly".to_string());
self.status_line_limit_display(window, &label)
}
StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()),
StatusLineItem::ContextWindowSize => self
.status_line_context_window_size()
.map(|cws| format!("{} window", format_tokens_compact(cws))),
StatusLineItem::TotalInputTokens => Some(format!(
"{} in",
format_tokens_compact(self.status_line_total_usage().input_tokens)
)),
StatusLineItem::TotalOutputTokens => Some(format!(
"{} out",
format_tokens_compact(self.status_line_total_usage().output_tokens)
)),
StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()),
StatusLineItem::FastMode => Some(
if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
"Fast on".to_string()
} else {
"Fast off".to_string()
},
),
}
}
fn status_line_context_window_size(&self) -> Option<i64> {
self.token_info
.as_ref()
@@ -0,0 +1,662 @@
//! Status-line and terminal-title rendering helpers for `ChatWidget`.
//!
//! Keeping this logic in a focused submodule makes the additive title/status
//! behavior easier to review without paging through the rest of `chatwidget.rs`.
use super::*;
/// Items shown in the terminal title when the user has not configured a
/// custom selection. Intentionally minimal: spinner + project name.
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["spinner", "project"];
/// Braille-pattern dot-spinner frames for the terminal title animation.
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/// Time between spinner frame advances in the terminal title.
pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
/// 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.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) enum TerminalTitleStatusKind {
Working,
WaitingForBackgroundTerminal,
Undoing,
#[default]
Thinking,
}
#[derive(Debug)]
/// Parsed status-surface configuration for one refresh pass.
///
/// The status line and terminal title share some expensive or stateful inputs
/// (notably git branch lookup and invalid-item warnings). This snapshot lets one
/// refresh pass compute those shared concerns once, then render both surfaces
/// from the same selection set.
struct StatusSurfaceSelections {
status_line_items: Vec<StatusLineItem>,
invalid_status_line_items: Vec<String>,
terminal_title_items: Vec<TerminalTitleItem>,
invalid_terminal_title_items: Vec<String>,
}
impl StatusSurfaceSelections {
fn uses_git_branch(&self) -> bool {
self.status_line_items.contains(&StatusLineItem::GitBranch)
|| self
.terminal_title_items
.contains(&TerminalTitleItem::GitBranch)
}
}
/// 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.
#[derive(Clone, Debug)]
pub(super) struct CachedProjectRootName {
pub(super) cwd: PathBuf,
pub(super) root_name: Option<String>,
}
impl ChatWidget {
fn status_surface_selections(&self) -> StatusSurfaceSelections {
let (status_line_items, invalid_status_line_items) = self.status_line_items_with_invalids();
let (terminal_title_items, invalid_terminal_title_items) =
self.terminal_title_items_with_invalids();
StatusSurfaceSelections {
status_line_items,
invalid_status_line_items,
terminal_title_items,
invalid_terminal_title_items,
}
}
fn warn_invalid_status_line_items_once(&mut self, invalid_items: &[String]) {
if self.thread_id.is_some()
&& !invalid_items.is_empty()
&& self
.status_line_invalid_items_warned
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
let label = if invalid_items.len() == 1 {
"item"
} else {
"items"
};
let message = format!(
"Ignored invalid status line {label}: {}.",
proper_join(invalid_items)
);
self.on_warning(message);
}
}
fn warn_invalid_terminal_title_items_once(&mut self, invalid_items: &[String]) {
if self.thread_id.is_some()
&& !invalid_items.is_empty()
&& self
.terminal_title_invalid_items_warned
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
let label = if invalid_items.len() == 1 {
"item"
} else {
"items"
};
let message = format!(
"Ignored invalid terminal title {label}: {}.",
proper_join(invalid_items)
);
self.on_warning(message);
}
}
fn sync_status_surface_shared_state(&mut self, selections: &StatusSurfaceSelections) {
if !selections.uses_git_branch() {
self.status_line_branch = None;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = false;
return;
}
let cwd = self.status_line_cwd().to_path_buf();
self.sync_status_line_branch_state(&cwd);
if !self.status_line_branch_lookup_complete {
self.request_status_line_branch(cwd);
}
}
fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) {
let enabled = !selections.status_line_items.is_empty();
self.bottom_pane.set_status_line_enabled(enabled);
if !enabled {
self.set_status_line(/*status_line*/ None);
return;
}
let mut parts = Vec::new();
for item in &selections.status_line_items {
if let Some(value) = self.status_line_value_for_item(item) {
parts.push(value);
}
}
let line = if parts.is_empty() {
None
} else {
Some(Line::from(parts.join(" · ")))
};
self.set_status_line(line);
}
/// Clears the terminal title Codex most recently wrote, if any.
///
/// This does not attempt to restore the shell or terminal's previous title;
/// it only clears the managed title and updates the cache after a successful
/// OSC write.
pub(crate) fn clear_managed_terminal_title(&mut self) -> std::io::Result<()> {
if self.last_terminal_title.is_some() {
clear_terminal_title()?;
self.last_terminal_title = None;
}
Ok(())
}
/// Renders and applies the terminal title for one parsed selection snapshot.
///
/// Empty selections clear the managed title. Non-empty selections render the
/// current values in configured order, skip unavailable segments, and cache
/// the last successfully written title so redundant OSC writes are avoided.
/// When the `spinner` item is present in an animated running state, this also
/// schedules the next frame so the spinner keeps advancing.
fn refresh_terminal_title_from_selections(&mut self, selections: &StatusSurfaceSelections) {
if selections.terminal_title_items.is_empty() {
if let Err(err) = self.clear_managed_terminal_title() {
tracing::debug!(error = %err, "failed to clear terminal title");
}
return;
}
let now = Instant::now();
let mut previous = None;
let title = selections
.terminal_title_items
.iter()
.copied()
.filter_map(|item| {
self.terminal_title_value_for_item(item, now)
.map(|value| (item, value))
})
.fold(String::new(), |mut title, (item, value)| {
title.push_str(item.separator_from_previous(previous));
title.push_str(&value);
previous = Some(item);
title
});
let title = (!title.is_empty()).then_some(title);
let should_animate_spinner =
self.should_animate_terminal_title_spinner_with_selections(selections);
if self.last_terminal_title == title {
if should_animate_spinner {
self.frame_requester
.schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL);
}
return;
}
match title {
Some(title) => match set_terminal_title(&title) {
Ok(SetTerminalTitleResult::Applied) => {
self.last_terminal_title = Some(title);
}
Ok(SetTerminalTitleResult::NoVisibleContent) => {
if let Err(err) = self.clear_managed_terminal_title() {
tracing::debug!(error = %err, "failed to clear terminal title");
}
}
Err(err) => {
tracing::debug!(error = %err, "failed to set terminal title");
}
},
None => {
if let Err(err) = self.clear_managed_terminal_title() {
tracing::debug!(error = %err, "failed to clear terminal title");
}
}
}
if should_animate_spinner {
self.frame_requester
.schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL);
}
}
/// Recomputes both status surfaces from one shared config snapshot.
///
/// This is the common refresh entrypoint for the footer status line and the
/// terminal title. It parses both configurations once, emits invalid-item
/// warnings once, synchronizes shared cached state (such as git-branch
/// lookup), then renders each surface from that shared snapshot.
pub(crate) fn refresh_status_surfaces(&mut self) {
let selections = self.status_surface_selections();
self.warn_invalid_status_line_items_once(&selections.invalid_status_line_items);
self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items);
self.sync_status_surface_shared_state(&selections);
self.refresh_status_line_from_selections(&selections);
self.refresh_terminal_title_from_selections(&selections);
}
/// Recomputes and emits the terminal title from config and runtime state.
pub(crate) fn refresh_terminal_title(&mut self) {
let selections = self.status_surface_selections();
self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items);
self.sync_status_surface_shared_state(&selections);
self.refresh_terminal_title_from_selections(&selections);
}
pub(super) fn request_status_line_branch_refresh(&mut self) {
let selections = self.status_surface_selections();
if !selections.uses_git_branch() {
return;
}
let cwd = self.status_line_cwd().to_path_buf();
self.sync_status_line_branch_state(&cwd);
self.request_status_line_branch(cwd);
}
/// Parses configured status-line ids into known items and collects unknown ids.
///
/// Unknown ids are deduplicated in insertion order for warning messages.
fn status_line_items_with_invalids(&self) -> (Vec<StatusLineItem>, Vec<String>) {
parse_items_with_invalids(self.configured_status_line_items())
}
pub(super) fn configured_status_line_items(&self) -> Vec<String> {
self.config.tui_status_line.clone().unwrap_or_else(|| {
DEFAULT_STATUS_LINE_ITEMS
.iter()
.map(ToString::to_string)
.collect()
})
}
/// Parses configured terminal-title ids into known items and collects unknown ids.
///
/// Unknown ids are deduplicated in insertion order for warning messages.
fn terminal_title_items_with_invalids(&self) -> (Vec<TerminalTitleItem>, Vec<String>) {
parse_items_with_invalids(self.configured_terminal_title_items())
}
/// Returns the configured terminal-title ids, or the default ordering when unset.
pub(super) fn configured_terminal_title_items(&self) -> Vec<String> {
self.config.tui_terminal_title.clone().unwrap_or_else(|| {
DEFAULT_TERMINAL_TITLE_ITEMS
.iter()
.map(ToString::to_string)
.collect()
})
}
fn status_line_cwd(&self) -> &Path {
self.current_cwd
.as_deref()
.unwrap_or(self.config.cwd.as_path())
}
/// Resolves the project root associated with `cwd`.
///
/// Git repository root wins when available. Otherwise we fall back to the
/// nearest project config layer so non-git projects can still surface a
/// stable project label.
fn status_line_project_root_for_cwd(&self, cwd: &Path) -> Option<PathBuf> {
if let Some(repo_root) = get_git_repo_root(cwd) {
return Some(repo_root);
}
self.config
.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.iter()
.find_map(|layer| match &layer.name {
ConfigLayerSource::Project { dot_codex_folder } => {
dot_codex_folder.as_path().parent().map(Path::to_path_buf)
}
_ => None,
})
}
fn status_line_project_root_name_for_cwd(&self, cwd: &Path) -> Option<String> {
self.status_line_project_root_for_cwd(cwd).map(|root| {
root.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| format_directory_display(&root, /*max_width*/ 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, /*max_width*/ None)),
)
})?;
Some(Self::truncate_terminal_title_part(
project, /*max_chars*/ 24,
))
}
/// Resets git-branch cache state when the status-line cwd changes.
///
/// The branch cache is keyed by cwd because branch lookup is performed relative to that path.
/// Keeping stale branch values across cwd changes would surface incorrect repository context.
fn sync_status_line_branch_state(&mut self, cwd: &Path) {
if self
.status_line_branch_cwd
.as_ref()
.is_some_and(|path| path == cwd)
{
return;
}
self.status_line_branch_cwd = Some(cwd.to_path_buf());
self.status_line_branch = None;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = false;
}
/// Starts an async git-branch lookup unless one is already running.
///
/// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject
/// stale completions after directory changes.
fn request_status_line_branch(&mut self, cwd: PathBuf) {
if self.status_line_branch_pending {
return;
}
self.status_line_branch_pending = true;
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let branch = current_branch_name(&cwd).await;
tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch });
});
}
/// Resolves a display string for one configured status-line item.
///
/// Returning `None` means "omit this item for now", not "configuration error". Callers rely on
/// this to keep partially available status lines readable while waiting for session, token, or
/// git metadata.
pub(super) fn status_line_value_for_item(&mut self, item: &StatusLineItem) -> Option<String> {
match item {
StatusLineItem::ModelName => Some(self.model_display_name().to_string()),
StatusLineItem::ModelWithReasoning => {
let label =
Self::status_line_reasoning_effort_label(self.effective_reasoning_effort());
let fast_label = if self
.should_show_fast_status(self.current_model(), self.config.service_tier)
{
" fast"
} else {
""
};
Some(format!("{} {label}{fast_label}", self.model_display_name()))
}
StatusLineItem::CurrentDir => {
Some(format_directory_display(
self.status_line_cwd(),
/*max_width*/ None,
))
}
StatusLineItem::ProjectRoot => self.status_line_project_root_name(),
StatusLineItem::GitBranch => self.status_line_branch.clone(),
StatusLineItem::UsedTokens => {
let usage = self.status_line_total_usage();
let total = usage.tokens_in_context_window();
if total <= 0 {
None
} else {
Some(format!("{} used", format_tokens_compact(total)))
}
}
StatusLineItem::ContextRemaining => self
.status_line_context_remaining_percent()
.map(|remaining| format!("{remaining}% left")),
StatusLineItem::ContextUsed => self
.status_line_context_used_percent()
.map(|used| format!("{used}% used")),
StatusLineItem::FiveHourLimit => {
let window = self
.rate_limit_snapshots_by_limit_id
.get("codex")
.and_then(|s| s.primary.as_ref());
let label = window
.and_then(|window| window.window_minutes)
.map(get_limits_duration)
.unwrap_or_else(|| "5h".to_string());
self.status_line_limit_display(window, &label)
}
StatusLineItem::WeeklyLimit => {
let window = self
.rate_limit_snapshots_by_limit_id
.get("codex")
.and_then(|s| s.secondary.as_ref());
let label = window
.and_then(|window| window.window_minutes)
.map(get_limits_duration)
.unwrap_or_else(|| "weekly".to_string());
self.status_line_limit_display(window, &label)
}
StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()),
StatusLineItem::ContextWindowSize => self
.status_line_context_window_size()
.map(|cws| format!("{} window", format_tokens_compact(cws))),
StatusLineItem::TotalInputTokens => Some(format!(
"{} in",
format_tokens_compact(self.status_line_total_usage().input_tokens)
)),
StatusLineItem::TotalOutputTokens => Some(format!(
"{} out",
format_tokens_compact(self.status_line_total_usage().output_tokens)
)),
StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()),
StatusLineItem::FastMode => Some(
if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
"Fast on".to_string()
} else {
"Fast off".to_string()
},
),
}
}
/// Resolves one configured terminal-title item into a displayable segment.
///
/// Returning `None` means "omit this segment for now" so callers can keep
/// the configured order while hiding values that are not yet available.
fn terminal_title_value_for_item(
&mut self,
item: TerminalTitleItem,
now: Instant,
) -> Option<String> {
match item {
TerminalTitleItem::AppName => Some("codex".to_string()),
TerminalTitleItem::Project => self.terminal_title_project_name(),
TerminalTitleItem::Spinner => self.terminal_title_spinner_text_at(now),
TerminalTitleItem::Status => Some(self.terminal_title_status_text()),
TerminalTitleItem::Thread => self.thread_name.as_ref().and_then(|name| {
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(Self::truncate_terminal_title_part(
trimmed.to_string(),
/*max_chars*/ 48,
))
}
}),
TerminalTitleItem::GitBranch => self.status_line_branch.as_ref().map(|branch| {
Self::truncate_terminal_title_part(branch.clone(), /*max_chars*/ 32)
}),
TerminalTitleItem::Model => Some(Self::truncate_terminal_title_part(
self.model_display_name().to_string(),
/*max_chars*/ 32,
)),
TerminalTitleItem::TaskProgress => self.terminal_title_task_progress(),
}
}
/// Computes the compact runtime status label used by the terminal title.
///
/// Startup takes precedence over normal task states, and idle state renders
/// as `Ready` regardless of the last active status bucket.
pub(super) fn terminal_title_status_text(&self) -> String {
if self.mcp_startup_status.is_some() {
return "Starting".to_string();
}
match self.terminal_title_status_kind {
TerminalTitleStatusKind::Working if !self.bottom_pane.is_task_running() => {
"Ready".to_string()
}
TerminalTitleStatusKind::WaitingForBackgroundTerminal
if !self.bottom_pane.is_task_running() =>
{
"Ready".to_string()
}
TerminalTitleStatusKind::Thinking if !self.bottom_pane.is_task_running() => {
"Ready".to_string()
}
TerminalTitleStatusKind::Working => "Working".to_string(),
TerminalTitleStatusKind::WaitingForBackgroundTerminal => "Waiting".to_string(),
TerminalTitleStatusKind::Undoing => "Undoing".to_string(),
TerminalTitleStatusKind::Thinking => "Thinking".to_string(),
}
}
pub(super) fn terminal_title_spinner_text_at(&self, now: Instant) -> Option<String> {
if !self.config.animations {
return None;
}
if !self.terminal_title_has_active_progress() {
return None;
}
Some(self.terminal_title_spinner_frame_at(now).to_string())
}
fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str {
let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin);
let frame_index =
(elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize;
TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()]
}
fn terminal_title_uses_spinner(&self) -> bool {
self.config
.tui_terminal_title
.as_ref()
.is_none_or(|items| items.iter().any(|item| item == "spinner"))
}
fn terminal_title_has_active_progress(&self) -> bool {
self.mcp_startup_status.is_some()
|| self.bottom_pane.is_task_running()
|| self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing
}
pub(super) fn should_animate_terminal_title_spinner(&self) -> bool {
self.config.animations
&& self.terminal_title_uses_spinner()
&& self.terminal_title_has_active_progress()
}
fn should_animate_terminal_title_spinner_with_selections(
&self,
selections: &StatusSurfaceSelections,
) -> bool {
self.config.animations
&& selections
.terminal_title_items
.contains(&TerminalTitleItem::Spinner)
&& self.terminal_title_has_active_progress()
}
/// Formats the last `update_plan` progress snapshot for terminal-title display.
pub(super) fn terminal_title_task_progress(&self) -> Option<String> {
let (completed, total) = self.last_plan_progress?;
if total == 0 {
return None;
}
Some(format!("Tasks {completed}/{total}"))
}
/// Truncates a title segment by grapheme cluster and appends `...` when needed.
pub(super) fn truncate_terminal_title_part(value: String, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let mut graphemes = value.graphemes(true);
let head: String = graphemes.by_ref().take(max_chars).collect();
if graphemes.next().is_none() || max_chars <= 3 {
return head;
}
let mut truncated = head.graphemes(true).take(max_chars - 3).collect::<String>();
truncated.push_str("...");
truncated
}
}
fn parse_items_with_invalids<T>(ids: impl IntoIterator<Item = String>) -> (Vec<T>, Vec<String>)
where
T: std::str::FromStr,
{
let mut invalid = Vec::new();
let mut invalid_seen = HashSet::new();
let mut items = Vec::new();
for id in ids {
match id.parse::<T>() {
Ok(item) => items.push(item),
Err(_) => {
if invalid_seen.insert(id.clone()) {
invalid.push(format!(r#""{id}""#));
}
}
}
}
(items, invalid)
}
@@ -1946,6 +1946,7 @@ async fn helpers_are_available_and_do_not_panic() {
model: Some(resolved_model),
startup_tooltip_override: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
session_telemetry,
};
let mut w = ChatWidget::new_with_app_event(init);
@@ -2048,6 +2049,7 @@ async fn make_chatwidget_manual(
stream_controller: None,
plan_stream_controller: None,
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
terminal_title_status_kind: TerminalTitleStatusKind::Working,
last_copyable_output: None,
running_commands: HashMap::new(),
collab_agent_metadata: HashMap::new(),
@@ -2099,6 +2101,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,
@@ -2110,6 +2113,11 @@ async fn make_chatwidget_manual(
current_cwd: None,
session_network_proxy: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
last_terminal_title: None,
terminal_title_setup_original_items: None,
terminal_title_animation_origin: Instant::now(),
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -6848,6 +6856,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() {
model: Some(resolved_model.clone()),
startup_tooltip_override: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
session_telemetry,
};
@@ -6892,6 +6901,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() {
model: Some(resolved_model.clone()),
startup_tooltip_override: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
session_telemetry,
};
+1
View File
@@ -141,6 +141,7 @@ mod status_indicator_widget;
mod streaming;
mod style;
mod terminal_palette;
mod terminal_title;
mod text_formatting;
mod theme_picker;
mod tooltips;
@@ -38,6 +38,7 @@ pub enum SlashCommand {
Mention,
Status,
DebugConfig,
Title,
Statusline,
Theme,
Mcp,
@@ -86,6 +87,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",
@@ -180,6 +182,7 @@ impl SlashCommand {
SlashCommand::Agent | SlashCommand::MultiAgents => true,
SlashCommand::Statusline => false,
SlashCommand::Theme => false,
SlashCommand::Title => false,
}
}
@@ -0,0 +1,224 @@
//! Terminal-title output helpers for the TUI.
//!
//! This module owns the low-level OSC title write path and the sanitization
//! that happens immediately before we emit it. It is intentionally narrow:
//! callers decide when the title should change and whether an empty title means
//! "leave the old title alone" or "clear the title Codex last wrote".
//! This module does not attempt to read or restore the terminal's previous
//! title because that is not portable across terminals.
//!
//! Sanitization is necessary because title content is assembled from untrusted
//! text sources such as model output, thread names, project paths, and config.
//! Before we place that text inside an OSC sequence, we strip:
//! - control characters that could terminate or reshape the escape sequence
//! - bidi/invisible formatting codepoints that can visually reorder or hide
//! text (the same family of issues discussed in Trojan Source writeups)
//! - redundant whitespace that would make titles noisy or hard to scan
use std::fmt;
use std::io;
use std::io::IsTerminal;
use std::io::stdout;
use crossterm::Command;
use ratatui::crossterm::execute;
/// Practical upper bound on title length, measured in Rust `char`s.
///
/// Most terminals silently truncate titles beyond a few hundred characters.
/// 240 leaves headroom for the OSC framing bytes while keeping titles
/// readable in tab bars and window managers.
const MAX_TERMINAL_TITLE_CHARS: usize = 240;
/// Outcome of a [`set_terminal_title`] call.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) enum SetTerminalTitleResult {
/// A sanitized title was written, or stdout is not a terminal so no write was needed.
Applied,
/// Sanitization removed every visible character, so no title was emitted.
///
/// This is distinct from clearing the title. Callers decide whether an
/// empty post-sanitization value should result in no-op behavior, clearing
/// the title Codex manages, or some other fallback.
NoVisibleContent,
}
/// Writes a sanitized OSC window-title sequence to stdout.
///
/// The input is treated as untrusted display text: control characters,
/// invisible formatting characters, and redundant whitespace are removed before
/// the title is emitted. If sanitization removes all visible content, the
/// function returns [`SetTerminalTitleResult::NoVisibleContent`] instead of
/// clearing the title because clearing and restoring are policy decisions for
/// higher-level callers. Mechanically, sanitization collapses whitespace runs
/// to single spaces, drops disallowed codepoints, and bounds the result to
/// [`MAX_TERMINAL_TITLE_CHARS`] visible characters before writing OSC 0.
pub(crate) fn set_terminal_title(title: &str) -> io::Result<SetTerminalTitleResult> {
if !stdout().is_terminal() {
return Ok(SetTerminalTitleResult::Applied);
}
let title = sanitize_terminal_title(title);
if title.is_empty() {
return Ok(SetTerminalTitleResult::NoVisibleContent);
}
execute!(stdout(), SetWindowTitle(title))?;
Ok(SetTerminalTitleResult::Applied)
}
/// Clears the current terminal title by writing an empty OSC title payload.
///
/// This clears the visible title; it does not restore whatever title the shell
/// or a previous program may have set before Codex started managing the title.
pub(crate) fn clear_terminal_title() -> io::Result<()> {
if !stdout().is_terminal() {
return Ok(());
}
execute!(stdout(), SetWindowTitle(String::new()))
}
#[derive(Debug, Clone)]
struct SetWindowTitle(String);
impl Command for SetWindowTitle {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
// xterm/ctlseqs documents OSC 0/2 title sequences with ST (ESC \) termination.
// Most terminals also accept BEL for compatibility, but ST is the canonical form.
write!(f, "\x1b]0;{}\x1b\\", self.0)
}
#[cfg(windows)]
fn execute_winapi(&self) -> io::Result<()> {
Err(std::io::Error::other(
"tried to execute SetWindowTitle using WinAPI; use ANSI instead",
))
}
#[cfg(windows)]
fn is_ansi_code_supported(&self) -> bool {
true
}
}
/// Normalizes untrusted title text into a single bounded display line.
///
/// This removes terminal control characters, strips invisible/bidi formatting
/// characters, collapses any whitespace run into a single ASCII space, and
/// truncates after [`MAX_TERMINAL_TITLE_CHARS`] emitted characters.
fn sanitize_terminal_title(title: &str) -> String {
let mut sanitized = String::new();
let mut chars_written = 0;
let mut pending_space = false;
for ch in title.chars() {
if ch.is_whitespace() {
// Only set pending if we've already written content; this
// strips leading whitespace without an extra trim pass.
pending_space = !sanitized.is_empty();
continue;
}
if is_disallowed_terminal_title_char(ch) {
continue;
}
if pending_space {
let remaining = MAX_TERMINAL_TITLE_CHARS.saturating_sub(chars_written);
if remaining > 1 {
sanitized.push(' ');
chars_written += 1;
pending_space = false;
}
}
if chars_written >= MAX_TERMINAL_TITLE_CHARS {
break;
}
sanitized.push(ch);
chars_written += 1;
}
sanitized
}
/// Returns whether `ch` should be dropped from terminal-title output.
///
/// This includes both plain control characters and a curated set of invisible
/// formatting codepoints. The bidi entries here cover the Trojan-Source-style
/// text-reordering controls that can make a title render misleadingly relative
/// to its underlying byte sequence.
fn is_disallowed_terminal_title_char(ch: char) -> bool {
if ch.is_control() {
return true;
}
// Strip Trojan-Source-related bidi controls plus common non-rendering
// formatting characters so title text cannot smuggle terminal control
// semantics or visually misleading content.
matches!(
ch,
'\u{00AD}'
| '\u{034F}'
| '\u{061C}'
| '\u{180E}'
| '\u{200B}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{206F}'
| '\u{FE00}'..='\u{FE0F}'
| '\u{FEFF}'
| '\u{FFF9}'..='\u{FFFB}'
| '\u{1BCA0}'..='\u{1BCA3}'
| '\u{E0100}'..='\u{E01EF}'
)
}
#[cfg(test)]
mod tests {
use super::MAX_TERMINAL_TITLE_CHARS;
use super::SetWindowTitle;
use super::sanitize_terminal_title;
use crossterm::Command;
use pretty_assertions::assert_eq;
#[test]
fn sanitizes_terminal_title() {
let sanitized =
sanitize_terminal_title(" Project\t|\nWorking\x1b\x07\u{009D}\u{009C} | Thread ");
assert_eq!(sanitized, "Project | Working | Thread");
}
#[test]
fn strips_invisible_format_chars_from_terminal_title() {
let sanitized = sanitize_terminal_title(
"Pro\u{202E}j\u{2066}e\u{200F}c\u{061C}t\u{200B} \u{FEFF}T\u{2060}itle",
);
assert_eq!(sanitized, "Project Title");
}
#[test]
fn truncates_terminal_title() {
let input = "a".repeat(MAX_TERMINAL_TITLE_CHARS + 10);
let sanitized = sanitize_terminal_title(&input);
assert_eq!(sanitized.len(), MAX_TERMINAL_TITLE_CHARS);
}
#[test]
fn truncation_prefers_visible_char_over_pending_space() {
let input = format!("{} b", "a".repeat(MAX_TERMINAL_TITLE_CHARS - 1));
let sanitized = sanitize_terminal_title(&input);
assert_eq!(sanitized.len(), MAX_TERMINAL_TITLE_CHARS);
assert_eq!(sanitized.chars().last(), Some('b'));
}
#[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\\");
}
}
@@ -112,6 +112,8 @@ trust_level = "trusted"
"resume".to_string(),
"--last".to_string(),
"--no-alt-screen".to_string(),
"--enable".to_string(),
"tui_app_server".to_string(),
"-C".to_string(),
repo_root.display().to_string(),
"-c".to_string(),