From bff4435c8058b06850f5d5f2b1c09d8ebe6ce21b Mon Sep 17 00:00:00 2001
From: Michael Bolin
Date: Sun, 10 Aug 2025 14:19:27 -0700
Subject: [PATCH 01/45] docs: update the docs to explain how to authenticate on
a headless machine (#2121)
Users on "headless" machines, such as WSL users, are understandable
having trouble authenticating successfully. To date, I have been
providing one-off user support on issues such as
https://github.com/openai/codex/issues/2000, but we need a more detailed
explanation that we can link to so that users can self-serve. This PR
aims to provide detailed information that we can link to in response to
user issues going forward.
That said, it would also be helpful if we employed heuristics to detect
this issue at runtime, and/or we should just link to these docs as part
of the `codex login` flow.
---
README.md | 44 ++++++++++++++++++++++++++++++++++++++------
1 file changed, 38 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 89063b7e7b..0c01654d66 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,9 @@
- [Quickstart](#quickstart)
- [Installing and running Codex CLI](#installing-and-running-codex-cli)
- [Using Codex with your ChatGPT plan](#using-codex-with-your-chatgpt-plan)
- - [Connecting through VPS or remote](#connecting-through-vps-or-remote)
+ - [Connecting on a "Headless" Machine](#connecting-on-a-headless-machine)
+ - [Authenticate locally and copy your credentials to the "headless" machine](#authenticate-locally-and-copy-your-credentials-to-the-headless-machine)
+ - [Connecting through VPS or remote](#connecting-through-vps-or-remote)
- [Usage-based billing alternative: Use an OpenAI API key](#usage-based-billing-alternative-use-an-openai-api-key)
- [Choosing Codex's level of autonomy](#choosing-codexs-level-of-autonomy)
- [**1. Read/write**](#1-readwrite)
@@ -99,17 +101,47 @@ Each archive contains a single entry with the platform baked into the name (e.g.
-After you run `codex` select Sign in with ChatGPT. You'll need a Plus, Pro, or Team ChatGPT account, and will get access to our latest models, including `gpt-5`, at no extra cost to your plan. (Enterprise is coming soon.)
+Run `codex` and select **Sign in with ChatGPT**. You'll need a Plus, Pro, or Team ChatGPT account, and will get access to our latest models, including `gpt-5`, at no extra cost to your plan. (Enterprise is coming soon.)
-> Important: If you've used the Codex CLI before, you'll need to follow these steps to migrate from usage-based billing with your API key:
+> Important: If you've used the Codex CLI before, follow these steps to migrate from usage-based billing with your API key:
>
-> 1. Update the CLI with `codex update` and ensure `codex --version` is greater than 0.13
-> 2. Ensure that there is no `OPENAI_API_KEY` environment variable set. (Check that `env | grep 'OPENAI_API_KEY'` returns empty)
+> 1. Update the CLI and ensure `codex --version` is `0.20.0` or later
+> 2. Delete `~/.codex/auth.json` (this should be `C:\Users\USERNAME\.codex\auth.json` on Windows)
> 3. Run `codex login` again
If you encounter problems with the login flow, please comment on [this issue](https://github.com/openai/codex/issues/1243).
-### Connecting through VPS or remote
+### Connecting on a "Headless" Machine
+
+Today, the login process entails running a server on `localhost:1455`. If you are on a "headless" server, such as a Docker container or are `ssh`'d into a remote machine, loading `localhost:1455` in the browser on your local machine will not automatically connect to the webserver running on the _headless_ machine, so you must use one of the following workarounds:
+
+#### Authenticate locally and copy your credentials to the "headless" machine
+
+The easiest solution is likely to run through the `codex login` process on your local machine such that `localhost:1455` _is_ accessible in your web browser. When you complete the authentication process, an `auth.json` file should be available at `$CODEX_HOME/auth.json` (on Mac/Linux, `$CODEX_HOME` defaults to `~/.codex` whereas on Windows, it defaults to `%USERPROFILE%\.codex`).
+
+Because the `auth.json` file is not tied to a specific host, once you complete the authentication flow locally, you can copy the `$CODEX_HOME/auth.json` file to the headless machine and then `codex` should "just work" on that machine. Note to copy a file to a Docker container, you can do:
+
+```shell
+# substitute MY_CONTAINER with the name or id of your Docker container:
+CONTAINER_HOME=$(docker exec MY_CONTAINER printenv HOME)
+docker exec MY_CONTAINER mkdir -p "$CONTAINER_HOME/.codex"
+docker cp auth.json MY_CONTAINER:"$CONTAINER_HOME/.codex/auth.json"
+```
+
+whereas if you are `ssh`'d into a remote machine, you likely want to use [`scp`](https://en.wikipedia.org/wiki/Secure_copy_protocol):
+
+```shell
+ssh user@remote 'mkdir -p ~/.codex'
+scp ~/.codex/auth.json user@remote:~/.codex/auth.json
+```
+
+or try this one-liner:
+
+```shell
+ssh user@remote 'mkdir -p ~/.codex && cat > ~/.codex/auth.json' < ~/.codex/auth.json
+```
+
+#### Connecting through VPS or remote
If you run Codex on a remote machine (VPS/server) without a local browser, the login helper starts a server on `localhost:1455` on the remote host. To complete login in your local browser, forward that port to your machine before starting the login flow:
From f146981b734a6cc2de8eceebb94cac8f53b3f694 Mon Sep 17 00:00:00 2001
From: Yaroslav
Date: Mon, 11 Aug 2025 03:57:39 +0300
Subject: [PATCH 02/45] =?UTF-8?q?feat:=20add=20JSON=20schema=20sanitizatio?=
=?UTF-8?q?n=20for=20MCP=20tools=20to=20ensure=20compatibil=E2=80=A6=20(#1?=
=?UTF-8?q?975)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
…ity with internal JsonSchema enum
Closes: #1973
Co-authored-by: Dylan Hurd
---
codex-rs/core/src/openai_tools.rs | 334 +++++++++++++++++++++++++++++-
1 file changed, 333 insertions(+), 1 deletion(-)
diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs
index 1c92c07c12..ad794c38f1 100644
--- a/codex-rs/core/src/openai_tools.rs
+++ b/codex-rs/core/src/openai_tools.rs
@@ -1,5 +1,6 @@
use serde::Deserialize;
use serde::Serialize;
+use serde_json::Value as JsonValue;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::HashMap;
@@ -81,6 +82,8 @@ pub(crate) enum JsonSchema {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option,
},
+ /// MCP schema allows "number" | "integer" for Number
+ #[serde(alias = "integer")]
Number {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option,
@@ -296,7 +299,13 @@ pub(crate) fn mcp_tool_to_openai_tool(
input_schema.properties = Some(serde_json::Value::Object(serde_json::Map::new()));
}
- let serialized_input_schema = serde_json::to_value(input_schema)?;
+ // Serialize to a raw JSON value so we can sanitize schemas coming from MCP
+ // servers. Some servers omit the top-level or nested `type` in JSON
+ // Schemas (e.g. using enum/anyOf), or use unsupported variants like
+ // `integer`. Our internal JsonSchema is a small subset and requires
+ // `type`, so we coerce/sanitize here for compatibility.
+ let mut serialized_input_schema = serde_json::to_value(input_schema)?;
+ sanitize_json_schema(&mut serialized_input_schema);
let input_schema = serde_json::from_value::(serialized_input_schema)?;
Ok(ResponsesApiTool {
@@ -307,6 +316,120 @@ pub(crate) fn mcp_tool_to_openai_tool(
})
}
+/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited
+/// JsonSchema enum. This function:
+/// - Ensures every schema object has a "type". If missing, infers it from
+/// common keywords (properties => object, items => array, enum/const/format => string)
+/// and otherwise defaults to "string".
+/// - Fills required child fields (e.g. array items, object properties) with
+/// permissive defaults when absent.
+fn sanitize_json_schema(value: &mut JsonValue) {
+ match value {
+ JsonValue::Bool(_) => {
+ // JSON Schema boolean form: true/false. Coerce to an accept-all string.
+ *value = json!({ "type": "string" });
+ }
+ JsonValue::Array(arr) => {
+ for v in arr.iter_mut() {
+ sanitize_json_schema(v);
+ }
+ }
+ JsonValue::Object(map) => {
+ // First, recursively sanitize known nested schema holders
+ if let Some(props) = map.get_mut("properties") {
+ if let Some(props_map) = props.as_object_mut() {
+ for (_k, v) in props_map.iter_mut() {
+ sanitize_json_schema(v);
+ }
+ }
+ }
+ if let Some(items) = map.get_mut("items") {
+ sanitize_json_schema(items);
+ }
+ // Some schemas use oneOf/anyOf/allOf - sanitize their entries
+ for combiner in ["oneOf", "anyOf", "allOf", "prefixItems"] {
+ if let Some(v) = map.get_mut(combiner) {
+ sanitize_json_schema(v);
+ }
+ }
+
+ // Normalize/ensure type
+ let mut ty = map
+ .get("type")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ // If type is an array (union), pick first supported; else leave to inference
+ if ty.is_none() {
+ if let Some(JsonValue::Array(types)) = map.get("type") {
+ for t in types {
+ if let Some(tt) = t.as_str() {
+ if matches!(
+ tt,
+ "object" | "array" | "string" | "number" | "integer" | "boolean"
+ ) {
+ ty = Some(tt.to_string());
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // Infer type if still missing
+ if ty.is_none() {
+ if map.contains_key("properties")
+ || map.contains_key("required")
+ || map.contains_key("additionalProperties")
+ {
+ ty = Some("object".to_string());
+ } else if map.contains_key("items") || map.contains_key("prefixItems") {
+ ty = Some("array".to_string());
+ } else if map.contains_key("enum")
+ || map.contains_key("const")
+ || map.contains_key("format")
+ {
+ ty = Some("string".to_string());
+ } else if map.contains_key("minimum")
+ || map.contains_key("maximum")
+ || map.contains_key("exclusiveMinimum")
+ || map.contains_key("exclusiveMaximum")
+ || map.contains_key("multipleOf")
+ {
+ ty = Some("number".to_string());
+ }
+ }
+ // If we still couldn't infer, default to string
+ let ty = ty.unwrap_or_else(|| "string".to_string());
+ map.insert("type".to_string(), JsonValue::String(ty.to_string()));
+
+ // Ensure object schemas have properties map
+ if ty == "object" {
+ if !map.contains_key("properties") {
+ map.insert(
+ "properties".to_string(),
+ JsonValue::Object(serde_json::Map::new()),
+ );
+ }
+ // If additionalProperties is an object schema, sanitize it too.
+ // Leave booleans as-is, since JSON Schema allows boolean here.
+ if let Some(ap) = map.get_mut("additionalProperties") {
+ let is_bool = matches!(ap, JsonValue::Bool(_));
+ if !is_bool {
+ sanitize_json_schema(ap);
+ }
+ }
+ }
+
+ // Ensure array schemas have items
+ if ty == "array" && !map.contains_key("items") {
+ map.insert("items".to_string(), json!({ "type": "string" }));
+ }
+ }
+ _ => {}
+ }
+}
+
/// Returns a list of OpenAiTools based on the provided config and MCP tools.
/// Note that the keys of mcp_tools should be fully qualified names. See
/// [`McpConnectionManager`] for more details.
@@ -351,6 +474,7 @@ pub(crate) fn get_openai_tools(
mod tests {
use crate::model_family::find_family_for_model;
use mcp_types::ToolInputSchema;
+ use pretty_assertions::assert_eq;
use super::*;
@@ -497,4 +621,212 @@ mod tests {
})
);
}
+
+ #[test]
+ fn test_mcp_tool_property_missing_type_defaults_to_string() {
+ let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
+ let config = ToolsConfig::new(
+ &model_family,
+ AskForApproval::Never,
+ SandboxPolicy::ReadOnly,
+ false,
+ );
+
+ let tools = get_openai_tools(
+ &config,
+ Some(HashMap::from([(
+ "dash/search".to_string(),
+ mcp_types::Tool {
+ name: "search".to_string(),
+ input_schema: ToolInputSchema {
+ properties: Some(serde_json::json!({
+ "query": {
+ "description": "search query"
+ }
+ })),
+ required: None,
+ r#type: "object".to_string(),
+ },
+ output_schema: None,
+ title: None,
+ annotations: None,
+ description: Some("Search docs".to_string()),
+ },
+ )])),
+ );
+
+ assert_eq_tool_names(&tools, &["shell", "dash/search"]);
+
+ assert_eq!(
+ tools[1],
+ OpenAiTool::Function(ResponsesApiTool {
+ name: "dash/search".to_string(),
+ parameters: JsonSchema::Object {
+ properties: BTreeMap::from([(
+ "query".to_string(),
+ JsonSchema::String {
+ description: Some("search query".to_string())
+ }
+ )]),
+ required: None,
+ additional_properties: None,
+ },
+ description: "Search docs".to_string(),
+ strict: false,
+ })
+ );
+ }
+
+ #[test]
+ fn test_mcp_tool_integer_normalized_to_number() {
+ let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
+ let config = ToolsConfig::new(
+ &model_family,
+ AskForApproval::Never,
+ SandboxPolicy::ReadOnly,
+ false,
+ );
+
+ let tools = get_openai_tools(
+ &config,
+ Some(HashMap::from([(
+ "dash/paginate".to_string(),
+ mcp_types::Tool {
+ name: "paginate".to_string(),
+ input_schema: ToolInputSchema {
+ properties: Some(serde_json::json!({
+ "page": { "type": "integer" }
+ })),
+ required: None,
+ r#type: "object".to_string(),
+ },
+ output_schema: None,
+ title: None,
+ annotations: None,
+ description: Some("Pagination".to_string()),
+ },
+ )])),
+ );
+
+ assert_eq_tool_names(&tools, &["shell", "dash/paginate"]);
+ assert_eq!(
+ tools[1],
+ OpenAiTool::Function(ResponsesApiTool {
+ name: "dash/paginate".to_string(),
+ parameters: JsonSchema::Object {
+ properties: BTreeMap::from([(
+ "page".to_string(),
+ JsonSchema::Number { description: None }
+ )]),
+ required: None,
+ additional_properties: None,
+ },
+ description: "Pagination".to_string(),
+ strict: false,
+ })
+ );
+ }
+
+ #[test]
+ fn test_mcp_tool_array_without_items_gets_default_string_items() {
+ let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
+ let config = ToolsConfig::new(
+ &model_family,
+ AskForApproval::Never,
+ SandboxPolicy::ReadOnly,
+ false,
+ );
+
+ let tools = get_openai_tools(
+ &config,
+ Some(HashMap::from([(
+ "dash/tags".to_string(),
+ mcp_types::Tool {
+ name: "tags".to_string(),
+ input_schema: ToolInputSchema {
+ properties: Some(serde_json::json!({
+ "tags": { "type": "array" }
+ })),
+ required: None,
+ r#type: "object".to_string(),
+ },
+ output_schema: None,
+ title: None,
+ annotations: None,
+ description: Some("Tags".to_string()),
+ },
+ )])),
+ );
+
+ assert_eq_tool_names(&tools, &["shell", "dash/tags"]);
+ assert_eq!(
+ tools[1],
+ OpenAiTool::Function(ResponsesApiTool {
+ name: "dash/tags".to_string(),
+ parameters: JsonSchema::Object {
+ properties: BTreeMap::from([(
+ "tags".to_string(),
+ JsonSchema::Array {
+ items: Box::new(JsonSchema::String { description: None }),
+ description: None
+ }
+ )]),
+ required: None,
+ additional_properties: None,
+ },
+ description: "Tags".to_string(),
+ strict: false,
+ })
+ );
+ }
+
+ #[test]
+ fn test_mcp_tool_anyof_defaults_to_string() {
+ let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
+ let config = ToolsConfig::new(
+ &model_family,
+ AskForApproval::Never,
+ SandboxPolicy::ReadOnly,
+ false,
+ );
+
+ let tools = get_openai_tools(
+ &config,
+ Some(HashMap::from([(
+ "dash/value".to_string(),
+ mcp_types::Tool {
+ name: "value".to_string(),
+ input_schema: ToolInputSchema {
+ properties: Some(serde_json::json!({
+ "value": { "anyOf": [ { "type": "string" }, { "type": "number" } ] }
+ })),
+ required: None,
+ r#type: "object".to_string(),
+ },
+ output_schema: None,
+ title: None,
+ annotations: None,
+ description: Some("AnyOf Value".to_string()),
+ },
+ )])),
+ );
+
+ assert_eq_tool_names(&tools, &["shell", "dash/value"]);
+ assert_eq!(
+ tools[1],
+ OpenAiTool::Function(ResponsesApiTool {
+ name: "dash/value".to_string(),
+ parameters: JsonSchema::Object {
+ properties: BTreeMap::from([(
+ "value".to_string(),
+ JsonSchema::String { description: None }
+ )]),
+ required: None,
+ additional_properties: None,
+ },
+ description: "AnyOf Value".to_string(),
+ strict: false,
+ })
+ );
+ }
}
From 9d8d7d8704083fea6cefe6e7a6863f9775807385 Mon Sep 17 00:00:00 2001
From: Gabriel Peal
Date: Sun, 10 Aug 2025 21:32:56 -0700
Subject: [PATCH 03/45] Middle-truncate tool output and show more lines (#2096)
Command output can contain important bits of information at the
beginning or end. This shows a bit more output and truncates in the
middle.
This will work better paired with
https://github.com/openai/codex/pull/2095 which will omit output for
simple successful reads/searches/etc.
------
https://chatgpt.com/codex/tasks/task_i_68978cd19f9c832cac4975e44dcd99a0
---
codex-rs/tui/src/history_cell.rs | 78 ++++++++++++++++++--------------
1 file changed, 45 insertions(+), 33 deletions(-)
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 3658dc7ed7..43144a648a 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -133,7 +133,7 @@ pub(crate) enum HistoryCell {
PatchApplyResult { view: TextBlock },
}
-const TOOL_CALL_MAX_LINES: usize = 3;
+const TOOL_CALL_MAX_LINES: usize = 5;
fn title_case(s: &str) -> String {
if s.is_empty() {
@@ -194,6 +194,48 @@ impl HistoryCell {
.unwrap_or(0)
}
+ fn output_lines(src: &str) -> Vec> {
+ let lines: Vec<&str> = src.lines().collect();
+ let total = lines.len();
+ let limit = TOOL_CALL_MAX_LINES;
+
+ let mut out = Vec::new();
+
+ let head_end = total.min(limit);
+ for (i, raw) in lines[..head_end].iter().enumerate() {
+ let mut line = ansi_escape_line(raw);
+ let prefix = if i == 0 { " ⎿ " } else { " " };
+ line.spans.insert(0, prefix.into());
+ line.spans.iter_mut().for_each(|span| {
+ span.style = span.style.add_modifier(Modifier::DIM);
+ });
+ out.push(line);
+ }
+
+ // If we will ellipsize less than the limit, just show it.
+ let show_ellipsis = total > 2 * limit;
+ if show_ellipsis {
+ let omitted = total - 2 * limit;
+ out.push(Line::from(format!("… +{omitted} lines")));
+ }
+
+ let tail_start = if show_ellipsis {
+ total - limit
+ } else {
+ head_end
+ };
+ for raw in lines[tail_start..].iter() {
+ let mut line = ansi_escape_line(raw);
+ line.spans.insert(0, " ".into());
+ line.spans.iter_mut().for_each(|span| {
+ span.style = span.style.add_modifier(Modifier::DIM);
+ });
+ out.push(line);
+ }
+
+ out
+ }
+
pub(crate) fn new_session_info(
config: &Config,
event: SessionConfiguredEvent,
@@ -308,27 +350,7 @@ impl HistoryCell {
}
let src = if exit_code == 0 { stdout } else { stderr };
-
- let mut lines_iter = src.lines();
- for (idx, raw) in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES).enumerate() {
- let mut line = ansi_escape_line(raw);
- let prefix = if idx == 0 { " ⎿ " } else { " " };
- line.spans.insert(0, prefix.into());
- line.spans.iter_mut().for_each(|span| {
- span.style = span.style.add_modifier(Modifier::DIM);
- });
- lines.push(line);
- }
- let remaining = lines_iter.count();
- if remaining > 0 {
- let mut more = Line::from(format!("... +{remaining} lines"));
- // Continuation/ellipsis is treated as a subsequent line for prefixing
- more.spans.insert(0, " ".into());
- more.spans.iter_mut().for_each(|span| {
- span.style = span.style.add_modifier(Modifier::DIM);
- });
- lines.push(more);
- }
+ lines.extend(Self::output_lines(&src));
lines.push(Line::from(""));
HistoryCell::CompletedExecCommand {
@@ -813,17 +835,7 @@ impl HistoryCell {
lines.push(Line::from("✘ Failed to apply patch".magenta().bold()));
if !stderr.trim().is_empty() {
- let mut iter = stderr.lines();
- for (i, raw) in iter.by_ref().take(TOOL_CALL_MAX_LINES).enumerate() {
- let prefix = if i == 0 { " ⎿ " } else { " " };
- let s = format!("{prefix}{raw}");
- lines.push(ansi_escape_line(&s).dim());
- }
- let remaining = iter.count();
- if remaining > 0 {
- lines.push(Line::from(""));
- lines.push(Line::from(format!("... +{remaining} lines")).dim());
- }
+ lines.extend(Self::output_lines(&stderr));
}
lines.push(Line::from(""));
From a191945ed696c2e0a61d33995a474e5fd9446b24 Mon Sep 17 00:00:00 2001
From: ae
Date: Mon, 11 Aug 2025 07:19:15 -0700
Subject: [PATCH 04/45] fix: token usage display and context calculation
(#2117)
- I had a recent conversation where the one-liner showed using 11M
tokens! But looking into it 10M were cached. So I looked into it and I
think we had a regression here. ->
- Use blended total tokens for chat composer usage display
- Compute remaining context using tokens_in_context_window helper
------
https://chatgpt.com/codex/tasks/task_i_68981a16c0a4832cbf416017390930e5
---
codex-rs/tui/src/bottom_pane/chat_composer.rs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index 2743ada547..09ff8b7ab3 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -698,14 +698,15 @@ impl WidgetRef for &ChatComposer {
let token_usage = &token_usage_info.total_token_usage;
hint.push(Span::from(" "));
hint.push(
- Span::from(format!("{} tokens used", token_usage.total_tokens))
+ Span::from(format!("{} tokens used", token_usage.blended_total()))
.style(Style::default().add_modifier(Modifier::DIM)),
);
let last_token_usage = &token_usage_info.last_token_usage;
if let Some(context_window) = token_usage_info.model_context_window {
let percent_remaining: u8 = if context_window > 0 {
let percent = 100.0
- - (last_token_usage.total_tokens as f32 / context_window as f32
+ - (last_token_usage.tokens_in_context_window() as f32
+ / context_window as f32
* 100.0);
percent.clamp(0.0, 100.0) as u8
} else {
From c61911524d839f5d56842faee0c46f6ef52d4387 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 11 Aug 2025 09:08:21 -0700
Subject: [PATCH 05/45] chore(deps): bump tokio-util from 0.7.15 to 0.7.16 in
/codex-rs (#2155)
Bumps [tokio-util](https://github.com/tokio-rs/tokio) from 0.7.15 to
0.7.16.
Commits
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
codex-rs/Cargo.lock | 4 ++--
codex-rs/core/Cargo.toml | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 4eddf7bd7b..5dbc2421fa 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -4781,9 +4781,9 @@ dependencies = [
[[package]]
name = "tokio-util"
-version = "0.7.15"
+version = "0.7.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
+checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
dependencies = [
"bytes",
"futures-core",
diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml
index 006a218abf..0f03c3b647 100644
--- a/codex-rs/core/Cargo.toml
+++ b/codex-rs/core/Cargo.toml
@@ -46,7 +46,7 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
-tokio-util = "0.7.14"
+tokio-util = "0.7.16"
toml = "0.9.4"
toml_edit = "0.23.3"
tracing = { version = "0.1.41", features = ["log"] }
From 0aa7efe05b28f07f35570ce2fa0bad406782c444 Mon Sep 17 00:00:00 2001
From: pakrym-oai
Date: Mon, 11 Aug 2025 10:35:03 -0700
Subject: [PATCH 06/45] Trace RAW sse events (#2056)
For easier parsing.
---
codex-rs/core/src/client.rs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index 0caf1170a6..ad08782b6d 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -403,6 +403,8 @@ async fn process_sse(
}
};
+ trace!("SSE event: {}", sse.data);
+
let event: SseEvent = match serde_json::from_str(&sse.data) {
Ok(event) => event,
Err(e) => {
@@ -411,7 +413,6 @@ async fn process_sse(
}
};
- trace!(?event, "SSE event");
match event.kind.as_str() {
// Individual output item finalised. Forward immediately so the
// rest of the agent can stream assistant text/functions *live*
From fa0a879444a13b1ed1445cc21b78a4a740ac37f0 Mon Sep 17 00:00:00 2001
From: aibrahim-oai
Date: Mon, 11 Aug 2025 10:41:23 -0700
Subject: [PATCH 07/45] show feedback message after /Compact command (#2162)
This PR updates ChatWidget to ensure that when AgentMessage,
AgentReasoning, or AgentReasoningRawContent events arrive without any
streamed deltas, the final text from the event is rendered before the
stream is finalized. Previously, these handlers ignored the event text
in such cases, relying solely on prior deltas.
---
codex-rs/tui/src/chatwidget.rs | 26 ++++++++++++++++++--------
1 file changed, 18 insertions(+), 8 deletions(-)
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 344f025842..0362a678b1 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -311,10 +311,12 @@ impl ChatWidget<'_> {
self.request_redraw();
}
- EventMsg::AgentMessage(AgentMessageEvent { message: _ }) => {
- // Final assistant answer: commit all remaining rows and close with
- // a blank line. Use the final text if provided, otherwise rely on
- // streamed deltas already in the builder.
+ EventMsg::AgentMessage(AgentMessageEvent { message }) => {
+ // AgentMessage: if no deltas were streamed, render the final text.
+ if self.current_stream != Some(StreamKind::Answer) && !message.is_empty() {
+ self.begin_stream(StreamKind::Answer);
+ self.stream_push_and_maybe_commit(&message);
+ }
self.finalize_stream(StreamKind::Answer);
self.request_redraw();
}
@@ -332,8 +334,12 @@ impl ChatWidget<'_> {
self.stream_push_and_maybe_commit(&delta);
self.request_redraw();
}
- EventMsg::AgentReasoning(AgentReasoningEvent { text: _ }) => {
- // Final reasoning: commit remaining rows and close with a blank.
+ EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
+ // Final reasoning: if no deltas were streamed, render the final text.
+ if self.current_stream != Some(StreamKind::Reasoning) && !text.is_empty() {
+ self.begin_stream(StreamKind::Reasoning);
+ self.stream_push_and_maybe_commit(&text);
+ }
self.finalize_stream(StreamKind::Reasoning);
self.request_redraw();
}
@@ -346,8 +352,12 @@ impl ChatWidget<'_> {
self.stream_push_and_maybe_commit(&delta);
self.request_redraw();
}
- EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text: _ }) => {
- // Finalize the raw reasoning stream just like the summarized reasoning event.
+ EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
+ // Final raw reasoning content: if no deltas were streamed, render the final text.
+ if self.current_stream != Some(StreamKind::Reasoning) && !text.is_empty() {
+ self.begin_stream(StreamKind::Reasoning);
+ self.stream_push_and_maybe_commit(&text);
+ }
self.finalize_stream(StreamKind::Reasoning);
self.request_redraw();
}
From 7f6408720b66204a4ac8b2432515de7447acaa21 Mon Sep 17 00:00:00 2001
From: Gabriel Peal
Date: Mon, 11 Aug 2025 11:26:15 -0700
Subject: [PATCH 08/45] [1/3] Parse exec commands and format them more nicely
in the UI (#2095)
# Note for reviewers
The bulk of this PR is in in the new file, `parse_command.rs`. This file
is designed to be written TDD and implemented with Codex. Do not worry
about reviewing the code, just review the unit tests (if you want). If
any cases are missing, we'll add more tests and have Codex fix them.
I think the best approach will be to land and iterate. I have some
follow-ups I want to do after this lands. The next PR after this will
let us merge (and dedupe) multiple sequential cells of the same such as
multiple read commands. The deduping will also be important because the
model often reads the same file multiple times in a row in chunks
===
This PR formats common commands like reading, formatting, testing, etc
more nicely:
It tries to extract things like file names, tests and falls back to the
cmd if it doesn't. It also only shows stdout/err if the command failed.
Part 2: https://github.com/openai/codex/pull/2097
Part 3: https://github.com/openai/codex/pull/2110
---
AGENTS.md | 2 +
codex-rs/core/src/codex.rs | 2 +
codex-rs/core/src/lib.rs | 1 +
codex-rs/core/src/parse_command.rs | 2045 +++++++++++++++++
codex-rs/core/src/protocol.rs | 2 +
.../src/event_processor_with_human_output.rs | 1 +
codex-rs/mcp-server/src/mcp_protocol.rs | 4 +-
codex-rs/tui/src/chatwidget.rs | 16 +-
codex-rs/tui/src/history_cell.rs | 231 +-
codex-rs/tui/src/user_approval_widget.rs | 4 +-
10 files changed, 2225 insertions(+), 83 deletions(-)
create mode 100644 codex-rs/core/src/parse_command.rs
diff --git a/AGENTS.md b/AGENTS.md
index 5c3f659c35..af25482795 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,8 @@
In the codex-rs folder where the rust code lives:
+- Crate names are prefixed with `codex-`. For examole, the `core` folder's crate is named `codex-core`
+- When using format! and you can inline variables into {}, always do that.
- Never add or modify any code related to `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` or `CODEX_SANDBOX_ENV_VAR`.
- You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations.
- Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate.
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 3bf6288fdf..3fcac51bdc 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -65,6 +65,7 @@ use crate::models::ResponseItem;
use crate::models::ShellToolCallParams;
use crate::openai_tools::ToolsConfig;
use crate::openai_tools::get_openai_tools;
+use crate::parse_command::parse_command;
use crate::plan_tool::handle_update_plan;
use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageDeltaEvent;
@@ -402,6 +403,7 @@ impl Session {
call_id,
command: command_for_display.clone(),
cwd,
+ parsed_cmd: parse_command(&command_for_display),
}),
};
let event = Event {
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
index c728bd3125..b36689f057 100644
--- a/codex-rs/core/src/lib.rs
+++ b/codex-rs/core/src/lib.rs
@@ -28,6 +28,7 @@ mod mcp_connection_manager;
mod mcp_tool_call;
mod message_history;
mod model_provider_info;
+pub mod parse_command;
pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;
pub use model_provider_info::ModelProviderInfo;
pub use model_provider_info::WireApi;
diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs
new file mode 100644
index 0000000000..01dc6e3227
--- /dev/null
+++ b/codex-rs/core/src/parse_command.rs
@@ -0,0 +1,2045 @@
+use crate::bash::try_parse_bash;
+use crate::bash::try_parse_word_only_commands_sequence;
+use serde::Deserialize;
+use serde::Serialize;
+use shlex::split as shlex_split;
+
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
+pub enum ParsedCommand {
+ Read {
+ cmd: Vec,
+ name: String,
+ },
+ ListFiles {
+ cmd: Vec,
+ path: Option,
+ },
+ Search {
+ cmd: Vec,
+ query: Option,
+ path: Option,
+ },
+ Format {
+ cmd: Vec,
+ tool: Option,
+ targets: Option>,
+ },
+ Test {
+ cmd: Vec,
+ },
+ Lint {
+ cmd: Vec,
+ tool: Option,
+ targets: Option>,
+ },
+ Unknown {
+ cmd: Vec,
+ },
+}
+
+/// DO NOT REVIEW THIS CODE BY HAND
+/// This parsing code is quite complex and not easy to hand-modify.
+/// The easiest way to iterate is to add unit tests and have Codex fix the implementation.
+/// To encourage this, the tests have been put directly below this function rather than at the bottom of the
+///
+/// Parses metadata out of an arbitrary command.
+/// These commands are model driven and could include just about anything.
+/// The parsing is slightly lossy due to the ~infinite expressiveness of an arbitrary command.
+/// The goal of the parsed metadata is to be able to provide the user with a human readable gis
+/// of what it is doing.
+pub fn parse_command(command: &[String]) -> Vec {
+ // Parse and then collapse consecutive duplicate commands to avoid redundant summaries.
+ let parsed = parse_command_impl(command);
+ let mut deduped: Vec = Vec::with_capacity(parsed.len());
+ for cmd in parsed.into_iter() {
+ if deduped.last().is_some_and(|prev| prev == &cmd) {
+ continue;
+ }
+ deduped.push(cmd);
+ }
+ deduped
+}
+
+#[cfg(test)]
+#[allow(clippy::items_after_test_module)]
+/// Tests are at the top to encourage using TDD + Codex to fix the implementation.
+mod tests {
+ use super::*;
+
+ fn shlex_split_safe(s: &str) -> Vec {
+ shlex_split(s).unwrap_or_else(|| s.split_whitespace().map(|s| s.to_string()).collect())
+ }
+
+ fn vec_str(args: &[&str]) -> Vec {
+ args.iter().map(|s| s.to_string()).collect()
+ }
+
+ fn assert_parsed(args: &[String], expected: Vec) {
+ let out = parse_command(args);
+ assert_eq!(out, expected);
+ }
+
+ #[test]
+ fn git_status_is_unknown() {
+ assert_parsed(
+ &vec_str(&["git", "status"]),
+ vec![ParsedCommand::Unknown {
+ cmd: vec_str(&["git", "status"]),
+ }],
+ );
+ }
+
+ #[test]
+ fn handles_complex_bash_command_head() {
+ let inner =
+ "rg --version && node -v && pnpm -v && rg --files | wc -l && rg --files | head -n 40";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![
+ // Expect commands in left-to-right execution order
+ ParsedCommand::Search {
+ cmd: vec_str(&["rg", "--version"]),
+ query: None,
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: vec_str(&["node", "-v"]),
+ },
+ ParsedCommand::Unknown {
+ cmd: vec_str(&["pnpm", "-v"]),
+ },
+ ParsedCommand::Search {
+ cmd: vec_str(&["rg", "--files"]),
+ query: None,
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: vec_str(&["head", "-n", "40"]),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn supports_searching_for_navigate_to_route() -> anyhow::Result<()> {
+ let inner = "rg -n \"navigate-to-route\" -S";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe(inner),
+ query: Some("navigate-to-route".to_string()),
+ path: None,
+ }],
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn handles_complex_bash_command() {
+ let inner = "rg -n \"BUG|FIXME|TODO|XXX|HACK\" -S | head -n 200";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![
+ ParsedCommand::Search {
+ cmd: vec_str(&["rg", "-n", "BUG|FIXME|TODO|XXX|HACK", "-S"]),
+ query: Some("BUG|FIXME|TODO|XXX|HACK".to_string()),
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: vec_str(&["head", "-n", "200"]),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn supports_rg_files_with_path_and_pipe() {
+ let inner = "rg --files webview/src | sed -n";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Search {
+ cmd: vec_str(&["rg", "--files", "webview/src"]),
+ query: None,
+ path: Some("webview".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_rg_files_then_head() {
+ let inner = "rg --files | head -n 50";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![
+ ParsedCommand::Search {
+ cmd: vec_str(&["rg", "--files"]),
+ query: None,
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: vec_str(&["head", "-n", "50"]),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn supports_cat() {
+ let inner = "cat webview/README.md";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "README.md".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_ls_with_pipe() {
+ let inner = "ls -la | sed -n '1,120p'";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::ListFiles {
+ cmd: vec_str(&["ls", "-la"]),
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_head_n() {
+ let inner = "head -n 50 Cargo.toml";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "Cargo.toml".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_cat_sed_n() {
+ let inner = "cat tui/Cargo.toml | sed -n '1,200p'";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "Cargo.toml".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_tail_n_plus() {
+ let inner = "tail -n +522 README.md";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "README.md".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_tail_n_last_lines() {
+ let inner = "tail -n 30 README.md";
+ let out = parse_command(&vec_str(&["bash", "-lc", inner]));
+ assert_eq!(
+ out,
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "README.md".to_string(),
+ }]
+ );
+ }
+
+ #[test]
+ fn supports_npm_run_build_is_unknown() {
+ assert_parsed(
+ &vec_str(&["npm", "run", "build"]),
+ vec![ParsedCommand::Unknown {
+ cmd: vec_str(&["npm", "run", "build"]),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_npm_run_with_forwarded_args() {
+ assert_parsed(
+ &vec_str(&[
+ "npm",
+ "run",
+ "lint",
+ "--",
+ "--max-warnings",
+ "0",
+ "--format",
+ "json",
+ ]),
+ vec![ParsedCommand::Lint {
+ cmd: vec_str(&[
+ "npm",
+ "run",
+ "lint",
+ "--",
+ "--max-warnings",
+ "0",
+ "--format",
+ "json",
+ ]),
+ tool: Some("npm-script:lint".to_string()),
+ targets: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_grep_recursive_current_dir() {
+ assert_parsed(
+ &vec_str(&["grep", "-R", "CODEX_SANDBOX_ENV_VAR", "-n", "."]),
+ vec![ParsedCommand::Search {
+ cmd: vec_str(&["grep", "-R", "CODEX_SANDBOX_ENV_VAR", "-n", "."]),
+ query: Some("CODEX_SANDBOX_ENV_VAR".to_string()),
+ path: Some(".".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_grep_recursive_specific_file() {
+ assert_parsed(
+ &vec_str(&[
+ "grep",
+ "-R",
+ "CODEX_SANDBOX_ENV_VAR",
+ "-n",
+ "core/src/spawn.rs",
+ ]),
+ vec![ParsedCommand::Search {
+ cmd: vec_str(&[
+ "grep",
+ "-R",
+ "CODEX_SANDBOX_ENV_VAR",
+ "-n",
+ "core/src/spawn.rs",
+ ]),
+ query: Some("CODEX_SANDBOX_ENV_VAR".to_string()),
+ path: Some("spawn.rs".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_grep_query_with_slashes_not_shortened() {
+ // Query strings may contain slashes and should not be shortened to the basename.
+ // Previously, grep queries were passed through short_display_path, which is incorrect.
+ assert_parsed(
+ &shlex_split_safe("grep -R src/main.rs -n ."),
+ vec![ParsedCommand::Search {
+ cmd: vec_str(&["grep", "-R", "src/main.rs", "-n", "."]),
+ query: Some("src/main.rs".to_string()),
+ path: Some(".".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_grep_weird_backtick_in_query() {
+ assert_parsed(
+ &shlex_split_safe("grep -R COD`EX_SANDBOX -n"),
+ vec![ParsedCommand::Search {
+ cmd: vec_str(&["grep", "-R", "COD`EX_SANDBOX", "-n"]),
+ query: Some("COD`EX_SANDBOX".to_string()),
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_cd_and_rg_files() {
+ assert_parsed(
+ &shlex_split_safe("cd codex-rs && rg --files"),
+ vec![
+ ParsedCommand::Unknown {
+ cmd: vec_str(&["cd", "codex-rs"]),
+ },
+ ParsedCommand::Search {
+ cmd: vec_str(&["rg", "--files"]),
+ query: None,
+ path: None,
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn echo_then_cargo_test_sequence() {
+ assert_parsed(
+ &shlex_split_safe("echo Running tests... && cargo test --all-features --quiet"),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&["cargo", "test", "--all-features", "--quiet"]),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_cargo_fmt_and_test_with_config() {
+ assert_parsed(
+ &shlex_split_safe(
+ "cargo fmt -- --config imports_granularity=Item && cargo test -p core --all-features",
+ ),
+ vec![
+ ParsedCommand::Format {
+ cmd: shlex_split_safe("cargo fmt -- --config imports_granularity=Item"),
+ tool: Some("cargo fmt".to_string()),
+ targets: None,
+ },
+ ParsedCommand::Test {
+ cmd: vec_str(&["cargo", "test", "-p", "core", "--all-features"]),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn recognizes_rustfmt_and_clippy() {
+ assert_parsed(
+ &shlex_split_safe("rustfmt src/main.rs"),
+ vec![ParsedCommand::Format {
+ cmd: vec_str(&["rustfmt", "src/main.rs"]),
+ tool: Some("rustfmt".to_string()),
+ targets: Some(vec!["src/main.rs".to_string()]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("cargo clippy -p core --all-features -- -D warnings"),
+ vec![ParsedCommand::Lint {
+ cmd: vec_str(&[
+ "cargo",
+ "clippy",
+ "-p",
+ "core",
+ "--all-features",
+ "--",
+ "-D",
+ "warnings",
+ ]),
+ tool: Some("cargo clippy".to_string()),
+ targets: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn recognizes_pytest_go_and_tools() {
+ assert_parsed(
+ &shlex_split_safe(
+ "pytest -k 'Login and not slow' tests/test_login.py::TestLogin::test_ok",
+ ),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&[
+ "pytest",
+ "-k",
+ "Login and not slow",
+ "tests/test_login.py::TestLogin::test_ok",
+ ]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("go fmt ./..."),
+ vec![ParsedCommand::Format {
+ cmd: vec_str(&["go", "fmt", "./..."]),
+ tool: Some("go fmt".to_string()),
+ targets: Some(vec!["./...".to_string()]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("go test ./pkg -run TestThing"),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&["go", "test", "./pkg", "-run", "TestThing"]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("eslint . --max-warnings 0"),
+ vec![ParsedCommand::Lint {
+ cmd: vec_str(&["eslint", ".", "--max-warnings", "0"]),
+ tool: Some("eslint".to_string()),
+ targets: Some(vec![".".to_string()]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("prettier -w ."),
+ vec![ParsedCommand::Format {
+ cmd: vec_str(&["prettier", "-w", "."]),
+ tool: Some("prettier".to_string()),
+ targets: Some(vec![".".to_string()]),
+ }],
+ );
+ }
+
+ #[test]
+ fn recognizes_jest_and_vitest_filters() {
+ assert_parsed(
+ &shlex_split_safe("jest -t 'should work' src/foo.test.ts"),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&["jest", "-t", "should work", "src/foo.test.ts"]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("vitest -t 'runs' src/foo.test.tsx"),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&["vitest", "-t", "runs", "src/foo.test.tsx"]),
+ }],
+ );
+ }
+
+ #[test]
+ fn recognizes_npx_and_scripts() {
+ assert_parsed(
+ &shlex_split_safe("npx eslint src"),
+ vec![ParsedCommand::Lint {
+ cmd: vec_str(&["npx", "eslint", "src"]),
+ tool: Some("eslint".to_string()),
+ targets: Some(vec!["src".to_string()]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("npx prettier -c ."),
+ vec![ParsedCommand::Format {
+ cmd: vec_str(&["npx", "prettier", "-c", "."]),
+ tool: Some("prettier".to_string()),
+ targets: Some(vec![".".to_string()]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("pnpm run lint -- --max-warnings 0"),
+ vec![ParsedCommand::Lint {
+ cmd: vec_str(&["pnpm", "run", "lint", "--", "--max-warnings", "0"]),
+ tool: Some("pnpm-script:lint".to_string()),
+ targets: None,
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("npm test"),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&["npm", "test"]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("yarn test"),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&["yarn", "test"]),
+ }],
+ );
+ }
+
+ // ---- is_small_formatting_command unit tests ----
+ #[test]
+ fn small_formatting_always_true_commands() {
+ for cmd in [
+ "wc", "tr", "cut", "sort", "uniq", "xargs", "tee", "column", "awk",
+ ] {
+ assert!(is_small_formatting_command(&shlex_split_safe(cmd)));
+ assert!(is_small_formatting_command(&shlex_split_safe(&format!(
+ "{cmd} -x"
+ ))));
+ }
+ }
+
+ #[test]
+ fn head_behavior() {
+ // No args -> small formatting
+ assert!(is_small_formatting_command(&vec_str(&["head"])));
+ // Numeric count only -> not considered small formatting by implementation
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "head -n 40"
+ )));
+ // With explicit file -> not small formatting
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "head -n 40 file.txt"
+ )));
+ // File only (no count) -> treated as small formatting by implementation
+ assert!(is_small_formatting_command(&vec_str(&["head", "file.txt"])));
+ }
+
+ #[test]
+ fn tail_behavior() {
+ // No args -> small formatting
+ assert!(is_small_formatting_command(&vec_str(&["tail"])));
+ // Numeric with plus offset -> not small formatting
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "tail -n +10"
+ )));
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "tail -n +10 file.txt"
+ )));
+ // Numeric count
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "tail -n 30"
+ )));
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "tail -n 30 file.txt"
+ )));
+ // File only -> small formatting by implementation
+ assert!(is_small_formatting_command(&vec_str(&["tail", "file.txt"])));
+ }
+
+ #[test]
+ fn sed_behavior() {
+ // Plain sed -> small formatting
+ assert!(is_small_formatting_command(&vec_str(&["sed"])));
+ // sed -n (no file) -> still small formatting
+ assert!(is_small_formatting_command(&vec_str(&["sed", "-n", "10p"])));
+ // Valid range with file -> not small formatting
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "sed -n 10p file.txt"
+ )));
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "sed -n 1,200p file.txt"
+ )));
+ // Invalid ranges with file -> small formatting
+ assert!(is_small_formatting_command(&shlex_split_safe(
+ "sed -n p file.txt"
+ )));
+ assert!(is_small_formatting_command(&shlex_split_safe(
+ "sed -n +10p file.txt"
+ )));
+ }
+
+ #[test]
+ fn empty_tokens_is_not_small() {
+ let empty: Vec = Vec::new();
+ assert!(!is_small_formatting_command(&empty));
+ }
+
+ #[test]
+ fn supports_nl_then_sed_reading() {
+ let inner = "nl -ba core/src/parse_command.rs | sed -n '1200,1720p'";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "parse_command.rs".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_sed_n() {
+ let inner = "sed -n '2000,2200p' tui/src/history_cell.rs";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "history_cell.rs".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn filters_out_printf() {
+ let inner =
+ r#"printf "\n===== ansi-escape/Cargo.toml =====\n"; cat -- ansi-escape/Cargo.toml"#;
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe("cat -- ansi-escape/Cargo.toml"),
+ name: "Cargo.toml".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn drops_yes_in_pipelines() {
+ // Inside bash -lc, `yes | rg --files` should focus on the primary command.
+ let inner = "yes | rg --files";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Search {
+ cmd: vec_str(&["rg", "--files"]),
+ query: None,
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_sed_n_then_nl_as_search() {
+ // Ensure `sed -n '' | nl -ba` is summarized as a search for that file.
+ let args = shlex_split_safe(
+ "sed -n '260,640p' exec/src/event_processor_with_human_output.rs | nl -ba",
+ );
+ assert_parsed(
+ &args,
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(
+ "sed -n '260,640p' exec/src/event_processor_with_human_output.rs",
+ ),
+ name: "event_processor_with_human_output.rs".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn preserves_rg_with_spaces() {
+ assert_parsed(
+ &shlex_split_safe("yes | rg -n 'foo bar' -S"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg -n 'foo bar' -S"),
+ query: Some("foo bar".to_string()),
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn ls_with_glob() {
+ assert_parsed(
+ &shlex_split_safe("ls -I '*.test.js'"),
+ vec![ParsedCommand::ListFiles {
+ cmd: shlex_split_safe("ls -I '*.test.js'"),
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn trim_on_semicolon() {
+ assert_parsed(
+ &shlex_split_safe("rg foo ; echo done"),
+ vec![
+ ParsedCommand::Search {
+ cmd: shlex_split_safe("rg foo"),
+ query: Some("foo".to_string()),
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: shlex_split_safe("echo done"),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn split_on_or_connector() {
+ // Ensure we split commands on the logical OR operator as well.
+ assert_parsed(
+ &shlex_split_safe("rg foo || echo done"),
+ vec![
+ ParsedCommand::Search {
+ cmd: shlex_split_safe("rg foo"),
+ query: Some("foo".to_string()),
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: shlex_split_safe("echo done"),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn strips_true_in_sequence() {
+ // `true` should be dropped from parsed sequences
+ assert_parsed(
+ &shlex_split_safe("true && rg --files"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --files"),
+ query: None,
+ path: None,
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("rg --files && true"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --files"),
+ query: None,
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn strips_true_inside_bash_lc() {
+ let inner = "true && rg --files";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --files"),
+ query: None,
+ path: None,
+ }],
+ );
+
+ let inner2 = "rg --files || true";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner2]),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --files"),
+ query: None,
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn shorten_path_on_windows() {
+ assert_parsed(
+ &shlex_split_safe(r#"cat "pkg\src\main.rs""#),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(r#"cat "pkg\src\main.rs""#),
+ name: "main.rs".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn head_with_no_space() {
+ assert_parsed(
+ &shlex_split_safe("bash -lc 'head -n50 Cargo.toml'"),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe("head -n50 Cargo.toml"),
+ name: "Cargo.toml".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn bash_dash_c_pipeline_parsing() {
+ // Ensure -c is handled similarly to -lc by normalization
+ let inner = "rg --files | head -n 1";
+ assert_parsed(
+ &shlex_split_safe(inner),
+ vec![
+ ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --files"),
+ query: None,
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: shlex_split_safe("head -n 1"),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn tail_with_no_space() {
+ assert_parsed(
+ &shlex_split_safe("bash -lc 'tail -n+10 README.md'"),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe("tail -n+10 README.md"),
+ name: "README.md".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn pnpm_test_is_parsed_as_test() {
+ assert_parsed(
+ &shlex_split_safe("pnpm test"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("pnpm test"),
+ }],
+ );
+ }
+
+ #[test]
+ fn pnpm_exec_vitest_is_unknown() {
+ // From commands_combined: cd codex-cli && pnpm exec vitest run tests/... --threads=false --passWithNoTests
+ let inner = "cd codex-cli && pnpm exec vitest run tests/file-tag-utils.test.ts --threads=false --passWithNoTests";
+ assert_parsed(
+ &shlex_split_safe(inner),
+ vec![
+ ParsedCommand::Unknown {
+ cmd: shlex_split_safe("cd codex-cli"),
+ },
+ ParsedCommand::Unknown {
+ cmd: shlex_split_safe(
+ "pnpm exec vitest run tests/file-tag-utils.test.ts --threads=false --passWithNoTests",
+ ),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn cargo_test_with_crate() {
+ assert_parsed(
+ &shlex_split_safe("cargo test -p codex-core parse_command::"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("cargo test -p codex-core parse_command::"),
+ }],
+ );
+ }
+
+ #[test]
+ fn cargo_test_with_crate_2() {
+ assert_parsed(
+ &shlex_split_safe(
+ "cd core && cargo test -q parse_command::tests::bash_dash_c_pipeline_parsing parse_command::tests::fd_file_finder_variants",
+ ),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe(
+ "cargo test -q parse_command::tests::bash_dash_c_pipeline_parsing parse_command::tests::fd_file_finder_variants",
+ ),
+ }],
+ );
+ }
+
+ #[test]
+ fn cargo_test_with_crate_3() {
+ assert_parsed(
+ &shlex_split_safe("cd core && cargo test -q parse_command::tests"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("cargo test -q parse_command::tests"),
+ }],
+ );
+ }
+
+ #[test]
+ fn cargo_test_with_crate_4() {
+ assert_parsed(
+ &shlex_split_safe("cd core && cargo test --all-features parse_command -- --nocapture"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("cargo test --all-features parse_command -- --nocapture"),
+ }],
+ );
+ }
+
+ // Additional coverage for other common tools/frameworks
+ #[test]
+ fn recognizes_black_and_ruff() {
+ // black formats Python code
+ assert_parsed(
+ &shlex_split_safe("black src"),
+ vec![ParsedCommand::Format {
+ cmd: shlex_split_safe("black src"),
+ tool: Some("black".to_string()),
+ targets: Some(vec!["src".to_string()]),
+ }],
+ );
+
+ // ruff check is a linter; ensure we collect targets
+ assert_parsed(
+ &shlex_split_safe("ruff check ."),
+ vec![ParsedCommand::Lint {
+ cmd: shlex_split_safe("ruff check ."),
+ tool: Some("ruff".to_string()),
+ targets: Some(vec![".".to_string()]),
+ }],
+ );
+
+ // ruff format is a formatter
+ assert_parsed(
+ &shlex_split_safe("ruff format pkg/"),
+ vec![ParsedCommand::Format {
+ cmd: shlex_split_safe("ruff format pkg/"),
+ tool: Some("ruff".to_string()),
+ targets: Some(vec!["pkg/".to_string()]),
+ }],
+ );
+ }
+
+ #[test]
+ fn recognizes_pnpm_monorepo_test_and_npm_format_script() {
+ // pnpm -r test in a monorepo should still parse as a test action
+ assert_parsed(
+ &shlex_split_safe("pnpm -r test"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("pnpm -r test"),
+ }],
+ );
+
+ // npm run format should be recognized as a format action
+ assert_parsed(
+ &shlex_split_safe("npm run format -- -w ."),
+ vec![ParsedCommand::Format {
+ cmd: shlex_split_safe("npm run format -- -w ."),
+ tool: Some("npm-script:format".to_string()),
+ targets: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn yarn_test_is_parsed_as_test() {
+ assert_parsed(
+ &shlex_split_safe("yarn test"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("yarn test"),
+ }],
+ );
+ }
+
+ #[test]
+ fn pytest_file_only_and_go_run_regex() {
+ // pytest invoked with a file path should be captured as a filter
+ assert_parsed(
+ &shlex_split_safe("pytest tests/test_example.py"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("pytest tests/test_example.py"),
+ }],
+ );
+
+ // go test with -run regex should capture the filter
+ assert_parsed(
+ &shlex_split_safe("go test ./... -run '^TestFoo$'"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("go test ./... -run '^TestFoo$'"),
+ }],
+ );
+ }
+
+ #[test]
+ fn grep_with_query_and_path() {
+ assert_parsed(
+ &shlex_split_safe("grep -R TODO src"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("grep -R TODO src"),
+ query: Some("TODO".to_string()),
+ path: Some("src".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn rg_with_equals_style_flags() {
+ assert_parsed(
+ &shlex_split_safe("rg --colors=never -n foo src"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --colors=never -n foo src"),
+ query: Some("foo".to_string()),
+ path: Some("src".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn cat_with_double_dash_and_sed_ranges() {
+ // cat -- should be treated as a read of that file
+ assert_parsed(
+ &shlex_split_safe("cat -- ./-strange-file-name"),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe("cat -- ./-strange-file-name"),
+ name: "-strange-file-name".to_string(),
+ }],
+ );
+
+ // sed -n should be treated as a read of
+ assert_parsed(
+ &shlex_split_safe("sed -n '12,20p' Cargo.toml"),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe("sed -n '12,20p' Cargo.toml"),
+ name: "Cargo.toml".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn drop_trailing_nl_in_pipeline() {
+ // When an `nl` stage has only flags, it should be dropped from the summary
+ assert_parsed(
+ &shlex_split_safe("rg --files | nl -ba"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --files"),
+ query: None,
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn ls_with_time_style_and_path() {
+ assert_parsed(
+ &shlex_split_safe("ls --time-style=long-iso ./dist"),
+ vec![ParsedCommand::ListFiles {
+ cmd: shlex_split_safe("ls --time-style=long-iso ./dist"),
+ // short_display_path drops "dist" and shows "." as the last useful segment
+ path: Some(".".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn eslint_with_config_path_and_target() {
+ assert_parsed(
+ &shlex_split_safe("eslint -c .eslintrc.json src"),
+ vec![ParsedCommand::Lint {
+ cmd: shlex_split_safe("eslint -c .eslintrc.json src"),
+ tool: Some("eslint".to_string()),
+ targets: Some(vec!["src".to_string()]),
+ }],
+ );
+ }
+
+ #[test]
+ fn npx_eslint_with_config_path_and_target() {
+ assert_parsed(
+ &shlex_split_safe("npx eslint -c .eslintrc src"),
+ vec![ParsedCommand::Lint {
+ cmd: shlex_split_safe("npx eslint -c .eslintrc src"),
+ tool: Some("eslint".to_string()),
+ targets: Some(vec!["src".to_string()]),
+ }],
+ );
+ }
+
+ #[test]
+ fn fd_file_finder_variants() {
+ assert_parsed(
+ &shlex_split_safe("fd -t f src/"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("fd -t f src/"),
+ query: None,
+ path: Some("src".to_string()),
+ }],
+ );
+
+ // fd with query and path should capture both
+ assert_parsed(
+ &shlex_split_safe("fd main src"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("fd main src"),
+ query: Some("main".to_string()),
+ path: Some("src".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn find_basic_name_filter() {
+ assert_parsed(
+ &shlex_split_safe("find . -name '*.rs'"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("find . -name '*.rs'"),
+ query: Some("*.rs".to_string()),
+ path: Some(".".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn find_type_only_path() {
+ assert_parsed(
+ &shlex_split_safe("find src -type f"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("find src -type f"),
+ query: None,
+ path: Some("src".to_string()),
+ }],
+ );
+ }
+}
+
+pub fn parse_command_impl(command: &[String]) -> Vec {
+ let normalized = normalize_tokens(command);
+
+ if let Some(commands) = parse_bash_lc_commands(command, &normalized) {
+ return commands;
+ }
+
+ let parts = if contains_connectors(&normalized) {
+ split_on_connectors(&normalized)
+ } else {
+ vec![normalized.clone()]
+ };
+
+ // Preserve left-to-right execution order for all commands, including bash -c/-lc
+ // so summaries reflect the order they will run.
+
+ // Map each pipeline segment to its parsed summary.
+ let mut parsed: Vec = parts
+ .iter()
+ .map(|tokens| summarize_main_tokens(tokens))
+ .collect();
+
+ // If a pipeline ends with `nl` using only flags (e.g., `| nl -ba`), drop it so the
+ // main action (e.g., a sed range over a file) is surfaced cleanly.
+ if parsed.len() >= 2 {
+ let has_and_and = normalized.iter().any(|t| t == "&&");
+ let contains_test = parsed
+ .iter()
+ .any(|pc| matches!(pc, ParsedCommand::Test { .. }));
+ parsed.retain(|pc| match pc {
+ ParsedCommand::Unknown { cmd } => {
+ if let Some(first) = cmd.first() {
+ // Drop cosmetic echo segments in chained commands
+ if has_and_and && first == "echo" {
+ return false;
+ }
+ // In non-bash chained commands, ignore directory changes like `cd foo`
+ // when the sequence includes a recognized test command. Preserve `cd`
+ // for other sequences (e.g., followed by a search command).
+ if has_and_and && contains_test && first == "cd" {
+ return false;
+ }
+ // Drop no-op commands like `true`
+ if cmd.len() == 1 && first == "true" {
+ return false;
+ }
+ if first == "nl" {
+ // Treat `nl` without an explicit file operand as formatting-only.
+ return cmd.iter().skip(1).any(|a| !a.starts_with('-'));
+ }
+ }
+ true
+ }
+ _ => true,
+ });
+ }
+
+ // Also drop standalone `true` commands when not part of a chained `&&` context above
+ parsed.retain(|pc| match pc {
+ ParsedCommand::Unknown { cmd } => {
+ !(cmd.len() == 1 && cmd.first().is_some_and(|s| s == "true"))
+ }
+ _ => true,
+ });
+
+ parsed
+}
+
+/// Validates that this is a `sed -n 123,123p` command.
+fn is_valid_sed_n_arg(arg: Option<&str>) -> bool {
+ let s = match arg {
+ Some(s) => s,
+ None => return false,
+ };
+ let core = match s.strip_suffix('p') {
+ Some(rest) => rest,
+ None => return false,
+ };
+ let parts: Vec<&str> = core.split(',').collect();
+ match parts.as_slice() {
+ [num] => !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()),
+ [a, b] => {
+ !a.is_empty()
+ && !b.is_empty()
+ && a.chars().all(|c| c.is_ascii_digit())
+ && b.chars().all(|c| c.is_ascii_digit())
+ }
+ _ => false,
+ }
+}
+
+/// Normalize a command by:
+/// - Removing `yes`/`no`/`bash -c`/`bash -lc` prefixes.
+/// - Splitting on `|` and `&&`/`||`/`;
+fn normalize_tokens(cmd: &[String]) -> Vec {
+ match cmd {
+ [first, pipe, rest @ ..] if (first == "yes" || first == "y") && pipe == "|" => {
+ // Do not re-shlex already-tokenized input; just drop the prefix.
+ rest.to_vec()
+ }
+ [first, pipe, rest @ ..] if (first == "no" || first == "n") && pipe == "|" => {
+ // Do not re-shlex already-tokenized input; just drop the prefix.
+ rest.to_vec()
+ }
+ [bash, flag, script] if bash == "bash" && (flag == "-c" || flag == "-lc") => {
+ shlex_split(script)
+ .unwrap_or_else(|| vec!["bash".to_string(), flag.clone(), script.clone()])
+ }
+ _ => cmd.to_vec(),
+ }
+}
+
+fn contains_connectors(tokens: &[String]) -> bool {
+ tokens
+ .iter()
+ .any(|t| t == "&&" || t == "||" || t == "|" || t == ";")
+}
+
+fn split_on_connectors(tokens: &[String]) -> Vec> {
+ let mut out: Vec> = Vec::new();
+ let mut cur: Vec = Vec::new();
+ for t in tokens {
+ if t == "&&" || t == "||" || t == "|" || t == ";" {
+ if !cur.is_empty() {
+ out.push(std::mem::take(&mut cur));
+ }
+ } else {
+ cur.push(t.clone());
+ }
+ }
+ if !cur.is_empty() {
+ out.push(cur);
+ }
+ out
+}
+
+fn trim_at_connector(tokens: &[String]) -> Vec {
+ let idx = tokens
+ .iter()
+ .position(|t| t == "|" || t == "&&" || t == "||" || t == ";")
+ .unwrap_or(tokens.len());
+ tokens[..idx].to_vec()
+}
+
+/// Shorten a path to the last component, excluding `build`/`dist`/`node_modules`/`src`.
+/// It also pulls out a useful path from a directory such as:
+/// - webview/src -> webview
+/// - foo/src/ -> foo
+/// - packages/app/node_modules/ -> app
+fn short_display_path(path: &str) -> String {
+ // Normalize separators and drop any trailing slash for display.
+ let normalized = path.replace('\\', "/");
+ let trimmed = normalized.trim_end_matches('/');
+ let mut parts = trimmed.split('/').rev().filter(|p| {
+ !p.is_empty() && *p != "build" && *p != "dist" && *p != "node_modules" && *p != "src"
+ });
+ parts
+ .next()
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| trimmed.to_string())
+}
+
+// Skip values consumed by specific flags and ignore --flag=value style arguments.
+fn skip_flag_values<'a>(args: &'a [String], flags_with_vals: &[&str]) -> Vec<&'a String> {
+ let mut out: Vec<&'a String> = Vec::new();
+ let mut skip_next = false;
+ for (i, a) in args.iter().enumerate() {
+ if skip_next {
+ skip_next = false;
+ continue;
+ }
+ if a == "--" {
+ // From here on, everything is positional operands; push the rest and break.
+ for rest in &args[i + 1..] {
+ out.push(rest);
+ }
+ break;
+ }
+ if a.starts_with("--") && a.contains('=') {
+ // --flag=value form: treat as a flag taking a value; skip entirely.
+ continue;
+ }
+ if flags_with_vals.contains(&a.as_str()) {
+ // This flag consumes the next argument as its value.
+ if i + 1 < args.len() {
+ skip_next = true;
+ }
+ continue;
+ }
+ out.push(a);
+ }
+ out
+}
+
+/// Common flags for ESLint that take a following value and should not be
+/// considered positional targets.
+const ESLINT_FLAGS_WITH_VALUES: &[&str] = &[
+ "-c",
+ "--config",
+ "--parser",
+ "--parser-options",
+ "--rulesdir",
+ "--plugin",
+ "--max-warnings",
+ "--format",
+];
+
+fn collect_non_flag_targets(args: &[String]) -> Option> {
+ let mut targets = Vec::new();
+ let mut skip_next = false;
+ for (i, a) in args.iter().enumerate() {
+ if a == "--" {
+ break;
+ }
+ if skip_next {
+ skip_next = false;
+ continue;
+ }
+ if a == "-p"
+ || a == "--package"
+ || a == "--features"
+ || a == "-C"
+ || a == "--config"
+ || a == "--config-path"
+ || a == "--out-dir"
+ || a == "-o"
+ || a == "--run"
+ || a == "--max-warnings"
+ || a == "--format"
+ {
+ if i + 1 < args.len() {
+ skip_next = true;
+ }
+ continue;
+ }
+ if a.starts_with('-') {
+ continue;
+ }
+ targets.push(a.clone());
+ }
+ if targets.is_empty() {
+ None
+ } else {
+ Some(targets)
+ }
+}
+
+fn collect_non_flag_targets_with_flags(
+ args: &[String],
+ flags_with_vals: &[&str],
+) -> Option> {
+ let targets: Vec = skip_flag_values(args, flags_with_vals)
+ .into_iter()
+ .filter(|a| !a.starts_with('-'))
+ .cloned()
+ .collect();
+ if targets.is_empty() {
+ None
+ } else {
+ Some(targets)
+ }
+}
+
+fn is_pathish(s: &str) -> bool {
+ s == "."
+ || s == ".."
+ || s.starts_with("./")
+ || s.starts_with("../")
+ || s.contains('/')
+ || s.contains('\\')
+}
+
+fn parse_fd_query_and_path(tail: &[String]) -> (Option, Option) {
+ let args_no_connector = trim_at_connector(tail);
+ // fd has several flags that take values (e.g., -t/--type, -e/--extension).
+ // Skip those values when extracting positional operands.
+ let candidates = skip_flag_values(
+ &args_no_connector,
+ &[
+ "-t",
+ "--type",
+ "-e",
+ "--extension",
+ "-E",
+ "--exclude",
+ "--search-path",
+ ],
+ );
+ let non_flags: Vec<&String> = candidates
+ .into_iter()
+ .filter(|p| !p.starts_with('-'))
+ .collect();
+ match non_flags.as_slice() {
+ [one] => {
+ if is_pathish(one) {
+ (None, Some(short_display_path(one)))
+ } else {
+ (Some((*one).clone()), None)
+ }
+ }
+ [q, p, ..] => (Some((*q).clone()), Some(short_display_path(p))),
+ _ => (None, None),
+ }
+}
+
+fn parse_find_query_and_path(tail: &[String]) -> (Option, Option) {
+ let args_no_connector = trim_at_connector(tail);
+ // First positional argument (excluding common unary operators) is the root path
+ let mut path: Option = None;
+ for a in &args_no_connector {
+ if !a.starts_with('-') && *a != "!" && *a != "(" && *a != ")" {
+ path = Some(short_display_path(a));
+ break;
+ }
+ }
+ // Extract a common name/path/regex pattern if present
+ let mut query: Option = None;
+ let mut i = 0;
+ while i < args_no_connector.len() {
+ let a = &args_no_connector[i];
+ if a == "-name" || a == "-iname" || a == "-path" || a == "-regex" {
+ if i + 1 < args_no_connector.len() {
+ query = Some(args_no_connector[i + 1].clone());
+ }
+ break;
+ }
+ i += 1;
+ }
+ (query, path)
+}
+
+fn classify_npm_like(tool: &str, tail: &[String], full_cmd: &[String]) -> Option {
+ let mut r = tail;
+ if tool == "pnpm" && r.first().map(|s| s.as_str()) == Some("-r") {
+ r = &r[1..];
+ }
+ let mut script_name: Option = None;
+ if r.first().map(|s| s.as_str()) == Some("run") {
+ script_name = r.get(1).cloned();
+ } else {
+ let is_test_cmd = (tool == "npm" && r.first().map(|s| s.as_str()) == Some("t"))
+ || ((tool == "npm" || tool == "pnpm" || tool == "yarn")
+ && r.first().map(|s| s.as_str()) == Some("test"));
+ if is_test_cmd {
+ script_name = Some("test".to_string());
+ }
+ }
+ if let Some(name) = script_name {
+ let lname = name.to_lowercase();
+ if lname == "test" || lname == "unit" || lname == "jest" || lname == "vitest" {
+ return Some(ParsedCommand::Test {
+ cmd: full_cmd.to_vec(),
+ });
+ }
+ if lname == "lint" || lname == "eslint" {
+ return Some(ParsedCommand::Lint {
+ cmd: full_cmd.to_vec(),
+ tool: Some(format!("{tool}-script:{name}")),
+ targets: None,
+ });
+ }
+ if lname == "format" || lname == "fmt" || lname == "prettier" {
+ return Some(ParsedCommand::Format {
+ cmd: full_cmd.to_vec(),
+ tool: Some(format!("{tool}-script:{name}")),
+ targets: None,
+ });
+ }
+ }
+ None
+}
+
+fn parse_bash_lc_commands(
+ original: &[String],
+ normalized: &[String],
+) -> Option> {
+ let [bash, flag, script] = original else {
+ return None;
+ };
+ if bash != "bash" || flag != "-lc" {
+ return None;
+ }
+ if let Some(tree) = try_parse_bash(script) {
+ if let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script) {
+ if !all_commands.is_empty() {
+ let script_tokens = shlex_split(script)
+ .unwrap_or_else(|| vec!["bash".to_string(), flag.clone(), script.clone()]);
+ // Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we
+ // bias toward the primary command when pipelines are present.
+ // First, drop obvious small formatting helpers (e.g., wc/awk/etc).
+ let had_multiple_commands = all_commands.len() > 1;
+ // The bash AST walker yields commands in right-to-left order for
+ // connector/pipeline sequences. Reverse to reflect actual execution order.
+ let mut filtered_commands = drop_small_formatting_commands(all_commands);
+ filtered_commands.reverse();
+ if filtered_commands.is_empty() {
+ return Some(vec![ParsedCommand::Unknown {
+ cmd: normalized.to_vec(),
+ }]);
+ }
+ let mut commands: Vec = filtered_commands
+ .into_iter()
+ .map(|tokens| summarize_main_tokens(&tokens))
+ .collect();
+ // Drop no-op `true` commands
+ commands.retain(|pc| match pc {
+ ParsedCommand::Unknown { cmd } => {
+ !(cmd.len() == 1 && cmd.first().is_some_and(|s| s == "true"))
+ }
+ _ => true,
+ });
+ commands = maybe_collapse_cat_sed(commands, &script_tokens);
+ if commands.len() == 1 {
+ // If we reduced to a single command, attribute the full original script
+ // for clearer UX in file-reading and listing scenarios, or when there were
+ // no connectors in the original script. For search commands that came from
+ // a pipeline (e.g. `rg --files | sed -n`), keep only the primary command.
+ let had_connectors = had_multiple_commands
+ || script_tokens
+ .iter()
+ .any(|t| t == "|" || t == "&&" || t == "||" || t == ";");
+ commands = commands
+ .into_iter()
+ .map(|pc| match pc {
+ ParsedCommand::Read { name, cmd } => {
+ if had_connectors {
+ let has_pipe = script_tokens.iter().any(|t| t == "|");
+ let has_sed_n = script_tokens.windows(2).any(|w| {
+ w.first().map(|s| s.as_str()) == Some("sed")
+ && w.get(1).map(|s| s.as_str()) == Some("-n")
+ });
+ if has_pipe && has_sed_n {
+ ParsedCommand::Read {
+ cmd: script_tokens.clone(),
+ name,
+ }
+ } else {
+ ParsedCommand::Read { cmd, name }
+ }
+ } else {
+ ParsedCommand::Read {
+ cmd: script_tokens.clone(),
+ name,
+ }
+ }
+ }
+ ParsedCommand::ListFiles { path, cmd } => {
+ if had_connectors {
+ ParsedCommand::ListFiles { cmd, path }
+ } else {
+ ParsedCommand::ListFiles {
+ cmd: script_tokens.clone(),
+ path,
+ }
+ }
+ }
+ ParsedCommand::Search { cmd, query, path } => {
+ if had_connectors {
+ ParsedCommand::Search { cmd, query, path }
+ } else {
+ ParsedCommand::Search {
+ cmd: script_tokens.clone(),
+ query,
+ path,
+ }
+ }
+ }
+ ParsedCommand::Format { tool, targets, .. } => ParsedCommand::Format {
+ cmd: script_tokens.clone(),
+ tool,
+ targets,
+ },
+ ParsedCommand::Test { .. } => ParsedCommand::Test {
+ cmd: script_tokens.clone(),
+ },
+ ParsedCommand::Lint { tool, targets, .. } => ParsedCommand::Lint {
+ cmd: script_tokens.clone(),
+ tool,
+ targets,
+ },
+ ParsedCommand::Unknown { .. } => ParsedCommand::Unknown {
+ cmd: script_tokens.clone(),
+ },
+ })
+ .collect();
+ }
+ return Some(commands);
+ }
+ }
+ }
+ Some(vec![ParsedCommand::Unknown {
+ cmd: normalized.to_vec(),
+ }])
+}
+
+/// Return true if this looks like a small formatting helper in a pipeline.
+/// Examples: `head -n 40`, `tail -n +10`, `wc -l`, `awk ...`, `cut ...`, `tr ...`.
+/// We try to keep variants that clearly include a file path (e.g. `tail -n 30 file`).
+fn is_small_formatting_command(tokens: &[String]) -> bool {
+ if tokens.is_empty() {
+ return false;
+ }
+ let cmd = tokens[0].as_str();
+ match cmd {
+ // Always formatting; typically used in pipes.
+ // `nl` is special-cased below to allow `nl ` to be treated as a read command.
+ "wc" | "tr" | "cut" | "sort" | "uniq" | "xargs" | "tee" | "column" | "awk" | "yes"
+ | "printf" => true,
+ "head" => {
+ // Treat as formatting when no explicit file operand is present.
+ // Common forms: `head -n 40`, `head -c 100`.
+ // Keep cases like `head -n 40 file`.
+ tokens.len() < 3
+ }
+ "tail" => {
+ // Treat as formatting when no explicit file operand is present.
+ // Common forms: `tail -n +10`, `tail -n 30`.
+ // Keep cases like `tail -n 30 file`.
+ tokens.len() < 3
+ }
+ "sed" => {
+ // Keep `sed -n file` (treated as a file read elsewhere);
+ // otherwise consider it a formatting helper in a pipeline.
+ tokens.len() < 4
+ || !(tokens[1] == "-n" && is_valid_sed_n_arg(tokens.get(2).map(|s| s.as_str())))
+ }
+ _ => false,
+ }
+}
+
+fn drop_small_formatting_commands(mut commands: Vec>) -> Vec> {
+ commands.retain(|tokens| !is_small_formatting_command(tokens));
+ commands
+}
+
+fn maybe_collapse_cat_sed(
+ commands: Vec,
+ script_tokens: &[String],
+) -> Vec {
+ if commands.len() < 2 {
+ return commands;
+ }
+ let drop_leading_sed = match (&commands[0], &commands[1]) {
+ (ParsedCommand::Unknown { cmd: sed_cmd }, ParsedCommand::Read { cmd: cat_cmd, .. }) => {
+ let is_sed_n = sed_cmd.first().map(|s| s.as_str()) == Some("sed")
+ && sed_cmd.get(1).map(|s| s.as_str()) == Some("-n")
+ && is_valid_sed_n_arg(sed_cmd.get(2).map(|s| s.as_str()))
+ && sed_cmd.len() == 3;
+ let is_cat_file =
+ cat_cmd.first().map(|s| s.as_str()) == Some("cat") && cat_cmd.len() == 2;
+ is_sed_n && is_cat_file
+ }
+ _ => false,
+ };
+ if drop_leading_sed {
+ if let ParsedCommand::Read { name, .. } = &commands[1] {
+ return vec![ParsedCommand::Read {
+ cmd: script_tokens.to_vec(),
+ name: name.clone(),
+ }];
+ }
+ }
+ commands
+}
+
+fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand {
+ match main_cmd.split_first() {
+ // (sed-specific logic handled below in dedicated arm returning Read)
+ Some((head, tail))
+ if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("fmt") =>
+ {
+ ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("cargo fmt".to_string()),
+ targets: collect_non_flag_targets(&tail[1..]),
+ }
+ }
+ Some((head, tail))
+ if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("clippy") =>
+ {
+ ParsedCommand::Lint {
+ cmd: main_cmd.to_vec(),
+ tool: Some("cargo clippy".to_string()),
+ targets: collect_non_flag_targets(&tail[1..]),
+ }
+ }
+ Some((head, tail))
+ if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("test") =>
+ {
+ ParsedCommand::Test {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ Some((head, tail)) if head == "rustfmt" => ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("rustfmt".to_string()),
+ targets: collect_non_flag_targets(tail),
+ },
+ Some((head, tail)) if head == "go" && tail.first().map(|s| s.as_str()) == Some("fmt") => {
+ ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("go fmt".to_string()),
+ targets: collect_non_flag_targets(&tail[1..]),
+ }
+ }
+ Some((head, tail)) if head == "go" && tail.first().map(|s| s.as_str()) == Some("test") => {
+ ParsedCommand::Test {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ Some((head, _)) if head == "pytest" => ParsedCommand::Test {
+ cmd: main_cmd.to_vec(),
+ },
+ Some((head, tail)) if head == "eslint" => {
+ // Treat configuration flags with values (e.g. `-c .eslintrc`) as non-targets.
+ let targets = collect_non_flag_targets_with_flags(tail, ESLINT_FLAGS_WITH_VALUES);
+ ParsedCommand::Lint {
+ cmd: main_cmd.to_vec(),
+ tool: Some("eslint".to_string()),
+ targets,
+ }
+ }
+ Some((head, tail)) if head == "prettier" => ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("prettier".to_string()),
+ targets: collect_non_flag_targets(tail),
+ },
+ Some((head, tail)) if head == "black" => ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("black".to_string()),
+ targets: collect_non_flag_targets(tail),
+ },
+ Some((head, tail))
+ if head == "ruff" && tail.first().map(|s| s.as_str()) == Some("check") =>
+ {
+ ParsedCommand::Lint {
+ cmd: main_cmd.to_vec(),
+ tool: Some("ruff".to_string()),
+ targets: collect_non_flag_targets(&tail[1..]),
+ }
+ }
+ Some((head, tail))
+ if head == "ruff" && tail.first().map(|s| s.as_str()) == Some("format") =>
+ {
+ ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("ruff".to_string()),
+ targets: collect_non_flag_targets(&tail[1..]),
+ }
+ }
+ Some((head, _)) if (head == "jest" || head == "vitest") => ParsedCommand::Test {
+ cmd: main_cmd.to_vec(),
+ },
+ Some((head, tail))
+ if head == "npx" && tail.first().map(|s| s.as_str()) == Some("eslint") =>
+ {
+ let targets = collect_non_flag_targets_with_flags(&tail[1..], ESLINT_FLAGS_WITH_VALUES);
+ ParsedCommand::Lint {
+ cmd: main_cmd.to_vec(),
+ tool: Some("eslint".to_string()),
+ targets,
+ }
+ }
+ Some((head, tail))
+ if head == "npx" && tail.first().map(|s| s.as_str()) == Some("prettier") =>
+ {
+ ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("prettier".to_string()),
+ targets: collect_non_flag_targets(&tail[1..]),
+ }
+ }
+ // NPM-like scripts including yarn
+ Some((tool, tail)) if (tool == "pnpm" || tool == "npm" || tool == "yarn") => {
+ if let Some(cmd) = classify_npm_like(tool, tail, main_cmd) {
+ cmd
+ } else {
+ ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ }
+ Some((head, tail)) if head == "ls" => {
+ // Avoid treating option values as paths (e.g., ls -I "*.test.js").
+ let candidates = skip_flag_values(
+ tail,
+ &[
+ "-I",
+ "-w",
+ "--block-size",
+ "--format",
+ "--time-style",
+ "--color",
+ "--quoting-style",
+ ],
+ );
+ let path = candidates
+ .into_iter()
+ .find(|p| !p.starts_with('-'))
+ .map(|p| short_display_path(p));
+ ParsedCommand::ListFiles {
+ cmd: main_cmd.to_vec(),
+ path,
+ }
+ }
+ Some((head, tail)) if head == "rg" => {
+ let args_no_connector = trim_at_connector(tail);
+ let has_files_flag = args_no_connector.iter().any(|a| a == "--files");
+ let non_flags: Vec<&String> = args_no_connector
+ .iter()
+ .filter(|p| !p.starts_with('-'))
+ .collect();
+ let (query, path) = if has_files_flag {
+ (None, non_flags.first().map(|s| short_display_path(s)))
+ } else {
+ (
+ non_flags.first().cloned().map(|s| s.to_string()),
+ non_flags.get(1).map(|s| short_display_path(s)),
+ )
+ };
+ ParsedCommand::Search {
+ cmd: main_cmd.to_vec(),
+ query,
+ path,
+ }
+ }
+ Some((head, tail)) if head == "fd" => {
+ let (query, path) = parse_fd_query_and_path(tail);
+ ParsedCommand::Search {
+ cmd: main_cmd.to_vec(),
+ query,
+ path,
+ }
+ }
+ Some((head, tail)) if head == "find" => {
+ // Basic find support: capture path and common name filter
+ let (query, path) = parse_find_query_and_path(tail);
+ ParsedCommand::Search {
+ cmd: main_cmd.to_vec(),
+ query,
+ path,
+ }
+ }
+ Some((head, tail)) if head == "grep" => {
+ let args_no_connector = trim_at_connector(tail);
+ let non_flags: Vec<&String> = args_no_connector
+ .iter()
+ .filter(|p| !p.starts_with('-'))
+ .collect();
+ // Do not shorten the query: grep patterns may legitimately contain slashes
+ // and should be preserved verbatim. Only paths should be shortened.
+ let query = non_flags.first().cloned().map(|s| s.to_string());
+ let path = non_flags.get(1).map(|s| short_display_path(s));
+ ParsedCommand::Search {
+ cmd: main_cmd.to_vec(),
+ query,
+ path,
+ }
+ }
+ Some((head, tail)) if head == "cat" => {
+ // Support both `cat ` and `cat -- ` forms.
+ let effective_tail: &[String] = if tail.first().map(|s| s.as_str()) == Some("--") {
+ &tail[1..]
+ } else {
+ tail
+ };
+ if effective_tail.len() == 1 {
+ let name = short_display_path(&effective_tail[0]);
+ ParsedCommand::Read {
+ cmd: main_cmd.to_vec(),
+ name,
+ }
+ } else {
+ ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ }
+ Some((head, tail)) if head == "head" => {
+ // Support `head -n 50 file` and `head -n50 file` forms.
+ let has_valid_n = match tail.split_first() {
+ Some((first, rest)) if first == "-n" => rest
+ .first()
+ .is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())),
+ Some((first, _)) if first.starts_with("-n") => {
+ first[2..].chars().all(|c| c.is_ascii_digit())
+ }
+ _ => false,
+ };
+ if has_valid_n {
+ // Build candidates skipping the numeric value consumed by `-n` when separated.
+ let mut candidates: Vec<&String> = Vec::new();
+ let mut i = 0;
+ while i < tail.len() {
+ if i == 0 && tail[i] == "-n" && i + 1 < tail.len() {
+ let n = &tail[i + 1];
+ if n.chars().all(|c| c.is_ascii_digit()) {
+ i += 2;
+ continue;
+ }
+ }
+ candidates.push(&tail[i]);
+ i += 1;
+ }
+ if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) {
+ let name = short_display_path(p);
+ return ParsedCommand::Read {
+ cmd: main_cmd.to_vec(),
+ name,
+ };
+ }
+ }
+ ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ Some((head, tail)) if head == "tail" => {
+ // Support `tail -n +10 file` and `tail -n+10 file` forms.
+ let has_valid_n = match tail.split_first() {
+ Some((first, rest)) if first == "-n" => rest.first().is_some_and(|n| {
+ let s = n.strip_prefix('+').unwrap_or(n);
+ !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
+ }),
+ Some((first, _)) if first.starts_with("-n") => {
+ let v = &first[2..];
+ let s = v.strip_prefix('+').unwrap_or(v);
+ !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
+ }
+ _ => false,
+ };
+ if has_valid_n {
+ // Build candidates skipping the numeric value consumed by `-n` when separated.
+ let mut candidates: Vec<&String> = Vec::new();
+ let mut i = 0;
+ while i < tail.len() {
+ if i == 0 && tail[i] == "-n" && i + 1 < tail.len() {
+ let n = &tail[i + 1];
+ let s = n.strip_prefix('+').unwrap_or(n);
+ if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
+ i += 2;
+ continue;
+ }
+ }
+ candidates.push(&tail[i]);
+ i += 1;
+ }
+ if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) {
+ let name = short_display_path(p);
+ return ParsedCommand::Read {
+ cmd: main_cmd.to_vec(),
+ name,
+ };
+ }
+ }
+ ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ Some((head, tail)) if head == "nl" => {
+ // Avoid treating option values as paths (e.g., nl -s " ").
+ let candidates = skip_flag_values(tail, &["-s", "-w", "-v", "-i", "-b"]);
+ if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) {
+ let name = short_display_path(p);
+ ParsedCommand::Read {
+ cmd: main_cmd.to_vec(),
+ name,
+ }
+ } else {
+ ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ }
+ Some((head, tail))
+ if head == "sed"
+ && tail.len() >= 3
+ && tail[0] == "-n"
+ && is_valid_sed_n_arg(tail.get(1).map(|s| s.as_str())) =>
+ {
+ if let Some(path) = tail.get(2) {
+ let name = short_display_path(path);
+ ParsedCommand::Read {
+ cmd: main_cmd.to_vec(),
+ name,
+ }
+ } else {
+ ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ }
+ // Other commands
+ _ => ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ },
+ }
+}
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
index 9008ad307d..4972f10d98 100644
--- a/codex-rs/core/src/protocol.rs
+++ b/codex-rs/core/src/protocol.rs
@@ -21,6 +21,7 @@ use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::message_history::HistoryEntry;
use crate::model_provider_info::ModelProviderInfo;
+use crate::parse_command::ParsedCommand;
use crate::plan_tool::UpdatePlanArgs;
/// Submission Queue Entry - requests from user
@@ -579,6 +580,7 @@ pub struct ExecCommandBeginEvent {
pub command: Vec,
/// The command's working directory if not the default cwd for the agent.
pub cwd: PathBuf,
+ pub parsed_cmd: Vec,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs
index a2ae813183..1d35dcb73f 100644
--- a/codex-rs/exec/src/event_processor_with_human_output.rs
+++ b/codex-rs/exec/src/event_processor_with_human_output.rs
@@ -255,6 +255,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
call_id,
command,
cwd,
+ parsed_cmd: _,
}) => {
self.call_id_to_command.insert(
call_id.clone(),
diff --git a/codex-rs/mcp-server/src/mcp_protocol.rs b/codex-rs/mcp-server/src/mcp_protocol.rs
index 2f8858a37b..0528e18a39 100644
--- a/codex-rs/mcp-server/src/mcp_protocol.rs
+++ b/codex-rs/mcp-server/src/mcp_protocol.rs
@@ -936,6 +936,7 @@ mod tests {
call_id: "c1".into(),
command: vec!["bash".into(), "-lc".into(), "echo hi".into()],
cwd: std::path::PathBuf::from("/work"),
+ parsed_cmd: vec![],
}),
};
@@ -947,7 +948,8 @@ mod tests {
"type": "exec_command_begin",
"call_id": "c1",
"command": ["bash", "-lc", "echo hi"],
- "cwd": "/work"
+ "cwd": "/work",
+ "parsed_cmd": []
}
}
});
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 0362a678b1..a3a51aba44 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -5,6 +5,7 @@ use std::sync::Arc;
use codex_core::codex_wrapper::CodexConversation;
use codex_core::codex_wrapper::init_codex;
use codex_core::config::Config;
+use codex_core::parse_command::ParsedCommand;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
@@ -57,6 +58,7 @@ struct RunningCommand {
command: Vec,
#[allow(dead_code)]
cwd: PathBuf,
+ parsed_cmd: Vec,
}
pub(crate) struct ChatWidget<'a> {
@@ -452,6 +454,7 @@ impl ChatWidget<'_> {
call_id,
command,
cwd,
+ parsed_cmd,
}) => {
self.finalize_active_stream();
// Ensure the status indicator is visible while the command runs.
@@ -462,9 +465,11 @@ impl ChatWidget<'_> {
RunningCommand {
command: command.clone(),
cwd: cwd.clone(),
+ parsed_cmd: parsed_cmd.clone(),
},
);
- self.active_history_cell = Some(HistoryCell::new_active_exec_command(command));
+ self.active_history_cell =
+ Some(HistoryCell::new_active_exec_command(command, parsed_cmd));
}
EventMsg::ExecCommandOutputDelta(_) => {
// TODO
@@ -494,14 +499,19 @@ impl ChatWidget<'_> {
// Compute summary before moving stdout into the history cell.
let cmd = self.running_commands.remove(&call_id);
self.active_history_cell = None;
+ let (command, parsed_cmd) = match cmd {
+ Some(cmd) => (cmd.command, cmd.parsed_cmd),
+ None => (vec![call_id], vec![]),
+ };
self.add_to_history(HistoryCell::new_completed_exec_command(
- cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]),
+ command,
+ parsed_cmd,
CommandOutput {
exit_code,
stdout,
stderr,
},
- ));
+ ))
}
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: _,
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 43144a648a..3149de3551 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -1,3 +1,4 @@
+use crate::colors::LIGHT_BLUE;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::slash_command::SlashCommand;
@@ -8,6 +9,7 @@ use codex_ansi_escape::ansi_escape_line;
use codex_common::create_config_summary_entries;
use codex_common::elapsed::format_duration;
use codex_core::config::Config;
+use codex_core::parse_command::ParsedCommand;
use codex_core::plan_tool::PlanItemArg;
use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
@@ -160,6 +162,7 @@ impl HistoryCell {
/// Return a cloned, plain representation of the cell's lines suitable for
/// one‑shot insertion into the terminal scrollback. Image cells are
/// represented with a simple placeholder for now.
+ /// These lines are also rendered directly by ratatui wrapped in a Paragraph.
pub(crate) fn plain_lines(&self) -> Vec> {
match self {
HistoryCell::WelcomeMessage { view }
@@ -194,48 +197,6 @@ impl HistoryCell {
.unwrap_or(0)
}
- fn output_lines(src: &str) -> Vec> {
- let lines: Vec<&str> = src.lines().collect();
- let total = lines.len();
- let limit = TOOL_CALL_MAX_LINES;
-
- let mut out = Vec::new();
-
- let head_end = total.min(limit);
- for (i, raw) in lines[..head_end].iter().enumerate() {
- let mut line = ansi_escape_line(raw);
- let prefix = if i == 0 { " ⎿ " } else { " " };
- line.spans.insert(0, prefix.into());
- line.spans.iter_mut().for_each(|span| {
- span.style = span.style.add_modifier(Modifier::DIM);
- });
- out.push(line);
- }
-
- // If we will ellipsize less than the limit, just show it.
- let show_ellipsis = total > 2 * limit;
- if show_ellipsis {
- let omitted = total - 2 * limit;
- out.push(Line::from(format!("… +{omitted} lines")));
- }
-
- let tail_start = if show_ellipsis {
- total - limit
- } else {
- head_end
- };
- for raw in lines[tail_start..].iter() {
- let mut line = ansi_escape_line(raw);
- line.spans.insert(0, " ".into());
- line.spans.iter_mut().for_each(|span| {
- span.style = span.style.add_modifier(Modifier::DIM);
- });
- out.push(line);
- }
-
- out
- }
-
pub(crate) fn new_session_info(
config: &Config,
event: SessionConfiguredEvent,
@@ -303,59 +264,99 @@ impl HistoryCell {
}
}
- pub(crate) fn new_active_exec_command(command: Vec) -> Self {
- let command_escaped = strip_bash_lc_and_escape(&command);
-
- let mut lines: Vec> = Vec::new();
- let mut iter = command_escaped.lines();
- if let Some(first) = iter.next() {
- lines.push(Line::from(vec![
- "▌ ".cyan(),
- "Running command ".magenta(),
- first.to_string().into(),
- ]));
- } else {
- lines.push(Line::from(vec!["▌ ".cyan(), "Running command".magenta()]));
- }
- for cont in iter {
- lines.push(Line::from(cont.to_string()));
- }
- lines.push(Line::from(""));
-
+ pub(crate) fn new_active_exec_command(
+ command: Vec,
+ parsed: Vec,
+ ) -> Self {
+ let lines = HistoryCell::exec_command_lines(&command, &parsed, None);
HistoryCell::ActiveExecCommand {
view: TextBlock::new(lines),
}
}
- pub(crate) fn new_completed_exec_command(command: Vec, output: CommandOutput) -> Self {
- let CommandOutput {
- exit_code,
- stdout,
- stderr,
- } = output;
+ pub(crate) fn new_completed_exec_command(
+ command: Vec,
+ parsed: Vec,
+ output: CommandOutput,
+ ) -> Self {
+ let lines = HistoryCell::exec_command_lines(&command, &parsed, Some(&output));
+ HistoryCell::CompletedExecCommand {
+ view: TextBlock::new(lines),
+ }
+ }
+ fn exec_command_lines(
+ command: &[String],
+ parsed: &[ParsedCommand],
+ output: Option<&CommandOutput>,
+ ) -> Vec> {
+ if parsed.is_empty() {
+ HistoryCell::new_exec_command_generic(command, output)
+ } else {
+ HistoryCell::new_parsed_command(parsed, output)
+ }
+ }
+
+ fn new_parsed_command(
+ parsed_commands: &[ParsedCommand],
+ output: Option<&CommandOutput>,
+ ) -> Vec> {
+ let mut lines: Vec = vec![Line::from("⚙︎ Working")];
+
+ for (i, parsed) in parsed_commands.iter().enumerate() {
+ let str = match parsed {
+ ParsedCommand::Read { name, .. } => format!("📖 {name}"),
+ ParsedCommand::ListFiles { cmd, path } => match path {
+ Some(p) => format!("📂 {p}"),
+ None => format!("📂 {}", shlex_join_safe(cmd)),
+ },
+ ParsedCommand::Search { query, path, cmd } => match (query, path) {
+ (Some(q), Some(p)) => format!("🔎 {q} in {p}"),
+ (Some(q), None) => format!("🔎 {q}"),
+ (None, Some(p)) => format!("🔎 {p}"),
+ (None, None) => format!("🔎 {}", shlex_join_safe(cmd)),
+ },
+ ParsedCommand::Format { .. } => "✨ Formatting".to_string(),
+ ParsedCommand::Test { cmd } => format!("🧪 {}", shlex_join_safe(cmd)),
+ ParsedCommand::Lint { cmd, .. } => format!("🧹 {}", shlex_join_safe(cmd)),
+ ParsedCommand::Unknown { cmd } => format!("⌨️ {}", shlex_join_safe(cmd)),
+ };
+
+ let prefix = if i == 0 { " L " } else { " " };
+ lines.push(Line::from(vec![
+ Span::styled(prefix, Style::default().add_modifier(Modifier::DIM)),
+ Span::styled(str, Style::default().fg(LIGHT_BLUE)),
+ ]));
+ }
+
+ lines.extend(output_lines(output, true, false));
+ lines.push(Line::from(""));
+
+ lines
+ }
+
+ fn new_exec_command_generic(
+ command: &[String],
+ output: Option<&CommandOutput>,
+ ) -> Vec> {
let mut lines: Vec> = Vec::new();
- let command_escaped = strip_bash_lc_and_escape(&command);
+ let command_escaped = strip_bash_lc_and_escape(command);
let mut cmd_lines = command_escaped.lines();
if let Some(first) = cmd_lines.next() {
lines.push(Line::from(vec![
- "⚡ Ran command ".magenta(),
+ "⚡ Running ".to_string().magenta(),
first.to_string().into(),
]));
} else {
- lines.push(Line::from("⚡ Ran command".magenta()));
+ lines.push(Line::from("⚡ Running".to_string().magenta()));
}
for cont in cmd_lines {
lines.push(Line::from(cont.to_string()));
}
- let src = if exit_code == 0 { stdout } else { stderr };
- lines.extend(Self::output_lines(&src));
- lines.push(Line::from(""));
+ lines.extend(output_lines(output, false, true));
- HistoryCell::CompletedExecCommand {
- view: TextBlock::new(lines),
- }
+ lines
}
pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> Self {
@@ -835,7 +836,15 @@ impl HistoryCell {
lines.push(Line::from("✘ Failed to apply patch".magenta().bold()));
if !stderr.trim().is_empty() {
- lines.extend(Self::output_lines(&stderr));
+ lines.extend(output_lines(
+ Some(&CommandOutput {
+ exit_code: 1,
+ stdout: String::new(),
+ stderr,
+ }),
+ true,
+ true,
+ ));
}
lines.push(Line::from(""));
@@ -854,6 +863,67 @@ impl WidgetRef for &HistoryCell {
}
}
+fn output_lines(
+ output: Option<&CommandOutput>,
+ only_err: bool,
+ include_angle_pipe: bool,
+) -> Vec> {
+ let CommandOutput {
+ exit_code,
+ stdout,
+ stderr,
+ } = match output {
+ Some(output) if only_err && output.exit_code == 0 => return vec![],
+ Some(output) => output,
+ None => return vec![],
+ };
+
+ let src = if *exit_code == 0 { stdout } else { stderr };
+ let lines: Vec<&str> = src.lines().collect();
+ let total = lines.len();
+ let limit = TOOL_CALL_MAX_LINES;
+
+ let mut out = Vec::new();
+
+ let head_end = total.min(limit);
+ for (i, raw) in lines[..head_end].iter().enumerate() {
+ let mut line = ansi_escape_line(raw);
+ let prefix = if i == 0 && include_angle_pipe {
+ " ⎿ "
+ } else {
+ " "
+ };
+ line.spans.insert(0, prefix.into());
+ line.spans.iter_mut().for_each(|span| {
+ span.style = span.style.add_modifier(Modifier::DIM);
+ });
+ out.push(line);
+ }
+
+ // If we will ellipsize less than the limit, just show it.
+ let show_ellipsis = total > 2 * limit;
+ if show_ellipsis {
+ let omitted = total - 2 * limit;
+ out.push(Line::from(format!("… +{omitted} lines")));
+ }
+
+ let tail_start = if show_ellipsis {
+ total - limit
+ } else {
+ head_end
+ };
+ for raw in lines[tail_start..].iter() {
+ let mut line = ansi_escape_line(raw);
+ line.spans.insert(0, " ".into());
+ line.spans.iter_mut().for_each(|span| {
+ span.style = span.style.add_modifier(Modifier::DIM);
+ });
+ out.push(line);
+ }
+
+ out
+}
+
fn create_diff_summary(title: &str, changes: HashMap) -> Vec> {
let mut files: Vec = Vec::new();
@@ -1008,3 +1078,10 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
];
Line::from(invocation_spans)
}
+
+fn shlex_join_safe(command: &[String]) -> String {
+ match shlex::try_join(command.iter().map(|s| s.as_str())) {
+ Ok(cmd) => cmd,
+ Err(_) => command.join(" "),
+ }
+}
diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs
index 966b8d68f9..d9cd709b3e 100644
--- a/codex-rs/tui/src/user_approval_widget.rs
+++ b/codex-rs/tui/src/user_approval_widget.rs
@@ -247,7 +247,7 @@ impl UserApprovalWidget<'_> {
match decision {
ReviewDecision::Approved => {
lines.push(Line::from(vec![
- "✓ ".fg(Color::Green),
+ "✔ ".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
@@ -258,7 +258,7 @@ impl UserApprovalWidget<'_> {
}
ReviewDecision::ApprovedForSession => {
lines.push(Line::from(vec![
- "✓ ".fg(Color::Green),
+ "✔ ".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
From c6b46fe2202dde895e7870594242a96376faeac2 Mon Sep 17 00:00:00 2001
From: Dylan
Date: Mon, 11 Aug 2025 11:38:47 -0700
Subject: [PATCH 09/45] [mcp-server] Support
CodexToolCallApprovalPolicy::OnRequest (#2187)
## Summary
#1865 added `AskForApproval::OnRequest`, but missed adding it to our
custom struct in `mcp-server`. This adds the missing configuration
## Testing
- [x] confirmed locally
---
codex-rs/mcp-server/src/codex_tool_config.rs | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs
index 899451a50d..4af3e29c48 100644
--- a/codex-rs/mcp-server/src/codex_tool_config.rs
+++ b/codex-rs/mcp-server/src/codex_tool_config.rs
@@ -34,7 +34,7 @@ pub struct CodexToolCallParam {
pub cwd: Option,
/// Approval policy for shell commands generated by the model:
- /// `untrusted`, `on-failure`, `never`.
+ /// `untrusted`, `on-failure`, `on-request`, `never`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_policy: Option,
@@ -63,6 +63,7 @@ pub struct CodexToolCallParam {
pub enum CodexToolCallApprovalPolicy {
Untrusted,
OnFailure,
+ OnRequest,
Never,
}
@@ -71,6 +72,7 @@ impl From for AskForApproval {
match value {
CodexToolCallApprovalPolicy::Untrusted => AskForApproval::UnlessTrusted,
CodexToolCallApprovalPolicy::OnFailure => AskForApproval::OnFailure,
+ CodexToolCallApprovalPolicy::OnRequest => AskForApproval::OnRequest,
CodexToolCallApprovalPolicy::Never => AskForApproval::Never,
}
}
@@ -244,10 +246,11 @@ mod tests {
"type": "object",
"properties": {
"approval-policy": {
- "description": "Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`.",
+ "description": "Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `on-request`, `never`.",
"enum": [
"untrusted",
"on-failure",
+ "on-request",
"never"
],
"type": "string"
From b76a562c49b04e85854ec0e9681d2358a80f0527 Mon Sep 17 00:00:00 2001
From: Gabriel Peal
Date: Mon, 11 Aug 2025 11:43:58 -0700
Subject: [PATCH 10/45] [2/3] Retain the TUI last exec history cell so that it
can be updated by the next tool call (#2097)
Right now, every time an exec ends, we emit it to history which makes it
immutable. In order to be able to update or merge successive tool calls
(which will be useful after https://github.com/openai/codex/pull/2095),
we need to retain it as the active cell.
This also changes the cell to contain the metadata necessary to render
it so it can be updated rather than baking in the final text lines when
the cell is created.
Part 1: https://github.com/openai/codex/pull/2095
Part 3: https://github.com/openai/codex/pull/2110
---
codex-rs/tui/src/chatwidget.rs | 96 +++++++++++++++++++++-----------
codex-rs/tui/src/history_cell.rs | 47 +++++++++-------
2 files changed, 89 insertions(+), 54 deletions(-)
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index a3a51aba44..1c81b2cf56 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -65,7 +65,7 @@ pub(crate) struct ChatWidget<'a> {
app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender,
bottom_pane: BottomPane<'a>,
- active_history_cell: Option,
+ active_exec_cell: Option,
config: Config,
initial_user_message: Option,
total_token_usage: TokenUsage,
@@ -114,7 +114,7 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio
impl ChatWidget<'_> {
fn interrupt_running_task(&mut self) {
if self.bottom_pane.is_task_running() {
- self.active_history_cell = None;
+ self.active_exec_cell = None;
self.bottom_pane.clear_ctrl_c_quit_hint();
self.submit_op(Op::Interrupt);
self.bottom_pane.set_task_running(false);
@@ -131,7 +131,7 @@ impl ChatWidget<'_> {
fn layout_areas(&self, area: Rect) -> [Rect; 2] {
Layout::vertical([
Constraint::Max(
- self.active_history_cell
+ self.active_exec_cell
.as_ref()
.map_or(0, |c| c.desired_height(area.width)),
),
@@ -210,7 +210,7 @@ impl ChatWidget<'_> {
has_input_focus: true,
enhanced_keys_supported,
}),
- active_history_cell: None,
+ active_exec_cell: None,
config,
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
@@ -232,7 +232,7 @@ impl ChatWidget<'_> {
pub fn desired_height(&self, width: u16) -> u16 {
self.bottom_pane.desired_height(width)
+ self
- .active_history_cell
+ .active_exec_cell
.as_ref()
.map_or(0, |c| c.desired_height(width))
}
@@ -255,6 +255,7 @@ impl ChatWidget<'_> {
}
fn add_to_history(&mut self, cell: HistoryCell) {
+ self.flush_active_exec_cell();
self.app_event_tx
.send(AppEvent::InsertHistory(cell.plain_lines()));
}
@@ -298,6 +299,16 @@ impl ChatWidget<'_> {
pub(crate) fn handle_codex_event(&mut self, event: Event) {
let Event { id, msg } = event;
+
+ match msg {
+ EventMsg::AgentMessageDelta(_)
+ | EventMsg::AgentReasoningDelta(_)
+ | EventMsg::ExecCommandOutputDelta(_) => {}
+ _ => {
+ tracing::info!("handle_codex_event: {:?}", msg);
+ }
+ }
+
match msg {
EventMsg::SessionConfigured(event) => {
self.bottom_pane
@@ -456,6 +467,8 @@ impl ChatWidget<'_> {
cwd,
parsed_cmd,
}) => {
+ // TODO: merge this into the active exec call.
+ self.flush_active_exec_cell();
self.finalize_active_stream();
// Ensure the status indicator is visible while the command runs.
self.bottom_pane
@@ -468,9 +481,37 @@ impl ChatWidget<'_> {
parsed_cmd: parsed_cmd.clone(),
},
);
- self.active_history_cell =
+ self.active_exec_cell =
Some(HistoryCell::new_active_exec_command(command, parsed_cmd));
}
+ EventMsg::ExecCommandEnd(ExecCommandEndEvent {
+ call_id,
+ exit_code,
+ duration: _,
+ stdout,
+ stderr,
+ }) => {
+ // Compute summary before moving stdout into the history cell.
+ let cmd = self.running_commands.remove(&call_id);
+ let parsed_cmd = match &cmd {
+ Some(RunningCommand { parsed_cmd, .. }) => parsed_cmd.clone(),
+ _ => vec![],
+ };
+ if let Some(cmd) = cmd {
+ // Replace the active running cell with the finalized result,
+ // but keep it as the active cell so it can be merged with
+ // subsequent commands before being committed.
+ self.active_exec_cell = Some(HistoryCell::new_completed_exec_command(
+ cmd.command,
+ parsed_cmd,
+ CommandOutput {
+ exit_code,
+ stdout,
+ stderr,
+ },
+ ));
+ }
+ }
EventMsg::ExecCommandOutputDelta(_) => {
// TODO
}
@@ -489,36 +530,12 @@ impl ChatWidget<'_> {
self.add_to_history(HistoryCell::new_patch_apply_failure(event.stderr));
}
}
- EventMsg::ExecCommandEnd(ExecCommandEndEvent {
- call_id,
- exit_code,
- duration: _,
- stdout,
- stderr,
- }) => {
- // Compute summary before moving stdout into the history cell.
- let cmd = self.running_commands.remove(&call_id);
- self.active_history_cell = None;
- let (command, parsed_cmd) = match cmd {
- Some(cmd) => (cmd.command, cmd.parsed_cmd),
- None => (vec![call_id], vec![]),
- };
- self.add_to_history(HistoryCell::new_completed_exec_command(
- command,
- parsed_cmd,
- CommandOutput {
- exit_code,
- stdout,
- stderr,
- },
- ))
- }
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: _,
invocation,
}) => {
self.finalize_active_stream();
- self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation));
+ self.active_exec_cell = Some(HistoryCell::new_active_mcp_tool_call(invocation));
}
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: _,
@@ -526,7 +543,7 @@ impl ChatWidget<'_> {
invocation,
result,
}) => {
- self.add_to_history(HistoryCell::new_completed_mcp_tool_call(
+ let completed = HistoryCell::new_completed_mcp_tool_call(
80,
invocation,
duration,
@@ -535,7 +552,8 @@ impl ChatWidget<'_> {
.map(|r| r.is_error.unwrap_or(false))
.unwrap_or(false),
result,
- ));
+ );
+ self.active_exec_cell = Some(completed);
}
EventMsg::GetHistoryEntryResponse(event) => {
let codex_core::protocol::GetHistoryEntryResponseEvent {
@@ -679,11 +697,21 @@ impl ChatWidget<'_> {
// Ensure the waiting status is visible (composer replaced).
self.bottom_pane
.update_status_text("waiting for model".to_string());
+ self.flush_active_exec_cell();
self.emit_stream_header(kind);
}
}
+ fn flush_active_exec_cell(&mut self) {
+ if let Some(active) = self.active_exec_cell.take() {
+ self.app_event_tx
+ .send(AppEvent::InsertHistory(active.plain_lines()));
+ }
+ }
+
fn stream_push_and_maybe_commit(&mut self, delta: &str) {
+ self.flush_active_exec_cell();
+
self.live_builder.push_fragment(delta);
// Commit overflow rows (small batches) while keeping the last N rows visible.
@@ -765,7 +793,7 @@ impl WidgetRef for &ChatWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
(&self.bottom_pane).render(bottom_pane_area, buf);
- if let Some(cell) = &self.active_history_cell {
+ if let Some(cell) = &self.active_exec_cell {
cell.render_ref(active_cell_area, buf);
}
}
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 3149de3551..6b9c5df7d3 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -81,12 +81,11 @@ pub(crate) enum HistoryCell {
/// Message from the user.
UserPrompt { view: TextBlock },
- // AgentMessage and AgentReasoning variants were unused and have been removed.
- /// An exec tool call that has not finished yet.
- ActiveExecCommand { view: TextBlock },
-
- /// Completed exec tool call.
- CompletedExecCommand { view: TextBlock },
+ Exec {
+ command: Vec,
+ parsed: Vec,
+ output: Option,
+ },
/// An MCP tool call that has not finished yet.
ActiveMcpToolCall { view: TextBlock },
@@ -123,7 +122,7 @@ pub(crate) enum HistoryCell {
SessionInfo { view: TextBlock },
/// A pending code patch that is awaiting user approval. Mirrors the
- /// behaviour of `ActiveExecCommand` so the user sees *what* patch the
+ /// behaviour of `ExecCell` so the user sees *what* patch the
/// model wants to apply before being prompted to approve or deny it.
PendingPatch { view: TextBlock },
@@ -173,15 +172,18 @@ impl HistoryCell {
| HistoryCell::PromptsOutput { view }
| HistoryCell::ErrorEvent { view }
| HistoryCell::SessionInfo { view }
- | HistoryCell::CompletedExecCommand { view }
| HistoryCell::CompletedMcpToolCall { view }
| HistoryCell::PendingPatch { view }
| HistoryCell::PlanUpdate { view }
| HistoryCell::PatchApplyResult { view }
- | HistoryCell::ActiveExecCommand { view, .. }
| HistoryCell::ActiveMcpToolCall { view, .. } => {
view.lines.iter().map(line_to_static).collect()
}
+ HistoryCell::Exec {
+ command,
+ parsed,
+ output,
+ } => HistoryCell::exec_command_lines(command, parsed, output.as_ref()),
HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![
Line::from("tool result (image output omitted)"),
Line::from(""),
@@ -268,10 +270,7 @@ impl HistoryCell {
command: Vec,
parsed: Vec,
) -> Self {
- let lines = HistoryCell::exec_command_lines(&command, &parsed, None);
- HistoryCell::ActiveExecCommand {
- view: TextBlock::new(lines),
- }
+ HistoryCell::new_exec_cell(command, parsed, None)
}
pub(crate) fn new_completed_exec_command(
@@ -279,9 +278,18 @@ impl HistoryCell {
parsed: Vec,
output: CommandOutput,
) -> Self {
- let lines = HistoryCell::exec_command_lines(&command, &parsed, Some(&output));
- HistoryCell::CompletedExecCommand {
- view: TextBlock::new(lines),
+ HistoryCell::new_exec_cell(command, parsed, Some(output))
+ }
+
+ fn new_exec_cell(
+ command: Vec,
+ parsed: Vec,
+ output: Option,
+ ) -> Self {
+ HistoryCell::Exec {
+ command,
+ parsed,
+ output,
}
}
@@ -290,10 +298,9 @@ impl HistoryCell {
parsed: &[ParsedCommand],
output: Option<&CommandOutput>,
) -> Vec> {
- if parsed.is_empty() {
- HistoryCell::new_exec_command_generic(command, output)
- } else {
- HistoryCell::new_parsed_command(parsed, output)
+ match parsed.is_empty() {
+ true => HistoryCell::new_exec_command_generic(command, output),
+ false => HistoryCell::new_parsed_command(parsed, output),
}
}
From 0cf57e1f42b54fb1ed9e6af6fdfd18da106e4298 Mon Sep 17 00:00:00 2001
From: pakrym-oai
Date: Mon, 11 Aug 2025 11:52:05 -0700
Subject: [PATCH 11/45] Include output truncation message in tool call results
(#2183)
To avoid model being confused about incomplete output.
---
codex-rs/core/src/codex.rs | 58 ++++++++---------
codex-rs/core/src/exec.rs | 52 +++++++++++++---
codex-rs/core/src/shell.rs | 2 +-
codex-rs/core/tests/exec.rs | 76 ++++++++++++++++++++---
codex-rs/core/tests/exec_stream_events.rs | 6 +-
codex-rs/linux-sandbox/tests/landlock.rs | 6 +-
6 files changed, 146 insertions(+), 54 deletions(-)
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 3fcac51bdc..2dd48bf366 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -51,6 +51,7 @@ use crate::exec::ExecParams;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
+use crate::exec::StreamOutput;
use crate::exec::process_exec_tool_call;
use crate::exec_env::create_env;
use crate::mcp_connection_manager::McpConnectionManager;
@@ -431,8 +432,8 @@ impl Session {
// Because stdout and stderr could each be up to 100 KiB, we send
// truncated versions.
const MAX_STREAM_OUTPUT: usize = 5 * 1024; // 5KiB
- let stdout = stdout.chars().take(MAX_STREAM_OUTPUT).collect();
- let stderr = stderr.chars().take(MAX_STREAM_OUTPUT).collect();
+ let stdout = stdout.text.chars().take(MAX_STREAM_OUTPUT).collect();
+ let stderr = stderr.text.chars().take(MAX_STREAM_OUTPUT).collect();
let msg = if is_apply_patch {
EventMsg::PatchApplyEnd(PatchApplyEndEvent {
@@ -504,8 +505,8 @@ impl Session {
Err(e) => {
output_stderr = ExecToolCallOutput {
exit_code: -1,
- stdout: String::new(),
- stderr: get_error_message_ui(e),
+ stdout: StreamOutput::new(String::new()),
+ stderr: StreamOutput::new(get_error_message_ui(e)),
duration: Duration::default(),
};
&output_stderr
@@ -1977,19 +1978,10 @@ async fn handle_container_exec_with_params(
match output_result {
Ok(output) => {
- let ExecToolCallOutput {
- exit_code,
- stdout,
- stderr,
- duration,
- } = &output;
+ let ExecToolCallOutput { exit_code, .. } = &output;
let is_success = *exit_code == 0;
- let content = format_exec_output(
- if is_success { stdout } else { stderr },
- *exit_code,
- *duration,
- );
+ let content = format_exec_output(&output);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
@@ -2118,19 +2110,10 @@ async fn handle_sandbox_error(
match retry_output_result {
Ok(retry_output) => {
- let ExecToolCallOutput {
- exit_code,
- stdout,
- stderr,
- duration,
- } = &retry_output;
+ let ExecToolCallOutput { exit_code, .. } = &retry_output;
let is_success = *exit_code == 0;
- let content = format_exec_output(
- if is_success { stdout } else { stderr },
- *exit_code,
- *duration,
- );
+ let content = format_exec_output(&retry_output);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
@@ -2163,7 +2146,14 @@ async fn handle_sandbox_error(
}
/// Exec output is a pre-serialized JSON payload
-fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> String {
+fn format_exec_output(exec_output: &ExecToolCallOutput) -> String {
+ let ExecToolCallOutput {
+ exit_code,
+ stdout,
+ stderr,
+ duration,
+ } = exec_output;
+
#[derive(Serialize)]
struct ExecMetadata {
exit_code: i32,
@@ -2179,10 +2169,20 @@ fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> Strin
// round to 1 decimal place
let duration_seconds = ((duration.as_secs_f32()) * 10.0).round() / 10.0;
+ let is_success = *exit_code == 0;
+ let output = if is_success { stdout } else { stderr };
+
+ let mut formatted_output = output.text.clone();
+ if let Some(truncated_after_lines) = output.truncated_after_lines {
+ formatted_output.push_str(&format!(
+ "\n\n[Output truncated after {truncated_after_lines} lines: too many lines or bytes.]",
+ ));
+ }
+
let payload = ExecOutput {
- output,
+ output: &formatted_output,
metadata: ExecMetadata {
- exit_code,
+ exit_code: *exit_code,
duration_seconds,
},
};
diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs
index 10606b6821..c964466f78 100644
--- a/codex-rs/core/src/exec.rs
+++ b/codex-rs/core/src/exec.rs
@@ -130,8 +130,8 @@ pub async fn process_exec_tool_call(
let duration = start.elapsed();
match raw_output_result {
Ok(raw_output) => {
- let stdout = String::from_utf8_lossy(&raw_output.stdout).to_string();
- let stderr = String::from_utf8_lossy(&raw_output.stderr).to_string();
+ let stdout = raw_output.stdout.from_utf8_lossy();
+ let stderr = raw_output.stderr.from_utf8_lossy();
#[cfg(target_family = "unix")]
match raw_output.exit_status.signal() {
@@ -146,7 +146,9 @@ pub async fn process_exec_tool_call(
if exit_code != 0 && is_likely_sandbox_denied(sandbox_type, exit_code) {
return Err(CodexErr::Sandbox(SandboxErr::Denied(
- exit_code, stdout, stderr,
+ exit_code,
+ stdout.text,
+ stderr.text,
)));
}
@@ -243,18 +245,41 @@ fn is_likely_sandbox_denied(sandbox_type: SandboxType, exit_code: i32) -> bool {
true
}
+#[derive(Debug)]
+pub struct StreamOutput {
+ pub text: T,
+ pub truncated_after_lines: Option,
+}
#[derive(Debug)]
pub struct RawExecToolCallOutput {
pub exit_status: ExitStatus,
- pub stdout: Vec,
- pub stderr: Vec,
+ pub stdout: StreamOutput>,
+ pub stderr: StreamOutput>,
+}
+
+impl StreamOutput {
+ pub fn new(text: String) -> Self {
+ Self {
+ text,
+ truncated_after_lines: None,
+ }
+ }
+}
+
+impl StreamOutput> {
+ pub fn from_utf8_lossy(&self) -> StreamOutput {
+ StreamOutput {
+ text: String::from_utf8_lossy(&self.text).to_string(),
+ truncated_after_lines: self.truncated_after_lines,
+ }
+ }
}
#[derive(Debug)]
pub struct ExecToolCallOutput {
pub exit_code: i32,
- pub stdout: String,
- pub stderr: String,
+ pub stdout: StreamOutput,
+ pub stderr: StreamOutput,
pub duration: Duration,
}
@@ -363,7 +388,7 @@ async fn read_capped(
max_lines: usize,
stream: Option,
is_stderr: bool,
-) -> io::Result> {
+) -> io::Result>> {
let mut buf = Vec::with_capacity(max_output.min(8 * 1024));
let mut tmp = [0u8; 8192];
@@ -413,7 +438,16 @@ async fn read_capped(
// Continue reading to EOF to avoid back-pressure, but discard once caps are hit.
}
- Ok(buf)
+ let truncated = remaining_lines == 0 || remaining_bytes == 0;
+
+ Ok(StreamOutput {
+ text: buf,
+ truncated_after_lines: if truncated {
+ Some((max_lines - remaining_lines) as u32)
+ } else {
+ None
+ },
+ })
}
#[cfg(unix)]
diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs
index de0764f75e..cc58eb7460 100644
--- a/codex-rs/core/src/shell.rs
+++ b/codex-rs/core/src/shell.rs
@@ -230,7 +230,7 @@ mod tests {
assert_eq!(output.exit_code, 0, "input: {input:?} output: {output:?}");
if let Some(expected) = expected_output {
assert_eq!(
- output.stdout, expected,
+ output.stdout.text, expected,
"input: {input:?} output: {output:?}"
);
}
diff --git a/codex-rs/core/tests/exec.rs b/codex-rs/core/tests/exec.rs
index f1b9e78e67..9bead7ef60 100644
--- a/codex-rs/core/tests/exec.rs
+++ b/codex-rs/core/tests/exec.rs
@@ -1,10 +1,11 @@
#![cfg(target_os = "macos")]
-#![expect(clippy::expect_used)]
+#![expect(clippy::unwrap_used, clippy::expect_used)]
use std::collections::HashMap;
use std::sync::Arc;
use codex_core::exec::ExecParams;
+use codex_core::exec::ExecToolCallOutput;
use codex_core::exec::SandboxType;
use codex_core::exec::process_exec_tool_call;
use codex_core::protocol::SandboxPolicy;
@@ -12,14 +13,20 @@ use codex_core::spawn::CODEX_SANDBOX_ENV_VAR;
use tempfile::TempDir;
use tokio::sync::Notify;
+use codex_core::error::Result;
+
use codex_core::get_platform_sandbox;
-async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>, should_be_ok: bool) {
+fn skip_test() -> bool {
if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) {
eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test.");
- return;
+ return true;
}
+ false
+}
+
+async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result {
let sandbox_type = get_platform_sandbox().expect("should be able to get sandbox type");
assert_eq!(sandbox_type, SandboxType::MacosSeatbelt);
@@ -35,31 +42,82 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>, should_be_ok: bool) {
let ctrl_c = Arc::new(Notify::new());
let policy = SandboxPolicy::new_read_only_policy();
- let result = process_exec_tool_call(params, sandbox_type, ctrl_c, &policy, &None, None).await;
-
- assert!(result.is_ok() == should_be_ok);
+ process_exec_tool_call(params, sandbox_type, ctrl_c, &policy, &None, None).await
}
/// Command succeeds with exit code 0 normally
#[tokio::test]
async fn exit_code_0_succeeds() {
+ if skip_test() {
+ return;
+ }
+
let tmp = TempDir::new().expect("should be able to create temp dir");
let cmd = vec!["echo", "hello"];
- run_test_cmd(tmp, cmd, true).await
+ let output = run_test_cmd(tmp, cmd).await.unwrap();
+ assert_eq!(output.stdout.text, "hello\n");
+ assert_eq!(output.stderr.text, "");
+ assert_eq!(output.stdout.truncated_after_lines, None);
+}
+
+/// Command succeeds with exit code 0 normally
+#[tokio::test]
+async fn truncates_output_lines() {
+ if skip_test() {
+ return;
+ }
+
+ let tmp = TempDir::new().expect("should be able to create temp dir");
+ let cmd = vec!["seq", "300"];
+
+ #[expect(clippy::unwrap_used)]
+ let output = run_test_cmd(tmp, cmd).await.unwrap();
+
+ let expected_output = (1..=256)
+ .map(|i| format!("{i}\n"))
+ .collect::>()
+ .join("");
+ assert_eq!(output.stdout.text, expected_output);
+ assert_eq!(output.stdout.truncated_after_lines, Some(256));
+}
+
+/// Command succeeds with exit code 0 normally
+#[tokio::test]
+async fn truncates_output_bytes() {
+ if skip_test() {
+ return;
+ }
+
+ let tmp = TempDir::new().expect("should be able to create temp dir");
+ // each line is 1000 bytes
+ let cmd = vec!["bash", "-lc", "seq 15 | awk '{printf \"%-1000s\\n\", $0}'"];
+
+ let output = run_test_cmd(tmp, cmd).await.unwrap();
+
+ assert_eq!(output.stdout.text.len(), 10240);
+ assert_eq!(output.stdout.truncated_after_lines, Some(10));
}
/// Command not found returns exit code 127, this is not considered a sandbox error
#[tokio::test]
async fn exit_command_not_found_is_ok() {
+ if skip_test() {
+ return;
+ }
+
let tmp = TempDir::new().expect("should be able to create temp dir");
let cmd = vec!["/bin/bash", "-c", "nonexistent_command_12345"];
- run_test_cmd(tmp, cmd, true).await
+ run_test_cmd(tmp, cmd).await.unwrap();
}
/// Writing a file fails and should be considered a sandbox error
#[tokio::test]
async fn write_file_fails_as_sandbox_error() {
+ if skip_test() {
+ return;
+ }
+
let tmp = TempDir::new().expect("should be able to create temp dir");
let path = tmp.path().join("test.txt");
let cmd = vec![
@@ -67,5 +125,5 @@ async fn write_file_fails_as_sandbox_error() {
path.to_str().expect("should be able to get path"),
];
- run_test_cmd(tmp, cmd, false).await;
+ assert!(run_test_cmd(tmp, cmd).await.is_err());
}
diff --git a/codex-rs/core/tests/exec_stream_events.rs b/codex-rs/core/tests/exec_stream_events.rs
index 534b25513a..36632afd2a 100644
--- a/codex-rs/core/tests/exec_stream_events.rs
+++ b/codex-rs/core/tests/exec_stream_events.rs
@@ -76,7 +76,7 @@ async fn test_exec_stdout_stream_events_echo() {
};
assert_eq!(result.exit_code, 0);
- assert_eq!(result.stdout, "hello-world\n");
+ assert_eq!(result.stdout.text, "hello-world\n");
let streamed = collect_stdout_events(rx);
// We should have received at least the same contents (possibly in one chunk)
@@ -128,8 +128,8 @@ async fn test_exec_stderr_stream_events_echo() {
};
assert_eq!(result.exit_code, 0);
- assert_eq!(result.stdout, "");
- assert_eq!(result.stderr, "oops\n");
+ assert_eq!(result.stdout.text, "");
+ assert_eq!(result.stderr.text, "oops\n");
// Collect only stderr delta events
let mut err = Vec::new();
diff --git a/codex-rs/linux-sandbox/tests/landlock.rs b/codex-rs/linux-sandbox/tests/landlock.rs
index 96298c6563..c7081dbca2 100644
--- a/codex-rs/linux-sandbox/tests/landlock.rs
+++ b/codex-rs/linux-sandbox/tests/landlock.rs
@@ -72,8 +72,8 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
.unwrap();
if res.exit_code != 0 {
- println!("stdout:\n{}", res.stdout);
- println!("stderr:\n{}", res.stderr);
+ println!("stdout:\n{}", res.stdout.text);
+ println!("stderr:\n{}", res.stderr.text);
panic!("exit code: {}", res.exit_code);
}
}
@@ -164,7 +164,7 @@ async fn assert_network_blocked(cmd: &[&str]) {
.await;
let (exit_code, stdout, stderr) = match result {
- Ok(output) => (output.exit_code, output.stdout, output.stderr),
+ Ok(output) => (output.exit_code, output.stdout.text, output.stderr.text),
Err(CodexErr::Sandbox(SandboxErr::Denied(exit_code, stdout, stderr))) => {
(exit_code, stdout, stderr)
}
From 85e4f564a33a3a877fc4f279bf870c7ef9ed2645 Mon Sep 17 00:00:00 2001
From: aibrahim-oai
Date: Mon, 11 Aug 2025 12:31:34 -0700
Subject: [PATCH 12/45] Chores: Refactor approval Patch UI. Stack: [1/2]
(#2049)
- Moved the logic for the apply patch in its own file
Stack:
#2050
-> #2049
---
codex-rs/tui/src/diff_render.rs | 152 ++++++++++++++++++++++++++++++
codex-rs/tui/src/history_cell.rs | 155 +------------------------------
codex-rs/tui/src/lib.rs | 1 +
3 files changed, 156 insertions(+), 152 deletions(-)
create mode 100644 codex-rs/tui/src/diff_render.rs
diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs
new file mode 100644
index 0000000000..f536681732
--- /dev/null
+++ b/codex-rs/tui/src/diff_render.rs
@@ -0,0 +1,152 @@
+use ratatui::style::Color;
+use ratatui::style::Modifier;
+use ratatui::style::Style;
+use ratatui::text::Line as RtLine;
+use ratatui::text::Span as RtSpan;
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use codex_core::protocol::FileChange;
+
+struct FileSummary {
+ display_path: String,
+ added: usize,
+ removed: usize,
+}
+
+pub(crate) fn create_diff_summary(
+ title: &str,
+ changes: HashMap,
+) -> Vec> {
+ let mut files: Vec = Vec::new();
+
+ // Count additions/deletions from a unified diff body
+ let count_from_unified = |diff: &str| -> (usize, usize) {
+ if let Ok(patch) = diffy::Patch::from_str(diff) {
+ let mut adds = 0usize;
+ let mut dels = 0usize;
+ for hunk in patch.hunks() {
+ for line in hunk.lines() {
+ match line {
+ diffy::Line::Insert(_) => adds += 1,
+ diffy::Line::Delete(_) => dels += 1,
+ _ => {}
+ }
+ }
+ }
+ (adds, dels)
+ } else {
+ let mut adds = 0usize;
+ let mut dels = 0usize;
+ for l in diff.lines() {
+ if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
+ continue;
+ }
+ match l.as_bytes().first() {
+ Some(b'+') => adds += 1,
+ Some(b'-') => dels += 1,
+ _ => {}
+ }
+ }
+ (adds, dels)
+ }
+ };
+
+ for (path, change) in &changes {
+ use codex_core::protocol::FileChange::*;
+ match change {
+ Add { content } => {
+ let added = content.lines().count();
+ files.push(FileSummary {
+ display_path: path.display().to_string(),
+ added,
+ removed: 0,
+ });
+ }
+ Delete => {
+ let removed = std::fs::read_to_string(path)
+ .ok()
+ .map(|s| s.lines().count())
+ .unwrap_or(0);
+ files.push(FileSummary {
+ display_path: path.display().to_string(),
+ added: 0,
+ removed,
+ });
+ }
+ Update {
+ unified_diff,
+ move_path,
+ } => {
+ let (added, removed) = count_from_unified(unified_diff);
+ let display_path = if let Some(new_path) = move_path {
+ format!("{} → {}", path.display(), new_path.display())
+ } else {
+ path.display().to_string()
+ };
+ files.push(FileSummary {
+ display_path,
+ added,
+ removed,
+ });
+ }
+ }
+ }
+
+ let file_count = files.len();
+ let total_added: usize = files.iter().map(|f| f.added).sum();
+ let total_removed: usize = files.iter().map(|f| f.removed).sum();
+ let noun = if file_count == 1 { "file" } else { "files" };
+
+ let mut out: Vec> = Vec::new();
+
+ // Header
+ let mut header_spans: Vec> = Vec::new();
+ header_spans.push(RtSpan::styled(
+ title.to_owned(),
+ Style::default()
+ .fg(Color::Magenta)
+ .add_modifier(Modifier::BOLD),
+ ));
+ header_spans.push(RtSpan::raw(" to "));
+ header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
+ header_spans.push(RtSpan::raw("("));
+ header_spans.push(RtSpan::styled(
+ format!("+{total_added}"),
+ Style::default().fg(Color::Green),
+ ));
+ header_spans.push(RtSpan::raw(" "));
+ header_spans.push(RtSpan::styled(
+ format!("-{total_removed}"),
+ Style::default().fg(Color::Red),
+ ));
+ header_spans.push(RtSpan::raw(")"));
+ out.push(RtLine::from(header_spans));
+
+ // Dimmed per-file lines with prefix
+ for (idx, f) in files.iter().enumerate() {
+ let mut spans: Vec> = Vec::new();
+ spans.push(RtSpan::raw(f.display_path.clone()));
+ spans.push(RtSpan::raw(" ("));
+ spans.push(RtSpan::styled(
+ format!("+{}", f.added),
+ Style::default().fg(Color::Green),
+ ));
+ spans.push(RtSpan::raw(" "));
+ spans.push(RtSpan::styled(
+ format!("-{}", f.removed),
+ Style::default().fg(Color::Red),
+ ));
+ spans.push(RtSpan::raw(")"));
+
+ let mut line = RtLine::from(spans);
+ let prefix = if idx == 0 { " ⎿ " } else { " " };
+ line.spans.insert(0, prefix.into());
+ line.spans.iter_mut().for_each(|span| {
+ span.style = span.style.add_modifier(Modifier::DIM);
+ });
+ out.push(line);
+ }
+
+ out
+}
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 6b9c5df7d3..29b62ecb3e 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -1,4 +1,5 @@
use crate::colors::LIGHT_BLUE;
+use crate::diff_render::create_diff_summary;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::slash_command::SlashCommand;
@@ -28,8 +29,6 @@ use ratatui::prelude::*;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
-use ratatui::text::Line as RtLine;
-use ratatui::text::Span as RtSpan;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
@@ -45,12 +44,6 @@ pub(crate) struct CommandOutput {
pub(crate) stderr: String,
}
-struct FileSummary {
- display_path: String,
- added: usize,
- removed: usize,
-}
-
pub(crate) enum PatchEventType {
ApprovalRequest,
ApplyBegin { auto_approved: bool },
@@ -803,7 +796,7 @@ impl HistoryCell {
event_type: PatchEventType,
changes: HashMap,
) -> Self {
- let title = match event_type {
+ let title = match &event_type {
PatchEventType::ApprovalRequest => "proposed patch",
PatchEventType::ApplyBegin {
auto_approved: true,
@@ -821,15 +814,7 @@ impl HistoryCell {
}
};
- let summary_lines = create_diff_summary(title, changes);
-
- let mut lines: Vec> = Vec::new();
-
- for line in summary_lines {
- lines.push(line);
- }
-
- lines.push(Line::from(""));
+ let lines: Vec> = create_diff_summary(title, changes);
HistoryCell::PendingPatch {
view: TextBlock::new(lines),
@@ -931,140 +916,6 @@ fn output_lines(
out
}
-fn create_diff_summary(title: &str, changes: HashMap) -> Vec> {
- let mut files: Vec = Vec::new();
-
- // Count additions/deletions from a unified diff body
- let count_from_unified = |diff: &str| -> (usize, usize) {
- if let Ok(patch) = diffy::Patch::from_str(diff) {
- let mut adds = 0usize;
- let mut dels = 0usize;
- for hunk in patch.hunks() {
- for line in hunk.lines() {
- match line {
- diffy::Line::Insert(_) => adds += 1,
- diffy::Line::Delete(_) => dels += 1,
- _ => {}
- }
- }
- }
- (adds, dels)
- } else {
- let mut adds = 0usize;
- let mut dels = 0usize;
- for l in diff.lines() {
- if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
- continue;
- }
- match l.as_bytes().first() {
- Some(b'+') => adds += 1,
- Some(b'-') => dels += 1,
- _ => {}
- }
- }
- (adds, dels)
- }
- };
-
- for (path, change) in &changes {
- use codex_core::protocol::FileChange::*;
- match change {
- Add { content } => {
- let added = content.lines().count();
- files.push(FileSummary {
- display_path: path.display().to_string(),
- added,
- removed: 0,
- });
- }
- Delete => {
- let removed = std::fs::read_to_string(path)
- .ok()
- .map(|s| s.lines().count())
- .unwrap_or(0);
- files.push(FileSummary {
- display_path: path.display().to_string(),
- added: 0,
- removed,
- });
- }
- Update {
- unified_diff,
- move_path,
- } => {
- let (added, removed) = count_from_unified(unified_diff);
- let display_path = if let Some(new_path) = move_path {
- format!("{} → {}", path.display(), new_path.display())
- } else {
- path.display().to_string()
- };
- files.push(FileSummary {
- display_path,
- added,
- removed,
- });
- }
- }
- }
-
- let file_count = files.len();
- let total_added: usize = files.iter().map(|f| f.added).sum();
- let total_removed: usize = files.iter().map(|f| f.removed).sum();
- let noun = if file_count == 1 { "file" } else { "files" };
-
- let mut out: Vec> = Vec::new();
-
- // Header
- let mut header_spans: Vec> = Vec::new();
- header_spans.push(RtSpan::styled(
- title.to_owned(),
- Style::default()
- .fg(Color::Magenta)
- .add_modifier(Modifier::BOLD),
- ));
- header_spans.push(RtSpan::raw(" to "));
- header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
- header_spans.push(RtSpan::raw("("));
- header_spans.push(RtSpan::styled(
- format!("+{total_added}"),
- Style::default().fg(Color::Green),
- ));
- header_spans.push(RtSpan::raw(" "));
- header_spans.push(RtSpan::styled(
- format!("-{total_removed}"),
- Style::default().fg(Color::Red),
- ));
- header_spans.push(RtSpan::raw(")"));
- out.push(RtLine::from(header_spans));
-
- // Dimmed per-file lines with prefix
- for (idx, f) in files.iter().enumerate() {
- let mut spans: Vec> = Vec::new();
- spans.push(RtSpan::raw(f.display_path.clone()));
- spans.push(RtSpan::raw(" ("));
- spans.push(RtSpan::styled(
- format!("+{}", f.added),
- Style::default().fg(Color::Green),
- ));
- spans.push(RtSpan::raw(" "));
- spans.push(RtSpan::styled(
- format!("-{}", f.removed),
- Style::default().fg(Color::Red),
- ));
- spans.push(RtSpan::raw(")"));
-
- let mut line = RtLine::from(spans);
- let prefix = if idx == 0 { " ⎿ " } else { " " };
- line.spans.insert(0, prefix.into());
- line.spans.iter_mut().for_each(|span| {
- span.style = span.style.add_modifier(Modifier::DIM);
- });
- out.push(line);
- }
-
- out
-}
-
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
let args_str = invocation
.arguments
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index e15a235a71..27c850ca61 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -31,6 +31,7 @@ mod citation_regex;
mod cli;
mod colors;
pub mod custom_terminal;
+mod diff_render;
mod exec_command;
mod file_search;
mod get_git_diff;
From 4368f075d0bcd5323044dfb7b28b6e689d7832e8 Mon Sep 17 00:00:00 2001
From: Gabriel Peal
Date: Mon, 11 Aug 2025 12:40:12 -0700
Subject: [PATCH 13/45] [3/3] Merge sequential exec commands (#2110)
This PR merges and dedupes sequential exec cells so they stack neatly on
sequential lines rather than separate blocks.
This is particularly useful because the model will often sed 200 lines
of a file multiple times in a row and this nicely collapses them.
https://github.com/user-attachments/assets/04cccda5-e2ba-4a97-a613-4547587aa15c
Part 1: https://github.com/openai/codex/pull/2095
Part 2: https://github.com/openai/codex/pull/2097
---
codex-rs/tui/src/chatwidget.rs | 267 ++++++++++++++++++++++++++++++-
codex-rs/tui/src/history_cell.rs | 81 +++++++---
2 files changed, 315 insertions(+), 33 deletions(-)
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 1c81b2cf56..d03a51bd70 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -47,6 +47,7 @@ use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
use crate::history_cell::CommandOutput;
+use crate::history_cell::ExecCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
use crate::live_wrap::RowBuilder;
@@ -467,8 +468,6 @@ impl ChatWidget<'_> {
cwd,
parsed_cmd,
}) => {
- // TODO: merge this into the active exec call.
- self.flush_active_exec_cell();
self.finalize_active_stream();
// Ensure the status indicator is visible while the command runs.
self.bottom_pane
@@ -481,8 +480,19 @@ impl ChatWidget<'_> {
parsed_cmd: parsed_cmd.clone(),
},
);
- self.active_exec_cell =
- Some(HistoryCell::new_active_exec_command(command, parsed_cmd));
+ let active_exec_cell = self.active_exec_cell.take();
+ let merge_result = merge_cells(&command, &parsed_cmd, &active_exec_cell);
+ self.active_exec_cell = match merge_result {
+ MergeResult::Merge(cell) => Some(cell),
+ MergeResult::Drop => active_exec_cell,
+ MergeResult::NewCell(cell) => {
+ if let Some(active) = active_exec_cell {
+ self.app_event_tx
+ .send(AppEvent::InsertHistory(active.plain_lines()));
+ }
+ Some(cell)
+ }
+ }
}
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
@@ -493,11 +503,15 @@ impl ChatWidget<'_> {
}) => {
// Compute summary before moving stdout into the history cell.
let cmd = self.running_commands.remove(&call_id);
- let parsed_cmd = match &cmd {
- Some(RunningCommand { parsed_cmd, .. }) => parsed_cmd.clone(),
- _ => vec![],
- };
if let Some(cmd) = cmd {
+ // Preserve any merged parsed commands already present on the
+ // active cell; otherwise, fall back to this command's parsed.
+ let parsed_cmd = match &self.active_exec_cell {
+ Some(HistoryCell::Exec(ExecCell { parsed, .. })) if !parsed.is_empty() => {
+ parsed.clone()
+ }
+ _ => cmd.parsed_cmd.clone(),
+ };
// Replace the active running cell with the finalized result,
// but keep it as the active cell so it can be merged with
// subsequent commands before being committed.
@@ -826,3 +840,240 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU
total_tokens: current_usage.total_tokens + new_usage.total_tokens,
}
}
+
+enum MergeResult {
+ Merge(HistoryCell),
+ Drop,
+ NewCell(HistoryCell),
+}
+
+// Determine whether to and how to merge two consecutive exec cells.
+fn merge_cells(
+ new_command: &[String],
+ new_parsed: &[ParsedCommand],
+ active_exec_cell: &Option,
+) -> MergeResult {
+ let ExecCell {
+ command: _existing_command,
+ parsed: existing_parsed,
+ output: existing_output,
+ } = match active_exec_cell {
+ Some(HistoryCell::Exec(cell)) => cell,
+ _ => {
+ // There is no existing exec cell.
+ return MergeResult::NewCell(HistoryCell::new_active_exec_command(
+ new_command.to_vec(),
+ new_parsed.to_vec(),
+ ));
+ }
+ };
+ let existing_last = existing_parsed.last();
+ let new_last = new_parsed.last();
+
+ // Drop the first command if it is a read and matches the last command.
+ // This is a common pattern the model does and it simplifies the output to dedupe.
+ let drop_first = if let (
+ Some(ParsedCommand::Read {
+ name: existing_name,
+ ..
+ }),
+ Some(ParsedCommand::Read { name: new_name, .. }),
+ ) = (existing_last, new_last)
+ {
+ existing_name == new_name
+ } else {
+ false
+ };
+
+ if drop_first && new_parsed.len() == 1 {
+ // There is only one command and it was deduped.
+ return MergeResult::Drop;
+ }
+ let existing_exit_code = existing_output.as_ref().map(|o| o.exit_code);
+ if let Some(code) = existing_exit_code {
+ if code != 0 {
+ // If the previous command failed, don't merge so the user can see stderr.
+ // Start a fresh cell for the new command instead of duplicating the old one.
+ return MergeResult::NewCell(HistoryCell::new_active_exec_command(
+ new_command.to_vec(),
+ new_parsed.to_vec(),
+ ));
+ }
+ }
+
+ let mut merged_parsed = existing_parsed.to_vec();
+ if drop_first {
+ merged_parsed.extend(new_parsed[1..].to_vec());
+ } else {
+ merged_parsed.extend(new_parsed.to_vec());
+ }
+
+ MergeResult::Merge(HistoryCell::new_active_exec_command(
+ new_command.to_vec(),
+ merged_parsed,
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::history_cell::CommandOutput;
+
+ fn read_cmd(name: &str) -> ParsedCommand {
+ ParsedCommand::Read {
+ cmd: vec!["cat".to_string(), name.to_string()],
+ name: name.to_string(),
+ }
+ }
+
+ fn unknown_cmd(cmd: &str) -> ParsedCommand {
+ ParsedCommand::Unknown {
+ cmd: cmd.split_whitespace().map(|s| s.to_string()).collect(),
+ }
+ }
+
+ #[test]
+ fn when_no_active_exec_cell_creates_new_cell() {
+ let new_command = vec!["echo".to_string(), "hi".to_string()];
+ let new_parsed = vec![read_cmd("a")];
+
+ let result = merge_cells(&new_command, &new_parsed, &None);
+
+ match result {
+ MergeResult::NewCell(cell) => match cell {
+ HistoryCell::Exec(ExecCell {
+ command,
+ parsed,
+ output,
+ }) => {
+ assert_eq!(command, new_command);
+ assert_eq!(parsed, new_parsed);
+ assert!(output.is_none());
+ }
+ _ => panic!("expected Exec cell"),
+ },
+ _ => panic!("expected NewCell"),
+ }
+ }
+
+ #[test]
+ fn drops_duplicate_trailing_read_when_new_has_only_one_read() {
+ // existing last = Read("foo"), new last = Read("foo"), new_parsed.len() == 1
+ let active = Some(HistoryCell::new_active_exec_command(
+ vec!["bash".into(), "-lc".into(), "cat foo".into()],
+ vec![read_cmd("foo")],
+ ));
+ let new_command = vec!["cat".into(), "foo".into()];
+ let new_parsed = vec![read_cmd("foo")];
+
+ let result = merge_cells(&new_command, &new_parsed, &active);
+ match result {
+ MergeResult::Drop => {}
+ _ => panic!("expected Drop"),
+ }
+ }
+
+ #[test]
+ fn does_not_merge_when_previous_command_failed() {
+ // existing exit_code != 0 forces starting a fresh cell
+ let active = Some(HistoryCell::new_completed_exec_command(
+ vec!["bash".into(), "-lc".into(), "cat bar".into()],
+ vec![read_cmd("bar")],
+ CommandOutput {
+ exit_code: 1,
+ stdout: String::new(),
+ stderr: "err".into(),
+ },
+ ));
+ // Ensure drop_first condition is false (different name)
+ let new_command = vec!["cat".into(), "baz".into()];
+ let new_parsed = vec![read_cmd("baz")];
+
+ let result = merge_cells(&new_command, &new_parsed, &active);
+ match result {
+ MergeResult::NewCell(cell) => match cell {
+ HistoryCell::Exec(ExecCell {
+ command, parsed, ..
+ }) => {
+ assert_eq!(command, new_command);
+ assert_eq!(parsed, new_parsed);
+ }
+ _ => panic!("expected Exec cell"),
+ },
+ _ => panic!("expected NewCell"),
+ }
+ }
+
+ #[test]
+ fn merges_with_drop_first_true_when_new_len_gt_one() {
+ // existing last Read("file.txt"), new starts with same Read then more
+ let active = Some(HistoryCell::new_active_exec_command(
+ vec!["cat".into(), "file.txt".into()],
+ vec![read_cmd("file.txt")],
+ ));
+ let new_command = vec!["bash".into(), "-lc".into(), "sed -n 1,20p file.txt".into()];
+ // Place the duplicate Read as the LAST element to satisfy drop_first condition
+ let leading = unknown_cmd("tail -n 20");
+ let new_parsed = vec![leading.clone(), read_cmd("file.txt")];
+
+ let result = merge_cells(&new_command, &new_parsed, &active);
+ match result {
+ MergeResult::Merge(cell) => match cell {
+ HistoryCell::Exec(ExecCell {
+ command, parsed, ..
+ }) => {
+ assert_eq!(command, new_command);
+ // Expect existing parsed + new_parsed[1..]
+ assert_eq!(parsed.len(), 2);
+ match (&parsed[0], &parsed[1]) {
+ (
+ ParsedCommand::Read { name, .. },
+ ParsedCommand::Read { name: n2, .. },
+ ) => {
+ assert_eq!(name, "file.txt");
+ assert_eq!(n2, "file.txt");
+ }
+ _ => panic!("unexpected parsed commands"),
+ }
+ }
+ _ => panic!("expected Exec cell"),
+ },
+ _ => panic!("expected Merge"),
+ }
+ }
+
+ #[test]
+ fn merges_without_drop_first_when_last_commands_differ() {
+ // existing last Read("file1.txt"), new last Read("file2.txt"); should concatenate
+ let active = Some(HistoryCell::new_active_exec_command(
+ vec!["cat".into(), "file1.txt".into()],
+ vec![read_cmd("file1.txt")],
+ ));
+ let new_command = vec!["bash".into(), "-lc".into(), "cat file2.txt".into()];
+ let t2 = read_cmd("file2.txt");
+ let extra = unknown_cmd("echo done");
+ let new_parsed = vec![t2.clone(), extra.clone()];
+
+ let result = merge_cells(&new_command, &new_parsed, &active);
+ match result {
+ MergeResult::Merge(cell) => match cell {
+ HistoryCell::Exec(ExecCell {
+ command, parsed, ..
+ }) => {
+ assert_eq!(command, new_command);
+ assert_eq!(parsed.len(), 3);
+ match (&parsed[0], &parsed[1], &parsed[2]) {
+ (ParsedCommand::Read { name: n1, .. }, p2, p3) => {
+ assert_eq!(n1, "file1.txt");
+ assert_eq!(p2, &t2);
+ assert_eq!(p3, &extra);
+ }
+ _ => panic!("unexpected parsed commands"),
+ }
+ }
+ _ => panic!("expected Exec cell"),
+ },
+ _ => panic!("expected Merge"),
+ }
+ }
+}
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 29b62ecb3e..1236d4b550 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -38,6 +38,7 @@ use std::path::PathBuf;
use std::time::Duration;
use tracing::error;
+#[derive(Clone)]
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
pub(crate) stdout: String,
@@ -64,27 +65,37 @@ fn line_to_static(line: &Line) -> Line<'static> {
}
}
+pub(crate) struct ExecCell {
+ pub(crate) command: Vec,
+ pub(crate) parsed: Vec,
+ pub(crate) output: Option,
+}
+
/// Represents an event to display in the conversation history. Returns its
/// `Vec>` representation to make it easier to display in a
/// scrollable list.
pub(crate) enum HistoryCell {
/// Welcome message.
- WelcomeMessage { view: TextBlock },
-
- /// Message from the user.
- UserPrompt { view: TextBlock },
-
- Exec {
- command: Vec,
- parsed: Vec,
- output: Option,
+ WelcomeMessage {
+ view: TextBlock,
},
+ /// Message from the user.
+ UserPrompt {
+ view: TextBlock,
+ },
+
+ Exec(ExecCell),
+
/// An MCP tool call that has not finished yet.
- ActiveMcpToolCall { view: TextBlock },
+ ActiveMcpToolCall {
+ view: TextBlock,
+ },
/// Completed MCP tool call where we show the result serialized as JSON.
- CompletedMcpToolCall { view: TextBlock },
+ CompletedMcpToolCall {
+ view: TextBlock,
+ },
/// Completed MCP tool call where the result is an image.
/// Admittedly, [mcp_types::CallToolResult] can have multiple content types,
@@ -94,37 +105,57 @@ pub(crate) enum HistoryCell {
// resized version avoids doing the potentially expensive rescale twice
// because the scroll-view first calls `height()` for layouting and then
// `render_window()` for painting.
- CompletedMcpToolCallWithImageOutput { _image: DynamicImage },
+ CompletedMcpToolCallWithImageOutput {
+ _image: DynamicImage,
+ },
/// Background event.
- BackgroundEvent { view: TextBlock },
+ BackgroundEvent {
+ view: TextBlock,
+ },
/// Output from the `/diff` command.
- GitDiffOutput { view: TextBlock },
+ GitDiffOutput {
+ view: TextBlock,
+ },
/// Output from the `/status` command.
- StatusOutput { view: TextBlock },
+ StatusOutput {
+ view: TextBlock,
+ },
/// Output from the `/prompts` command.
- PromptsOutput { view: TextBlock },
+ PromptsOutput {
+ view: TextBlock,
+ },
/// Error event from the backend.
- ErrorEvent { view: TextBlock },
+ ErrorEvent {
+ view: TextBlock,
+ },
/// Info describing the newly-initialized session.
- SessionInfo { view: TextBlock },
+ SessionInfo {
+ view: TextBlock,
+ },
/// A pending code patch that is awaiting user approval. Mirrors the
/// behaviour of `ExecCell` so the user sees *what* patch the
/// model wants to apply before being prompted to approve or deny it.
- PendingPatch { view: TextBlock },
+ PendingPatch {
+ view: TextBlock,
+ },
/// A human‑friendly rendering of the model's current plan and step
/// statuses provided via the `update_plan` tool.
- PlanUpdate { view: TextBlock },
+ PlanUpdate {
+ view: TextBlock,
+ },
/// Result of applying a patch (success or failure) with optional output.
- PatchApplyResult { view: TextBlock },
+ PatchApplyResult {
+ view: TextBlock,
+ },
}
const TOOL_CALL_MAX_LINES: usize = 5;
@@ -172,11 +203,11 @@ impl HistoryCell {
| HistoryCell::ActiveMcpToolCall { view, .. } => {
view.lines.iter().map(line_to_static).collect()
}
- HistoryCell::Exec {
+ HistoryCell::Exec(ExecCell {
command,
parsed,
output,
- } => HistoryCell::exec_command_lines(command, parsed, output.as_ref()),
+ }) => HistoryCell::exec_command_lines(command, parsed, output.as_ref()),
HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![
Line::from("tool result (image output omitted)"),
Line::from(""),
@@ -279,11 +310,11 @@ impl HistoryCell {
parsed: Vec,
output: Option,
) -> Self {
- HistoryCell::Exec {
+ HistoryCell::Exec(ExecCell {
command,
parsed,
output,
- }
+ })
}
fn exec_command_lines(
From 5f8984aa7d550955eb5f894d5c29adc2b9901da2 Mon Sep 17 00:00:00 2001
From: Dylan
Date: Mon, 11 Aug 2025 13:11:04 -0700
Subject: [PATCH 14/45] [apply-patch] Support applypatch command string (#2186)
## Summary
GPT-OSS and `gpt-5-mini` have training artifacts that cause the models
to occasionally use `applypatch` instead of `apply_patch`. I think
long-term we'll want to provide `apply_patch` as a first class tool, but
for now let's silently handle this case to avoid hurting model
performance
## Testing
- [x] Added unit test
---
codex-rs/apply-patch/src/lib.rs | 28 +++++++++++++++++++++++++++-
1 file changed, 27 insertions(+), 1 deletion(-)
diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs
index 61b1b68f9e..262d219d6d 100644
--- a/codex-rs/apply-patch/src/lib.rs
+++ b/codex-rs/apply-patch/src/lib.rs
@@ -82,8 +82,9 @@ pub struct ApplyPatchArgs {
}
pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
+ const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
match argv {
- [cmd, body] if cmd == "apply_patch" => match parse_patch(body) {
+ [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
Ok(source) => MaybeApplyPatch::Body(source),
Err(e) => MaybeApplyPatch::PatchParseError(e),
},
@@ -722,6 +723,31 @@ mod tests {
}
}
+ #[test]
+ fn test_literal_applypatch() {
+ let args = strs_to_strings(&[
+ "applypatch",
+ r#"*** Begin Patch
+*** Add File: foo
++hi
+*** End Patch
+"#,
+ ]);
+
+ match maybe_parse_apply_patch(&args) {
+ MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => {
+ assert_eq!(
+ hunks,
+ vec![Hunk::AddFile {
+ path: PathBuf::from("foo"),
+ contents: "hi\n".to_string()
+ }]
+ );
+ }
+ result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
+ }
+ }
+
#[test]
fn test_heredoc() {
let args = strs_to_strings(&[
From a48372ce5dbfdef94fdb91a173df93119a5aee90 Mon Sep 17 00:00:00 2001
From: ae
Date: Mon, 11 Aug 2025 14:15:41 -0700
Subject: [PATCH 15/45] feat: add a /mention slash command (#2114)
- To help people discover @mentions.
- Command just places a @ in the composer.
- #2115 then improves the behavior of @mentions with empty queries.
---
codex-rs/tui/src/app.rs | 5 ++
codex-rs/tui/src/bottom_pane/chat_composer.rs | 46 +++++++++++++++++++
codex-rs/tui/src/bottom_pane/mod.rs | 5 ++
codex-rs/tui/src/chatwidget.rs | 4 ++
codex-rs/tui/src/slash_command.rs | 2 +
5 files changed, 62 insertions(+)
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index a97948f3ea..4a0adb8de7 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -366,6 +366,11 @@ impl App<'_> {
widget.add_diff_output(text);
}
}
+ SlashCommand::Mention => {
+ if let AppState::Chat { widget } = &mut self.app_state {
+ widget.insert_str("@");
+ }
+ }
SlashCommand::Status => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_status_output();
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index 09ff8b7ab3..78506f572c 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -198,6 +198,12 @@ impl ChatComposer {
self.set_has_focus(has_focus);
}
+ pub(crate) fn insert_str(&mut self, text: &str) {
+ self.textarea.insert_str(text);
+ self.sync_command_popup();
+ self.sync_file_search_popup();
+ }
+
/// Handle a key event coming from the main UI.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let result = match &mut self.active_popup {
@@ -1078,6 +1084,46 @@ mod tests {
}
}
+ #[test]
+ fn slash_mention_dispatches_command_and_inserts_at() {
+ use crossterm::event::KeyCode;
+ use crossterm::event::KeyEvent;
+ use crossterm::event::KeyModifiers;
+ use std::sync::mpsc::TryRecvError;
+
+ let (tx, rx) = std::sync::mpsc::channel();
+ let sender = AppEventSender::new(tx);
+ let mut composer = ChatComposer::new(true, sender, false);
+
+ for ch in ['/', 'm', 'e', 'n', 't', 'i', 'o', 'n'] {
+ let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
+ }
+
+ let (result, _needs_redraw) =
+ composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
+
+ match result {
+ InputResult::None => {}
+ InputResult::Submitted(text) => {
+ panic!("expected command dispatch, but composer submitted literal text: {text}")
+ }
+ }
+ assert!(composer.textarea.is_empty(), "composer should be cleared");
+
+ match rx.try_recv() {
+ Ok(AppEvent::DispatchCommand(cmd)) => {
+ assert_eq!(cmd.command(), "mention");
+ composer.insert_str("@");
+ }
+ Ok(_other) => panic!("unexpected app event"),
+ Err(TryRecvError::Empty) => panic!("expected a DispatchCommand event for '/mention'"),
+ Err(TryRecvError::Disconnected) => {
+ panic!("app event channel disconnected")
+ }
+ }
+ assert_eq!(composer.textarea.text(), "@");
+ }
+
#[test]
fn test_multiple_pastes_submission() {
use crossterm::event::KeyCode;
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index 0c8610470c..4606f9b8ee 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -196,6 +196,11 @@ impl BottomPane<'_> {
}
}
+ pub(crate) fn insert_str(&mut self, text: &str) {
+ self.composer.insert_str(text);
+ self.request_redraw();
+ }
+
/// Update the status indicator text. Prefer replacing the composer with
/// the StatusIndicatorView so the input pane shows a single-line status
/// like: `▌ Working waiting for model`.
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index d03a51bd70..173ab64af2 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -676,6 +676,10 @@ impl ChatWidget<'_> {
self.submit_user_message(text.into());
}
+ pub(crate) fn insert_str(&mut self, text: &str) {
+ self.bottom_pane.insert_str(text);
+ }
+
pub(crate) fn token_usage(&self) -> &TokenUsage {
&self.total_token_usage
}
diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs
index e58ab8521e..0de1f6fae6 100644
--- a/codex-rs/tui/src/slash_command.rs
+++ b/codex-rs/tui/src/slash_command.rs
@@ -16,6 +16,7 @@ pub enum SlashCommand {
Init,
Compact,
Diff,
+ Mention,
Status,
Prompts,
Logout,
@@ -33,6 +34,7 @@ impl SlashCommand {
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Quit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
+ SlashCommand::Mention => "mention a file",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Prompts => "show example prompts",
SlashCommand::Logout => "log out of Codex",
From 52bd7f66607edfdb74da15e3c518cc4c8d55fa3e Mon Sep 17 00:00:00 2001
From: Michael Bolin
Date: Mon, 11 Aug 2025 15:08:58 -0700
Subject: [PATCH 16/45] fix: change the model used with the GitHub action from
o3 to gpt-5 (#2198)
`gpt-5` has been a valid slug since
https://github.com/openai/codex/pull/1942.
---
.github/codex/home/config.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/codex/home/config.toml b/.github/codex/home/config.toml
index bb1b362bb6..8e800ca068 100644
--- a/.github/codex/home/config.toml
+++ b/.github/codex/home/config.toml
@@ -1,3 +1,3 @@
-model = "o3"
+model = "gpt-5"
# Consider setting [mcp_servers] here!
From 6220e8ac2e4c18016ce53d6eff52dca349ddc4c6 Mon Sep 17 00:00:00 2001
From: Gabriel Peal
Date: Mon, 11 Aug 2025 16:11:46 -0700
Subject: [PATCH 17/45] [TUI] Split multiline commands (#2202)
Fixes:
---
codex-rs/tui/src/history_cell.rs | 31 +++++++++++++++++++++++++------
1 file changed, 25 insertions(+), 6 deletions(-)
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 1236d4b550..95ed8efac2 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -335,7 +335,7 @@ impl HistoryCell {
let mut lines: Vec = vec![Line::from("⚙︎ Working")];
for (i, parsed) in parsed_commands.iter().enumerate() {
- let str = match parsed {
+ let text = match parsed {
ParsedCommand::Read { name, .. } => format!("📖 {name}"),
ParsedCommand::ListFiles { cmd, path } => match path {
Some(p) => format!("📂 {p}"),
@@ -353,11 +353,14 @@ impl HistoryCell {
ParsedCommand::Unknown { cmd } => format!("⌨️ {}", shlex_join_safe(cmd)),
};
- let prefix = if i == 0 { " L " } else { " " };
- lines.push(Line::from(vec![
- Span::styled(prefix, Style::default().add_modifier(Modifier::DIM)),
- Span::styled(str, Style::default().fg(LIGHT_BLUE)),
- ]));
+ let first_prefix = if i == 0 { " L " } else { " " };
+ for (j, line_text) in text.lines().enumerate() {
+ let prefix = if j == 0 { first_prefix } else { " " };
+ lines.push(Line::from(vec![
+ Span::styled(prefix, Style::default().add_modifier(Modifier::DIM)),
+ Span::styled(line_text.to_string(), Style::default().fg(LIGHT_BLUE)),
+ ]));
+ }
}
lines.extend(output_lines(output, true, false));
@@ -974,3 +977,19 @@ fn shlex_join_safe(command: &[String]) -> String {
Err(_) => command.join(" "),
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parsed_command_with_newlines_starts_each_line_at_origin() {
+ let parsed = vec![ParsedCommand::Unknown {
+ cmd: vec!["printf".into(), "foo\nbar".into()],
+ }];
+ let lines = HistoryCell::exec_command_lines(&[], &parsed, None);
+ assert!(lines.len() >= 3);
+ assert_eq!(lines[1].spans[0].content, " L ");
+ assert_eq!(lines[2].spans[0].content, " ");
+ }
+}
From 6a6bf99e2c24925f773bb6459616216a240eee9a Mon Sep 17 00:00:00 2001
From: pakrym-oai
Date: Mon, 11 Aug 2025 16:37:45 -0700
Subject: [PATCH 18/45] Send prompt_cache_key (#2200)
To optimize prompt caching performance.
---
codex-rs/core/src/client.rs | 1 +
codex-rs/core/src/client_common.rs | 2 ++
2 files changed, 3 insertions(+)
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index ad08782b6d..8ab5ad9636 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -169,6 +169,7 @@ impl ModelClient {
store,
stream: true,
include,
+ prompt_cache_key: Some(self.session_id.to_string()),
};
let mut attempt = 0;
diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs
index b37b1e3f80..f05a57b6a2 100644
--- a/codex-rs/core/src/client_common.rs
+++ b/codex-rs/core/src/client_common.rs
@@ -215,6 +215,8 @@ pub(crate) struct ResponsesApiRequest<'a> {
pub(crate) store: bool,
pub(crate) stream: bool,
pub(crate) include: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) prompt_cache_key: Option,
}
pub(crate) fn create_reasoning_param_for_request(
From d33793d31d1e21b60b03ad3a6d9eaf9089415af7 Mon Sep 17 00:00:00 2001
From: Dylan
Date: Mon, 11 Aug 2025 17:03:13 -0700
Subject: [PATCH 19/45] [prompts] integration test prompt caching (#2189)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Our current approach to prompt caching is fragile! The current approach
works, but we are planning to update to a more resilient system (storing
them in the rollout file). Let's start adding some integration tests to
ensure stability while we migrate it.
## Testing
- [x] These are the tests 😎
---
codex-rs/core/tests/prompt_caching.rs | 137 ++++++++++++++++++++++++++
1 file changed, 137 insertions(+)
create mode 100644 codex-rs/core/tests/prompt_caching.rs
diff --git a/codex-rs/core/tests/prompt_caching.rs b/codex-rs/core/tests/prompt_caching.rs
new file mode 100644
index 0000000000..f460fc3004
--- /dev/null
+++ b/codex-rs/core/tests/prompt_caching.rs
@@ -0,0 +1,137 @@
+#![allow(clippy::expect_used, clippy::unwrap_used)]
+
+use codex_core::Codex;
+use codex_core::CodexSpawnOk;
+use codex_core::ModelProviderInfo;
+use codex_core::built_in_model_providers;
+use codex_core::protocol::EventMsg;
+use codex_core::protocol::InputItem;
+use codex_core::protocol::Op;
+use codex_login::CodexAuth;
+use core_test_support::load_default_config_for_test;
+use core_test_support::load_sse_fixture_with_id;
+use core_test_support::wait_for_event;
+use tempfile::TempDir;
+use wiremock::Mock;
+use wiremock::MockServer;
+use wiremock::ResponseTemplate;
+use wiremock::matchers::method;
+use wiremock::matchers::path;
+
+/// Build minimal SSE stream with completed marker using the JSON fixture.
+fn sse_completed(id: &str) -> String {
+ load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn prefixes_context_and_instructions_once_and_consistently_across_requests() {
+ #![allow(clippy::unwrap_used)]
+ use pretty_assertions::assert_eq;
+
+ let server = MockServer::start().await;
+
+ let sse = sse_completed("resp");
+ let template = ResponseTemplate::new(200)
+ .insert_header("content-type", "text/event-stream")
+ .set_body_raw(sse, "text/event-stream");
+
+ // Expect two POSTs to /v1/responses
+ Mock::given(method("POST"))
+ .and(path("/v1/responses"))
+ .respond_with(template)
+ .expect(2)
+ .mount(&server)
+ .await;
+
+ let model_provider = ModelProviderInfo {
+ base_url: Some(format!("{}/v1", server.uri())),
+ ..built_in_model_providers()["openai"].clone()
+ };
+
+ let cwd = TempDir::new().unwrap();
+ let codex_home = TempDir::new().unwrap();
+ let mut config = load_default_config_for_test(&codex_home);
+ config.cwd = cwd.path().to_path_buf();
+ config.model_provider = model_provider;
+ config.user_instructions = Some("be consistent and helpful".to_string());
+
+ let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
+ let CodexSpawnOk { codex, .. } = Codex::spawn(
+ config,
+ Some(CodexAuth::from_api_key("Test API Key")),
+ ctrl_c.clone(),
+ )
+ .await
+ .unwrap();
+
+ codex
+ .submit(Op::UserInput {
+ items: vec![InputItem::Text {
+ text: "hello 1".into(),
+ }],
+ })
+ .await
+ .unwrap();
+ wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
+
+ codex
+ .submit(Op::UserInput {
+ items: vec![InputItem::Text {
+ text: "hello 2".into(),
+ }],
+ })
+ .await
+ .unwrap();
+ wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
+
+ let requests = server.received_requests().await.unwrap();
+ assert_eq!(requests.len(), 2, "expected two POST requests");
+
+ let expected_env_text = format!(
+ "\n\nCurrent working directory: {}\nApproval policy: on-request\nSandbox policy: read-only\nNetwork access: restricted\n\n\n",
+ cwd.path().to_string_lossy()
+ );
+ let expected_ui_text =
+ "\n\nbe consistent and helpful\n\n";
+
+ let expected_env_msg = serde_json::json!({
+ "type": "message",
+ "id": serde_json::Value::Null,
+ "role": "user",
+ "content": [ { "type": "input_text", "text": expected_env_text } ]
+ });
+ let expected_ui_msg = serde_json::json!({
+ "type": "message",
+ "id": serde_json::Value::Null,
+ "role": "user",
+ "content": [ { "type": "input_text", "text": expected_ui_text } ]
+ });
+
+ let expected_user_message_1 = serde_json::json!({
+ "type": "message",
+ "id": serde_json::Value::Null,
+ "role": "user",
+ "content": [ { "type": "input_text", "text": "hello 1" } ]
+ });
+ let body1 = requests[0].body_json::().unwrap();
+ assert_eq!(
+ body1["input"],
+ serde_json::json!([expected_env_msg, expected_ui_msg, expected_user_message_1])
+ );
+
+ let expected_user_message_2 = serde_json::json!({
+ "type": "message",
+ "id": serde_json::Value::Null,
+ "role": "user",
+ "content": [ { "type": "input_text", "text": "hello 2" } ]
+ });
+ let body2 = requests[1].body_json::().unwrap();
+ let expected_body2 = serde_json::json!(
+ [
+ body1["input"].as_array().unwrap().as_slice(),
+ [expected_user_message_2].as_slice(),
+ ]
+ .concat()
+ );
+ assert_eq!(body2["input"], expected_body2);
+}
From ae81fbf83f882371308ce4ea5dcc334cb28d6164 Mon Sep 17 00:00:00 2001
From: Michael Bolin
Date: Mon, 11 Aug 2025 17:11:36 -0700
Subject: [PATCH 20/45] fix: remove unused import in release mode (#2201)
Moves `use codex_core::protocol::EventMsg` inside the block annotated
with `#[cfg(debug_assertions)]` since that was the only place in the
file that was using it.
This eliminates the `warning: unused import:` when building with `cargo
build --release` in `cargo-rs/tui`.
Note this was not breaking CI because we do not build release builds on
CI since we're impatient :P
---
codex-rs/tui/src/app.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 4a0adb8de7..e3290e10a8 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -11,7 +11,6 @@ use crate::slash_command::SlashCommand;
use crate::tui;
use codex_core::config::Config;
use codex_core::protocol::Event;
-use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use color_eyre::eyre::Result;
use crossterm::SynchronizedUpdate;
@@ -383,6 +382,7 @@ impl App<'_> {
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
+ use codex_core::protocol::EventMsg;
use std::collections::HashMap;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
From 8d2c5d0d98a2355a79431320609e30874a08a450 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 11 Aug 2025 17:13:37 -0700
Subject: [PATCH 21/45] chore(deps): bump toml from 0.9.4 to 0.9.5 in /codex-rs
(#2157)
Bumps [toml](https://github.com/toml-rs/toml) from 0.9.4 to 0.9.5.
Commits
bd21148
chore: Release
ff1cb9a
docs: Update changelog
39dd8b6
fix(parser): Improve bad quote error messages (#1014)
137338e
chore(deps): Update Rust crate serde_json to v1.0.142 (#1022)
d5b8c8a
fix(parser): Improve missing-open-quote errors
ce91354
fix(parser): Don't treat trailing quotes as separate items
8f424ed
fix(parser): Conjoin more values in unquoted string errors
2b9a81a
fix(parser): Reduce float false positives
f653841
fix(parser): Reduce float/bool false positives
f4864ef
test(parser): Add case for missing start quote
- See full diff in compare
view
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
codex-rs/Cargo.lock | 12 ++++++------
codex-rs/core/Cargo.toml | 2 +-
codex-rs/ollama/Cargo.toml | 2 +-
3 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 5dbc2421fa..40b3bcf764 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -661,7 +661,7 @@ dependencies = [
"clap",
"codex-core",
"serde",
- "toml 0.9.4",
+ "toml 0.9.5",
]
[[package]]
@@ -707,7 +707,7 @@ dependencies = [
"tokio",
"tokio-test",
"tokio-util",
- "toml 0.9.4",
+ "toml 0.9.5",
"toml_edit 0.23.3",
"tracing",
"tree-sitter",
@@ -836,7 +836,7 @@ dependencies = [
"tempfile",
"tokio",
"tokio-test",
- "toml 0.9.4",
+ "toml 0.9.5",
"tracing",
"tracing-subscriber",
"uuid",
@@ -855,7 +855,7 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
- "toml 0.9.4",
+ "toml 0.9.5",
"tracing",
"wiremock",
]
@@ -4806,9 +4806,9 @@ dependencies = [
[[package]]
name = "toml"
-version = "0.9.4"
+version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41ae868b5a0f67631c14589f7e250c1ea2c574ee5ba21c6c8dd4b1485705a5a1"
+checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8"
dependencies = [
"indexmap 2.10.0",
"serde",
diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml
index 0f03c3b647..ee527f3b72 100644
--- a/codex-rs/core/Cargo.toml
+++ b/codex-rs/core/Cargo.toml
@@ -47,7 +47,7 @@ tokio = { version = "1", features = [
"signal",
] }
tokio-util = "0.7.16"
-toml = "0.9.4"
+toml = "0.9.5"
toml_edit = "0.23.3"
tracing = { version = "0.1.41", features = ["log"] }
tree-sitter = "0.25.8"
diff --git a/codex-rs/ollama/Cargo.toml b/codex-rs/ollama/Cargo.toml
index ead9a06494..7fa1328385 100644
--- a/codex-rs/ollama/Cargo.toml
+++ b/codex-rs/ollama/Cargo.toml
@@ -24,7 +24,7 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
-toml = "0.9.2"
+toml = "0.9.5"
tracing = { version = "0.1.41", features = ["log"] }
wiremock = "0.6"
From e98bdad1a2b5cd532399cac7b1c57c080a6a278b Mon Sep 17 00:00:00 2001
From: Michael Bolin
Date: Mon, 11 Aug 2025 17:21:14 -0700
Subject: [PATCH 22/45] docs: update codex-rs/config.md to reflect that gpt-5
is the default model (#2199)
`gpt-5` has replaced `codex-mini-latest` as the default.
---
codex-rs/config.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/codex-rs/config.md b/codex-rs/config.md
index 848e7c0444..0d5df17cc8 100644
--- a/codex-rs/config.md
+++ b/codex-rs/config.md
@@ -17,7 +17,7 @@ Both the `--config` flag and the `config.toml` file support the following option
The model that Codex should use.
```toml
-model = "o3" # overrides the default of "codex-mini-latest"
+model = "o3" # overrides the default of "gpt-5"
```
## model_providers
@@ -213,7 +213,7 @@ Users can specify config values at multiple levels. Order of precedence is as fo
1. custom command-line argument, e.g., `--model o3`
2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself)
3. as an entry in `config.toml`, e.g., `model = "o3"`
-4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `codex-mini-latest`)
+4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5`)
## model_reasoning_effort
From 8e542dc79ae377e7aed0f42bb033bd5c9769f153 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 11 Aug 2025 17:52:26 -0700
Subject: [PATCH 23/45] chore(deps): bump clap from 4.5.41 to 4.5.43 in
/codex-rs (#2159)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[//]: # (dependabot-start)
⚠️ **Dependabot is rebasing this PR** ⚠️
Rebasing might not happen immediately, so don't worry if this takes some
time.
Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.
---
[//]: # (dependabot-end)
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.41 to 4.5.43.
Release notes
Sourced from clap's
releases.
v4.5.43
[4.5.43] - 2025-08-06
Fixes
- (help) In long help, list Possible Values before defaults,
rather than after, for a more consistent look
v4.5.42
[4.5.42] - 2025-07-30
Fixes
- Include subcommand visible long aliases in
--help
Changelog
Sourced from clap's
changelog.
[4.5.43] - 2025-08-06
Fixes
- (help) In long help, list Possible Values before defaults,
rather than after, for a more consistent look
[4.5.42] - 2025-07-30
Fixes
- Include subcommand visible long aliases in
--help
Commits
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
codex-rs/Cargo.lock | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 40b3bcf764..5d8364cccd 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -520,9 +520,9 @@ dependencies = [
[[package]]
name = "clap"
-version = "4.5.41"
+version = "4.5.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
+checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f"
dependencies = [
"clap_builder",
"clap_derive",
@@ -530,9 +530,9 @@ dependencies = [
[[package]]
name = "clap_builder"
-version = "4.5.41"
+version = "4.5.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
+checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65"
dependencies = [
"anstream",
"anstyle",
From 5188c8b6e6c41e315bfefa0a830a73434ff0869e Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 11 Aug 2025 17:58:07 -0700
Subject: [PATCH 24/45] chore(deps-dev): bump @types/node from 24.1.0 to 24.2.1
in /.github/actions/codex (#2164)
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/actions/codex/bun.lock | 8 +++++---
.github/actions/codex/package.json | 2 +-
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/.github/actions/codex/bun.lock b/.github/actions/codex/bun.lock
index 82e12cc4b6..ebe381784e 100644
--- a/.github/actions/codex/bun.lock
+++ b/.github/actions/codex/bun.lock
@@ -9,7 +9,7 @@
},
"devDependencies": {
"@types/bun": "^1.2.19",
- "@types/node": "^24.1.0",
+ "@types/node": "^24.2.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
},
@@ -50,7 +50,7 @@
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
- "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
+ "@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
@@ -72,7 +72,7 @@
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
- "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
+ "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"universal-user-agent": ["universal-user-agent@6.0.1", "", {}, "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ=="],
@@ -87,5 +87,7 @@
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="],
+
+ "bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
}
}
diff --git a/.github/actions/codex/package.json b/.github/actions/codex/package.json
index 6c7ae9002b..95583ff046 100644
--- a/.github/actions/codex/package.json
+++ b/.github/actions/codex/package.json
@@ -14,7 +14,7 @@
},
"devDependencies": {
"@types/bun": "^1.2.19",
- "@types/node": "^24.1.0",
+ "@types/node": "^24.2.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2"
}
From 39276e82d4a02e527a85694ee7e901bdbcc9548f Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 11 Aug 2025 18:00:59 -0700
Subject: [PATCH 25/45] chore(deps): bump clap_complete from 4.5.55 to 4.5.56
in /codex-rs (#2158)
Bumps [clap_complete](https://github.com/clap-rs/clap) from 4.5.55 to
4.5.56.
Commits
9cec100
chore: Release
00e72e0
docs: Update changelog
c7848ff
Merge pull request #6094
from epage/home
60184fb
feat(complete): Expand ~ in native completions
09969d3
chore(deps): Update Rust Stable to v1.89 (#6093)
520beb5
chore: Release
2bd8ab3
docs: Update changelog
220875b
Merge pull request #6091
from epage/possible
e5eb6c9
fix(help): Integrate 'Possible Values:' into 'Arg::help'
594a771
refactor(help): Make empty tracking more consistent
- Additional commits viewable in compare
view
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
codex-rs/Cargo.lock | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 5d8364cccd..049cc91736 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -543,9 +543,9 @@ dependencies = [
[[package]]
name = "clap_complete"
-version = "4.5.55"
+version = "4.5.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5abde44486daf70c5be8b8f8f1b66c49f86236edf6fa2abadb4d961c4c6229a"
+checksum = "67e4efcbb5da11a92e8a609233aa1e8a7d91e38de0be865f016d14700d45a7fd"
dependencies = [
"clap",
]
From 336952ae2ec3f5a8c3fda2cf22cda5938b72b2d5 Mon Sep 17 00:00:00 2001
From: aibrahim-oai
Date: Mon, 11 Aug 2025 18:32:59 -0700
Subject: [PATCH 26/45] TUI: Show apply patch diff. Stack: [2/2] (#2050)
Show the diff for apply patch
Stack:
-> #2050
#2049
---
codex-rs/tui/src/chatwidget.rs | 3 +-
codex-rs/tui/src/common.rs | 1 +
codex-rs/tui/src/diff_render.rs | 354 +++++++++++++++---
codex-rs/tui/src/history_cell.rs | 8 +-
codex-rs/tui/src/lib.rs | 1 +
..._tui__diff_render__tests__add_details.snap | 14 +
...er__tests__update_details_with_rename.snap | 16 +
...f_render__tests__wrap_behavior_insert.snap | 12 +
8 files changed, 363 insertions(+), 46 deletions(-)
create mode 100644 codex-rs/tui/src/common.rs
create mode 100644 codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap
create mode 100644 codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap
create mode 100644 codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 173ab64af2..6719cfb7a7 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -46,6 +46,7 @@ use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
+use crate::common::DEFAULT_WRAP_COLS;
use crate::history_cell::CommandOutput;
use crate::history_cell::ExecCell;
use crate::history_cell::HistoryCell;
@@ -223,7 +224,7 @@ impl ChatWidget<'_> {
content_buffer: String::new(),
answer_buffer: String::new(),
running_commands: HashMap::new(),
- live_builder: RowBuilder::new(80),
+ live_builder: RowBuilder::new(DEFAULT_WRAP_COLS.into()),
current_stream: None,
stream_header_emitted: false,
live_max_rows: 3,
diff --git a/codex-rs/tui/src/common.rs b/codex-rs/tui/src/common.rs
new file mode 100644
index 0000000000..2c19b58706
--- /dev/null
+++ b/codex-rs/tui/src/common.rs
@@ -0,0 +1 @@
+pub(crate) const DEFAULT_WRAP_COLS: u16 = 80;
diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs
index f536681732..ada84b89d6 100644
--- a/codex-rs/tui/src/diff_render.rs
+++ b/codex-rs/tui/src/diff_render.rs
@@ -1,3 +1,4 @@
+use crossterm::terminal;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
@@ -6,36 +7,44 @@ use ratatui::text::Span as RtSpan;
use std::collections::HashMap;
use std::path::PathBuf;
+use crate::common::DEFAULT_WRAP_COLS;
use codex_core::protocol::FileChange;
-struct FileSummary {
- display_path: String,
- added: usize,
- removed: usize,
+use crate::history_cell::PatchEventType;
+
+const SPACES_AFTER_LINE_NUMBER: usize = 6;
+
+// Internal representation for diff line rendering
+enum DiffLineType {
+ Insert,
+ Delete,
+ Context,
}
pub(crate) fn create_diff_summary(
title: &str,
- changes: HashMap,
+ changes: &HashMap,
+ event_type: PatchEventType,
) -> Vec> {
- let mut files: Vec = Vec::new();
+ struct FileSummary {
+ display_path: String,
+ added: usize,
+ removed: usize,
+ }
- // Count additions/deletions from a unified diff body
let count_from_unified = |diff: &str| -> (usize, usize) {
if let Ok(patch) = diffy::Patch::from_str(diff) {
- let mut adds = 0usize;
- let mut dels = 0usize;
- for hunk in patch.hunks() {
- for line in hunk.lines() {
- match line {
- diffy::Line::Insert(_) => adds += 1,
- diffy::Line::Delete(_) => dels += 1,
- _ => {}
- }
- }
- }
- (adds, dels)
+ patch
+ .hunks()
+ .iter()
+ .flat_map(|h| h.lines())
+ .fold((0, 0), |(a, d), l| match l {
+ diffy::Line::Insert(_) => (a + 1, d),
+ diffy::Line::Delete(_) => (a, d + 1),
+ _ => (a, d),
+ })
} else {
+ // Fallback: manual scan to preserve counts even for unparsable diffs
let mut adds = 0usize;
let mut dels = 0usize;
for l in diff.lines() {
@@ -52,29 +61,23 @@ pub(crate) fn create_diff_summary(
}
};
- for (path, change) in &changes {
- use codex_core::protocol::FileChange::*;
+ let mut files: Vec = Vec::new();
+ for (path, change) in changes.iter() {
match change {
- Add { content } => {
- let added = content.lines().count();
- files.push(FileSummary {
- display_path: path.display().to_string(),
- added,
- removed: 0,
- });
- }
- Delete => {
- let removed = std::fs::read_to_string(path)
+ FileChange::Add { content } => files.push(FileSummary {
+ display_path: path.display().to_string(),
+ added: content.lines().count(),
+ removed: 0,
+ }),
+ FileChange::Delete => files.push(FileSummary {
+ display_path: path.display().to_string(),
+ added: 0,
+ removed: std::fs::read_to_string(path)
.ok()
.map(|s| s.lines().count())
- .unwrap_or(0);
- files.push(FileSummary {
- display_path: path.display().to_string(),
- added: 0,
- removed,
- });
- }
- Update {
+ .unwrap_or(0),
+ }),
+ FileChange::Update {
unified_diff,
move_path,
} => {
@@ -142,11 +145,278 @@ pub(crate) fn create_diff_summary(
let mut line = RtLine::from(spans);
let prefix = if idx == 0 { " ⎿ " } else { " " };
line.spans.insert(0, prefix.into());
- line.spans.iter_mut().for_each(|span| {
- span.style = span.style.add_modifier(Modifier::DIM);
- });
+ line.spans
+ .iter_mut()
+ .for_each(|span| span.style = span.style.add_modifier(Modifier::DIM));
out.push(line);
}
+ let show_details = matches!(
+ event_type,
+ PatchEventType::ApplyBegin {
+ auto_approved: true
+ } | PatchEventType::ApprovalRequest
+ );
+
+ if show_details {
+ out.extend(render_patch_details(changes));
+ }
+
out
}
+
+fn render_patch_details(changes: &HashMap) -> Vec> {
+ let mut out: Vec> = Vec::new();
+ let term_cols: usize = terminal::size()
+ .map(|(w, _)| w as usize)
+ .unwrap_or(DEFAULT_WRAP_COLS.into());
+
+ for (index, (path, change)) in changes.iter().enumerate() {
+ let is_first_file = index == 0;
+ // Add separator only between files (not at the very start)
+ if !is_first_file {
+ out.push(RtLine::from(vec![
+ RtSpan::raw(" "),
+ RtSpan::styled("...", style_dim()),
+ ]));
+ }
+ match change {
+ FileChange::Add { content } => {
+ for (i, raw) in content.lines().enumerate() {
+ let ln = i + 1;
+ out.extend(push_wrapped_diff_line(
+ ln,
+ DiffLineType::Insert,
+ raw,
+ term_cols,
+ ));
+ }
+ }
+ FileChange::Delete => {
+ let original = std::fs::read_to_string(path).unwrap_or_default();
+ for (i, raw) in original.lines().enumerate() {
+ let ln = i + 1;
+ out.extend(push_wrapped_diff_line(
+ ln,
+ DiffLineType::Delete,
+ raw,
+ term_cols,
+ ));
+ }
+ }
+ FileChange::Update {
+ unified_diff,
+ move_path: _,
+ } => {
+ if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
+ for h in patch.hunks() {
+ let mut old_ln = h.old_range().start();
+ let mut new_ln = h.new_range().start();
+ for l in h.lines() {
+ match l {
+ diffy::Line::Insert(text) => {
+ let s = text.trim_end_matches('\n');
+ out.extend(push_wrapped_diff_line(
+ new_ln,
+ DiffLineType::Insert,
+ s,
+ term_cols,
+ ));
+ new_ln += 1;
+ }
+ diffy::Line::Delete(text) => {
+ let s = text.trim_end_matches('\n');
+ out.extend(push_wrapped_diff_line(
+ old_ln,
+ DiffLineType::Delete,
+ s,
+ term_cols,
+ ));
+ old_ln += 1;
+ }
+ diffy::Line::Context(text) => {
+ let s = text.trim_end_matches('\n');
+ out.extend(push_wrapped_diff_line(
+ new_ln,
+ DiffLineType::Context,
+ s,
+ term_cols,
+ ));
+ old_ln += 1;
+ new_ln += 1;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ out.push(RtLine::from(RtSpan::raw("")));
+ }
+
+ out
+}
+
+fn push_wrapped_diff_line(
+ line_number: usize,
+ kind: DiffLineType,
+ text: &str,
+ term_cols: usize,
+) -> Vec> {
+ let indent = " ";
+ let ln_str = line_number.to_string();
+ let mut remaining_text: &str = text;
+
+ // Reserve a fixed number of spaces after the line number so that content starts
+ // at a consistent column. The sign ("+"/"-") is rendered as part of the content
+ // with the same background as the edit, not as a separate dimmed column.
+ let gap_after_ln = SPACES_AFTER_LINE_NUMBER.saturating_sub(ln_str.len());
+ let first_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
+ let cont_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
+
+ let mut first = true;
+ let (sign_opt, bg_style) = match kind {
+ DiffLineType::Insert => (Some('+'), Some(style_add())),
+ DiffLineType::Delete => (Some('-'), Some(style_del())),
+ DiffLineType::Context => (None, None),
+ };
+ let mut lines: Vec> = Vec::new();
+ while !remaining_text.is_empty() {
+ let prefix_cols = if first {
+ first_prefix_cols
+ } else {
+ cont_prefix_cols
+ };
+ // Fit the content for the current terminal row:
+ // compute how many columns are available after the prefix, then split
+ // at a UTF-8 character boundary so this row's chunk fits exactly.
+ let available_content_cols = term_cols.saturating_sub(prefix_cols).max(1);
+ let split_at_byte_index = remaining_text
+ .char_indices()
+ .nth(available_content_cols)
+ .map(|(i, _)| i)
+ .unwrap_or_else(|| remaining_text.len());
+ let (chunk, rest) = remaining_text.split_at(split_at_byte_index);
+ remaining_text = rest;
+
+ if first {
+ let mut spans: Vec> = Vec::new();
+ spans.push(RtSpan::raw(indent));
+ spans.push(RtSpan::styled(ln_str.clone(), style_dim()));
+ spans.push(RtSpan::raw(" ".repeat(gap_after_ln)));
+
+ // Prefix the content with the sign if it is an insertion or deletion, and color
+ // the sign with the same background as the edited text.
+ let display_chunk = match sign_opt {
+ Some(sign_char) => format!("{sign_char}{chunk}"),
+ None => chunk.to_string(),
+ };
+
+ let content_span = match bg_style {
+ Some(style) => RtSpan::styled(display_chunk, style),
+ None => RtSpan::raw(display_chunk),
+ };
+ spans.push(content_span);
+ lines.push(RtLine::from(spans));
+ first = false;
+ } else {
+ let hang_prefix = format!(
+ "{indent}{}{}",
+ " ".repeat(ln_str.len()),
+ " ".repeat(gap_after_ln)
+ );
+ let content_span = match bg_style {
+ Some(style) => RtSpan::styled(chunk.to_string(), style),
+ None => RtSpan::raw(chunk.to_string()),
+ };
+ lines.push(RtLine::from(vec![RtSpan::raw(hang_prefix), content_span]));
+ }
+ }
+ lines
+}
+
+fn style_dim() -> Style {
+ Style::default().add_modifier(Modifier::DIM)
+}
+
+fn style_add() -> Style {
+ Style::default().bg(Color::Green)
+}
+
+fn style_del() -> Style {
+ Style::default().bg(Color::Red)
+}
+
+#[allow(clippy::expect_used)]
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::history_cell::HistoryCell;
+ use crate::text_block::TextBlock;
+ use insta::assert_snapshot;
+ use ratatui::Terminal;
+ use ratatui::backend::TestBackend;
+
+ fn snapshot_lines(name: &str, lines: Vec>, width: u16, height: u16) {
+ let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
+ let cell = HistoryCell::PendingPatch {
+ view: TextBlock::new(lines),
+ };
+ terminal
+ .draw(|f| f.render_widget_ref(&cell, f.area()))
+ .expect("draw");
+ assert_snapshot!(name, terminal.backend());
+ }
+
+ #[test]
+ fn ui_snapshot_add_details() {
+ let mut changes: HashMap = HashMap::new();
+ changes.insert(
+ PathBuf::from("README.md"),
+ FileChange::Add {
+ content: "first line\nsecond line\n".to_string(),
+ },
+ );
+
+ let lines =
+ create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
+
+ snapshot_lines("add_details", lines, 80, 10);
+ }
+
+ #[test]
+ fn ui_snapshot_update_details_with_rename() {
+ let mut changes: HashMap = HashMap::new();
+
+ let original = "line one\nline two\nline three\n";
+ let modified = "line one\nline two changed\nline three\n";
+ let patch = diffy::create_patch(original, modified).to_string();
+
+ changes.insert(
+ PathBuf::from("src/lib.rs"),
+ FileChange::Update {
+ unified_diff: patch,
+ move_path: Some(PathBuf::from("src/lib_new.rs")),
+ },
+ );
+
+ let lines =
+ create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
+
+ snapshot_lines("update_details_with_rename", lines, 80, 12);
+ }
+
+ #[test]
+ fn ui_snapshot_wrap_behavior_insert() {
+ // Narrow width to force wrapping within our diff line rendering
+ let long_line = "this is a very long line that should wrap across multiple terminal columns and continue";
+
+ // Call the wrapping function directly so we can precisely control the width
+ let lines =
+ push_wrapped_diff_line(1, DiffLineType::Insert, long_line, DEFAULT_WRAP_COLS.into());
+
+ // Render into a small terminal to capture the visual layout
+ snapshot_lines("wrap_behavior_insert", lines, DEFAULT_WRAP_COLS + 10, 8);
+ }
+}
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 95ed8efac2..b49e59972c 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -449,7 +449,7 @@ impl HistoryCell {
}
pub(crate) fn new_completed_mcp_tool_call(
- num_cols: u16,
+ num_cols: usize,
invocation: McpInvocation,
duration: Duration,
success: bool,
@@ -487,7 +487,7 @@ impl HistoryCell {
format_and_truncate_tool_result(
&text.text,
TOOL_CALL_MAX_LINES,
- num_cols as usize,
+ num_cols,
)
}
mcp_types::ContentBlock::ImageContent(_) => {
@@ -848,7 +848,9 @@ impl HistoryCell {
}
};
- let lines: Vec> = create_diff_summary(title, changes);
+ let mut lines: Vec> = create_diff_summary(title, &changes, event_type);
+
+ lines.push(Line::from(""));
HistoryCell::PendingPatch {
view: TextBlock::new(lines),
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index 27c850ca61..8f64f3247d 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -30,6 +30,7 @@ mod chatwidget;
mod citation_regex;
mod cli;
mod colors;
+mod common;
pub mod custom_terminal;
mod diff_render;
mod exec_command;
diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap
new file mode 100644
index 0000000000..06fc8a68e8
--- /dev/null
+++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap
@@ -0,0 +1,14 @@
+---
+source: tui/src/diff_render.rs
+expression: terminal.backend()
+---
+"proposed patch to 1 file (+2 -0) "
+" ⎿ README.md (+2 -0) "
+" 1 +first line "
+" 2 +second line "
+" "
+" "
+" "
+" "
+" "
+" "
diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap
new file mode 100644
index 0000000000..0eebe09d73
--- /dev/null
+++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap
@@ -0,0 +1,16 @@
+---
+source: tui/src/diff_render.rs
+expression: terminal.backend()
+---
+"proposed patch to 1 file (+1 -1) "
+" ⎿ src/lib.rs → src/lib_new.rs (+1 -1) "
+" 1 line one "
+" 2 -line two "
+" 2 +line two changed "
+" 3 line three "
+" "
+" "
+" "
+" "
+" "
+" "
diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap
new file mode 100644
index 0000000000..641552d5a1
--- /dev/null
+++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap
@@ -0,0 +1,12 @@
+---
+source: tui/src/diff_render.rs
+expression: terminal.backend()
+---
+" 1 +this is a very long line that should wrap across multiple terminal col "
+" umns and continue "
+" "
+" "
+" "
+" "
+" "
+" "
From 7051a528a3227d9b28dc9a86828066b85454276e Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 11 Aug 2025 21:54:30 -0700
Subject: [PATCH 27/45] chore(deps-dev): bump @types/bun from 1.2.19 to 1.2.20
in /.github/actions/codex (#2163)
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/actions/codex/bun.lock | 10 +++-------
.github/actions/codex/package.json | 2 +-
2 files changed, 4 insertions(+), 8 deletions(-)
diff --git a/.github/actions/codex/bun.lock b/.github/actions/codex/bun.lock
index ebe381784e..15b55fafa7 100644
--- a/.github/actions/codex/bun.lock
+++ b/.github/actions/codex/bun.lock
@@ -8,7 +8,7 @@
"@actions/github": "^6.0.1",
},
"devDependencies": {
- "@types/bun": "^1.2.19",
+ "@types/bun": "^1.2.20",
"@types/node": "^24.2.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
@@ -48,7 +48,7 @@
"@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],
- "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
+ "@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="],
"@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
@@ -56,7 +56,7 @@
"before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="],
- "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
+ "bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -82,12 +82,8 @@
"@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
- "bun-types/@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="],
-
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="],
-
- "bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
}
}
diff --git a/.github/actions/codex/package.json b/.github/actions/codex/package.json
index 95583ff046..208d9fef78 100644
--- a/.github/actions/codex/package.json
+++ b/.github/actions/codex/package.json
@@ -13,7 +13,7 @@
"@actions/github": "^6.0.1"
},
"devDependencies": {
- "@types/bun": "^1.2.19",
+ "@types/bun": "^1.2.20",
"@types/node": "^24.2.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2"
From 320f150c680598fe17269f5caf1b9d4660af06e2 Mon Sep 17 00:00:00 2001
From: ae
Date: Mon, 11 Aug 2025 22:03:58 -0700
Subject: [PATCH 28/45] fix: update ctrl-z to suspend tui (#2113)
- Lean on ctrl-c and esc to interrupt.
- (Only on unix.)
https://github.com/user-attachments/assets/7ce6c57f-6ee2-40c2-8cd2-b31265f16c1c
---
codex-rs/Cargo.lock | 1 +
codex-rs/tui/Cargo.toml | 3 +++
codex-rs/tui/src/app.rs | 23 +++++++++++++++++++++--
codex-rs/tui/src/chatwidget.rs | 4 ----
4 files changed, 25 insertions(+), 6 deletions(-)
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 049cc91736..5ec773adc5 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -881,6 +881,7 @@ dependencies = [
"image",
"insta",
"lazy_static",
+ "libc",
"mcp-types",
"path-clean",
"pretty_assertions",
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index 719c631149..31f198f543 100644
--- a/codex-rs/tui/Cargo.toml
+++ b/codex-rs/tui/Cargo.toml
@@ -72,6 +72,9 @@ unicode-segmentation = "1.12.0"
unicode-width = "0.1"
uuid = "1"
+[target.'cfg(unix)'.dependencies]
+libc = "0.2"
+
[dev-dependencies]
chrono = { version = "0.4", features = ["serde"] }
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index e3290e10a8..ebce2699e8 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -255,9 +255,11 @@ impl App<'_> {
kind: KeyEventKind::Press,
..
} => {
- if let AppState::Chat { widget } = &mut self.app_state {
- widget.on_ctrl_z();
+ #[cfg(unix)]
+ {
+ self.suspend(terminal)?;
}
+ // No-op on non-Unix platforms.
}
KeyEvent {
code: KeyCode::Char('d'),
@@ -459,6 +461,23 @@ impl App<'_> {
Ok(())
}
+ #[cfg(unix)]
+ fn suspend(&mut self, terminal: &mut tui::Tui) -> Result<()> {
+ tui::restore()?;
+ // SAFETY: Unix-only code path. We intentionally send SIGTSTP to the
+ // current process group (pid 0) to trigger standard job-control
+ // suspension semantics. This FFI does not involve any raw pointers,
+ // is not called from a signal handler, and uses a constant signal.
+ // Errors from kill are acceptable (e.g., if already stopped) — the
+ // subsequent re-init path will still leave the terminal in a good state.
+ // We considered `nix`, but didn't think it was worth pulling in for this one call.
+ unsafe { libc::kill(0, libc::SIGTSTP) };
+ *terminal = tui::init(&self.config)?;
+ terminal.clear()?;
+ self.app_event_tx.send(AppEvent::RequestRedraw);
+ Ok(())
+ }
+
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
match &self.app_state {
AppState::Chat { widget } => widget.token_usage().clone(),
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 6719cfb7a7..3d312ffce0 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -652,10 +652,6 @@ impl ChatWidget<'_> {
}
}
- pub(crate) fn on_ctrl_z(&mut self) {
- self.interrupt_running_task();
- }
-
pub(crate) fn composer_is_empty(&self) -> bool {
self.bottom_pane.composer_is_empty()
}
From 596a9d6a96ff4e77ee2f9cc76b17e817e46e18a2 Mon Sep 17 00:00:00 2001
From: Michael Bolin
Date: Tue, 12 Aug 2025 08:59:35 -0700
Subject: [PATCH 29/45] fix: take ExecToolCallOutput by value to avoid clone()
(#2197)
Since the output could be a large string, it seemed like a win to avoid
the `clone()` in the common case.
---
codex-rs/core/src/codex.rs | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 2dd48bf366..936cd4ef98 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -1981,7 +1981,7 @@ async fn handle_container_exec_with_params(
let ExecToolCallOutput { exit_code, .. } = &output;
let is_success = *exit_code == 0;
- let content = format_exec_output(&output);
+ let content = format_exec_output(output);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
@@ -2113,7 +2113,7 @@ async fn handle_sandbox_error(
let ExecToolCallOutput { exit_code, .. } = &retry_output;
let is_success = *exit_code == 0;
- let content = format_exec_output(&retry_output);
+ let content = format_exec_output(retry_output);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
@@ -2146,7 +2146,7 @@ async fn handle_sandbox_error(
}
/// Exec output is a pre-serialized JSON payload
-fn format_exec_output(exec_output: &ExecToolCallOutput) -> String {
+fn format_exec_output(exec_output: ExecToolCallOutput) -> String {
let ExecToolCallOutput {
exit_code,
stdout,
@@ -2169,10 +2169,10 @@ fn format_exec_output(exec_output: &ExecToolCallOutput) -> String {
// round to 1 decimal place
let duration_seconds = ((duration.as_secs_f32()) * 10.0).round() / 10.0;
- let is_success = *exit_code == 0;
+ let is_success = exit_code == 0;
let output = if is_success { stdout } else { stderr };
- let mut formatted_output = output.text.clone();
+ let mut formatted_output = output.text;
if let Some(truncated_after_lines) = output.truncated_after_lines {
formatted_output.push_str(&format!(
"\n\n[Output truncated after {truncated_after_lines} lines: too many lines or bytes.]",
@@ -2182,7 +2182,7 @@ fn format_exec_output(exec_output: &ExecToolCallOutput) -> String {
let payload = ExecOutput {
output: &formatted_output,
metadata: ExecMetadata {
- exit_code: *exit_code,
+ exit_code,
duration_seconds,
},
};
From e8670ad84041dad3a46ac23a901d54e9c218f992 Mon Sep 17 00:00:00 2001
From: pakrym-oai
Date: Tue, 12 Aug 2025 09:20:59 -0700
Subject: [PATCH 30/45] Support truststore when available and add tracing
(#2232)
Supports minimal tracing and detection of working ssl cert.
---
codex-rs/login/src/login_with_chatgpt.py | 51 ++++++++++++++++++++++--
1 file changed, 47 insertions(+), 4 deletions(-)
diff --git a/codex-rs/login/src/login_with_chatgpt.py b/codex-rs/login/src/login_with_chatgpt.py
index ddcc6e66c7..252c4e06ae 100644
--- a/codex-rs/login/src/login_with_chatgpt.py
+++ b/codex-rs/login/src/login_with_chatgpt.py
@@ -45,11 +45,54 @@ DEFAULT_ISSUER = "https://auth.openai.com"
EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE = 13
CA_CONTEXT = None
-try:
- import ssl
- import certifi as _certifi
+CODEX_LOGIN_TRACE = os.environ.get("CODEX_LOGIN_TRACE", "false") in ["true", "1"]
+
+try:
+
+ def trace(msg: str) -> None:
+ if CODEX_LOGIN_TRACE:
+ print(msg)
+
+ def attempt_request(method: str) -> bool:
+ try:
+ with urllib.request.urlopen(
+ urllib.request.Request(
+ f"{DEFAULT_ISSUER}/.well-known/openid-configuration",
+ method="GET",
+ ),
+ context=CA_CONTEXT,
+ ) as resp:
+ if resp.status != 200:
+ trace(f"Request using {method} failed: {resp.status}")
+ return False
+
+ trace(f"Request using {method} succeeded")
+ return True
+ except Exception as e:
+ trace(f"Request using {method} failed: {e}")
+ return False
+
+ status = attempt_request("default settings")
+ if not status:
+ try:
+ import truststore
+
+ truststore.inject_into_ssl()
+ status = attempt_request("truststore")
+ except Exception as e:
+ trace(f"Failed to use truststore: {e}")
+
+ if not status:
+ try:
+ import ssl
+ import certifi as _certifi
+
+ CA_CONTEXT = ssl.create_default_context(cafile=_certifi.where())
+ status = attempt_request("certify")
+ except Exception as e:
+ trace(f"Failed to use certify: {e}")
+
- CA_CONTEXT = ssl.create_default_context(cafile=_certifi.where())
except Exception:
pass
From cb78f2333e30a5ca2ec0d0f013ecca894cb9b137 Mon Sep 17 00:00:00 2001
From: pakrym-oai
Date: Tue, 12 Aug 2025 09:40:04 -0700
Subject: [PATCH 31/45] Set user-agent (#2230)
Use the same well-defined value in all cases when sending user-agent
header
---
codex-rs/Cargo.lock | 14 ++++++++++
codex-rs/chatgpt/src/chatgpt_client.rs | 3 ++-
codex-rs/core/Cargo.toml | 4 ++-
codex-rs/core/src/client.rs | 2 ++
codex-rs/core/src/lib.rs | 1 +
codex-rs/core/src/user_agent.rs | 36 ++++++++++++++++++++++++++
codex-rs/tui/src/updates.rs | 8 +-----
7 files changed, 59 insertions(+), 9 deletions(-)
create mode 100644 codex-rs/core/src/user_agent.rs
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 5ec773adc5..a211bf16a0 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -689,9 +689,11 @@ dependencies = [
"mcp-types",
"mime_guess",
"openssl-sys",
+ "os_info",
"predicates",
"pretty_assertions",
"rand 0.9.2",
+ "regex-lite",
"reqwest",
"seccompiler",
"serde",
@@ -3044,6 +3046,18 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+[[package]]
+name = "os_info"
+version = "3.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3"
+dependencies = [
+ "log",
+ "plist",
+ "serde",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "overload"
version = "0.1.1"
diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs
index 907783bb81..db75632191 100644
--- a/codex-rs/chatgpt/src/chatgpt_client.rs
+++ b/codex-rs/chatgpt/src/chatgpt_client.rs
@@ -1,4 +1,5 @@
use codex_core::config::Config;
+use codex_core::user_agent::get_codex_user_agent;
use crate::chatgpt_token::get_chatgpt_token_data;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
@@ -30,7 +31,7 @@ pub(crate) async fn chatgpt_get_request(
.bearer_auth(&token.access_token)
.header("chatgpt-account-id", account_id?)
.header("Content-Type", "application/json")
- .header("User-Agent", "codex-cli")
+ .header("User-Agent", get_codex_user_agent(None))
.send()
.await
.context("Failed to send request")?;
diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml
index ee527f3b72..1ea1422bd4 100644
--- a/codex-rs/core/Cargo.toml
+++ b/codex-rs/core/Cargo.toml
@@ -27,11 +27,12 @@ futures = "0.3"
libc = "0.2.174"
mcp-types = { path = "../mcp-types" }
mime_guess = "2.0"
+os_info = "3.12.0"
rand = "0.9"
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
-serde_json = "1"
serde_bytes = "0.11"
+serde_json = "1"
sha1 = "0.10.6"
shlex = "1.3.0"
similar = "2.7.0"
@@ -75,6 +76,7 @@ core_test_support = { path = "tests/common" }
maplit = "1.0.2"
predicates = "3"
pretty_assertions = "1.4.1"
+regex-lite = "0.1.6"
tempfile = "3"
tokio-test = "0.4"
walkdir = "2.5.0"
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index 8ab5ad9636..4e31df2f46 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -38,6 +38,7 @@ use crate::model_provider_info::WireApi;
use crate::models::ResponseItem;
use crate::openai_tools::create_tools_json_for_responses_api;
use crate::protocol::TokenUsage;
+use crate::user_agent::get_codex_user_agent;
use crate::util::backoff;
use std::sync::Arc;
@@ -208,6 +209,7 @@ impl ModelClient {
.as_deref()
.unwrap_or("codex_cli_rs");
req_builder = req_builder.header("originator", originator);
+ req_builder = req_builder.header("User-Agent", get_codex_user_agent(Some(originator)));
let res = req_builder.send().await;
if let Ok(resp) = &res {
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
index b36689f057..f3247c3887 100644
--- a/codex-rs/core/src/lib.rs
+++ b/codex-rs/core/src/lib.rs
@@ -47,6 +47,7 @@ pub mod seatbelt;
pub mod shell;
pub mod spawn;
pub mod turn_diff_tracker;
+pub mod user_agent;
mod user_notification;
pub mod util;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
diff --git a/codex-rs/core/src/user_agent.rs b/codex-rs/core/src/user_agent.rs
new file mode 100644
index 0000000000..a0cf387069
--- /dev/null
+++ b/codex-rs/core/src/user_agent.rs
@@ -0,0 +1,36 @@
+const DEFAULT_ORIGINATOR: &str = "codex_cli_rs";
+
+pub fn get_codex_user_agent(originator: Option<&str>) -> String {
+ let build_version = env!("CARGO_PKG_VERSION");
+ let os_info = os_info::get();
+ format!(
+ "{}/{build_version} ({} {}; {})",
+ originator.unwrap_or(DEFAULT_ORIGINATOR),
+ os_info.os_type(),
+ os_info.version(),
+ os_info.architecture().unwrap_or("unknown"),
+ )
+}
+
+#[cfg(test)]
+#[allow(clippy::unwrap_used)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_get_codex_user_agent() {
+ let user_agent = get_codex_user_agent(None);
+ assert!(user_agent.starts_with("codex_cli_rs/"));
+ }
+
+ #[test]
+ #[cfg(target_os = "macos")]
+ fn test_macos() {
+ use regex_lite::Regex;
+ let user_agent = get_codex_user_agent(None);
+ let re =
+ Regex::new(r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\)$")
+ .unwrap();
+ assert!(re.is_match(&user_agent));
+ }
+}
diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs
index c7f7afd2a5..ef1cf3e960 100644
--- a/codex-rs/tui/src/updates.rs
+++ b/codex-rs/tui/src/updates.rs
@@ -67,13 +67,7 @@ async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
tag_name: latest_tag_name,
} = reqwest::Client::new()
.get(LATEST_RELEASE_URL)
- .header(
- "User-Agent",
- format!(
- "codex/{} (+https://github.com/openai/codex)",
- env!("CARGO_PKG_VERSION")
- ),
- )
+ .header("User-Agent", get_codex_user_agent(None))
.send()
.await?
.error_for_status()?
From 90d892f4fd5ffaf35b3dacabacdd260d76039581 Mon Sep 17 00:00:00 2001
From: Dylan
Date: Tue, 12 Aug 2025 10:19:07 -0700
Subject: [PATCH 32/45] [prompt] Restore important guidance for shell command
usage (#2211)
## Summary
In #1939 we overhauled a lot of our prompt. This was largely good, but
we're seeing some specific points of confusion from the model! This
prompt update attempts to address 3 of them:
- Enforcing the use of `ripgrep`, which is bundled as a dependency when
installed with homebrew. We should do the same on node (in progress)
- Explicit guidance on reading files in chunks.
- Slight adjustment to networking sandbox language. `enabled` /
`restricted` is anecdotally less confusing to the model and requires
less reasoning to escalate for approval.
We are going to continue iterating on shell usage and tools, but this
restores us to best practices for current model snapshots.
## Testing
- [x] evals
- [x] local testing
---
codex-rs/core/prompt.md | 51 ++++++++++++++++++++++++++++-------------
1 file changed, 35 insertions(+), 16 deletions(-)
diff --git a/codex-rs/core/prompt.md b/codex-rs/core/prompt.md
index 4711dd749a..df9161dd47 100644
--- a/codex-rs/core/prompt.md
+++ b/codex-rs/core/prompt.md
@@ -1,6 +1,7 @@
You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful.
Your capabilities:
+
- Receive user prompts and other context provided by the harness, such as files in the workspace.
- Communicate with the user by streaming thinking & responses, and by making & updating plans.
- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section.
@@ -20,11 +21,13 @@ Your default personality and tone is concise, direct, and friendly. You communic
Before making tool calls, send a brief preamble to the user explaining what you’re about to do. When sending preamble messages, follow these principles and examples:
- **Logically group related actions**: if you’re about to run several related commands, describe them together in one preamble rather than sending a separate note for each.
-- **Keep it concise**: be no more than 1-2 sentences (8–12 words for quick updates).
+- **Keep it concise**: be no more than 1-2 sentences, focused on immediate, tangible next steps. (8–12 words for quick updates).
- **Build on prior context**: if this is not your first tool call, use the preamble message to connect the dots with what’s been done so far and create a sense of momentum and clarity for the user to understand your next actions.
- **Keep your tone light, friendly and curious**: add small touches of personality in preambles feel collaborative and engaging.
+- **Exception**: Avoid adding a preamble for every trivial read (e.g., `cat` a single file) unless it’s part of a larger grouped action.
**Examples:**
+
- “I’ve explored the repo; now checking the API route definitions.”
- “Next, I’ll patch the config and update the related tests.”
- “I’m about to scaffold the CLI commands and helper functions.”
@@ -34,15 +37,12 @@ Before making tool calls, send a brief preamble to the user explaining what you
- “Alright, build pipeline order is interesting. Checking how it reports failures.”
- “Spotted a clever caching util; now hunting where it gets used.”
-**Avoiding a preamble for every trivial read (e.g., `cat` a single file) unless it’s part of a larger grouped action.
-- Jumping straight into tool calls without explaining what’s about to happen.
-- Writing overly long or speculative preambles — focus on immediate, tangible next steps.
-
## Planning
You have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go. Note that plans are not for padding out simple work with filler steps or stating the obvious. Do not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step.
Use a plan when:
+
- The task is non-trivial and will require multiple actions over a long time horizon.
- There are logical phases or dependencies where sequencing matters.
- The work has ambiguity that benefits from outlining high-level goals.
@@ -52,6 +52,7 @@ Use a plan when:
- You generate additional steps while working, and plan to do them before yielding to the user
Skip a plan when:
+
- The task is simple and direct.
- Breaking it down would only produce literal or trivial steps.
@@ -115,10 +116,11 @@ If you need to write a plan, only write high quality plans, not low quality ones
You are a coding agent. Please keep going until the query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer.
You MUST adhere to the following criteria when solving queries:
+
- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
- Analyzing code for vulnerabilities is allowed.
- Showing user code and tool call details is allowed.
-- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]}
+- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]}
If completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines:
@@ -148,21 +150,25 @@ For all of testing, running, building, and formatting, do not attempt to fix unr
The Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from.
Filesystem sandboxing prevents you from editing files without user approval. The options are:
-- *read-only*: You can only read files.
-- *workspace-write*: You can read files. You can write to files in your workspace folder, but not outside it.
-- *danger-full-access*: No filesystem sandboxing.
+
+- **read-only**: You can only read files.
+- **workspace-write**: You can read files. You can write to files in your workspace folder, but not outside it.
+- **danger-full-access**: No filesystem sandboxing.
Network sandboxing prevents you from accessing network without approval. Options are
-- *ON*
-- *OFF*
+
+- **restricted**
+- **enabled**
Approvals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. Approval options are
-- *untrusted*: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
-- *on-failure*: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
-- *on-request*: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
-- *never*: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
+
+- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
+- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
+- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
+- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
When you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
+
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp)
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
@@ -207,6 +213,7 @@ Brevity is very important as a default. You should be very concise (i.e. no more
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
**Section Headers**
+
- Use only when they improve clarity — they are not mandatory for every answer.
- Choose descriptive names that fit the content
- Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**`
@@ -214,6 +221,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer.
**Bullets**
+
- Use `-` followed by a space for every bullet.
- Bold the keyword, then colon + concise description.
- Merge related points when possible; avoid a bullet for every trivial detail.
@@ -222,11 +230,13 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Use consistent keyword phrasing and formatting across sections.
**Monospace**
+
- Wrap all commands, file paths, env vars, and code identifiers in backticks (`` `...` ``).
- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command.
- Never mix monospace and bold markers; choose one based on whether it’s a keyword (`**`) or inline code/path (`` ` ``).
**Structure**
+
- Place related bullets together; don’t mix unrelated concepts in the same section.
- Order sections from general → specific → supporting info.
- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it.
@@ -235,6 +245,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Simple results → minimal headers, possibly just a short list or paragraph.
**Tone**
+
- Keep the voice collaborative and natural, like a coding partner handing off work.
- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition
- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”).
@@ -242,6 +253,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Use parallel structure in lists for consistency.
**Don’t**
+
- Don’t use literal words “bold” or “monospace” in the content.
- Don’t nest bullets or create deep hierarchies.
- Don’t output ANSI escape codes directly — the CLI renderer applies them.
@@ -252,7 +264,14 @@ Generally, ensure your final answers adapt their shape and depth to the request.
For casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting.
-# Tools
+# Tool Guidelines
+
+## Shell commands
+
+When using the shell, you must adhere to the following guidelines:
+
+- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
+- Read files in chunks with a max chunk size of 250 lines. Do not use python scripts to attempt to output larger chunks of a file. Command line output will be truncated after 10 kilobytes or 256 lines of output, regardless of the command used.
## `apply_patch`
From eaa3969e681c1cea592777361183eb27c6c0a1a9 Mon Sep 17 00:00:00 2001
From: Ed Bayes
Date: Tue, 12 Aug 2025 13:26:57 -0700
Subject: [PATCH 33/45] Show "Update plan" in TUI plan updates (#2192)
## Summary
- Display "Update plan" instead of "Update to do" when the plan is
updated in the TUI
## Testing
- `just fmt`
- `just fix` *(fails: E0658 `let` expressions in this position are
unstable)*
- `cargo test --all-features` *(fails: E0658 `let` expressions in this
position are unstable)*
------
https://chatgpt.com/codex/tasks/task_i_6897f78fc5908322be488f02db42a5b9
---
codex-rs/tui/src/history_cell.rs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index b49e59972c..b31d5ff8a6 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -739,10 +739,10 @@ impl HistoryCell {
let mut header: Vec = Vec::new();
header.push(Span::raw("📋"));
header.push(Span::styled(
- " Updated",
+ " Update plan",
Style::default().add_modifier(Modifier::BOLD).magenta(),
));
- header.push(Span::raw(" to do list ["));
+ header.push(Span::raw(" ["));
if filled > 0 {
header.push(Span::styled(
"█".repeat(filled),
From 6c254ca3e7bb7e30cbcc51dfc26c9591de09460f Mon Sep 17 00:00:00 2001
From: pakrym-oai
Date: Tue, 12 Aug 2025 15:35:20 -0700
Subject: [PATCH 34/45] Fix release build (#2244)
Missing import.
---
codex-rs/tui/src/updates.rs | 1 +
1 file changed, 1 insertion(+)
diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs
index ef1cf3e960..fae170ff1a 100644
--- a/codex-rs/tui/src/updates.rs
+++ b/codex-rs/tui/src/updates.rs
@@ -9,6 +9,7 @@ use std::path::Path;
use std::path::PathBuf;
use codex_core::config::Config;
+use codex_core::user_agent::get_codex_user_agent;
pub fn get_upgrade_version(config: &Config) -> Option {
let version_file = version_filepath(config);
From 12cf0dd868cc38eb6198d716110d6e186025d411 Mon Sep 17 00:00:00 2001
From: pakrym-oai
Date: Tue, 12 Aug 2025 15:43:07 -0700
Subject: [PATCH 35/45] Better implementation of interrupt on Esc (#2111)
Use existing abstractions
---
codex-rs/tui/src/app.rs | 14 --------------
.../tui/src/bottom_pane/status_indicator_view.rs | 9 +++++++++
codex-rs/tui/src/chatwidget.rs | 8 --------
codex-rs/tui/src/status_indicator_widget.rs | 13 +++++++------
4 files changed, 16 insertions(+), 28 deletions(-)
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index ebce2699e8..cd2b308dcc 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -235,20 +235,6 @@ impl App<'_> {
self.app_event_tx.send(AppEvent::ExitRequest);
}
},
- KeyEvent {
- code: KeyCode::Esc,
- kind: KeyEventKind::Press,
- ..
- } => match &mut self.app_state {
- AppState::Chat { widget } => {
- if !widget.on_esc() {
- self.dispatch_key_event(key_event);
- }
- }
- AppState::Onboarding { .. } => {
- self.dispatch_key_event(key_event);
- }
- },
KeyEvent {
code: KeyCode::Char('z'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
index a944271e45..cad4f0f272 100644
--- a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
+++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
@@ -1,7 +1,10 @@
+use crossterm::event::KeyCode;
+use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::widgets::WidgetRef;
use crate::app_event_sender::AppEventSender;
+use crate::bottom_pane::BottomPane;
use crate::status_indicator_widget::StatusIndicatorWidget;
use super::BottomPaneView;
@@ -40,4 +43,10 @@ impl BottomPaneView<'_> for StatusIndicatorView {
fn render(&self, area: ratatui::layout::Rect, buf: &mut Buffer) {
self.view.render_ref(area, buf);
}
+
+ fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) {
+ if key_event.code == KeyCode::Esc {
+ self.view.interrupt();
+ }
+ }
}
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 3d312ffce0..2c28446fb0 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -624,14 +624,6 @@ impl ChatWidget<'_> {
self.bottom_pane.on_file_search_result(query, matches);
}
- pub(crate) fn on_esc(&mut self) -> bool {
- if self.bottom_pane.is_task_running() {
- self.interrupt_running_task();
- return true;
- }
- false
- }
-
/// Handle Ctrl-C key press.
/// Returns CancellationEvent::Handled if the event was consumed by the UI, or
/// CancellationEvent::Ignored if the caller should handle it (e.g. exit).
diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs
index 6ef293208d..b686f45d09 100644
--- a/codex-rs/tui/src/status_indicator_widget.rs
+++ b/codex-rs/tui/src/status_indicator_widget.rs
@@ -9,6 +9,7 @@ use std::thread;
use std::time::Duration;
use std::time::Instant;
+use codex_core::protocol::Op;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
@@ -44,11 +45,7 @@ pub(crate) struct StatusIndicatorWidget {
frame_idx: Arc,
running: Arc,
start_time: Instant,
- // Keep one sender alive to prevent the channel from closing while the
- // animation thread is still running. The field itself is currently not
- // accessed anywhere, therefore the leading underscore silences the
- // `dead_code` warning without affecting behavior.
- _app_event_tx: AppEventSender,
+ app_event_tx: AppEventSender,
}
impl StatusIndicatorWidget {
@@ -82,7 +79,7 @@ impl StatusIndicatorWidget {
running,
start_time: Instant::now(),
- _app_event_tx: app_event_tx,
+ app_event_tx,
}
}
@@ -120,6 +117,10 @@ impl StatusIndicatorWidget {
self.reveal_len_at_base = shown_now.min(new_len);
}
+ pub(crate) fn interrupt(&self) {
+ self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
+ }
+
/// Reset the animation and start revealing `text` from the beginning.
#[cfg(test)]
pub(crate) fn restart_with_text(&mut self, text: String) {
From 97a27ffc77b1866205a4c01b37cf33f789f361de Mon Sep 17 00:00:00 2001
From: pakrym-oai
Date: Tue, 12 Aug 2025 15:56:45 -0700
Subject: [PATCH 36/45] Fix build break and build release (#2242)
Build release profile for one configuration.
---
.github/workflows/rust-ci.yml | 22 +++++++++++++++++-----
.github/workflows/rust-release.yml | 2 +-
2 files changed, 18 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml
index 97075a1f07..0fd67175a9 100644
--- a/.github/workflows/rust-ci.yml
+++ b/.github/workflows/rust-ci.yml
@@ -34,7 +34,7 @@ jobs:
# CI to validate on different os/targets
lint_build_test:
- name: ${{ matrix.runner }} - ${{ matrix.target }}
+ name: ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.profile == 'release' && ' (release)' || '' }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
defaults:
@@ -49,18 +49,31 @@ jobs:
include:
- runner: macos-14
target: aarch64-apple-darwin
+ profile: dev
- runner: macos-14
target: x86_64-apple-darwin
+ profile: dev
+ - runner: macos-14
+ target: aarch64-apple-darwin
+ profile: release
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
+ profile: dev
+ - runner: ubuntu-24.04
+ target: x86_64-unknown-linux-musl
+ profile: release
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
+ profile: dev
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
+ profile: dev
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
+ profile: dev
- runner: windows-latest
target: x86_64-pc-windows-msvc
+ profile: dev
steps:
- uses: actions/checkout@v4
@@ -77,7 +90,7 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ github.workspace }}/codex-rs/target/
- key: cargo-${{ matrix.runner }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
+ key: cargo-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Install musl build tools
@@ -86,7 +99,6 @@ jobs:
- name: cargo clippy
id: clippy
- continue-on-error: true
run: cargo clippy --target ${{ matrix.target }} --all-features --tests -- -D warnings
# Running `cargo build` from the workspace root builds the workspace using
@@ -98,12 +110,12 @@ jobs:
id: build
if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' }}
continue-on-error: true
- run: find . -name Cargo.toml -mindepth 2 -maxdepth 2 -print0 | xargs -0 -n1 -I{} bash -c 'cd "$(dirname "{}")" && cargo build'
+ run: find . -name Cargo.toml -mindepth 2 -maxdepth 2 -print0 | xargs -0 -n1 -I{} bash -c 'cd "$(dirname "{}")" && cargo build --profile ${{ matrix.profile }}'
- name: cargo test
id: test
continue-on-error: true
- run: cargo test --all-features --target ${{ matrix.target }}
+ run: cargo test --all-features --target ${{ matrix.target }} --profile ${{ matrix.profile }}
env:
RUST_BACKTRACE: 1
diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml
index 8bf7361da9..d3e313b3cc 100644
--- a/.github/workflows/rust-release.yml
+++ b/.github/workflows/rust-release.yml
@@ -87,7 +87,7 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ github.workspace }}/codex-rs/target/
- key: cargo-release-${{ matrix.runner }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
+ key: cargo-release-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }}
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Install musl build tools
From 6340acd8852ecc74d4eb735608f7f1eac5a0af5f Mon Sep 17 00:00:00 2001
From: easong-openai
Date: Tue, 12 Aug 2025 17:37:28 -0700
Subject: [PATCH 37/45] Re-add markdown streaming (#2029)
Wait for newlines, then render markdown on a line by line basis. Word wrap it for the current terminal size and then spit it out line by line into the UI. Also adds tests and fixes some UI regressions.
---
.codespellrc | 2 +-
codex-rs/Cargo.lock | 1 +
codex-rs/core/src/chat_completions.rs | 3 +
codex-rs/core/src/client.rs | 14 +-
codex-rs/core/src/client_common.rs | 1 +
codex-rs/core/src/codex.rs | 8 +
codex-rs/core/src/protocol.rs | 8 +-
codex-rs/core/tests/common/lib.rs | 4 +-
.../src/event_processor_with_human_output.rs | 8 +
codex-rs/mcp-server/src/codex_tool_runner.rs | 1 +
codex-rs/mcp-server/src/conversation_loop.rs | 1 +
codex-rs/tui/Cargo.toml | 3 +
codex-rs/tui/src/app.rs | 42 +-
codex-rs/tui/src/app_event.rs | 5 +
codex-rs/tui/src/app_event_sender.rs | 6 +
.../src/bottom_pane/approval_modal_view.rs | 2 -
.../tui/src/bottom_pane/live_ring_widget.rs | 45 -
codex-rs/tui/src/bottom_pane/mod.rs | 154 +-
codex-rs/tui/src/bottom_pane/textarea.rs | 7 +-
codex-rs/tui/src/chatwidget.rs | 1130 +-
codex-rs/tui/src/chatwidget/agent.rs | 53 +
codex-rs/tui/src/chatwidget/interrupts.rs | 89 +
codex-rs/tui/src/chatwidget/tests.rs | 834 +
codex-rs/tui/src/chatwidget_stream_tests.rs | 1 +
codex-rs/tui/src/history_cell.rs | 50 +-
codex-rs/tui/src/insert_history.rs | 191 +-
codex-rs/tui/src/lib.rs | 16 +
codex-rs/tui/src/log_layer.rs | 3 -
codex-rs/tui/src/markdown.rs | 335 +-
codex-rs/tui/src/markdown_stream.rs | 549 +
codex-rs/tui/src/render/line_utils.rs | 45 +
codex-rs/tui/src/render/markdown_utils.rs | 72 +
codex-rs/tui/src/render/mod.rs | 2 +
codex-rs/tui/src/session_log.rs | 243 +
codex-rs/tui/src/streaming/controller.rs | 281 +
codex-rs/tui/src/streaming/mod.rs | 130 +
codex-rs/tui/src/user_approval_widget.rs | 5 -
.../tui/tests/fixtures/binary-size-log.jsonl | 24109 ++++++++++++++++
.../tests/fixtures/ideal-binary-response.txt | 298 +
codex-rs/tui/tests/fixtures/oss-story.jsonl | 8041 ++++++
codex-rs/tui/tests/vt100_history.rs | 44 +-
codex-rs/tui/tests/vt100_streaming_no_dup.rs | 77 +
42 files changed, 35887 insertions(+), 1026 deletions(-)
delete mode 100644 codex-rs/tui/src/bottom_pane/live_ring_widget.rs
create mode 100644 codex-rs/tui/src/chatwidget/agent.rs
create mode 100644 codex-rs/tui/src/chatwidget/interrupts.rs
create mode 100644 codex-rs/tui/src/chatwidget/tests.rs
create mode 100644 codex-rs/tui/src/chatwidget_stream_tests.rs
create mode 100644 codex-rs/tui/src/markdown_stream.rs
create mode 100644 codex-rs/tui/src/render/line_utils.rs
create mode 100644 codex-rs/tui/src/render/markdown_utils.rs
create mode 100644 codex-rs/tui/src/render/mod.rs
create mode 100644 codex-rs/tui/src/session_log.rs
create mode 100644 codex-rs/tui/src/streaming/controller.rs
create mode 100644 codex-rs/tui/src/streaming/mod.rs
create mode 100644 codex-rs/tui/tests/fixtures/binary-size-log.jsonl
create mode 100644 codex-rs/tui/tests/fixtures/ideal-binary-response.txt
create mode 100644 codex-rs/tui/tests/fixtures/oss-story.jsonl
create mode 100644 codex-rs/tui/tests/vt100_streaming_no_dup.rs
diff --git a/.codespellrc b/.codespellrc
index f01272c61d..eefde42a4e 100644
--- a/.codespellrc
+++ b/.codespellrc
@@ -1,6 +1,6 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
-skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts
+skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl
check-hidden = true
ignore-regex = ^\s*"image/\S+": ".*|\b(afterAll)\b
ignore-words-list = ratatui,ser
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index a211bf16a0..41392633be 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -885,6 +885,7 @@ dependencies = [
"lazy_static",
"libc",
"mcp-types",
+ "once_cell",
"path-clean",
"pretty_assertions",
"rand 0.8.5",
diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs
index dae140bc02..840e808fc7 100644
--- a/codex-rs/core/src/chat_completions.rs
+++ b/codex-rs/core/src/chat_completions.rs
@@ -588,6 +588,9 @@ where
Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(_)))) => {
continue;
}
+ Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded))) => {
+ continue;
+ }
}
}
}
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index 4e31df2f46..f0229d45ae 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -507,12 +507,18 @@ async fn process_sse(
| "response.function_call_arguments.delta"
| "response.in_progress"
| "response.output_item.added"
- | "response.output_text.done"
- | "response.reasoning_summary_part.added"
- | "response.reasoning_summary_text.done" => {
- // Currently, we ignore these events, but we handle them
+ | "response.output_text.done" => {
+ // Currently, we ignore this event, but we handle it
// separately to skip the logging message in the `other` case.
}
+ "response.reasoning_summary_part.added" => {
+ // Boundary between reasoning summary sections (e.g., titles).
+ let event = ResponseEvent::ReasoningSummaryPartAdded;
+ if tx_event.send(Ok(event)).await.is_err() {
+ return;
+ }
+ }
+ "response.reasoning_summary_text.done" => {}
other => debug!(other, "sse event"),
}
}
diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs
index f05a57b6a2..440d250b62 100644
--- a/codex-rs/core/src/client_common.rs
+++ b/codex-rs/core/src/client_common.rs
@@ -144,6 +144,7 @@ pub enum ResponseEvent {
OutputTextDelta(String),
ReasoningSummaryDelta(String),
ReasoningContentDelta(String),
+ ReasoningSummaryPartAdded,
}
#[derive(Debug, Serialize)]
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 936cd4ef98..b0905e34ab 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -75,6 +75,7 @@ use crate::protocol::AgentReasoningDeltaEvent;
use crate::protocol::AgentReasoningEvent;
use crate::protocol::AgentReasoningRawContentDeltaEvent;
use crate::protocol::AgentReasoningRawContentEvent;
+use crate::protocol::AgentReasoningSectionBreakEvent;
use crate::protocol::ApplyPatchApprovalRequestEvent;
use crate::protocol::AskForApproval;
use crate::protocol::BackgroundEventEvent;
@@ -1477,6 +1478,13 @@ async fn try_run_turn(
};
sess.tx_event.send(event).await.ok();
}
+ ResponseEvent::ReasoningSummaryPartAdded => {
+ let event = Event {
+ id: sub_id.to_string(),
+ msg: EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent {}),
+ };
+ sess.tx_event.send(event).await.ok();
+ }
ResponseEvent::ReasoningContentDelta(delta) => {
if sess.show_raw_agent_reasoning {
let event = Event {
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
index 4972f10d98..e984b95f98 100644
--- a/codex-rs/core/src/protocol.rs
+++ b/codex-rs/core/src/protocol.rs
@@ -235,8 +235,7 @@ impl SandboxPolicy {
}
}
- /// Always returns `true` for now, as we do not yet support restricting read
- /// access.
+ /// Always returns `true`; restricting read access is not supported.
pub fn has_full_disk_read_access(&self) -> bool {
true
}
@@ -384,6 +383,8 @@ pub enum EventMsg {
/// Agent reasoning content delta event from agent.
AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent),
+ /// Signaled when the model begins a new reasoning summary section (e.g., a new titled block).
+ AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent),
/// Ack the client's configure message.
SessionConfigured(SessionConfiguredEvent),
@@ -531,6 +532,9 @@ pub struct AgentReasoningRawContentDeltaEvent {
pub delta: String,
}
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct AgentReasoningSectionBreakEvent {}
+
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentReasoningDeltaEvent {
pub delta: String,
diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs
index 834ec38298..18bae310be 100644
--- a/codex-rs/core/tests/common/lib.rs
+++ b/codex-rs/core/tests/common/lib.rs
@@ -90,9 +90,11 @@ pub async fn wait_for_event_with_timeout(
where
F: FnMut(&codex_core::protocol::EventMsg) -> bool,
{
+ use tokio::time::Duration;
use tokio::time::timeout;
loop {
- let ev = timeout(wait_time, codex.next_event())
+ // Allow a bit more time to accommodate async startup work (e.g. config IO, tool discovery)
+ let ev = timeout(wait_time.max(Duration::from_secs(5)), codex.next_event())
.await
.expect("timeout waiting for event")
.expect("stream ended unexpectedly");
diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs
index 1d35dcb73f..22cf130462 100644
--- a/codex-rs/exec/src/event_processor_with_human_output.rs
+++ b/codex-rs/exec/src/event_processor_with_human_output.rs
@@ -210,6 +210,14 @@ impl EventProcessor for EventProcessorWithHumanOutput {
#[allow(clippy::expect_used)]
std::io::stdout().flush().expect("could not flush stdout");
}
+ EventMsg::AgentReasoningSectionBreak(_) => {
+ if !self.show_agent_reasoning {
+ return CodexStatus::Running;
+ }
+ println!();
+ #[allow(clippy::expect_used)]
+ std::io::stdout().flush().expect("could not flush stdout");
+ }
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
if !self.show_raw_agent_reasoning {
return CodexStatus::Running;
diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs
index b91c4a7609..edb5a205e8 100644
--- a/codex-rs/mcp-server/src/codex_tool_runner.rs
+++ b/codex-rs/mcp-server/src/codex_tool_runner.rs
@@ -257,6 +257,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::TaskStarted
| EventMsg::TokenCount(_)
| EventMsg::AgentReasoning(_)
+ | EventMsg::AgentReasoningSectionBreak(_)
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::ExecCommandBegin(_)
diff --git a/codex-rs/mcp-server/src/conversation_loop.rs b/codex-rs/mcp-server/src/conversation_loop.rs
index 80c34760c5..d5c414bf74 100644
--- a/codex-rs/mcp-server/src/conversation_loop.rs
+++ b/codex-rs/mcp-server/src/conversation_loop.rs
@@ -95,6 +95,7 @@ pub async fn run_conversation_loop(
| EventMsg::TaskStarted
| EventMsg::TokenCount(_)
| EventMsg::AgentReasoning(_)
+ | EventMsg::AgentReasoningSectionBreak(_)
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::ExecCommandBegin(_)
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index 31f198f543..182667a060 100644
--- a/codex-rs/tui/Cargo.toml
+++ b/codex-rs/tui/Cargo.toml
@@ -14,6 +14,8 @@ path = "src/lib.rs"
[features]
# Enable vt100-based tests (emulator) when running with `--features vt100-tests`.
vt100-tests = []
+# Gate verbose debug logging inside the TUI implementation.
+debug-logs = []
[lints]
workspace = true
@@ -39,6 +41,7 @@ crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
diffy = "0.4.2"
image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
lazy_static = "1"
+once_cell = "1"
mcp-types = { path = "../mcp-types" }
path-clean = "1.0.1"
ratatui = { version = "0.29.0", features = [
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index cd2b308dcc..69d27022fa 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -31,7 +31,7 @@ use std::thread;
use std::time::Duration;
/// Time window for debouncing redraw requests.
-const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10);
+const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
/// Top-level application state: which full-screen view is currently active.
#[allow(clippy::large_enum_variant)]
@@ -63,6 +63,9 @@ pub(crate) struct App<'a> {
pending_history_lines: Vec>,
enhanced_keys_supported: bool,
+
+ /// Controls the animation thread that sends CommitTick events.
+ commit_anim_running: Arc,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -110,10 +113,8 @@ impl App<'_> {
app_event_tx.send(AppEvent::RequestRedraw);
}
crossterm::event::Event::Paste(pasted) => {
- // Many terminals convert newlines to \r when
- // pasting, e.g. [iTerm2][]. But [tui-textarea
- // expects \n][tui-textarea]. This seems like a bug
- // in tui-textarea IMO, but work around it for now.
+ // Many terminals convert newlines to \r when pasting (e.g., iTerm2),
+ // but tui-textarea expects \n. Normalize CR to LF.
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
let pasted = pasted.replace("\r", "\n");
@@ -172,6 +173,7 @@ impl App<'_> {
file_search,
pending_redraw,
enhanced_keys_supported,
+ commit_anim_running: Arc::new(AtomicBool::new(false)),
}
}
@@ -188,7 +190,7 @@ impl App<'_> {
// redraw is already pending so we can return early.
if self
.pending_redraw
- .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
+ .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
return;
@@ -199,7 +201,7 @@ impl App<'_> {
thread::spawn(move || {
thread::sleep(REDRAW_DEBOUNCE);
tx.send(AppEvent::Redraw);
- pending_redraw.store(false, Ordering::SeqCst);
+ pending_redraw.store(false, Ordering::Release);
});
}
@@ -220,6 +222,30 @@ impl App<'_> {
AppEvent::Redraw => {
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
}
+ AppEvent::StartCommitAnimation => {
+ if self
+ .commit_anim_running
+ .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
+ .is_ok()
+ {
+ let tx = self.app_event_tx.clone();
+ let running = self.commit_anim_running.clone();
+ thread::spawn(move || {
+ while running.load(Ordering::Relaxed) {
+ thread::sleep(Duration::from_millis(50));
+ tx.send(AppEvent::CommitTick);
+ }
+ });
+ }
+ }
+ AppEvent::StopCommitAnimation => {
+ self.commit_anim_running.store(false, Ordering::Release);
+ }
+ AppEvent::CommitTick => {
+ if let AppState::Chat { widget } = &mut self.app_state {
+ widget.on_commit_tick();
+ }
+ }
AppEvent::KeyEvent(key_event) => {
match key_event {
KeyEvent {
@@ -276,7 +302,7 @@ impl App<'_> {
self.dispatch_key_event(key_event);
}
_ => {
- // Ignore Release key events for now.
+ // Ignore Release key events.
}
};
}
diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs
index 7f96fe1e47..a52f8baf39 100644
--- a/codex-rs/tui/src/app_event.rs
+++ b/codex-rs/tui/src/app_event.rs
@@ -7,6 +7,7 @@ use crate::app::ChatWidgetArgs;
use crate::slash_command::SlashCommand;
#[allow(clippy::large_enum_variant)]
+#[derive(Debug)]
pub(crate) enum AppEvent {
CodexEvent(Event),
@@ -50,6 +51,10 @@ pub(crate) enum AppEvent {
InsertHistory(Vec>),
+ StartCommitAnimation,
+ StopCommitAnimation,
+ CommitTick,
+
/// Onboarding: result of login_with_chatgpt.
OnboardingAuthComplete(Result<(), String>),
OnboardingComplete(ChatWidgetArgs),
diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs
index f6c8c18c98..901bb41024 100644
--- a/codex-rs/tui/src/app_event_sender.rs
+++ b/codex-rs/tui/src/app_event_sender.rs
@@ -1,6 +1,7 @@
use std::sync::mpsc::Sender;
use crate::app_event::AppEvent;
+use crate::session_log;
#[derive(Clone, Debug)]
pub(crate) struct AppEventSender {
@@ -15,6 +16,11 @@ impl AppEventSender {
/// Send an event to the app event channel. If it fails, we swallow the
/// error and log it.
pub(crate) fn send(&self, event: AppEvent) {
+ // Record inbound events for high-fidelity session replay.
+ // Avoid double-logging Ops; those are logged at the point of submission.
+ if !matches!(event, AppEvent::CodexOp(_)) {
+ session_log::log_inbound_app_event(&event);
+ }
if let Err(e) = self.app_event_tx.send(event) {
tracing::error!("failed to send event: {e}");
}
diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
index 8e9ff6d936..57bb0ad73a 100644
--- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
+++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
@@ -75,14 +75,12 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
mod tests {
use super::*;
use crate::app_event::AppEvent;
- use std::path::PathBuf;
use std::sync::mpsc::channel;
fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
- cwd: PathBuf::from("/tmp"),
reason: None,
}
}
diff --git a/codex-rs/tui/src/bottom_pane/live_ring_widget.rs b/codex-rs/tui/src/bottom_pane/live_ring_widget.rs
deleted file mode 100644
index 13f91acc5d..0000000000
--- a/codex-rs/tui/src/bottom_pane/live_ring_widget.rs
+++ /dev/null
@@ -1,45 +0,0 @@
-use ratatui::buffer::Buffer;
-use ratatui::layout::Rect;
-use ratatui::text::Line;
-use ratatui::widgets::Paragraph;
-use ratatui::widgets::WidgetRef;
-
-/// Minimal rendering-only widget for the transient ring rows.
-pub(crate) struct LiveRingWidget {
- max_rows: u16,
- rows: Vec>, // newest at the end
-}
-
-impl LiveRingWidget {
- pub fn new() -> Self {
- Self {
- max_rows: 3,
- rows: Vec::new(),
- }
- }
-
- pub fn set_max_rows(&mut self, n: u16) {
- self.max_rows = n.max(1);
- }
-
- pub fn set_rows(&mut self, rows: Vec>) {
- self.rows = rows;
- }
-
- pub fn desired_height(&self, _width: u16) -> u16 {
- let len = self.rows.len() as u16;
- len.min(self.max_rows)
- }
-}
-
-impl WidgetRef for LiveRingWidget {
- fn render_ref(&self, area: Rect, buf: &mut Buffer) {
- if area.height == 0 {
- return;
- }
- let visible = self.rows.len().saturating_sub(self.max_rows as usize);
- let slice = &self.rows[visible..];
- let para = Paragraph::new(slice.to_vec());
- para.render_ref(area, buf);
- }
-}
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index 4606f9b8ee..69f174f148 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -9,7 +9,6 @@ use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
-use ratatui::text::Line;
use ratatui::widgets::WidgetRef;
mod approval_modal_view;
@@ -18,7 +17,6 @@ mod chat_composer;
mod chat_composer_history;
mod command_popup;
mod file_search_popup;
-mod live_ring_widget;
mod popup_consts;
mod scroll_state;
mod selection_popup_common;
@@ -57,10 +55,6 @@ pub(crate) struct BottomPane<'a> {
/// not replace the composer; it augments it.
live_status: Option,
- /// Optional transient ring shown above the composer. This is a rendering-only
- /// container used during development before we wire it to ChatWidget events.
- live_ring: Option,
-
/// True if the active view is the StatusIndicatorView that replaces the
/// composer during a running task.
status_view_active: bool,
@@ -88,7 +82,6 @@ impl BottomPane<'_> {
is_task_running: false,
ctrl_c_quit_hint: false,
live_status: None,
- live_ring: None,
status_view_active: false,
}
}
@@ -99,26 +92,14 @@ impl BottomPane<'_> {
.as_ref()
.map(|s| s.desired_height(width))
.unwrap_or(0);
- let ring_h = self
- .live_ring
- .as_ref()
- .map(|r| r.desired_height(width))
- .unwrap_or(0);
let view_height = if let Some(view) = self.active_view.as_ref() {
- // Add a single blank spacer line between live ring and status view when active.
- let spacer = if self.live_ring.is_some() && self.status_view_active {
- 1
- } else {
- 0
- };
- spacer + view.desired_height(width)
+ view.desired_height(width)
} else {
self.composer.desired_height(width)
};
overlay_status_h
- .saturating_add(ring_h)
.saturating_add(view_height)
.saturating_add(Self::BOTTOM_PAD_LINES)
}
@@ -357,43 +338,11 @@ impl BottomPane<'_> {
self.composer.on_file_search_result(query, matches);
self.request_redraw();
}
-
- /// Set the rows and cap for the transient live ring overlay.
- pub(crate) fn set_live_ring_rows(&mut self, max_rows: u16, rows: Vec>) {
- let mut w = live_ring_widget::LiveRingWidget::new();
- w.set_max_rows(max_rows);
- w.set_rows(rows);
- self.live_ring = Some(w);
- }
-
- pub(crate) fn clear_live_ring(&mut self) {
- self.live_ring = None;
- }
-
- // Removed restart_live_status_with_text – no longer used by the current streaming UI.
}
impl WidgetRef for &BottomPane<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut y_offset = 0u16;
- if let Some(ring) = &self.live_ring {
- let live_h = ring.desired_height(area.width).min(area.height);
- if live_h > 0 {
- let live_rect = Rect {
- x: area.x,
- y: area.y,
- width: area.width,
- height: live_h,
- };
- ring.render_ref(live_rect, buf);
- y_offset = live_h;
- }
- }
- // Spacer between live ring and status view when active
- if self.live_ring.is_some() && self.status_view_active && y_offset < area.height {
- // Leave one empty line
- y_offset = y_offset.saturating_add(1);
- }
if let Some(status) = &self.live_status {
let live_h = status
.desired_height(area.width)
@@ -443,15 +392,12 @@ mod tests {
use crate::app_event::AppEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
- use ratatui::text::Line;
- use std::path::PathBuf;
use std::sync::mpsc::channel;
fn exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
id: "1".to_string(),
command: vec!["echo".into(), "ok".into()],
- cwd: PathBuf::from("."),
reason: None,
}
}
@@ -471,103 +417,7 @@ mod tests {
assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c());
}
- #[test]
- fn live_ring_renders_above_composer() {
- let (tx_raw, _rx) = channel::();
- let tx = AppEventSender::new(tx_raw);
- let mut pane = BottomPane::new(BottomPaneParams {
- app_event_tx: tx,
- has_input_focus: true,
- enhanced_keys_supported: false,
- });
-
- // Provide 4 rows with max_rows=3; only the last 3 should be visible.
- pane.set_live_ring_rows(
- 3,
- vec![
- Line::from("one".to_string()),
- Line::from("two".to_string()),
- Line::from("three".to_string()),
- Line::from("four".to_string()),
- ],
- );
-
- let area = Rect::new(0, 0, 10, 5);
- let mut buf = Buffer::empty(area);
- (&pane).render_ref(area, &mut buf);
-
- // Extract the first 3 rows and assert they contain the last three lines.
- let mut lines: Vec = Vec::new();
- for y in 0..3 {
- let mut s = String::new();
- for x in 0..area.width {
- s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
- }
- lines.push(s.trim_end().to_string());
- }
- assert_eq!(lines, vec!["two", "three", "four"]);
- }
-
- #[test]
- fn status_indicator_visible_with_live_ring() {
- let (tx_raw, _rx) = channel::();
- let tx = AppEventSender::new(tx_raw);
- let mut pane = BottomPane::new(BottomPaneParams {
- app_event_tx: tx,
- has_input_focus: true,
- enhanced_keys_supported: false,
- });
-
- // Simulate task running which replaces composer with the status indicator.
- pane.set_task_running(true);
- pane.update_status_text("waiting for model".to_string());
-
- // Provide 2 rows in the live ring (e.g., streaming CoT) and ensure the
- // status indicator remains visible below them.
- pane.set_live_ring_rows(
- 2,
- vec![
- Line::from("cot1".to_string()),
- Line::from("cot2".to_string()),
- ],
- );
-
- // Allow some frames so the dot animation is present.
- std::thread::sleep(std::time::Duration::from_millis(120));
-
- // Height should include both ring rows, 1 spacer, and the 1-line status.
- let area = Rect::new(0, 0, 30, 4);
- let mut buf = Buffer::empty(area);
- (&pane).render_ref(area, &mut buf);
-
- // Top two rows are the live ring.
- let mut r0 = String::new();
- let mut r1 = String::new();
- for x in 0..area.width {
- r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
- r1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
- }
- assert!(r0.contains("cot1"), "expected first live row: {r0:?}");
- assert!(r1.contains("cot2"), "expected second live row: {r1:?}");
-
- // Row 2 is the spacer (blank)
- let mut r2 = String::new();
- for x in 0..area.width {
- r2.push(buf[(x, 2)].symbol().chars().next().unwrap_or(' '));
- }
- assert!(r2.trim().is_empty(), "expected blank spacer line: {r2:?}");
-
- // Bottom row is the status line; it should contain the left bar and "Working".
- let mut r3 = String::new();
- for x in 0..area.width {
- r3.push(buf[(x, 3)].symbol().chars().next().unwrap_or(' '));
- }
- assert_eq!(buf[(0, 3)].symbol().chars().next().unwrap_or(' '), '▌');
- assert!(
- r3.contains("Working"),
- "expected Working header in status line: {r3:?}"
- );
- }
+ // live ring removed; related tests deleted.
#[test]
fn overlay_not_shown_above_approval_modal() {
diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs
index c45c86e5af..d1b4567509 100644
--- a/codex-rs/tui/src/bottom_pane/textarea.rs
+++ b/codex-rs/tui/src/bottom_pane/textarea.rs
@@ -109,7 +109,7 @@ impl TextArea {
self.wrapped_lines(width).len() as u16
}
- #[allow(dead_code)]
+ #[cfg_attr(not(test), allow(dead_code))]
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.cursor_pos_with_state(area, &TextAreaState::default())
}
@@ -359,8 +359,9 @@ impl TextArea {
} => {
self.move_cursor_to_end_of_line(true);
}
- o => {
- tracing::debug!("Unhandled key event in TextArea: {:?}", o);
+ _o => {
+ #[cfg(feature = "debug-logs")]
+ tracing::debug!("Unhandled key event in TextArea: {:?}", _o);
}
}
}
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 2c28446fb0..0d6911d202 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -1,9 +1,6 @@
use std::collections::HashMap;
use std::path::PathBuf;
-use std::sync::Arc;
-use codex_core::codex_wrapper::CodexConversation;
-use codex_core::codex_wrapper::init_codex;
use codex_core::config::Config;
use codex_core::parse_command::ParsedCommand;
use codex_core::protocol::AgentMessageDeltaEvent;
@@ -37,8 +34,7 @@ use ratatui::layout::Rect;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use tokio::sync::mpsc::UnboundedSender;
-use tokio::sync::mpsc::unbounded_channel;
-use tracing::info;
+use tracing::debug;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -46,20 +42,23 @@ use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
-use crate::common::DEFAULT_WRAP_COLS;
+use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::CommandOutput;
-use crate::history_cell::ExecCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
-use crate::live_wrap::RowBuilder;
+// streaming internals are provided by crate::streaming and crate::markdown_stream
use crate::user_approval_widget::ApprovalRequest;
+mod interrupts;
+use self::interrupts::InterruptManager;
+mod agent;
+use self::agent::spawn_agent;
+use crate::streaming::controller::AppEventHistorySink;
+use crate::streaming::controller::StreamController;
use codex_file_search::FileMatch;
-use ratatui::style::Stylize;
+// Track information about an in-flight exec command.
struct RunningCommand {
command: Vec,
- #[allow(dead_code)]
- cwd: PathBuf,
parsed_cmd: Vec,
}
@@ -72,17 +71,16 @@ pub(crate) struct ChatWidget<'a> {
initial_user_message: Option,
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
- reasoning_buffer: String,
- content_buffer: String,
- // Buffer for streaming assistant answer text; we do not surface partial
- // We wait for the final AgentMessage event and then emit the full text
- // at once into scrollback so the history contains a single message.
- answer_buffer: String,
+ // Stream lifecycle controller
+ stream: StreamController,
+ // Track the most recently active stream kind in the current turn
+ last_stream_kind: Option,
running_commands: HashMap,
- live_builder: RowBuilder,
- current_stream: Option,
- stream_header_emitted: bool,
- live_max_rows: u16,
+ task_complete_pending: bool,
+ // Queue of interruptive UI events deferred during an active write cycle
+ interrupts: InterruptManager,
+ // Whether a redraw is needed after handling the current event
+ needs_redraw: bool,
}
struct UserMessage {
@@ -90,11 +88,7 @@ struct UserMessage {
image_paths: Vec,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum StreamKind {
- Answer,
- Reasoning,
-}
+use crate::streaming::StreamKind;
impl From for UserMessage {
fn from(text: String) -> Self {
@@ -114,19 +108,349 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio
}
impl ChatWidget<'_> {
+ #[inline]
+ fn mark_needs_redraw(&mut self) {
+ self.needs_redraw = true;
+ }
+ // --- Small event handlers ---
+ fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
+ self.bottom_pane
+ .set_history_metadata(event.history_log_id, event.history_entry_count);
+ self.add_to_history(HistoryCell::new_session_info(&self.config, event, true));
+ if let Some(user_message) = self.initial_user_message.take() {
+ self.submit_user_message(user_message);
+ }
+ self.mark_needs_redraw();
+ }
+
+ fn on_agent_message(&mut self, message: String) {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ let finished = self.stream.apply_final_answer(&message, &sink);
+ self.handle_if_stream_finished(finished);
+ self.mark_needs_redraw();
+ }
+
+ fn on_agent_message_delta(&mut self, delta: String) {
+ self.handle_streaming_delta(StreamKind::Answer, delta);
+ }
+
+ fn on_agent_reasoning_delta(&mut self, delta: String) {
+ self.handle_streaming_delta(StreamKind::Reasoning, delta);
+ }
+
+ fn on_agent_reasoning_final(&mut self) {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ let finished = self.stream.finalize(StreamKind::Reasoning, false, &sink);
+ self.handle_if_stream_finished(finished);
+ self.mark_needs_redraw();
+ }
+
+ fn on_reasoning_section_break(&mut self) {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ self.stream.insert_reasoning_section_break(&sink);
+ }
+
+ // Raw reasoning uses the same flow as summarized reasoning
+
+ fn on_task_started(&mut self) {
+ self.bottom_pane.clear_ctrl_c_quit_hint();
+ self.bottom_pane.set_task_running(true);
+ self.set_waiting_for_model_status();
+ self.stream.reset_headers_for_new_turn();
+ self.last_stream_kind = None;
+ self.mark_needs_redraw();
+ }
+
+ fn on_task_complete(&mut self) {
+ // If a stream is currently active, finalize only that stream to flush any tail
+ // without emitting stray headers for other streams.
+ if self.stream.is_write_cycle_active() {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ if let Some(kind) = self.last_stream_kind {
+ let _ = self.stream.finalize(kind, true, &sink);
+ }
+ }
+ // Mark task stopped and request redraw now that all content is in history.
+ self.bottom_pane.set_task_running(false);
+ self.mark_needs_redraw();
+ }
+
+ fn on_token_count(&mut self, token_usage: TokenUsage) {
+ self.total_token_usage = add_token_usage(&self.total_token_usage, &token_usage);
+ self.last_token_usage = token_usage;
+ self.bottom_pane.set_token_usage(
+ self.total_token_usage.clone(),
+ self.last_token_usage.clone(),
+ self.config.model_context_window,
+ );
+ }
+
+ fn on_error(&mut self, message: String) {
+ self.add_to_history(HistoryCell::new_error_event(message));
+ self.bottom_pane.set_task_running(false);
+ self.stream.clear_all();
+ self.mark_needs_redraw();
+ }
+
+ fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) {
+ self.add_to_history(HistoryCell::new_plan_update(update));
+ }
+
+ fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) {
+ let id2 = id.clone();
+ let ev2 = ev.clone();
+ self.defer_or_handle(
+ |q| q.push_exec_approval(id, ev),
+ |s| s.handle_exec_approval_now(id2, ev2),
+ );
+ }
+
+ fn on_apply_patch_approval_request(&mut self, id: String, ev: ApplyPatchApprovalRequestEvent) {
+ let id2 = id.clone();
+ let ev2 = ev.clone();
+ self.defer_or_handle(
+ |q| q.push_apply_patch_approval(id, ev),
+ |s| s.handle_apply_patch_approval_now(id2, ev2),
+ );
+ }
+
+ fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) {
+ let ev2 = ev.clone();
+ self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2));
+ }
+
+ fn on_exec_command_output_delta(
+ &mut self,
+ _ev: codex_core::protocol::ExecCommandOutputDeltaEvent,
+ ) {
+ // TODO: Handle streaming exec output if/when implemented
+ }
+
+ fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
+ self.add_to_history(HistoryCell::new_patch_event(
+ PatchEventType::ApplyBegin {
+ auto_approved: event.auto_approved,
+ },
+ event.changes,
+ ));
+ }
+
+ fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) {
+ let ev2 = event.clone();
+ self.defer_or_handle(
+ |q| q.push_patch_end(event),
+ |s| s.handle_patch_apply_end_now(ev2),
+ );
+ }
+
+ fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) {
+ let ev2 = ev.clone();
+ self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2));
+ }
+
+ fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) {
+ let ev2 = ev.clone();
+ self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2));
+ }
+
+ fn on_mcp_tool_call_end(&mut self, ev: McpToolCallEndEvent) {
+ let ev2 = ev.clone();
+ self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2));
+ }
+
+ fn on_get_history_entry_response(
+ &mut self,
+ event: codex_core::protocol::GetHistoryEntryResponseEvent,
+ ) {
+ let codex_core::protocol::GetHistoryEntryResponseEvent {
+ offset,
+ log_id,
+ entry,
+ } = event;
+ self.bottom_pane
+ .on_history_entry_response(log_id, offset, entry.map(|e| e.text));
+ }
+
+ fn on_shutdown_complete(&mut self) {
+ self.app_event_tx.send(AppEvent::ExitRequest);
+ }
+
+ fn on_turn_diff(&mut self, unified_diff: String) {
+ debug!("TurnDiffEvent: {unified_diff}");
+ }
+
+ fn on_background_event(&mut self, message: String) {
+ debug!("BackgroundEvent: {message}");
+ }
+ /// Periodic tick to commit at most one queued line to history with a small delay,
+ /// animating the output.
+ pub(crate) fn on_commit_tick(&mut self) {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ let finished = self.stream.on_commit_tick(&sink);
+ self.handle_if_stream_finished(finished);
+ }
+ fn is_write_cycle_active(&self) -> bool {
+ self.stream.is_write_cycle_active()
+ }
+
+ fn flush_interrupt_queue(&mut self) {
+ let mut mgr = std::mem::take(&mut self.interrupts);
+ mgr.flush_all(self);
+ self.interrupts = mgr;
+ }
+
+ #[inline]
+ fn defer_or_handle(
+ &mut self,
+ push: impl FnOnce(&mut InterruptManager),
+ handle: impl FnOnce(&mut Self),
+ ) {
+ // Preserve deterministic FIFO across queued interrupts: once anything
+ // is queued due to an active write cycle, continue queueing until the
+ // queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin).
+ if self.is_write_cycle_active() || !self.interrupts.is_empty() {
+ push(&mut self.interrupts);
+ } else {
+ handle(self);
+ }
+ }
+
+ #[inline]
+ fn handle_if_stream_finished(&mut self, finished: bool) {
+ if finished {
+ if self.task_complete_pending {
+ self.bottom_pane.set_task_running(false);
+ self.task_complete_pending = false;
+ }
+ self.flush_interrupt_queue();
+ }
+ }
+
+ #[inline]
+ fn set_waiting_for_model_status(&mut self) {
+ self.bottom_pane
+ .update_status_text("waiting for model".to_string());
+ }
+
+ #[inline]
+ fn handle_streaming_delta(&mut self, kind: StreamKind, delta: String) {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ self.set_waiting_for_model_status();
+ self.stream.begin(kind, &sink);
+ self.last_stream_kind = Some(kind);
+ self.stream.push_and_maybe_commit(&delta, &sink);
+ self.mark_needs_redraw();
+ }
+
+ pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) {
+ let running = self.running_commands.remove(&ev.call_id);
+ self.active_exec_cell = None;
+ let (command, parsed) = match running {
+ Some(rc) => (rc.command, rc.parsed_cmd),
+ None => (vec![ev.call_id.clone()], Vec::new()),
+ };
+ self.add_to_history(HistoryCell::new_completed_exec_command(
+ command,
+ parsed,
+ CommandOutput {
+ exit_code: ev.exit_code,
+ stdout: ev.stdout.clone(),
+ stderr: ev.stderr.clone(),
+ },
+ ));
+ }
+
+ pub(crate) fn handle_patch_apply_end_now(
+ &mut self,
+ event: codex_core::protocol::PatchApplyEndEvent,
+ ) {
+ if event.success {
+ self.add_to_history(HistoryCell::new_patch_apply_success(event.stdout));
+ } else {
+ self.add_to_history(HistoryCell::new_patch_apply_failure(event.stderr));
+ }
+ }
+
+ pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
+ // Log a background summary immediately so the history is chronological.
+ let cmdline = strip_bash_lc_and_escape(&ev.command);
+ let text = format!(
+ "command requires approval:\n$ {cmdline}{reason}",
+ reason = ev
+ .reason
+ .as_ref()
+ .map(|r| format!("\n{r}"))
+ .unwrap_or_default()
+ );
+ self.add_to_history(HistoryCell::new_background_event(text));
+
+ let request = ApprovalRequest::Exec {
+ id,
+ command: ev.command,
+ reason: ev.reason,
+ };
+ self.bottom_pane.push_approval_request(request);
+ self.mark_needs_redraw();
+ }
+
+ pub(crate) fn handle_apply_patch_approval_now(
+ &mut self,
+ id: String,
+ ev: ApplyPatchApprovalRequestEvent,
+ ) {
+ self.add_to_history(HistoryCell::new_patch_event(
+ PatchEventType::ApprovalRequest,
+ ev.changes.clone(),
+ ));
+
+ let request = ApprovalRequest::ApplyPatch {
+ id,
+ reason: ev.reason,
+ grant_root: ev.grant_root,
+ };
+ self.bottom_pane.push_approval_request(request);
+ self.mark_needs_redraw();
+ }
+
+ pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
+ // Ensure the status indicator is visible while the command runs.
+ self.bottom_pane
+ .update_status_text("running command".to_string());
+ self.running_commands.insert(
+ ev.call_id.clone(),
+ RunningCommand {
+ command: ev.command.clone(),
+ parsed_cmd: ev.parsed_cmd.clone(),
+ },
+ );
+ self.active_exec_cell = Some(HistoryCell::new_active_exec_command(
+ ev.command,
+ ev.parsed_cmd,
+ ));
+ }
+
+ pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
+ self.add_to_history(HistoryCell::new_active_mcp_tool_call(ev.invocation));
+ }
+ pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
+ self.add_to_history(HistoryCell::new_completed_mcp_tool_call(
+ 80,
+ ev.invocation,
+ ev.duration,
+ ev.result
+ .as_ref()
+ .map(|r| !r.is_error.unwrap_or(false))
+ .unwrap_or(false),
+ ev.result,
+ ));
+ }
fn interrupt_running_task(&mut self) {
if self.bottom_pane.is_task_running() {
self.active_exec_cell = None;
self.bottom_pane.clear_ctrl_c_quit_hint();
self.submit_op(Op::Interrupt);
self.bottom_pane.set_task_running(false);
- self.bottom_pane.clear_live_ring();
- self.live_builder = RowBuilder::new(self.live_builder.width());
- self.current_stream = None;
- self.stream_header_emitted = false;
- self.answer_buffer.clear();
- self.reasoning_buffer.clear();
- self.content_buffer.clear();
+ self.stream.clear_all();
self.request_redraw();
}
}
@@ -141,24 +465,7 @@ impl ChatWidget<'_> {
])
.areas(area)
}
- fn emit_stream_header(&mut self, kind: StreamKind) {
- use ratatui::text::Line as RLine;
- if self.stream_header_emitted {
- return;
- }
- let header = match kind {
- StreamKind::Reasoning => RLine::from("thinking".magenta().italic()),
- StreamKind::Answer => RLine::from("codex".magenta().bold()),
- };
- self.app_event_tx
- .send(AppEvent::InsertHistory(vec![header]));
- self.stream_header_emitted = true;
- }
- fn finalize_active_stream(&mut self) {
- if let Some(kind) = self.current_stream {
- self.finalize_stream(kind);
- }
- }
+
pub(crate) fn new(
config: Config,
app_event_tx: AppEventSender,
@@ -166,43 +473,7 @@ impl ChatWidget<'_> {
initial_images: Vec,
enhanced_keys_supported: bool,
) -> Self {
- let (codex_op_tx, mut codex_op_rx) = unbounded_channel::();
-
- let app_event_tx_clone = app_event_tx.clone();
- // Create the Codex asynchronously so the UI loads as quickly as possible.
- let config_for_agent_loop = config.clone();
- tokio::spawn(async move {
- let CodexConversation {
- codex,
- session_configured,
- ..
- } = match init_codex(config_for_agent_loop).await {
- Ok(vals) => vals,
- Err(e) => {
- // TODO: surface this error to the user.
- tracing::error!("failed to initialize codex: {e}");
- return;
- }
- };
-
- // Forward the captured `SessionInitialized` event that was consumed
- // inside `init_codex()` so it can be rendered in the UI.
- app_event_tx_clone.send(AppEvent::CodexEvent(session_configured.clone()));
- let codex = Arc::new(codex);
- let codex_clone = codex.clone();
- tokio::spawn(async move {
- while let Some(op) = codex_op_rx.recv().await {
- let id = codex_clone.submit(op).await;
- if let Err(e) = id {
- tracing::error!("failed to submit op: {e}");
- }
- }
- });
-
- while let Ok(event) = codex.next_event().await {
- app_event_tx_clone.send(AppEvent::CodexEvent(event));
- }
- });
+ let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone());
Self {
app_event_tx: app_event_tx.clone(),
@@ -213,21 +484,19 @@ impl ChatWidget<'_> {
enhanced_keys_supported,
}),
active_exec_cell: None,
- config,
+ config: config.clone(),
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
),
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
- reasoning_buffer: String::new(),
- content_buffer: String::new(),
- answer_buffer: String::new(),
+ stream: StreamController::new(config),
+ last_stream_kind: None,
running_commands: HashMap::new(),
- live_builder: RowBuilder::new(DEFAULT_WRAP_COLS.into()),
- current_stream: None,
- stream_header_emitted: false,
- live_max_rows: 3,
+ task_complete_pending: false,
+ interrupts: InterruptManager::new(),
+ needs_redraw: false,
}
}
@@ -256,6 +525,13 @@ impl ChatWidget<'_> {
self.bottom_pane.handle_paste(text);
}
+ fn flush_active_exec_cell(&mut self) {
+ if let Some(active) = self.active_exec_cell.take() {
+ self.app_event_tx
+ .send(AppEvent::InsertHistory(active.plain_lines()));
+ }
+ }
+
fn add_to_history(&mut self, cell: HistoryCell) {
self.flush_active_exec_cell();
self.app_event_tx
@@ -293,13 +569,15 @@ impl ChatWidget<'_> {
});
}
- // Only show text portion in conversation history for now.
+ // Only show the text portion in conversation history.
if !text.is_empty() {
self.add_to_history(HistoryCell::new_user_prompt(text.clone()));
}
}
pub(crate) fn handle_codex_event(&mut self, event: Event) {
+ // Reset redraw flag for this dispatch
+ self.needs_redraw = false;
let Event { id, msg } = event;
match msg {
@@ -312,285 +590,46 @@ impl ChatWidget<'_> {
}
match msg {
- EventMsg::SessionConfigured(event) => {
- self.bottom_pane
- .set_history_metadata(event.history_log_id, event.history_entry_count);
- // Record session information at the top of the conversation.
- self.add_to_history(HistoryCell::new_session_info(&self.config, event, true));
-
- if let Some(user_message) = self.initial_user_message.take() {
- // If the user provided an initial message, add it to the
- // conversation history.
- self.submit_user_message(user_message);
- }
-
- self.request_redraw();
- }
- EventMsg::AgentMessage(AgentMessageEvent { message }) => {
- // AgentMessage: if no deltas were streamed, render the final text.
- if self.current_stream != Some(StreamKind::Answer) && !message.is_empty() {
- self.begin_stream(StreamKind::Answer);
- self.stream_push_and_maybe_commit(&message);
- }
- self.finalize_stream(StreamKind::Answer);
- self.request_redraw();
- }
+ EventMsg::SessionConfigured(e) => self.on_session_configured(e),
+ EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message),
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
- self.begin_stream(StreamKind::Answer);
- self.answer_buffer.push_str(&delta);
- self.stream_push_and_maybe_commit(&delta);
- self.request_redraw();
+ self.on_agent_message_delta(delta)
}
- EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
- // Stream CoT into the live pane; keep input visible and commit
- // overflow rows incrementally to scrollback.
- self.begin_stream(StreamKind::Reasoning);
- self.reasoning_buffer.push_str(&delta);
- self.stream_push_and_maybe_commit(&delta);
- self.request_redraw();
- }
- EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
- // Final reasoning: if no deltas were streamed, render the final text.
- if self.current_stream != Some(StreamKind::Reasoning) && !text.is_empty() {
- self.begin_stream(StreamKind::Reasoning);
- self.stream_push_and_maybe_commit(&text);
- }
- self.finalize_stream(StreamKind::Reasoning);
- self.request_redraw();
- }
- EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
+ EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta })
+ | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
delta,
- }) => {
- // Treat raw reasoning content the same as summarized reasoning for UI flow.
- self.begin_stream(StreamKind::Reasoning);
- self.reasoning_buffer.push_str(&delta);
- self.stream_push_and_maybe_commit(&delta);
- self.request_redraw();
- }
- EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
- // Final raw reasoning content: if no deltas were streamed, render the final text.
- if self.current_stream != Some(StreamKind::Reasoning) && !text.is_empty() {
- self.begin_stream(StreamKind::Reasoning);
- self.stream_push_and_maybe_commit(&text);
- }
- self.finalize_stream(StreamKind::Reasoning);
- self.request_redraw();
- }
- EventMsg::TaskStarted => {
- self.bottom_pane.clear_ctrl_c_quit_hint();
- self.bottom_pane.set_task_running(true);
- // Replace composer with single-line spinner while waiting.
- self.bottom_pane
- .update_status_text("waiting for model".to_string());
- self.request_redraw();
- }
- EventMsg::TaskComplete(TaskCompleteEvent {
- last_agent_message: _,
- }) => {
- self.bottom_pane.set_task_running(false);
- self.bottom_pane.clear_live_ring();
- self.request_redraw();
- }
- EventMsg::TokenCount(token_usage) => {
- self.total_token_usage = add_token_usage(&self.total_token_usage, &token_usage);
- self.last_token_usage = token_usage;
- self.bottom_pane.set_token_usage(
- self.total_token_usage.clone(),
- self.last_token_usage.clone(),
- self.config.model_context_window,
- );
- }
- EventMsg::Error(ErrorEvent { message }) => {
- self.add_to_history(HistoryCell::new_error_event(message.clone()));
- self.bottom_pane.set_task_running(false);
- self.bottom_pane.clear_live_ring();
- self.live_builder = RowBuilder::new(self.live_builder.width());
- self.current_stream = None;
- self.stream_header_emitted = false;
- self.answer_buffer.clear();
- self.reasoning_buffer.clear();
- self.content_buffer.clear();
- self.request_redraw();
- }
- EventMsg::PlanUpdate(update) => {
- // Commit plan updates directly to history (no status-line preview).
- self.add_to_history(HistoryCell::new_plan_update(update));
- }
- EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
- call_id: _,
- command,
- cwd,
- reason,
- }) => {
- self.finalize_active_stream();
- let request = ApprovalRequest::Exec {
- id,
- command,
- cwd,
- reason,
- };
- self.bottom_pane.push_approval_request(request);
- self.request_redraw();
- }
- EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
- call_id: _,
- changes,
- reason,
- grant_root,
- }) => {
- self.finalize_active_stream();
- // ------------------------------------------------------------------
- // Before we even prompt the user for approval we surface the patch
- // summary in the main conversation so that the dialog appears in a
- // sensible chronological order:
- // (1) codex → proposes patch (HistoryCell::PendingPatch)
- // (2) UI → asks for approval (BottomPane)
- // This mirrors how command execution is shown (command begins →
- // approval dialog) and avoids surprising the user with a modal
- // prompt before they have seen *what* is being requested.
- // ------------------------------------------------------------------
- self.add_to_history(HistoryCell::new_patch_event(
- PatchEventType::ApprovalRequest,
- changes,
- ));
-
- // Now surface the approval request in the BottomPane as before.
- let request = ApprovalRequest::ApplyPatch {
- id,
- reason,
- grant_root,
- };
- self.bottom_pane.push_approval_request(request);
- self.request_redraw();
- }
- EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
- call_id,
- command,
- cwd,
- parsed_cmd,
- }) => {
- self.finalize_active_stream();
- // Ensure the status indicator is visible while the command runs.
- self.bottom_pane
- .update_status_text("running command".to_string());
- self.running_commands.insert(
- call_id,
- RunningCommand {
- command: command.clone(),
- cwd: cwd.clone(),
- parsed_cmd: parsed_cmd.clone(),
- },
- );
- let active_exec_cell = self.active_exec_cell.take();
- let merge_result = merge_cells(&command, &parsed_cmd, &active_exec_cell);
- self.active_exec_cell = match merge_result {
- MergeResult::Merge(cell) => Some(cell),
- MergeResult::Drop => active_exec_cell,
- MergeResult::NewCell(cell) => {
- if let Some(active) = active_exec_cell {
- self.app_event_tx
- .send(AppEvent::InsertHistory(active.plain_lines()));
- }
- Some(cell)
- }
- }
- }
- EventMsg::ExecCommandEnd(ExecCommandEndEvent {
- call_id,
- exit_code,
- duration: _,
- stdout,
- stderr,
- }) => {
- // Compute summary before moving stdout into the history cell.
- let cmd = self.running_commands.remove(&call_id);
- if let Some(cmd) = cmd {
- // Preserve any merged parsed commands already present on the
- // active cell; otherwise, fall back to this command's parsed.
- let parsed_cmd = match &self.active_exec_cell {
- Some(HistoryCell::Exec(ExecCell { parsed, .. })) if !parsed.is_empty() => {
- parsed.clone()
- }
- _ => cmd.parsed_cmd.clone(),
- };
- // Replace the active running cell with the finalized result,
- // but keep it as the active cell so it can be merged with
- // subsequent commands before being committed.
- self.active_exec_cell = Some(HistoryCell::new_completed_exec_command(
- cmd.command,
- parsed_cmd,
- CommandOutput {
- exit_code,
- stdout,
- stderr,
- },
- ));
- }
- }
- EventMsg::ExecCommandOutputDelta(_) => {
- // TODO
- }
- EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
- call_id: _,
- auto_approved,
- changes,
- }) => {
- self.add_to_history(HistoryCell::new_patch_event(
- PatchEventType::ApplyBegin { auto_approved },
- changes,
- ));
- }
- EventMsg::PatchApplyEnd(event) => {
- if !event.success {
- self.add_to_history(HistoryCell::new_patch_apply_failure(event.stderr));
- }
- }
- EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
- call_id: _,
- invocation,
- }) => {
- self.finalize_active_stream();
- self.active_exec_cell = Some(HistoryCell::new_active_mcp_tool_call(invocation));
- }
- EventMsg::McpToolCallEnd(McpToolCallEndEvent {
- call_id: _,
- duration,
- invocation,
- result,
- }) => {
- let completed = HistoryCell::new_completed_mcp_tool_call(
- 80,
- invocation,
- duration,
- result
- .as_ref()
- .map(|r| r.is_error.unwrap_or(false))
- .unwrap_or(false),
- result,
- );
- self.active_exec_cell = Some(completed);
- }
- EventMsg::GetHistoryEntryResponse(event) => {
- let codex_core::protocol::GetHistoryEntryResponseEvent {
- offset,
- log_id,
- entry,
- } = event;
-
- // Inform bottom pane / composer.
- self.bottom_pane
- .on_history_entry_response(log_id, offset, entry.map(|e| e.text));
- }
- EventMsg::ShutdownComplete => {
- self.app_event_tx.send(AppEvent::ExitRequest);
- }
- EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => {
- info!("TurnDiffEvent: {unified_diff}");
+ }) => self.on_agent_reasoning_delta(delta),
+ EventMsg::AgentReasoning(AgentReasoningEvent { .. })
+ | EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { .. }) => {
+ self.on_agent_reasoning_final()
}
+ EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
+ EventMsg::TaskStarted => self.on_task_started(),
+ EventMsg::TaskComplete(TaskCompleteEvent { .. }) => self.on_task_complete(),
+ EventMsg::TokenCount(token_usage) => self.on_token_count(token_usage),
+ EventMsg::Error(ErrorEvent { message }) => self.on_error(message),
+ EventMsg::PlanUpdate(update) => self.on_plan_update(update),
+ EventMsg::ExecApprovalRequest(ev) => self.on_exec_approval_request(id, ev),
+ EventMsg::ApplyPatchApprovalRequest(ev) => self.on_apply_patch_approval_request(id, ev),
+ EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev),
+ EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta),
+ EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev),
+ EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev),
+ EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev),
+ EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev),
+ EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev),
+ EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev),
+ EventMsg::ShutdownComplete => self.on_shutdown_complete(),
+ EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff),
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
- info!("BackgroundEvent: {message}");
+ self.on_background_event(message)
}
}
+ // Coalesce redraws: issue at most one after handling the event
+ if self.needs_redraw {
+ self.request_redraw();
+ self.needs_redraw = false;
+ }
}
/// Update the live log preview while a task is running.
@@ -648,8 +687,13 @@ impl ChatWidget<'_> {
self.bottom_pane.composer_is_empty()
}
+ pub(crate) fn insert_str(&mut self, text: &str) {
+ self.bottom_pane.insert_str(text);
+ }
/// Forward an `Op` directly to codex.
pub(crate) fn submit_op(&self, op: Op) {
+ // Record outbound operation for session replay fidelity.
+ crate::session_log::log_outbound_op(&op);
if let Err(e) = self.codex_op_tx.send(op) {
tracing::error!("failed to submit op: {e}");
}
@@ -665,10 +709,6 @@ impl ChatWidget<'_> {
self.submit_user_message(text.into());
}
- pub(crate) fn insert_str(&mut self, text: &str) {
- self.bottom_pane.insert_str(text);
- }
-
pub(crate) fn token_usage(&self) -> &TokenUsage {
&self.total_token_usage
}
@@ -688,114 +728,6 @@ impl ChatWidget<'_> {
}
}
-impl ChatWidget<'_> {
- fn begin_stream(&mut self, kind: StreamKind) {
- if let Some(current) = self.current_stream {
- if current != kind {
- self.finalize_stream(current);
- }
- }
-
- if self.current_stream != Some(kind) {
- self.current_stream = Some(kind);
- self.stream_header_emitted = false;
- // Clear any previous live content; we're starting a new stream.
- self.live_builder = RowBuilder::new(self.live_builder.width());
- // Ensure the waiting status is visible (composer replaced).
- self.bottom_pane
- .update_status_text("waiting for model".to_string());
- self.flush_active_exec_cell();
- self.emit_stream_header(kind);
- }
- }
-
- fn flush_active_exec_cell(&mut self) {
- if let Some(active) = self.active_exec_cell.take() {
- self.app_event_tx
- .send(AppEvent::InsertHistory(active.plain_lines()));
- }
- }
-
- fn stream_push_and_maybe_commit(&mut self, delta: &str) {
- self.flush_active_exec_cell();
-
- self.live_builder.push_fragment(delta);
-
- // Commit overflow rows (small batches) while keeping the last N rows visible.
- let drained = self
- .live_builder
- .drain_commit_ready(self.live_max_rows as usize);
- if !drained.is_empty() {
- let mut lines: Vec> = Vec::new();
- if !self.stream_header_emitted {
- match self.current_stream {
- Some(StreamKind::Reasoning) => {
- lines.push(ratatui::text::Line::from("thinking".magenta().italic()));
- }
- Some(StreamKind::Answer) => {
- lines.push(ratatui::text::Line::from("codex".magenta().bold()));
- }
- None => {}
- }
- self.stream_header_emitted = true;
- }
- for r in drained {
- lines.push(ratatui::text::Line::from(r.text));
- }
- self.app_event_tx.send(AppEvent::InsertHistory(lines));
- }
-
- // Update the live ring overlay lines (text-only, newest at bottom).
- let rows = self
- .live_builder
- .display_rows()
- .into_iter()
- .map(|r| ratatui::text::Line::from(r.text))
- .collect::>();
- self.bottom_pane
- .set_live_ring_rows(self.live_max_rows, rows);
- }
-
- fn finalize_stream(&mut self, kind: StreamKind) {
- if self.current_stream != Some(kind) {
- // Nothing to do; either already finalized or not the active stream.
- return;
- }
- // Flush any partial line as a full row, then drain all remaining rows.
- self.live_builder.end_line();
- let remaining = self.live_builder.drain_rows();
- // TODO: Re-add markdown rendering for assistant answers and reasoning.
- // When finalizing, pass the accumulated text through `markdown::append_markdown`
- // to build styled `Line<'static>` entries instead of raw plain text lines.
- if !remaining.is_empty() || !self.stream_header_emitted {
- let mut lines: Vec> = Vec::new();
- if !self.stream_header_emitted {
- match kind {
- StreamKind::Reasoning => {
- lines.push(ratatui::text::Line::from("thinking".magenta().italic()));
- }
- StreamKind::Answer => {
- lines.push(ratatui::text::Line::from("codex".magenta().bold()));
- }
- }
- self.stream_header_emitted = true;
- }
- for r in remaining {
- lines.push(ratatui::text::Line::from(r.text));
- }
- // Close the block with a blank line for readability.
- lines.push(ratatui::text::Line::from(""));
- self.app_event_tx.send(AppEvent::InsertHistory(lines));
- }
-
- // Clear the live overlay and reset state for the next stream.
- self.live_builder = RowBuilder::new(self.live_builder.width());
- self.bottom_pane.clear_live_ring();
- self.current_stream = None;
- self.stream_header_emitted = false;
- }
-}
-
impl WidgetRef for &ChatWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
@@ -834,239 +766,5 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU
}
}
-enum MergeResult {
- Merge(HistoryCell),
- Drop,
- NewCell(HistoryCell),
-}
-
-// Determine whether to and how to merge two consecutive exec cells.
-fn merge_cells(
- new_command: &[String],
- new_parsed: &[ParsedCommand],
- active_exec_cell: &Option,
-) -> MergeResult {
- let ExecCell {
- command: _existing_command,
- parsed: existing_parsed,
- output: existing_output,
- } = match active_exec_cell {
- Some(HistoryCell::Exec(cell)) => cell,
- _ => {
- // There is no existing exec cell.
- return MergeResult::NewCell(HistoryCell::new_active_exec_command(
- new_command.to_vec(),
- new_parsed.to_vec(),
- ));
- }
- };
- let existing_last = existing_parsed.last();
- let new_last = new_parsed.last();
-
- // Drop the first command if it is a read and matches the last command.
- // This is a common pattern the model does and it simplifies the output to dedupe.
- let drop_first = if let (
- Some(ParsedCommand::Read {
- name: existing_name,
- ..
- }),
- Some(ParsedCommand::Read { name: new_name, .. }),
- ) = (existing_last, new_last)
- {
- existing_name == new_name
- } else {
- false
- };
-
- if drop_first && new_parsed.len() == 1 {
- // There is only one command and it was deduped.
- return MergeResult::Drop;
- }
- let existing_exit_code = existing_output.as_ref().map(|o| o.exit_code);
- if let Some(code) = existing_exit_code {
- if code != 0 {
- // If the previous command failed, don't merge so the user can see stderr.
- // Start a fresh cell for the new command instead of duplicating the old one.
- return MergeResult::NewCell(HistoryCell::new_active_exec_command(
- new_command.to_vec(),
- new_parsed.to_vec(),
- ));
- }
- }
-
- let mut merged_parsed = existing_parsed.to_vec();
- if drop_first {
- merged_parsed.extend(new_parsed[1..].to_vec());
- } else {
- merged_parsed.extend(new_parsed.to_vec());
- }
-
- MergeResult::Merge(HistoryCell::new_active_exec_command(
- new_command.to_vec(),
- merged_parsed,
- ))
-}
-
#[cfg(test)]
-mod tests {
- use super::*;
- use crate::history_cell::CommandOutput;
-
- fn read_cmd(name: &str) -> ParsedCommand {
- ParsedCommand::Read {
- cmd: vec!["cat".to_string(), name.to_string()],
- name: name.to_string(),
- }
- }
-
- fn unknown_cmd(cmd: &str) -> ParsedCommand {
- ParsedCommand::Unknown {
- cmd: cmd.split_whitespace().map(|s| s.to_string()).collect(),
- }
- }
-
- #[test]
- fn when_no_active_exec_cell_creates_new_cell() {
- let new_command = vec!["echo".to_string(), "hi".to_string()];
- let new_parsed = vec![read_cmd("a")];
-
- let result = merge_cells(&new_command, &new_parsed, &None);
-
- match result {
- MergeResult::NewCell(cell) => match cell {
- HistoryCell::Exec(ExecCell {
- command,
- parsed,
- output,
- }) => {
- assert_eq!(command, new_command);
- assert_eq!(parsed, new_parsed);
- assert!(output.is_none());
- }
- _ => panic!("expected Exec cell"),
- },
- _ => panic!("expected NewCell"),
- }
- }
-
- #[test]
- fn drops_duplicate_trailing_read_when_new_has_only_one_read() {
- // existing last = Read("foo"), new last = Read("foo"), new_parsed.len() == 1
- let active = Some(HistoryCell::new_active_exec_command(
- vec!["bash".into(), "-lc".into(), "cat foo".into()],
- vec![read_cmd("foo")],
- ));
- let new_command = vec!["cat".into(), "foo".into()];
- let new_parsed = vec![read_cmd("foo")];
-
- let result = merge_cells(&new_command, &new_parsed, &active);
- match result {
- MergeResult::Drop => {}
- _ => panic!("expected Drop"),
- }
- }
-
- #[test]
- fn does_not_merge_when_previous_command_failed() {
- // existing exit_code != 0 forces starting a fresh cell
- let active = Some(HistoryCell::new_completed_exec_command(
- vec!["bash".into(), "-lc".into(), "cat bar".into()],
- vec![read_cmd("bar")],
- CommandOutput {
- exit_code: 1,
- stdout: String::new(),
- stderr: "err".into(),
- },
- ));
- // Ensure drop_first condition is false (different name)
- let new_command = vec!["cat".into(), "baz".into()];
- let new_parsed = vec![read_cmd("baz")];
-
- let result = merge_cells(&new_command, &new_parsed, &active);
- match result {
- MergeResult::NewCell(cell) => match cell {
- HistoryCell::Exec(ExecCell {
- command, parsed, ..
- }) => {
- assert_eq!(command, new_command);
- assert_eq!(parsed, new_parsed);
- }
- _ => panic!("expected Exec cell"),
- },
- _ => panic!("expected NewCell"),
- }
- }
-
- #[test]
- fn merges_with_drop_first_true_when_new_len_gt_one() {
- // existing last Read("file.txt"), new starts with same Read then more
- let active = Some(HistoryCell::new_active_exec_command(
- vec!["cat".into(), "file.txt".into()],
- vec![read_cmd("file.txt")],
- ));
- let new_command = vec!["bash".into(), "-lc".into(), "sed -n 1,20p file.txt".into()];
- // Place the duplicate Read as the LAST element to satisfy drop_first condition
- let leading = unknown_cmd("tail -n 20");
- let new_parsed = vec![leading.clone(), read_cmd("file.txt")];
-
- let result = merge_cells(&new_command, &new_parsed, &active);
- match result {
- MergeResult::Merge(cell) => match cell {
- HistoryCell::Exec(ExecCell {
- command, parsed, ..
- }) => {
- assert_eq!(command, new_command);
- // Expect existing parsed + new_parsed[1..]
- assert_eq!(parsed.len(), 2);
- match (&parsed[0], &parsed[1]) {
- (
- ParsedCommand::Read { name, .. },
- ParsedCommand::Read { name: n2, .. },
- ) => {
- assert_eq!(name, "file.txt");
- assert_eq!(n2, "file.txt");
- }
- _ => panic!("unexpected parsed commands"),
- }
- }
- _ => panic!("expected Exec cell"),
- },
- _ => panic!("expected Merge"),
- }
- }
-
- #[test]
- fn merges_without_drop_first_when_last_commands_differ() {
- // existing last Read("file1.txt"), new last Read("file2.txt"); should concatenate
- let active = Some(HistoryCell::new_active_exec_command(
- vec!["cat".into(), "file1.txt".into()],
- vec![read_cmd("file1.txt")],
- ));
- let new_command = vec!["bash".into(), "-lc".into(), "cat file2.txt".into()];
- let t2 = read_cmd("file2.txt");
- let extra = unknown_cmd("echo done");
- let new_parsed = vec![t2.clone(), extra.clone()];
-
- let result = merge_cells(&new_command, &new_parsed, &active);
- match result {
- MergeResult::Merge(cell) => match cell {
- HistoryCell::Exec(ExecCell {
- command, parsed, ..
- }) => {
- assert_eq!(command, new_command);
- assert_eq!(parsed.len(), 3);
- match (&parsed[0], &parsed[1], &parsed[2]) {
- (ParsedCommand::Read { name: n1, .. }, p2, p3) => {
- assert_eq!(n1, "file1.txt");
- assert_eq!(p2, &t2);
- assert_eq!(p3, &extra);
- }
- _ => panic!("unexpected parsed commands"),
- }
- }
- _ => panic!("expected Exec cell"),
- },
- _ => panic!("expected Merge"),
- }
- }
-}
+mod tests;
diff --git a/codex-rs/tui/src/chatwidget/agent.rs b/codex-rs/tui/src/chatwidget/agent.rs
new file mode 100644
index 0000000000..69e1c19c45
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/agent.rs
@@ -0,0 +1,53 @@
+use std::sync::Arc;
+
+use codex_core::codex_wrapper::CodexConversation;
+use codex_core::codex_wrapper::init_codex;
+use codex_core::config::Config;
+use codex_core::protocol::Op;
+use tokio::sync::mpsc::UnboundedSender;
+use tokio::sync::mpsc::unbounded_channel;
+
+use crate::app_event::AppEvent;
+use crate::app_event_sender::AppEventSender;
+
+/// Spawn the agent bootstrapper and op forwarding loop, returning the
+/// `UnboundedSender` used by the UI to submit operations.
+pub(crate) fn spawn_agent(config: Config, app_event_tx: AppEventSender) -> UnboundedSender {
+ let (codex_op_tx, mut codex_op_rx) = unbounded_channel::();
+
+ let app_event_tx_clone = app_event_tx.clone();
+ tokio::spawn(async move {
+ let CodexConversation {
+ codex,
+ session_configured,
+ ..
+ } = match init_codex(config).await {
+ Ok(vals) => vals,
+ Err(e) => {
+ // TODO: surface this error to the user.
+ tracing::error!("failed to initialize codex: {e}");
+ return;
+ }
+ };
+
+ // Forward the captured `SessionInitialized` event that was consumed
+ // inside `init_codex()` so it can be rendered in the UI.
+ app_event_tx_clone.send(AppEvent::CodexEvent(session_configured.clone()));
+ let codex = Arc::new(codex);
+ let codex_clone = codex.clone();
+ tokio::spawn(async move {
+ while let Some(op) = codex_op_rx.recv().await {
+ let id = codex_clone.submit(op).await;
+ if let Err(e) = id {
+ tracing::error!("failed to submit op: {e}");
+ }
+ }
+ });
+
+ while let Ok(event) = codex.next_event().await {
+ app_event_tx_clone.send(AppEvent::CodexEvent(event));
+ }
+ });
+
+ codex_op_tx
+}
diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs
new file mode 100644
index 0000000000..40fecb72f6
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/interrupts.rs
@@ -0,0 +1,89 @@
+use std::collections::VecDeque;
+
+use codex_core::protocol::ApplyPatchApprovalRequestEvent;
+use codex_core::protocol::ExecApprovalRequestEvent;
+use codex_core::protocol::ExecCommandBeginEvent;
+use codex_core::protocol::ExecCommandEndEvent;
+use codex_core::protocol::McpToolCallBeginEvent;
+use codex_core::protocol::McpToolCallEndEvent;
+use codex_core::protocol::PatchApplyEndEvent;
+
+use super::ChatWidget;
+
+#[derive(Debug)]
+pub(crate) enum QueuedInterrupt {
+ ExecApproval(String, ExecApprovalRequestEvent),
+ ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent),
+ ExecBegin(ExecCommandBeginEvent),
+ ExecEnd(ExecCommandEndEvent),
+ McpBegin(McpToolCallBeginEvent),
+ McpEnd(McpToolCallEndEvent),
+ PatchEnd(PatchApplyEndEvent),
+}
+
+#[derive(Default)]
+pub(crate) struct InterruptManager {
+ queue: VecDeque,
+}
+
+impl InterruptManager {
+ pub(crate) fn new() -> Self {
+ Self {
+ queue: VecDeque::new(),
+ }
+ }
+
+ #[inline]
+ pub(crate) fn is_empty(&self) -> bool {
+ self.queue.is_empty()
+ }
+
+ pub(crate) fn push_exec_approval(&mut self, id: String, ev: ExecApprovalRequestEvent) {
+ self.queue.push_back(QueuedInterrupt::ExecApproval(id, ev));
+ }
+
+ pub(crate) fn push_apply_patch_approval(
+ &mut self,
+ id: String,
+ ev: ApplyPatchApprovalRequestEvent,
+ ) {
+ self.queue
+ .push_back(QueuedInterrupt::ApplyPatchApproval(id, ev));
+ }
+
+ pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) {
+ self.queue.push_back(QueuedInterrupt::ExecBegin(ev));
+ }
+
+ pub(crate) fn push_exec_end(&mut self, ev: ExecCommandEndEvent) {
+ self.queue.push_back(QueuedInterrupt::ExecEnd(ev));
+ }
+
+ pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) {
+ self.queue.push_back(QueuedInterrupt::McpBegin(ev));
+ }
+
+ pub(crate) fn push_mcp_end(&mut self, ev: McpToolCallEndEvent) {
+ self.queue.push_back(QueuedInterrupt::McpEnd(ev));
+ }
+
+ pub(crate) fn push_patch_end(&mut self, ev: PatchApplyEndEvent) {
+ self.queue.push_back(QueuedInterrupt::PatchEnd(ev));
+ }
+
+ pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget<'_>) {
+ while let Some(q) = self.queue.pop_front() {
+ match q {
+ QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev),
+ QueuedInterrupt::ApplyPatchApproval(id, ev) => {
+ chat.handle_apply_patch_approval_now(id, ev)
+ }
+ QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev),
+ QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev),
+ QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev),
+ QueuedInterrupt::McpEnd(ev) => chat.handle_mcp_end_now(ev),
+ QueuedInterrupt::PatchEnd(ev) => chat.handle_patch_apply_end_now(ev),
+ }
+ }
+ }
+}
diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs
new file mode 100644
index 0000000000..e1135e2403
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/tests.rs
@@ -0,0 +1,834 @@
+#![allow(clippy::unwrap_used, clippy::expect_used, unnameable_test_items)]
+
+use super::*;
+use crate::app_event::AppEvent;
+use crate::app_event_sender::AppEventSender;
+use codex_core::config::Config;
+use codex_core::config::ConfigOverrides;
+use codex_core::config::ConfigToml;
+use codex_core::plan_tool::PlanItemArg;
+use codex_core::plan_tool::StepStatus;
+use codex_core::plan_tool::UpdatePlanArgs;
+use codex_core::protocol::AgentMessageDeltaEvent;
+use codex_core::protocol::AgentMessageEvent;
+use codex_core::protocol::AgentReasoningDeltaEvent;
+use codex_core::protocol::ApplyPatchApprovalRequestEvent;
+use codex_core::protocol::Event;
+use codex_core::protocol::EventMsg;
+use codex_core::protocol::FileChange;
+use codex_core::protocol::PatchApplyBeginEvent;
+use codex_core::protocol::PatchApplyEndEvent;
+use codex_core::protocol::TaskCompleteEvent;
+use crossterm::event::KeyCode;
+use crossterm::event::KeyEvent;
+use crossterm::event::KeyModifiers;
+use pretty_assertions::assert_eq;
+use std::fs::File;
+use std::io::BufRead;
+use std::io::BufReader;
+use std::io::Read;
+use std::path::PathBuf;
+use std::sync::mpsc::channel;
+use tokio::sync::mpsc::unbounded_channel;
+
+fn test_config() -> Config {
+ // Use base defaults to avoid depending on host state.
+ codex_core::config::Config::load_from_base_config_with_overrides(
+ ConfigToml::default(),
+ ConfigOverrides::default(),
+ std::env::temp_dir(),
+ )
+ .expect("config")
+}
+
+#[test]
+fn final_answer_without_newline_is_flushed_immediately() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+
+ // Set up a VT100 test terminal to capture ANSI visual output
+ let width: u16 = 80;
+ let height: u16 = 2000;
+ let viewport = ratatui::layout::Rect::new(0, height - 1, width, 1);
+ let backend = ratatui::backend::TestBackend::new(width, height);
+ let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
+ .expect("failed to construct terminal");
+ terminal.set_viewport_area(viewport);
+
+ // Simulate a streaming answer without any newline characters.
+ chat.handle_codex_event(Event {
+ id: "sub-a".into(),
+ msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
+ delta: "Hi! How can I help with codex-rs or anything else today?".into(),
+ }),
+ });
+
+ // Now simulate the final AgentMessage which should flush the pending line immediately.
+ chat.handle_codex_event(Event {
+ id: "sub-a".into(),
+ msg: EventMsg::AgentMessage(AgentMessageEvent {
+ message: "Hi! How can I help with codex-rs or anything else today?".into(),
+ }),
+ });
+
+ // Drain history insertions and verify the final line is present.
+ let cells = drain_insert_history(&rx);
+ assert!(
+ cells.iter().any(|lines| {
+ let s = lines
+ .iter()
+ .flat_map(|l| l.spans.iter())
+ .map(|sp| sp.content.clone())
+ .collect::();
+ s.contains("codex")
+ }),
+ "expected 'codex' header to be emitted",
+ );
+ let found_final = cells.iter().any(|lines| {
+ let s = lines
+ .iter()
+ .flat_map(|l| l.spans.iter())
+ .map(|sp| sp.content.clone())
+ .collect::();
+ s.contains("Hi! How can I help with codex-rs or anything else today?")
+ });
+ assert!(
+ found_final,
+ "expected final answer text to be flushed to history"
+ );
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn helpers_are_available_and_do_not_panic() {
+ let (tx_raw, _rx) = channel::();
+ let tx = AppEventSender::new(tx_raw);
+ let cfg = test_config();
+ let mut w = ChatWidget::new(cfg, tx, None, Vec::new(), false);
+ // Basic construction sanity.
+ let _ = &mut w;
+}
+
+// --- Helpers for tests that need direct construction and event draining ---
+fn make_chatwidget_manual() -> (
+ ChatWidget<'static>,
+ std::sync::mpsc::Receiver,
+ tokio::sync::mpsc::UnboundedReceiver,
+) {
+ let (tx_raw, rx) = channel::();
+ let app_event_tx = AppEventSender::new(tx_raw);
+ let (op_tx, op_rx) = unbounded_channel::();
+ let cfg = test_config();
+ let bottom = BottomPane::new(BottomPaneParams {
+ app_event_tx: app_event_tx.clone(),
+ has_input_focus: true,
+ enhanced_keys_supported: false,
+ });
+ let widget = ChatWidget {
+ app_event_tx,
+ codex_op_tx: op_tx,
+ bottom_pane: bottom,
+ active_exec_cell: None,
+ config: cfg.clone(),
+ initial_user_message: None,
+ total_token_usage: TokenUsage::default(),
+ last_token_usage: TokenUsage::default(),
+ stream: StreamController::new(cfg),
+ last_stream_kind: None,
+ running_commands: HashMap::new(),
+ task_complete_pending: false,
+ interrupts: InterruptManager::new(),
+ needs_redraw: false,
+ };
+ (widget, rx, op_rx)
+}
+
+fn drain_insert_history(
+ rx: &std::sync::mpsc::Receiver,
+) -> Vec>> {
+ let mut out = Vec::new();
+ while let Ok(ev) = rx.try_recv() {
+ if let AppEvent::InsertHistory(lines) = ev {
+ out.push(lines);
+ }
+ }
+ out
+}
+
+fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String {
+ let mut s = String::new();
+ for line in lines {
+ for span in &line.spans {
+ s.push_str(&span.content);
+ }
+ s.push('\n');
+ }
+ s
+}
+
+fn open_fixture(name: &str) -> std::fs::File {
+ // 1) Prefer fixtures within this crate
+ {
+ let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ p.push("tests");
+ p.push("fixtures");
+ p.push(name);
+ if let Ok(f) = File::open(&p) {
+ return f;
+ }
+ }
+ // 2) Fallback to parent (workspace root)
+ {
+ let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ p.push("..");
+ p.push(name);
+ if let Ok(f) = File::open(&p) {
+ return f;
+ }
+ }
+ // 3) Last resort: CWD
+ File::open(name).expect("open fixture file")
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn binary_size_transcript_matches_ideal_fixture() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+
+ // Set up a VT100 test terminal to capture ANSI visual output
+ let width: u16 = 80;
+ let height: u16 = 2000;
+ let viewport = ratatui::layout::Rect::new(0, height - 1, width, 1);
+ let backend = ratatui::backend::TestBackend::new(width, height);
+ let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
+ .expect("failed to construct terminal");
+ terminal.set_viewport_area(viewport);
+
+ // Replay the recorded session into the widget and collect transcript
+ let file = open_fixture("binary-size-log.jsonl");
+ let reader = BufReader::new(file);
+ let mut transcript = String::new();
+ let mut ansi: Vec = Vec::new();
+
+ for line in reader.lines() {
+ let line = line.expect("read line");
+ if line.trim().is_empty() || line.starts_with('#') {
+ continue;
+ }
+ let Ok(v): Result = serde_json::from_str(&line) else {
+ continue;
+ };
+ let Some(dir) = v.get("dir").and_then(|d| d.as_str()) else {
+ continue;
+ };
+ if dir != "to_tui" {
+ continue;
+ }
+ let Some(kind) = v.get("kind").and_then(|k| k.as_str()) else {
+ continue;
+ };
+
+ match kind {
+ "codex_event" => {
+ if let Some(payload) = v.get("payload") {
+ let ev: Event = serde_json::from_value(payload.clone()).expect("parse");
+ chat.handle_codex_event(ev);
+ while let Ok(app_ev) = rx.try_recv() {
+ if let AppEvent::InsertHistory(lines) = app_ev {
+ transcript.push_str(&lines_to_single_string(&lines));
+ crate::insert_history::insert_history_lines_to_writer(
+ &mut terminal,
+ &mut ansi,
+ lines,
+ );
+ }
+ }
+ }
+ }
+ "app_event" => {
+ if let Some(variant) = v.get("variant").and_then(|s| s.as_str()) {
+ if variant == "CommitTick" {
+ chat.on_commit_tick();
+ while let Ok(app_ev) = rx.try_recv() {
+ if let AppEvent::InsertHistory(lines) = app_ev {
+ transcript.push_str(&lines_to_single_string(&lines));
+ crate::insert_history::insert_history_lines_to_writer(
+ &mut terminal,
+ &mut ansi,
+ lines,
+ );
+ }
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ // Read the ideal fixture as-is
+ let mut f = open_fixture("ideal-binary-response.txt");
+ let mut ideal = String::new();
+ f.read_to_string(&mut ideal)
+ .expect("read ideal-binary-response.txt");
+ // Normalize line endings for Windows vs. Unix checkouts
+ let ideal = ideal.replace("\r\n", "\n");
+
+ // Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line
+ // and drop trailing empty lines so the shape matches the ideal fixture exactly.
+ let mut parser = vt100::Parser::new(height, width, 0);
+ parser.process(&ansi);
+ let mut lines: Vec = Vec::with_capacity(height as usize);
+ for row in 0..height {
+ let mut s = String::with_capacity(width as usize);
+ for col in 0..width {
+ if let Some(cell) = parser.screen().cell(row, col) {
+ if let Some(ch) = cell.contents().chars().next() {
+ s.push(ch);
+ } else {
+ s.push(' ');
+ }
+ } else {
+ s.push(' ');
+ }
+ }
+ // Trim trailing spaces to match plain text fixture
+ lines.push(s.trim_end().to_string());
+ }
+ while lines.last().is_some_and(|l| l.is_empty()) {
+ lines.pop();
+ }
+ // Compare only after the last session banner marker, and start at the next 'thinking' line.
+ const MARKER_PREFIX: &str = ">_ You are using OpenAI Codex in ";
+ let last_marker_line_idx = lines
+ .iter()
+ .rposition(|l| l.starts_with(MARKER_PREFIX))
+ .expect("marker not found in visible output");
+ let thinking_line_idx = (last_marker_line_idx + 1..lines.len())
+ .find(|&idx| lines[idx].trim_start() == "thinking")
+ .expect("no 'thinking' line found after marker");
+
+ let mut compare_lines: Vec = Vec::new();
+ // Ensure the first line is exactly 'thinking' without leading spaces to match the fixture
+ compare_lines.push(lines[thinking_line_idx].trim_start().to_string());
+ compare_lines.extend(lines[(thinking_line_idx + 1)..].iter().cloned());
+ let visible_after = compare_lines.join("\n");
+
+ // Optionally update the fixture when env var is set
+ if std::env::var("UPDATE_IDEAL").as_deref() == Ok("1") {
+ let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ p.push("tests");
+ p.push("fixtures");
+ p.push("ideal-binary-response.txt");
+ std::fs::write(&p, &visible_after).expect("write updated ideal fixture");
+ return;
+ }
+
+ // Exact equality with pretty diff on failure
+ assert_eq!(visible_after, ideal);
+}
+
+#[test]
+fn final_longer_answer_after_single_char_delta_is_complete() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+
+ // Simulate a stray delta without newline (e.g., punctuation).
+ chat.handle_codex_event(Event {
+ id: "sub-x".into(),
+ msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: "?".into() }),
+ });
+
+ // Now send the full final answer with no newline.
+ let full = "Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?";
+ chat.handle_codex_event(Event {
+ id: "sub-x".into(),
+ msg: EventMsg::AgentMessage(AgentMessageEvent {
+ message: full.into(),
+ }),
+ });
+
+ // Drain and assert the full message appears in history.
+ let cells = drain_insert_history(&rx);
+ let mut found = false;
+ for lines in &cells {
+ let s = lines
+ .iter()
+ .flat_map(|l| l.spans.iter())
+ .map(|sp| sp.content.clone())
+ .collect::();
+ if s.contains(full) {
+ found = true;
+ break;
+ }
+ }
+ assert!(
+ found,
+ "expected full final message to be flushed to history, cells={:?}",
+ cells.len()
+ );
+}
+
+#[test]
+fn apply_patch_events_emit_history_cells() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+
+ // 1) Approval request -> proposed patch summary cell
+ let mut changes = HashMap::new();
+ changes.insert(
+ PathBuf::from("foo.txt"),
+ FileChange::Add {
+ content: "hello\n".to_string(),
+ },
+ );
+ let ev = ApplyPatchApprovalRequestEvent {
+ call_id: "c1".into(),
+ changes,
+ reason: None,
+ grant_root: None,
+ };
+ chat.handle_codex_event(Event {
+ id: "s1".into(),
+ msg: EventMsg::ApplyPatchApprovalRequest(ev),
+ });
+ let cells = drain_insert_history(&rx);
+ assert!(!cells.is_empty(), "expected pending patch cell to be sent");
+ let blob = lines_to_single_string(cells.last().unwrap());
+ assert!(
+ blob.contains("proposed patch"),
+ "missing proposed patch header: {blob:?}"
+ );
+
+ // 2) Begin apply -> applying patch cell
+ let mut changes2 = HashMap::new();
+ changes2.insert(
+ PathBuf::from("foo.txt"),
+ FileChange::Add {
+ content: "hello\n".to_string(),
+ },
+ );
+ let begin = PatchApplyBeginEvent {
+ call_id: "c1".into(),
+ auto_approved: true,
+ changes: changes2,
+ };
+ chat.handle_codex_event(Event {
+ id: "s1".into(),
+ msg: EventMsg::PatchApplyBegin(begin),
+ });
+ let cells = drain_insert_history(&rx);
+ assert!(!cells.is_empty(), "expected applying patch cell to be sent");
+ let blob = lines_to_single_string(cells.last().unwrap());
+ assert!(
+ blob.contains("Applying patch"),
+ "missing applying patch header: {blob:?}"
+ );
+
+ // 3) End apply success -> success cell
+ let end = PatchApplyEndEvent {
+ call_id: "c1".into(),
+ stdout: "ok\n".into(),
+ stderr: String::new(),
+ success: true,
+ };
+ chat.handle_codex_event(Event {
+ id: "s1".into(),
+ msg: EventMsg::PatchApplyEnd(end),
+ });
+ let cells = drain_insert_history(&rx);
+ assert!(!cells.is_empty(), "expected applied patch cell to be sent");
+ let blob = lines_to_single_string(cells.last().unwrap());
+ assert!(
+ blob.contains("Applied patch"),
+ "missing applied patch header: {blob:?}"
+ );
+}
+
+#[test]
+fn apply_patch_approval_sends_op_with_submission_id() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+ // Simulate receiving an approval request with a distinct submission id and call id
+ let mut changes = HashMap::new();
+ changes.insert(
+ PathBuf::from("file.rs"),
+ FileChange::Add {
+ content: "fn main(){}\n".into(),
+ },
+ );
+ let ev = ApplyPatchApprovalRequestEvent {
+ call_id: "call-999".into(),
+ changes,
+ reason: None,
+ grant_root: None,
+ };
+ chat.handle_codex_event(Event {
+ id: "sub-123".into(),
+ msg: EventMsg::ApplyPatchApprovalRequest(ev),
+ });
+
+ // Approve via key press 'y'
+ chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
+
+ // Expect a CodexOp with PatchApproval carrying the submission id, not call id
+ let mut found = false;
+ while let Ok(app_ev) = rx.try_recv() {
+ if let AppEvent::CodexOp(Op::PatchApproval { id, decision }) = app_ev {
+ assert_eq!(id, "sub-123");
+ assert!(matches!(
+ decision,
+ codex_core::protocol::ReviewDecision::Approved
+ ));
+ found = true;
+ break;
+ }
+ }
+ assert!(found, "expected PatchApproval op to be sent");
+}
+
+#[test]
+fn apply_patch_full_flow_integration_like() {
+ let (mut chat, rx, mut op_rx) = make_chatwidget_manual();
+
+ // 1) Backend requests approval
+ let mut changes = HashMap::new();
+ changes.insert(
+ PathBuf::from("pkg.rs"),
+ FileChange::Add { content: "".into() },
+ );
+ chat.handle_codex_event(Event {
+ id: "sub-xyz".into(),
+ msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
+ call_id: "call-1".into(),
+ changes,
+ reason: None,
+ grant_root: None,
+ }),
+ });
+
+ // 2) User approves via 'y' and App receives a CodexOp
+ chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
+ let mut maybe_op: Option