Compare commits

...

44 Commits

Author SHA1 Message Date
Ahmed Ibrahim
5bbf94bd93 subagents 2025-08-23 11:31:52 -07:00
Ahmed Ibrahim
76c209d78c progress 2025-08-23 11:07:02 -07:00
Ahmed Ibrahim
d2fe780280 Merge branch 'aggregate-out-err' of https://github.com/openai/codex into aggregate-out-err 2025-08-23 09:47:51 -07:00
Ahmed Ibrahim
e172014062 commit 2025-08-23 09:46:54 -07:00
Ahmed Ibrahim
d4cb5fcdbd Update codex-rs/core/src/codex.rs
Co-authored-by: Gabriel Peal <gpeal@users.noreply.github.com>
2025-08-23 09:01:44 -07:00
Ahmed Ibrahim
549a5de99a Update codex-rs/core/src/codex.rs
Co-authored-by: Gabriel Peal <gpeal@users.noreply.github.com>
2025-08-23 09:01:15 -07:00
Ahmed Ibrahim
e0418bf4b9 rust 2025-08-22 20:08:51 -07:00
Ahmed Ibrahim
07c8dbc94d rust 2025-08-22 20:05:52 -07:00
Ahmed Ibrahim
bb9be76328 rust 2025-08-22 20:02:28 -07:00
Ahmed Ibrahim
b277a654fa rust 2025-08-22 19:54:58 -07:00
Ahmed Ibrahim
8752a9b049 rust 2025-08-22 19:51:45 -07:00
Ahmed Ibrahim
5af5856848 rust 2025-08-22 19:41:13 -07:00
Ahmed Ibrahim
16882fa090 rust 2025-08-22 19:38:32 -07:00
Ahmed Ibrahim
366d0738a4 rust 2025-08-22 19:35:02 -07:00
Ahmed Ibrahim
032f14aec8 aggregate-out-err 2025-08-22 18:13:37 -07:00
Ahmed Ibrahim
6ef0c2e8e7 aggregate-out-err 2025-08-22 18:13:03 -07:00
Ahmed Ibrahim
5db76dc66e dead code 2025-08-22 18:12:12 -07:00
Ahmed Ibrahim
1a04fa0379 cap to full 2025-08-22 18:08:52 -07:00
Ahmed Ibrahim
a5c14eb8c0 test 2025-08-22 18:01:06 -07:00
Ahmed Ibrahim
cd610fd409 tests 2025-08-22 18:00:11 -07:00
Ahmed Ibrahim
35130cf21b send-aggregated output 2025-08-22 17:58:33 -07:00
Ahmed Ibrahim
311ad0ce26 fork conversation from a previous message (#2575)
This can be the underlying logic in order to start a conversation from a
previous message. will need some love in the UI.

Base for building this: #2588
2025-08-22 17:06:09 -07:00
Jeremy Rose
5fa7d46ddf tui: fix resize on wezterm (#2600)
WezTerm doesn't respond to cursor queries during a synchronized update,
so resizing was broken there.
2025-08-22 16:33:18 -07:00
Jeremy Rose
d994019f3f tui: coalesce command output; show unabridged commands in transcript (#2590)
https://github.com/user-attachments/assets/effec7c7-732a-4b61-a2ae-3cb297b6b19b
2025-08-22 16:32:31 -07:00
Jeremy Rose
6de9541f0a tui: open transcript mode at the bottom (#2592)
this got lost when we switched /diff to use the pager.
2025-08-22 16:06:41 -07:00
wkrettek
85099017fd Fix typo in AGENTS.md (#2518)
- Change `examole` to `example`
2025-08-22 16:05:39 -07:00
dependabot[bot]
a5b2ebb49b chore(deps): bump reqwest from 0.12.22 to 0.12.23 in /codex-rs (#2492)
Bumps [reqwest](https://github.com/seanmonstar/reqwest) from 0.12.22 to
0.12.23.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/seanmonstar/reqwest/releases">reqwest's
releases</a>.</em></p>
<blockquote>
<h2>v0.12.23</h2>
<h2>tl;dr</h2>
<ul>
<li>🇺🇩🇸 Add <code>ClientBuilder::unix_socket(path)</code> option that
will force all requests over that Unix Domain Socket.</li>
<li>🔁 Add <code>ClientBuilder::retries(policy)</code> and
<code>reqwest::retry::Builder</code> to configure <a
href="https://seanmonstar.com/blog/reqwest-retries/">automatic
retries</a>.</li>
<li>Add <code>ClientBuilder::dns_resolver2()</code> with more ergonomic
argument bounds, allowing more resolver implementations.</li>
<li>Add <code>http3_*</code> options to
<code>blocking::ClientBuilder</code>.</li>
<li>Fix default TCP timeout values to enabled and faster.</li>
<li>Fix SOCKS proxies to default to port 1080</li>
<li>(wasm) Add cache methods to <code>RequestBuilder</code>.</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Minimize package size by <a
href="https://github.com/weiznich"><code>@​weiznich</code></a> in <a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2759">seanmonstar/reqwest#2759</a></li>
<li>chore(dev-dependencies): bump brotli by <a
href="https://github.com/seanmonstar"><code>@​seanmonstar</code></a> in
<a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2760">seanmonstar/reqwest#2760</a></li>
<li>upgrade hickory-dns to 0.25 by <a
href="https://github.com/seanmonstar"><code>@​seanmonstar</code></a> in
<a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2761">seanmonstar/reqwest#2761</a></li>
<li>Re-expose http3 options in blocking::clientBuilder by <a
href="https://github.com/ducaale"><code>@​ducaale</code></a> in <a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2770">seanmonstar/reqwest#2770</a></li>
<li>fix(proxy): restore default port 1080 for SOCKS proxies without
explicit port by <a
href="https://github.com/0x676e67"><code>@​0x676e67</code></a> in <a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2771">seanmonstar/reqwest#2771</a></li>
<li>ci: use msrv-aware cargo in msrv job by <a
href="https://github.com/seanmonstar"><code>@​seanmonstar</code></a> in
<a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2779">seanmonstar/reqwest#2779</a></li>
<li>feat: add request cache option for wasm by <a
href="https://github.com/Spxg"><code>@​Spxg</code></a> in <a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2775">seanmonstar/reqwest#2775</a></li>
<li>style(client): use <code>std::task::ready!</code> macro to simplify
<code>Poll</code> branch match by <a
href="https://github.com/0x676e67"><code>@​0x676e67</code></a> in <a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2781">seanmonstar/reqwest#2781</a></li>
<li>fix: add default tcp keepalive and user_timeout values by <a
href="https://github.com/seanmonstar"><code>@​seanmonstar</code></a> in
<a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2780">seanmonstar/reqwest#2780</a></li>
<li>feat: add unix_socket() option to client builder by <a
href="https://github.com/seanmonstar"><code>@​seanmonstar</code></a> in
<a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2624">seanmonstar/reqwest#2624</a></li>
<li>Add retry policies by <a
href="https://github.com/seanmonstar"><code>@​seanmonstar</code></a> in
<a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2763">seanmonstar/reqwest#2763</a></li>
<li>refactor: loosen retry <code>for_host</code> parameter bounds by <a
href="https://github.com/Enduriel"><code>@​Enduriel</code></a> in <a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2792">seanmonstar/reqwest#2792</a></li>
<li>feat: add dns_resolver2 that is more ergonomic and flexible by <a
href="https://github.com/seanmonstar"><code>@​seanmonstar</code></a> in
<a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2793">seanmonstar/reqwest#2793</a></li>
<li>Prepare v0.12.23 by <a
href="https://github.com/seanmonstar"><code>@​seanmonstar</code></a> in
<a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2795">seanmonstar/reqwest#2795</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/weiznich"><code>@​weiznich</code></a>
made their first contribution in <a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2759">seanmonstar/reqwest#2759</a></li>
<li><a href="https://github.com/Spxg"><code>@​Spxg</code></a> made their
first contribution in <a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2775">seanmonstar/reqwest#2775</a></li>
<li><a href="https://github.com/Enduriel"><code>@​Enduriel</code></a>
made their first contribution in <a
href="https://redirect.github.com/seanmonstar/reqwest/pull/2792">seanmonstar/reqwest#2792</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seanmonstar/reqwest/compare/v0.12.22...v0.12.23">https://github.com/seanmonstar/reqwest/compare/v0.12.22...v0.12.23</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md">reqwest's
changelog</a>.</em></p>
<blockquote>
<h2>v0.12.23</h2>
<ul>
<li>Add <code>ClientBuilder::unix_socket(path)</code> option that will
force all requests over that Unix Domain Socket.</li>
<li>Add <code>ClientBuilder::retries(policy)</code> and
<code>reqwest::retry::Builder</code> to configure automatic
retries.</li>
<li>Add <code>ClientBuilder::dns_resolver2()</code> with more ergonomic
argument bounds, allowing more resolver implementations.</li>
<li>Add <code>http3_*</code> options to
<code>blocking::ClientBuilder</code>.</li>
<li>Fix default TCP timeout values to enabled and faster.</li>
<li>Fix SOCKS proxies to default to port 1080</li>
<li>(wasm) Add cache methods to <code>RequestBuilder</code>.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="ae7375b547"><code>ae7375b</code></a>
v0.12.23</li>
<li><a
href="9aacdc1e2b"><code>9aacdc1</code></a>
feat: add dns_resolver2 that is more ergonomic and flexible (<a
href="https://redirect.github.com/seanmonstar/reqwest/issues/2793">#2793</a>)</li>
<li><a
href="221be11bc6"><code>221be11</code></a>
refactor: loosen retry <code>for_host</code> parameter bounds (<a
href="https://redirect.github.com/seanmonstar/reqwest/issues/2792">#2792</a>)</li>
<li><a
href="acd1b05994"><code>acd1b05</code></a>
feat: add reqwest::retry policies (<a
href="https://redirect.github.com/seanmonstar/reqwest/issues/2763">#2763</a>)</li>
<li><a
href="54b6022b0f"><code>54b6022</code></a>
feat: add <code>ClientBuilder::unix_socket()</code> option (<a
href="https://redirect.github.com/seanmonstar/reqwest/issues/2624">#2624</a>)</li>
<li><a
href="6358cefd24"><code>6358cef</code></a>
fix: add default tcp keepalive and user_timeout values (<a
href="https://redirect.github.com/seanmonstar/reqwest/issues/2780">#2780</a>)</li>
<li><a
href="21226a5bc3"><code>21226a5</code></a>
style(client): use <code>std::task::ready!</code> macro to simplify Poll
branch matching...</li>
<li><a
href="82086e796b"><code>82086e7</code></a>
feat: add request cache options for wasm (<a
href="https://redirect.github.com/seanmonstar/reqwest/issues/2775">#2775</a>)</li>
<li><a
href="2a0f7a3670"><code>2a0f7a3</code></a>
ci: use msrv-aware cargo in msrv job (<a
href="https://redirect.github.com/seanmonstar/reqwest/issues/2779">#2779</a>)</li>
<li><a
href="f1868036ca"><code>f186803</code></a>
fix(proxy): restore default port 1080 for SOCKS proxies without explicit
port...</li>
<li>Additional commits viewable in <a
href="https://github.com/seanmonstar/reqwest/compare/v0.12.22...v0.12.23">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=reqwest&package-manager=cargo&previous-version=0.12.22&new-version=0.12.23)](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)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

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 <dependency name> 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)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-22 15:57:33 -07:00
Gabriel Peal
697c7cf4bf Fix flakiness in shell command approval test (#2547)
## Summary
- read the shell exec approval request's actual id instead of assuming
it is always 0
- use that id when validating and responding in the test

## Testing
- `cargo test -p codex-mcp-server
test_shell_command_approval_triggers_elicitation`

------
https://chatgpt.com/codex/tasks/task_i_68a6ab9c732c832c81522cbf11812be0
2025-08-22 18:46:35 -04:00
dependabot[bot]
34ac698bef chore(deps): bump serde_json from 1.0.142 to 1.0.143 in /codex-rs (#2498)
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.142 to
1.0.143.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/serde-rs/json/releases">serde_json's
releases</a>.</em></p>
<blockquote>
<h2>v1.0.143</h2>
<ul>
<li>Implement Clone and Debug for serde_json::Map iterators (<a
href="https://redirect.github.com/serde-rs/json/issues/1264">#1264</a>,
thanks <a
href="https://github.com/xlambein"><code>@​xlambein</code></a>)</li>
<li>Implement Default for CompactFormatter (<a
href="https://redirect.github.com/serde-rs/json/issues/1268">#1268</a>,
thanks <a href="https://github.com/SOF3"><code>@​SOF3</code></a>)</li>
<li>Implement FromStr for serde_json::Map (<a
href="https://redirect.github.com/serde-rs/json/issues/1271">#1271</a>,
thanks <a
href="https://github.com/mickvangelderen"><code>@​mickvangelderen</code></a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="10102c49bf"><code>10102c4</code></a>
Release 1.0.143</li>
<li><a
href="2a5b85312c"><code>2a5b853</code></a>
Replace super::super with absolute path within crate</li>
<li><a
href="447170bd38"><code>447170b</code></a>
Merge pull request 1271 from
mickvangelderen/mick/impl-from-str-for-map</li>
<li><a
href="ec190d6dfd"><code>ec190d6</code></a>
Merge pull request <a
href="https://redirect.github.com/serde-rs/json/issues/1264">#1264</a>
from xlambein/master</li>
<li><a
href="8be664752f"><code>8be6647</code></a>
Merge pull request <a
href="https://redirect.github.com/serde-rs/json/issues/1268">#1268</a>
from SOF3/compact-default</li>
<li><a
href="ba5b3cccea"><code>ba5b3cc</code></a>
Revert &quot;Pin nightly toolchain used for miri job&quot;</li>
<li><a
href="fd35a02901"><code>fd35a02</code></a>
Implement FromStr for Map&lt;String, Value&gt;</li>
<li><a
href="bea0fe6b3e"><code>bea0fe6</code></a>
Implement Default for CompactFormatter</li>
<li><a
href="0c0e9f6bfa"><code>0c0e9f6</code></a>
Add Clone and Debug impls to map iterators</li>
<li>See full diff in <a
href="https://github.com/serde-rs/json/compare/v1.0.142...v1.0.143">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=serde_json&package-manager=cargo&previous-version=1.0.142&new-version=1.0.143)](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)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

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 <dependency name> 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)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-22 15:45:14 -07:00
Ahmed Ibrahim
097782c775 Move models.rs to protocol (#2595)
Moving models.rs to protocol so we can use them in `Codex` operations
2025-08-22 22:18:54 +00:00
Michael Bolin
8ba8089592 fix: prefer sending MCP structuredContent as the function call response, if available (#2594)
Prior to this change, when we got a `CallToolResult` from an MCP server,
we JSON-serialized its `content` field as the `content` to send back to
the model as part of the function call output that we send back to the
model. This meant that we were dropping the `structuredContent` on the
floor.

Though reading
https://modelcontextprotocol.io/specification/2025-06-18/schema#tool, it
appears that if `outputSchema` is specified, then `structuredContent`
should be set, which seems to be a "higher-fidelity" response to the
function call. This PR updates our handling of `CallToolResult` to
prefer using the JSON-serialization of `structuredContent`, if present,
using `content` as a fallback.

Also, it appears that the sense of `success` was inverted prior to this
PR!
2025-08-22 14:10:18 -07:00
Jeremy Rose
57c498159a test: simplify tests in config.rs (#2586)
this is much easier to read, thanks @bolinfest for the suggestion.
2025-08-22 14:04:21 -07:00
Jeremy Rose
bbf42f4e12 improve performance of 'cargo test -p codex-tui' (#2593)
before:

```
$ time cargo test -p codex-tui -q
[...]
cargo test -p codex-tui -q  39.89s user 10.77s system 98% cpu 51.328 total
```

after:

```
$ time cargo test -p codex-tui -q
[...]
cargo test -p codex-tui -q  1.37s user 0.64s system 29% cpu 6.699 total
```

the major offenders were the textarea fuzz test and the custom_terminal
doctests. (i think the doctests were being recompiled every time which
made them extra slow?)
2025-08-22 14:03:58 -07:00
Dylan
6f0b499594 [config] Detect git worktrees for project trust (#2585)
## Summary
When resolving our current directory as a project, we want to be a
little bit more clever:
1. If we're in a sub-directory of a git repo, resolve our project
against the root of the git repo
2. If we're in a git worktree, resolve the project against the root of
the git repo

## Testing
- [x] Added unit tests
- [x] Confirmed locally with a git worktree (the one i was using for
this feature)
2025-08-22 13:54:51 -07:00
Dylan
236c4f76a6 [apply_patch] freeform apply_patch tool (#2576)
## Summary
GPT-5 introduced the concept of [custom
tools](https://platform.openai.com/docs/guides/function-calling#custom-tools),
which allow the model to send a raw string result back, simplifying
json-escape issues. We are migrating gpt-5 to use this by default.

However, gpt-oss models do not support custom tools, only normal
functions. So we keep both tool definitions, and provide whichever one
the model family supports.

## Testing
- [x] Tested locally with various models
- [x] Unit tests pass
2025-08-22 13:42:34 -07:00
Eric Traut
dc42ec0eb4 Add AuthManager and enhance GetAuthStatus command (#2577)
This PR adds a central `AuthManager` struct that manages the auth
information used across conversations and the MCP server. Prior to this,
each conversation and the MCP server got their own private snapshots of
the auth information, and changes to one (such as a logout or token
refresh) were not seen by others.

This is especially problematic when multiple instances of the CLI are
run. For example, consider the case where you start CLI 1 and log in to
ChatGPT account X and then start CLI 2 and log out and then log in to
ChatGPT account Y. The conversation in CLI 1 is still using account X,
but if you create a new conversation, it will suddenly (and
unexpectedly) switch to account Y.

With the `AuthManager`, auth information is read from disk at the time
the `ConversationManager` is constructed, and it is cached in memory.
All new conversations use this same auth information, as do any token
refreshes.

The `AuthManager` is also used by the MCP server's GetAuthStatus
command, which now returns the auth method currently used by the MCP
server.

This PR also includes an enhancement to the GetAuthStatus command. It
now accepts two new (optional) input parameters: `include_token` and
`refresh_token`. Callers can use this to request the in-use auth token
and can optionally request to refresh the token.

The PR also adds tests for the login and auth APIs that I recently added
to the MCP server.
2025-08-22 13:10:11 -07:00
Ahmed Ibrahim
cdc77c10fb Fix/tui windows multiline paste (#2544)
Introduce a minimal paste-burst heuristic in the chat composer so Enter
is treated as a newline during paste-like bursts (plain chars arriving
in very short intervals), avoiding premature submit after the first line
on Windows consoles that lack bracketed paste.

- Detect tight sequences of plain Char events; open a short window where
Enter inserts a newline instead of submitting.
- Extend the window on newline to handle blank lines in pasted content.
- No behavior change for terminals that already emit Event::Paste; no
OS/env toggles added.
2025-08-22 12:23:58 -07:00
pap-openai
c5d21a4564 ctrl+v image + @file accepts images (#1695)
allow ctrl+v in TUI for images + @file that are images are appended as
raw files (and read by the model) rather than pasted as a path that
cannot be read by the model.

Re-used components and same interface we're using for copying pasted
content in
72504f1d9c.
@aibrahim-oai as you've implemented this, mind having a look at this
one?


https://github.com/user-attachments/assets/c6c1153b-6b32-4558-b9a2-f8c57d2be710

---------

Co-authored-by: easong-openai <easong@openai.com>
Co-authored-by: Daniel Edrisian <dedrisian@openai.com>
Co-authored-by: Michael Bolin <mbolin@openai.com>
2025-08-22 17:05:43 +00:00
Jeremy Rose
59f6b1654f improve suspend behavior (#2569)
This is a somewhat roundabout way to fix the issue that pressing ^Z
would put the shell prompt in the wrong place (overwriting some of the
status area below the composer). While I'm at it, clean up the suspend
logic and fix some suspend-while-in-alt-screen behavior too.
2025-08-22 09:41:15 -07:00
vjain419
80b00a193e feat(gpt5): add model_verbosity for GPT‑5 via Responses API (#2108)
**Summary**
- Adds `model_verbosity` config (values: low, medium, high).
- Sends `text.verbosity` only for GPT‑5 family models via the Responses
API.
- Updates docs and adds serialization tests.

**Motivation**
- GPT‑5 introduces a verbosity control to steer output length/detail
without pro
mpt surgery.
- Exposing it as a config knob keeps prompts stable and makes behavior
explicit
and repeatable.

**Changes**
- Config:
  - Added `Verbosity` enum (low|medium|high).
- Added optional `model_verbosity` to `ConfigToml`, `Config`, and
`ConfigProfi
le`.
- Request wiring:
  - Extended `ResponsesApiRequest` with optional `text` object.
- Populates `text.verbosity` only when model family is `gpt-5`; omitted
otherw
ise.
- Tests:
- Verifies `text.verbosity` serializes when set and is omitted when not
set.
- Docs:
  - Added “GPT‑5 Verbosity” section in `codex-rs/README.md`.
  - Added `model_verbosity` section to `codex-rs/config.md`.

**Usage**
- In `~/.codex/config.toml`:
  - `model = "gpt-5"`
  - `model_verbosity = "low"` (or `"medium"` default, `"high"`)
- CLI override example:
  - `codex -c model="gpt-5" -c model_verbosity="high"`

**API Impact**
- Requests to GPT‑5 via Responses API include: `text: { verbosity:
"low|medium|h
igh" }` when configured.
- For legacy models or Chat Completions providers, `text` is omitted.

**Backward Compatibility**
- Default behavior unchanged when `model_verbosity` is not set (server
default “
medium”).

**Testing**
- Added unit tests for serialization/omission of `text.verbosity`.
- Ran `cargo fmt` and `cargo test --all-features` (all green).

**Docs**
- `README.md`: new “GPT‑5 Verbosity” note under Config with example.
- `config.md`: new `model_verbosity` section.

**Out of Scope**
- No changes to temperature/top_p or other GPT‑5 parameters.
- No changes to Chat Completions wiring.

**Risks / Notes**
- If OpenAI changes the wire shape for verbosity, we may need to update
`Respons
esApiRequest`.
- Behavior gated to `gpt-5` model family to avoid unexpected effects
elsewhere.

**Checklist**
- [x] Code gated to GPT‑5 family only
- [x] Docs updated (`README.md`, `config.md`)
- [x] Tests added and passing
- [x] Formatting applied

Release note: Add `model_verbosity` config to control GPT‑5 output verbosity via the Responses API (low|medium|high).
2025-08-22 09:12:10 -07:00
Jeremy Rose
76dc3f6054 show diff output in the pager (#2568)
this shows `/diff` output in an overlay like the transcript, instead of
dumping it into history.



https://github.com/user-attachments/assets/48e79b65-7f66-45dd-97b3-d5c627ac7349
2025-08-22 08:24:13 -07:00
Dylan
e4c275d615 [apply-patch] Clean up apply-patch tool definitions (#2539)
## Summary
We've experienced a bit of drift in system prompting for `apply_patch`:
- As pointed out in #2030 , our prettier formatting started altering
prompt.md in a few ways
- We introduced a separate markdown file for apply_patch instructions in
#993, but currently duplicate them in the prompt.md file
- We added a first-class apply_patch tool in #2303, which has yet
another definition

This PR starts to consolidate our logic in a few ways:
- We now only use
`apply_patch_tool_instructions.md](https://github.com/openai/codex/compare/dh--apply-patch-tool-definition?expand=1#diff-d4fffee5f85cb1975d3f66143a379e6c329de40c83ed5bf03ffd3829df985bea)
for system instructions
- We no longer include apply_patch system instructions if the tool is
specified

I'm leaving the definition in openai_tools.rs as duplicated text for now
because we're going to be iterated on the first-class tool soon.

## Testing
- [x] Added integration tests to verify prompt stability
- [x] Tested locally with several different models (gpt-5, gpt-oss,
o4-mini)
2025-08-21 20:07:41 -07:00
Dylan
9f71dcbf57 [shell_tool] Small updates to ensure shell consistency (#2571)
## Summary
Small update to hopefully improve some shell edge cases, and make the
function clearer to the model what is going on. Keeping `timeout` as an
alias means that calls with the previous name will still work.

## Test Plan
- [x] Tested locally, model still works
2025-08-21 19:58:07 -07:00
Jeremy Rose
750ca9e21d core: write explicit [projects] tables for trusted projects (#2523)
all of my trust_level settings in my ~/.codex/config.toml were on one
line.
2025-08-21 13:20:36 -07:00
85 changed files with 4646 additions and 911 deletions

View File

@@ -1,3 +1,7 @@
/codex-cli/dist
/codex-cli/node_modules
pnpm-lock.yaml
prompt.md
*_prompt.md
*_instructions.md

View File

@@ -2,7 +2,7 @@
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`
- Crate names are prefixed with `codex-`. For example, 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.

176
codex-rs/Cargo.lock generated
View File

@@ -186,6 +186,26 @@ version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
[[package]]
name = "arboard"
version = "3.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55f533f8e0af236ffe5eb979b99381df3258853f00ba2e44b6e1955292c75227"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"x11rb",
]
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
@@ -754,6 +774,7 @@ dependencies = [
"codex-arg0",
"codex-common",
"codex-core",
"codex-login",
"codex-ollama",
"codex-protocol",
"core_test_support",
@@ -902,13 +923,16 @@ dependencies = [
name = "codex-protocol"
version = "0.0.0"
dependencies = [
"base64 0.22.1",
"mcp-types",
"mime_guess",
"pretty_assertions",
"serde",
"serde_bytes",
"serde_json",
"strum 0.27.2",
"strum_macros 0.27.2",
"tracing",
"ts-rs",
"uuid",
]
@@ -928,6 +952,7 @@ name = "codex-tui"
version = "0.0.0"
dependencies = [
"anyhow",
"arboard",
"async-stream",
"base64 0.22.1",
"chrono",
@@ -962,6 +987,7 @@ dependencies = [
"strum 0.27.2",
"strum_macros 0.27.2",
"supports-color",
"tempfile",
"textwrap 0.16.2",
"tokio",
"tokio-stream",
@@ -1410,6 +1436,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "dispatch2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.9.1",
"objc2",
]
[[package]]
name = "display_container"
version = "0.9.0"
@@ -1863,6 +1899,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818"
dependencies = [
"libc",
"windows-targets 0.48.5",
]
[[package]]
name = "getopts"
version = "0.2.23"
@@ -3059,6 +3105,42 @@ dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
dependencies = [
"bitflags 2.9.1",
"objc2",
"objc2-core-graphics",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
dependencies = [
"bitflags 2.9.1",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4"
dependencies = [
"bitflags 2.9.1",
"dispatch2",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -3073,6 +3155,18 @@ checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
dependencies = [
"bitflags 2.9.1",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c"
dependencies = [
"bitflags 2.9.1",
"objc2",
"objc2-core-foundation",
]
[[package]]
@@ -3794,9 +3888,9 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]]
name = "reqwest"
version = "0.12.22"
version = "0.12.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -4188,9 +4282,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.142"
version = "1.0.143"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7"
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
dependencies = [
"indexmap 2.10.0",
"itoa",
@@ -5846,6 +5940,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -5884,6 +5993,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -5902,6 +6017,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -5920,6 +6041,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -5950,6 +6077,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -5968,6 +6101,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -5986,6 +6125,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -6004,6 +6149,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -6070,6 +6221,23 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
[[package]]
name = "x11rb"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12"
dependencies = [
"gethostname",
"rustix 0.38.44",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d"
[[package]]
name = "yaml-rust"
version = "0.4.5"

View File

@@ -1,17 +1,23 @@
To edit files, ALWAYS use the `shell` tool with `apply_patch` CLI. `apply_patch` effectively allows you to execute a diff/patch against a file, but the format of the diff specification is unique to this task, so pay careful attention to these instructions. To use the `apply_patch` CLI, you should call the shell tool with the following structure:
## `apply_patch`
```bash
{"cmd": ["apply_patch", "<<'EOF'\\n*** Begin Patch\\n[YOUR_PATCH]\\n*** End Patch\\nEOF\\n"], "workdir": "..."}
```
Use the `apply_patch` shell command to edit files.
Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
Where [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format.
*** Begin Patch
[ one or more file sections ]
*** End Patch
*** [ACTION] File: [path/to/file] -> ACTION can be one of Add, Update, or Delete.
For each snippet of code that needs to be changed, repeat the following:
[context_before] -> See below for further instructions on context.
- [old_code] -> Precede the old code with a minus sign.
+ [new_code] -> Precede the new, replacement code with a plus sign.
[context_after] -> See below for further instructions on context.
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
*** Update File: <path> - patch an existing file in place (optionally with a rename).
May be immediately followed by *** Move to: <new path> if you want to rename the file.
Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).
Within a hunk each line starts with:
For instructions on [context_before] and [context_after]:
- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first changes [context_after] lines in the second changes [context_before] lines.
@@ -25,16 +31,45 @@ For instructions on [context_before] and [context_after]:
- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
@@ class BaseClass
@@ def method():
@@ def method():
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code. An example of a message that you might pass as "input" to this function, in order to apply a patch, is shown below.
The full grammar definition is below:
Patch := Begin { FileOp } End
Begin := "*** Begin Patch" NEWLINE
End := "*** End Patch" NEWLINE
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile := "*** Delete File: " path NEWLINE
UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "*** Move to: " newPath NEWLINE
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
HunkLine := (" " | "-" | "+") text NEWLINE
A full patch can combine several operations:
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file
- File references can only be relative, NEVER ABSOLUTE.
You can invoke apply_patch like:
```bash
{"cmd": ["apply_patch", "<<'EOF'\\n*** Begin Patch\\n*** Update File: pygorithm/searching/binary_search.py\\n@@ class BaseClass\\n@@ def search():\\n- pass\\n+ raise NotImplementedError()\\n@@ class Subclass\\n@@ def search():\\n- pass\\n+ raise NotImplementedError()\\n*** End Patch\\nEOF\\n"], "workdir": "..."}
```
File references can only be relative, NEVER ABSOLUTE. After the apply_patch command is run, it will always say "Done!", regardless of whether the patch was successfully applied or not. However, you can determine if there are issue and errors by looking at any warnings or logging lines printed BEFORE the "Done!" is output.
shell {"command":["apply_patch","*** Begin Patch\n*** Add File: hello.txt\n+Hello, world!\n*** End Patch\n"]}
```

View File

@@ -9,6 +9,7 @@ use codex_core::config::ConfigOverrides;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Submission;
use codex_login::AuthManager;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tracing::error;
@@ -36,7 +37,10 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
// Use conversation_manager API to start a conversation
let conversation_manager = ConversationManager::default();
let conversation_manager = ConversationManager::new(AuthManager::shared(
config.codex_home.clone(),
config.preferred_auth_method,
));
let NewConversation {
conversation_id: _,
conversation,

View File

@@ -243,6 +243,25 @@ To disable reasoning summaries, set `model_reasoning_summary` to `"none"` in you
model_reasoning_summary = "none" # disable reasoning summaries
```
## model_verbosity
Controls output length/detail on GPT5 family models when using the Responses API. Supported values:
- `"low"`
- `"medium"` (default when omitted)
- `"high"`
When set, Codex includes a `text` object in the request payload with the configured verbosity, for example: `"text": { "verbosity": "low" }`.
Example:
```toml
model = "gpt-5"
model_verbosity = "low"
```
Note: This applies only to providers using the Responses API. Chat Completions providers are unaffected.
## model_supports_reasoning_summaries
By default, `reasoning` is only set on requests to OpenAI models that are known to support them. To force `reasoning` to set on requests to the current model, you can force this behavior by setting the following in `config.toml`:

View File

@@ -270,67 +270,6 @@ 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`
Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
**_ Begin Patch
[ one or more file sections ]
_** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
**_ Add File: <path> - create a new file. Every following line is a + line (the initial contents).
_** Delete File: <path> - remove an existing file. Nothing follows.
\*\*\* Update File: <path> - patch an existing file in place (optionally with a rename).
May be immediately followed by \*\*\* Move to: <new path> if you want to rename the file.
Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).
Within a hunk each line starts with:
- for inserted text,
* for removed text, or
space ( ) for context.
At the end of a truncated hunk you can emit \*\*\* End of File.
Patch := Begin { FileOp } End
Begin := "**_ Begin Patch" NEWLINE
End := "_** End Patch" NEWLINE
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "**_ Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile := "_** Delete File: " path NEWLINE
UpdateFile := "**_ Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "_** Move to: " newPath NEWLINE
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
HunkLine := (" " | "-" | "+") text NEWLINE
A full patch can combine several operations:
**_ Begin Patch
_** Add File: hello.txt
+Hello world
**_ Update File: src/app.py
_** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
**_ Delete File: obsolete.txt
_** End Patch
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file
You can invoke apply_patch like:
```
shell {"command":["apply_patch","*** Begin Patch\n*** Add File: hello.txt\n+Hello, world!\n*** End Patch\n"]}
```
## `update_plan`
A tool named `update_plan` is available to you. You can use it to keep an uptodate, stepbystep plan for the task.

View File

@@ -1,13 +1,13 @@
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::models::FunctionCallOutputPayload;
use crate::models::ResponseInputItem;
use crate::protocol::FileChange;
use crate::protocol::ReviewDecision;
use crate::safety::SafetyCheck;
use crate::safety::assess_patch_safety;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use std::collections::HashMap;
use std::path::PathBuf;

View File

@@ -22,11 +22,11 @@ use crate::client_common::ResponseStream;
use crate::error::CodexErr;
use crate::error::Result;
use crate::model_family::ModelFamily;
use crate::models::ContentItem;
use crate::models::ReasoningItemContent;
use crate::models::ResponseItem;
use crate::openai_tools::create_tools_json_for_chat_completions_api;
use crate::util::backoff;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ResponseItem;
/// Implementation for the classic Chat Completions API.
pub(crate) async fn stream_chat_completions(
@@ -102,6 +102,33 @@ pub(crate) async fn stream_chat_completions(
"content": output.content,
}));
}
ResponseItem::CustomToolCall {
id,
call_id: _,
name,
input,
status: _,
} => {
messages.push(json!({
"role": "assistant",
"content": null,
"tool_calls": [{
"id": id,
"type": "custom",
"custom": {
"name": name,
"input": input,
}
}]
}));
}
ResponseItem::CustomToolCallOutput { call_id, output } => {
messages.push(json!({
"role": "tool",
"tool_call_id": call_id,
"content": output,
}));
}
ResponseItem::Reasoning { .. } | ResponseItem::Other => {
// Omit these items from the conversation history.
continue;
@@ -482,16 +509,19 @@ where
// do NOT emit yet. Forward any other item (e.g. FunctionCall) right
// away so downstream consumers see it.
let is_assistant_delta = matches!(&item, crate::models::ResponseItem::Message { role, .. } if role == "assistant");
let is_assistant_delta = matches!(&item, codex_protocol::models::ResponseItem::Message { role, .. } if role == "assistant");
if is_assistant_delta {
// Only use the final assistant message if we have not
// seen any deltas; otherwise, deltas already built the
// cumulative text and this would duplicate it.
if this.cumulative.is_empty()
&& let crate::models::ResponseItem::Message { content, .. } = &item
&& let codex_protocol::models::ResponseItem::Message { content, .. } =
&item
&& let Some(text) = content.iter().find_map(|c| match c {
crate::models::ContentItem::OutputText { text } => Some(text),
codex_protocol::models::ContentItem::OutputText { text } => {
Some(text)
}
_ => None,
})
{
@@ -515,26 +545,27 @@ where
if !this.cumulative_reasoning.is_empty()
&& matches!(this.mode, AggregateMode::AggregatedOnly)
{
let aggregated_reasoning = crate::models::ResponseItem::Reasoning {
id: String::new(),
summary: Vec::new(),
content: Some(vec![
crate::models::ReasoningItemContent::ReasoningText {
text: std::mem::take(&mut this.cumulative_reasoning),
},
]),
encrypted_content: None,
};
let aggregated_reasoning =
codex_protocol::models::ResponseItem::Reasoning {
id: String::new(),
summary: Vec::new(),
content: Some(vec![
codex_protocol::models::ReasoningItemContent::ReasoningText {
text: std::mem::take(&mut this.cumulative_reasoning),
},
]),
encrypted_content: None,
};
this.pending
.push_back(ResponseEvent::OutputItemDone(aggregated_reasoning));
emitted_any = true;
}
if !this.cumulative.is_empty() {
let aggregated_message = crate::models::ResponseItem::Message {
let aggregated_message = codex_protocol::models::ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![crate::models::ContentItem::OutputText {
content: vec![codex_protocol::models::ContentItem::OutputText {
text: std::mem::take(&mut this.cumulative),
}],
};

View File

@@ -4,8 +4,8 @@ use std::sync::OnceLock;
use std::time::Duration;
use bytes::Bytes;
use codex_login::AuthManager;
use codex_login::AuthMode;
use codex_login::CodexAuth;
use eventsource_stream::Eventsource;
use futures::prelude::*;
use regex_lite::Regex;
@@ -28,6 +28,7 @@ use crate::client_common::ResponseEvent;
use crate::client_common::ResponseStream;
use crate::client_common::ResponsesApiRequest;
use crate::client_common::create_reasoning_param_for_request;
use crate::client_common::create_text_param_for_request;
use crate::config::Config;
use crate::error::CodexErr;
use crate::error::Result;
@@ -36,13 +37,13 @@ use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::model_family::ModelFamily;
use crate::model_provider_info::ModelProviderInfo;
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 codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::models::ResponseItem;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
@@ -60,7 +61,7 @@ struct Error {
#[derive(Debug, Clone)]
pub struct ModelClient {
config: Arc<Config>,
auth: Option<CodexAuth>,
auth_manager: Option<Arc<AuthManager>>,
client: reqwest::Client,
provider: ModelProviderInfo,
session_id: Uuid,
@@ -71,7 +72,7 @@ pub struct ModelClient {
impl ModelClient {
pub fn new(
config: Arc<Config>,
auth: Option<CodexAuth>,
auth_manager: Option<Arc<AuthManager>>,
provider: ModelProviderInfo,
effort: ReasoningEffortConfig,
summary: ReasoningSummaryConfig,
@@ -79,7 +80,7 @@ impl ModelClient {
) -> Self {
Self {
config,
auth,
auth_manager,
client: reqwest::Client::new(),
provider,
session_id,
@@ -140,7 +141,8 @@ impl ModelClient {
return stream_from_fixture(path, self.provider.clone()).await;
}
let auth = self.auth.clone();
let auth_manager = self.auth_manager.clone();
let auth = auth_manager.as_ref().and_then(|m| m.auth());
let auth_mode = auth.as_ref().map(|a| a.mode);
@@ -164,6 +166,19 @@ impl ModelClient {
let input_with_instructions = prompt.get_formatted_input();
// Only include `text.verbosity` for GPT-5 family models
let text = if self.config.model_family.family == "gpt-5" {
create_text_param_for_request(self.config.model_verbosity)
} else {
if self.config.model_verbosity.is_some() {
warn!(
"model_verbosity is set but ignored for non-gpt-5 model family: {}",
self.config.model_family.family
);
}
None
};
let payload = ResponsesApiRequest {
model: &self.config.model,
instructions: &full_instructions,
@@ -176,6 +191,7 @@ impl ModelClient {
stream: true,
include,
prompt_cache_key: Some(self.session_id.to_string()),
text,
};
let mut attempt = 0;
@@ -249,9 +265,10 @@ impl ModelClient {
.and_then(|s| s.parse::<u64>().ok());
if status == StatusCode::UNAUTHORIZED
&& let Some(a) = auth.as_ref()
&& let Some(manager) = auth_manager.as_ref()
&& manager.auth().is_some()
{
let _ = a.refresh_token().await;
let _ = manager.refresh_token().await;
}
// The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx
@@ -338,8 +355,8 @@ impl ModelClient {
self.summary
}
pub fn get_auth(&self) -> Option<CodexAuth> {
self.auth.clone()
pub fn get_auth_manager(&self) -> Option<Arc<AuthManager>> {
self.auth_manager.clone()
}
}
@@ -558,6 +575,8 @@ async fn process_sse<S>(
}
"response.content_part.done"
| "response.function_call_arguments.delta"
| "response.custom_tool_call_input.delta"
| "response.custom_tool_call_input.done" // also emitted as response.output_item.done
| "response.in_progress"
| "response.output_item.added"
| "response.output_text.done" => {

View File

@@ -1,12 +1,13 @@
use crate::config_types::Verbosity as VerbosityConfig;
use crate::error::Result;
use crate::model_family::ModelFamily;
use crate::models::ContentItem;
use crate::models::ResponseItem;
use crate::openai_tools::OpenAiTool;
use crate::protocol::TokenUsage;
use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use futures::Stream;
use serde::Serialize;
use std::borrow::Cow;
@@ -47,7 +48,18 @@ impl Prompt {
.as_deref()
.unwrap_or(BASE_INSTRUCTIONS);
let mut sections: Vec<&str> = vec![base];
if model.needs_special_apply_patch_instructions {
// When there are no custom instructions, add apply_patch_tool_instructions if either:
// - the model needs special instructions (4.1), or
// - there is no apply_patch tool present
let is_apply_patch_tool_present = self.tools.iter().any(|tool| match tool {
OpenAiTool::Function(f) => f.name == "apply_patch",
OpenAiTool::Freeform(f) => f.name == "apply_patch",
_ => false,
});
if self.base_instructions_override.is_none()
&& (model.needs_special_apply_patch_instructions || !is_apply_patch_tool_present)
{
sections.push(APPLY_PATCH_TOOL_INSTRUCTIONS);
}
Cow::Owned(sections.join("\n"))
@@ -89,6 +101,32 @@ pub(crate) struct Reasoning {
pub(crate) summary: ReasoningSummaryConfig,
}
/// Controls under the `text` field in the Responses API for GPT-5.
#[derive(Debug, Serialize, Default, Clone, Copy)]
pub(crate) struct TextControls {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) verbosity: Option<OpenAiVerbosity>,
}
#[derive(Debug, Serialize, Default, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub(crate) enum OpenAiVerbosity {
Low,
#[default]
Medium,
High,
}
impl From<VerbosityConfig> for OpenAiVerbosity {
fn from(v: VerbosityConfig) -> Self {
match v {
VerbosityConfig::Low => OpenAiVerbosity::Low,
VerbosityConfig::Medium => OpenAiVerbosity::Medium,
VerbosityConfig::High => OpenAiVerbosity::High,
}
}
}
/// Request object that is serialized as JSON and POST'ed when using the
/// Responses API.
#[derive(Debug, Serialize)]
@@ -109,6 +147,8 @@ pub(crate) struct ResponsesApiRequest<'a> {
pub(crate) include: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) prompt_cache_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) text: Option<TextControls>,
}
pub(crate) fn create_reasoning_param_for_request(
@@ -123,6 +163,14 @@ pub(crate) fn create_reasoning_param_for_request(
}
}
pub(crate) fn create_text_param_for_request(
verbosity: Option<VerbosityConfig>,
) -> Option<TextControls> {
verbosity.map(|v| TextControls {
verbosity: Some(v.into()),
})
}
pub(crate) struct ResponseStream {
pub(crate) rx_event: mpsc::Receiver<Result<ResponseEvent>>,
}
@@ -151,4 +199,57 @@ mod tests {
let full = prompt.get_full_instructions(&model_family);
assert_eq!(full, expected);
}
#[test]
fn serializes_text_verbosity_when_set() {
let input: Vec<ResponseItem> = vec![];
let tools: Vec<serde_json::Value> = vec![];
let req = ResponsesApiRequest {
model: "gpt-5",
instructions: "i",
input: &input,
tools: &tools,
tool_choice: "auto",
parallel_tool_calls: false,
reasoning: None,
store: true,
stream: true,
include: vec![],
prompt_cache_key: None,
text: Some(TextControls {
verbosity: Some(OpenAiVerbosity::Low),
}),
};
let v = serde_json::to_value(&req).expect("json");
assert_eq!(
v.get("text")
.and_then(|t| t.get("verbosity"))
.and_then(|s| s.as_str()),
Some("low")
);
}
#[test]
fn omits_text_when_not_set() {
let input: Vec<ResponseItem> = vec![];
let tools: Vec<serde_json::Value> = vec![];
let req = ResponsesApiRequest {
model: "gpt-5",
instructions: "i",
input: &input,
tools: &tools,
tool_choice: "auto",
parallel_tool_calls: false,
reasoning: None,
store: true,
stream: true,
include: vec![],
prompt_cache_key: None,
text: None,
};
let v = serde_json::to_value(&req).expect("json");
assert!(v.get("text").is_none());
}
}

View File

@@ -13,7 +13,8 @@ use async_channel::Sender;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::MaybeApplyPatchVerified;
use codex_apply_patch::maybe_parse_apply_patch_verified;
use codex_login::CodexAuth;
use codex_login::AuthManager;
use codex_protocol::protocol::ConversationHistoryResponseEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use futures::prelude::*;
@@ -56,14 +57,6 @@ use crate::exec_env::create_env;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_tool_call::handle_mcp_tool_call;
use crate::model_family::find_family_for_model;
use crate::models::ContentItem;
use crate::models::FunctionCallOutputPayload;
use crate::models::LocalShellAction;
use crate::models::ReasoningItemContent;
use crate::models::ReasoningItemReasoningSummary;
use crate::models::ResponseInputItem;
use crate::models::ResponseItem;
use crate::models::ShellToolCallParams;
use crate::openai_tools::ApplyPatchToolArgs;
use crate::openai_tools::ToolsConfig;
use crate::openai_tools::get_openai_tools;
@@ -108,6 +101,14 @@ use crate::user_notification::UserNotification;
use crate::util::backoff;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::LocalShellAction;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::ShellToolCallParams;
// A convenience extension trait for acquiring mutex locks where poisoning is
// unrecoverable and should abort the program. This avoids scattered `.unwrap()`
@@ -141,11 +142,23 @@ pub struct CodexSpawnOk {
}
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 64;
// Model-formatting limits: clients get full streams; oonly content sent to the model is truncated.
pub(crate) const MODEL_FORMAT_MAX_BYTES: usize = 10 * 1024; // 10 KiB
pub(crate) const MODEL_FORMAT_MAX_LINES: usize = 256; // lines
pub(crate) const MODEL_FORMAT_HEAD_LINES: usize = MODEL_FORMAT_MAX_LINES / 2;
pub(crate) const MODEL_FORMAT_TAIL_LINES: usize = MODEL_FORMAT_MAX_LINES - MODEL_FORMAT_HEAD_LINES; // 128
pub(crate) const MODEL_FORMAT_HEAD_BYTES: usize = MODEL_FORMAT_MAX_BYTES / 2;
impl Codex {
/// Spawn a new [`Codex`] and initialize the session.
pub async fn spawn(config: Config, auth: Option<CodexAuth>) -> CodexResult<CodexSpawnOk> {
let (tx_sub, rx_sub) = async_channel::bounded(64);
pub async fn spawn(
config: Config,
auth_manager: Arc<AuthManager>,
initial_history: Option<Vec<ResponseItem>>,
) -> CodexResult<CodexSpawnOk> {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
let user_instructions = get_user_instructions(&config).await;
@@ -169,17 +182,27 @@ impl Codex {
};
// Generate a unique ID for the lifetime of this Codex session.
let (session, turn_context) =
Session::new(configure_session, config.clone(), auth, tx_event.clone())
.await
.map_err(|e| {
error!("Failed to create session: {e:#}");
CodexErr::InternalAgentDied
})?;
let (session, turn_context) = Session::new(
configure_session,
config.clone(),
auth_manager.clone(),
tx_event.clone(),
initial_history,
)
.await
.map_err(|e| {
error!("Failed to create session: {e:#}");
CodexErr::InternalAgentDied
})?;
let session_id = session.session_id;
// This task will run until Op::Shutdown is received.
tokio::spawn(submission_loop(session, turn_context, config, rx_sub));
tokio::spawn(submission_loop(
session.clone(),
turn_context,
config,
rx_sub,
));
let codex = Codex {
next_id: AtomicU64::new(0),
tx_sub,
@@ -240,6 +263,15 @@ pub(crate) struct Session {
/// Manager for external MCP servers/tools.
mcp_connection_manager: McpConnectionManager,
/// Loaded subagent definitions from project and user scope.
subagents_registry: crate::subagents::registry::SubagentRegistry,
/// Auth manager used to spawn nested sessions (e.g., subagents).
auth_manager: Arc<AuthManager>,
/// Base configuration used to derive nested session configs.
base_config: Arc<Config>,
/// External notifier command (will be passed as args to exec()). When
/// `None` this feature is disabled.
notify: Option<Vec<String>>,
@@ -323,8 +355,9 @@ impl Session {
async fn new(
configure_session: ConfigureSession,
config: Arc<Config>,
auth: Option<CodexAuth>,
auth_manager: Arc<AuthManager>,
tx_event: Sender<Event>,
initial_history: Option<Vec<ResponseItem>>,
) -> anyhow::Result<(Arc<Self>, TurnContext)> {
let ConfigureSession {
provider,
@@ -384,14 +417,15 @@ impl Session {
}
let rollout_result = match rollout_res {
Ok((session_id, maybe_saved, recorder)) => {
let restored_items: Option<Vec<ResponseItem>> =
let restored_items: Option<Vec<ResponseItem>> = initial_history.or_else(|| {
maybe_saved.and_then(|saved_session| {
if saved_session.items.is_empty() {
None
} else {
Some(saved_session.items)
}
});
})
});
RolloutResult {
session_id,
rollout_recorder: Some(recorder),
@@ -467,12 +501,36 @@ impl Session {
// construct the model client.
let client = ModelClient::new(
config.clone(),
auth.clone(),
Some(auth_manager.clone()),
provider.clone(),
model_reasoning_effort,
model_reasoning_summary,
session_id,
);
// Build subagent registry paths and load once per session
let project_agents_dir = {
let mut p = cwd.clone();
p.push(".codex");
p.push("agents");
if p.exists() { Some(p) } else { None }
};
let user_agents_dir = {
let mut p = config.codex_home.clone();
p.push("agents");
if p.exists() { Some(p) } else { None }
};
let mut subagents_registry =
crate::subagents::registry::SubagentRegistry::new(project_agents_dir, user_agents_dir);
subagents_registry.load();
// Log discovered subagents for visibility in clients (e.g., TUI) after
// SessionConfigured so the first event contract remains intact.
post_session_configured_error_events.push(Event {
id: INITIAL_SUBMIT_ID.to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: format!("subagents discovered: {:?}", subagents_registry.all_names()),
}),
});
let turn_context = TurnContext {
client,
tools_config: ToolsConfig::new(
@@ -481,6 +539,7 @@ impl Session {
sandbox_policy.clone(),
config.include_plan_tool,
config.include_apply_patch_tool,
config.include_subagent_tool,
),
user_instructions,
base_instructions,
@@ -494,6 +553,9 @@ impl Session {
session_id,
tx_event: tx_event.clone(),
mcp_connection_manager,
subagents_registry,
auth_manager: auth_manager.clone(),
base_config: config.clone(),
notify,
state: Mutex::new(state),
rollout: Mutex::new(rollout_recorder),
@@ -553,6 +615,16 @@ impl Session {
}
}
/// Access auth manager for nested sessions.
pub(crate) fn auth_manager(&self) -> Arc<AuthManager> {
self.auth_manager.clone()
}
/// Access base config for nested sessions.
pub(crate) fn base_config(&self) -> Arc<Config> {
self.base_config.clone()
}
/// Sends the given event to the client and swallows the send event, if
/// any, logging it as an error.
pub(crate) async fn send_event(&self, event: Event) {
@@ -694,7 +766,6 @@ impl Session {
let _ = self.tx_event.send(event).await;
}
#[allow(clippy::too_many_arguments)]
async fn on_exec_command_end(
&self,
turn_diff_tracker: &mut TurnDiffTracker,
@@ -706,14 +777,15 @@ impl Session {
let ExecToolCallOutput {
stdout,
stderr,
aggregated_output,
duration,
exit_code,
} = output;
// 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.text.chars().take(MAX_STREAM_OUTPUT).collect();
let stderr = stderr.text.chars().take(MAX_STREAM_OUTPUT).collect();
// Send full stdout/stderr to clients; do not truncate.
let stdout = stdout.text.clone();
let stderr = stderr.text.clone();
let formatted_output = format_exec_output_str(output);
let aggregated_output: String = aggregated_output.text.clone();
let msg = if is_apply_patch {
EventMsg::PatchApplyEnd(PatchApplyEndEvent {
@@ -727,8 +799,10 @@ impl Session {
call_id: call_id.to_string(),
stdout,
stderr,
duration: *duration,
aggregated_output,
exit_code: *exit_code,
duration: *duration,
formatted_output,
})
};
@@ -786,6 +860,7 @@ impl Session {
exit_code: -1,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(get_error_message_ui(e)),
aggregated_output: StreamOutput::new(get_error_message_ui(e)),
duration: Duration::default(),
};
&output_stderr
@@ -1034,7 +1109,8 @@ async fn submission_loop(
let effective_effort = effort.unwrap_or(prev.client.get_reasoning_effort());
let effective_summary = summary.unwrap_or(prev.client.get_reasoning_summary());
let auth = prev.client.get_auth();
let auth_manager = prev.client.get_auth_manager();
// Build updated config for the client
let mut updated_config = (*config).clone();
updated_config.model = effective_model.clone();
@@ -1042,7 +1118,7 @@ async fn submission_loop(
let client = ModelClient::new(
Arc::new(updated_config),
auth,
auth_manager,
provider,
effective_effort,
effective_summary,
@@ -1061,6 +1137,7 @@ async fn submission_loop(
new_sandbox_policy.clone(),
config.include_plan_tool,
config.include_apply_patch_tool,
config.include_subagent_tool,
);
let new_turn_context = TurnContext {
@@ -1139,6 +1216,7 @@ async fn submission_loop(
sandbox_policy.clone(),
config.include_plan_tool,
config.include_apply_patch_tool,
config.include_subagent_tool,
),
user_instructions: turn_context.user_instructions.clone(),
base_instructions: turn_context.base_instructions.clone(),
@@ -1277,6 +1355,21 @@ async fn submission_loop(
}
break;
}
Op::GetHistory => {
let tx_event = sess.tx_event.clone();
let sub_id = sub.id.clone();
let event = Event {
id: sub_id.clone(),
msg: EventMsg::ConversationHistory(ConversationHistoryResponseEvent {
conversation_id: sess.session_id,
entries: sess.state.lock_unchecked().history.contents(),
}),
};
if let Err(e) = tx_event.send(event).await {
warn!("failed to send ConversationHistory event: {e}");
}
}
_ => {
// Ignore unknown ops; enum is non_exhaustive to allow extensions.
}
@@ -1398,29 +1491,38 @@ async fn run_task(
},
);
}
(
ResponseItem::CustomToolCall { .. },
Some(ResponseInputItem::CustomToolCallOutput { call_id, output }),
) => {
items_to_record_in_conversation_history.push(item);
items_to_record_in_conversation_history.push(
ResponseItem::CustomToolCallOutput {
call_id: call_id.clone(),
output: output.clone(),
},
);
}
(
ResponseItem::FunctionCall { .. },
Some(ResponseInputItem::McpToolCallOutput { call_id, result }),
) => {
items_to_record_in_conversation_history.push(item);
let (content, success): (String, Option<bool>) = match result {
Ok(CallToolResult {
content,
is_error,
structured_content: _,
}) => match serde_json::to_string(content) {
Ok(content) => (content, *is_error),
Err(e) => {
warn!("Failed to serialize MCP tool call output: {e}");
(e.to_string(), Some(true))
}
let output = match result {
Ok(call_tool_result) => {
convert_call_tool_result_to_function_call_output_payload(
call_tool_result,
)
}
Err(err) => FunctionCallOutputPayload {
content: err.clone(),
success: Some(false),
},
Err(e) => (e.clone(), Some(true)),
};
items_to_record_in_conversation_history.push(
ResponseItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload { content, success },
output,
},
);
}
@@ -1501,6 +1603,27 @@ async fn run_turn(
&turn_context.tools_config,
Some(sess.mcp_connection_manager.list_all_tools()),
);
tracing::trace!("Tools: {tools:?}");
// Log tool names for visibility in the TUI/debug logs.
#[allow(clippy::match_same_arms)]
let tool_names: Vec<String> = tools
.iter()
.map(|t| match t {
crate::openai_tools::OpenAiTool::Function(f) => f.name.clone(),
crate::openai_tools::OpenAiTool::LocalShell {} => "local_shell".to_string(),
crate::openai_tools::OpenAiTool::Freeform(f) => f.name.clone(),
})
.collect();
let _ = sess
.tx_event
.send(Event {
id: sub_id.clone(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: format!("tools available: {:?}", tool_names),
}),
})
.await;
let prompt = Prompt {
input,
@@ -1578,6 +1701,7 @@ async fn try_run_turn(
call_id: Some(call_id),
..
} => Some(call_id),
ResponseItem::CustomToolCallOutput { call_id, .. } => Some(call_id),
_ => None,
})
.collect::<Vec<_>>();
@@ -1595,6 +1719,7 @@ async fn try_run_turn(
call_id: Some(call_id),
..
} => Some(call_id),
ResponseItem::CustomToolCall { call_id, .. } => Some(call_id),
_ => None,
})
.filter_map(|call_id| {
@@ -1604,12 +1729,9 @@ async fn try_run_turn(
Some(call_id.clone())
}
})
.map(|call_id| ResponseItem::FunctionCallOutput {
.map(|call_id| ResponseItem::CustomToolCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content: "aborted".to_string(),
success: Some(false),
},
output: "aborted".to_string(),
})
.collect::<Vec<_>>()
};
@@ -1874,7 +1996,7 @@ async fn handle_response_item(
call_id,
..
} => {
info!("FunctionCall: {arguments}");
info!("FunctionCall: {name}({arguments})");
Some(
handle_function_call(
sess,
@@ -1931,10 +2053,32 @@ async fn handle_response_item(
.await,
)
}
ResponseItem::CustomToolCall {
id: _,
call_id,
name,
input,
status: _,
} => Some(
handle_custom_tool_call(
sess,
turn_context,
turn_diff_tracker,
sub_id.to_string(),
name,
input,
call_id,
)
.await,
),
ResponseItem::FunctionCallOutput { .. } => {
debug!("unexpected FunctionCallOutput from stream");
None
}
ResponseItem::CustomToolCallOutput { .. } => {
debug!("unexpected CustomToolCallOutput from stream");
None
}
ResponseItem::Other => None,
};
Ok(output)
@@ -1999,6 +2143,84 @@ async fn handle_function_call(
.await
}
"update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await,
"subagent_run" => {
#[derive(serde::Deserialize)]
struct Args {
name: String,
input: String,
#[serde(default)]
context: Option<String>,
}
let args = match serde_json::from_str::<Args>(&arguments) {
Ok(a) => a,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("failed to parse function arguments: {e}"),
success: Some(false),
},
};
}
};
let result = crate::subagents::runner::run(
sess,
turn_context,
&sess.subagents_registry,
crate::subagents::runner::RunSubagentArgs {
name: args.name,
input: args.input,
context: args.context,
},
&sub_id,
)
.await;
match result {
Ok(message) => ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: message,
success: Some(true),
},
},
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("subagent failed: {e}"),
success: Some(false),
},
},
}
}
"subagent_list" => {
#[derive(serde::Serialize)]
struct SubagentBrief<'a> {
name: &'a str,
description: &'a str,
}
let mut list = Vec::new();
for name in sess.subagents_registry.all_names() {
if let Some(def) = sess.subagents_registry.get(&name) {
list.push(SubagentBrief {
name: &def.name,
description: &def.description,
});
}
}
let payload = match serde_json::to_string(&list) {
Ok(s) => s,
Err(e) => format!("failed to serialize subagent list: {e}"),
};
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: payload,
success: Some(true),
},
}
}
_ => {
match sess.mcp_connection_manager.parse_tool_name(&name) {
Some((server, tool_name)) => {
@@ -2024,6 +2246,58 @@ async fn handle_function_call(
}
}
async fn handle_custom_tool_call(
sess: &Session,
turn_context: &TurnContext,
turn_diff_tracker: &mut TurnDiffTracker,
sub_id: String,
name: String,
input: String,
call_id: String,
) -> ResponseInputItem {
info!("CustomToolCall: {name} {input}");
match name.as_str() {
"apply_patch" => {
let exec_params = ExecParams {
command: vec!["apply_patch".to_string(), input.clone()],
cwd: turn_context.cwd.clone(),
timeout_ms: None,
env: HashMap::new(),
with_escalated_permissions: None,
justification: None,
};
let resp = handle_container_exec_with_params(
exec_params,
sess,
turn_context,
turn_diff_tracker,
sub_id,
call_id,
)
.await;
// Convert function-call style output into a custom tool call output
match resp {
ResponseInputItem::FunctionCallOutput { call_id, output } => {
ResponseInputItem::CustomToolCallOutput {
call_id,
output: output.content,
}
}
// Pass through if already a custom tool output or other variant
other => other,
}
}
_ => {
debug!("unexpected CustomToolCall from stream");
ResponseInputItem::CustomToolCallOutput {
call_id,
output: format!("unsupported custom tool call: {name}"),
}
}
}
}
fn to_exec_params(params: ShellToolCallParams, turn_context: &TurnContext) -> ExecParams {
ExecParams {
command: params.command,
@@ -2057,6 +2331,8 @@ fn parse_container_exec_arguments(
}
}
// (helper run_one_turn_collect removed as unused)
pub struct ExecInvokeArgs<'a> {
pub params: ExecParams,
pub sandbox_type: SandboxType,
@@ -2267,7 +2543,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 {
@@ -2400,7 +2676,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(),
@@ -2432,13 +2708,113 @@ async fn handle_sandbox_error(
}
}
fn format_exec_output_str(exec_output: &ExecToolCallOutput) -> String {
let ExecToolCallOutput {
aggregated_output, ..
} = exec_output;
// Head+tail truncation for the model: show the beginning and end with an elision.
// Clients still receive full streams; only this formatted summary is capped.
let s = aggregated_output.text.as_str();
let total_lines = s.lines().count();
if s.len() <= MODEL_FORMAT_MAX_BYTES && total_lines <= MODEL_FORMAT_MAX_LINES {
return s.to_string();
}
let lines: Vec<&str> = s.lines().collect();
let head_take = MODEL_FORMAT_HEAD_LINES.min(lines.len());
let tail_take = MODEL_FORMAT_TAIL_LINES.min(lines.len().saturating_sub(head_take));
let omitted = lines.len().saturating_sub(head_take + tail_take);
// Join head and tail blocks (lines() strips newlines; reinsert them)
let head_block = lines
.iter()
.take(head_take)
.cloned()
.collect::<Vec<_>>()
.join("\n");
let tail_block = if tail_take > 0 {
lines[lines.len() - tail_take..].join("\n")
} else {
String::new()
};
let marker = format!("\n[... omitted {omitted} of {total_lines} lines ...]\n\n");
// Byte budgets for head/tail around the marker
let mut head_budget = MODEL_FORMAT_HEAD_BYTES.min(MODEL_FORMAT_MAX_BYTES);
let tail_budget = MODEL_FORMAT_MAX_BYTES.saturating_sub(head_budget + marker.len());
if tail_budget == 0 && marker.len() >= MODEL_FORMAT_MAX_BYTES {
// Degenerate case: marker alone exceeds budget; return a clipped marker
return take_bytes_at_char_boundary(&marker, MODEL_FORMAT_MAX_BYTES).to_string();
}
if tail_budget == 0 {
// Make room for the marker by shrinking head
head_budget = MODEL_FORMAT_MAX_BYTES.saturating_sub(marker.len());
}
// Enforce line-count cap by trimming head/tail lines
let head_lines_text = head_block;
let tail_lines_text = tail_block;
// Build final string respecting byte budgets
let head_part = take_bytes_at_char_boundary(&head_lines_text, head_budget);
let mut result = String::with_capacity(MODEL_FORMAT_MAX_BYTES.min(s.len()));
result.push_str(head_part);
result.push_str(&marker);
let remaining = MODEL_FORMAT_MAX_BYTES.saturating_sub(result.len());
let tail_budget_final = remaining;
let tail_part = take_last_bytes_at_char_boundary(&tail_lines_text, tail_budget_final);
result.push_str(tail_part);
result
}
// Truncate a &str to a byte budget at a char boundary (prefix)
#[inline]
fn take_bytes_at_char_boundary(s: &str, maxb: usize) -> &str {
if s.len() <= maxb {
return s;
}
let mut last_ok = 0;
for (i, ch) in s.char_indices() {
let nb = i + ch.len_utf8();
if nb > maxb {
break;
}
last_ok = nb;
}
&s[..last_ok]
}
// Take a suffix of a &str within a byte budget at a char boundary
#[inline]
fn take_last_bytes_at_char_boundary(s: &str, maxb: usize) -> &str {
if s.len() <= maxb {
return s;
}
let mut start = s.len();
let mut used = 0usize;
for (i, ch) in s.char_indices().rev() {
let nb = ch.len_utf8();
if used + nb > maxb {
break;
}
start = i;
used += nb;
if start == 0 {
break;
}
}
&s[start..]
}
/// 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,
stderr,
duration,
..
} = exec_output;
#[derive(Serialize)]
@@ -2456,20 +2832,12 @@ 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 output = if is_success { stdout } else { stderr };
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.]",
));
}
let formatted_output = format_exec_output_str(exec_output);
let payload = ExecOutput {
output: &formatted_output,
metadata: ExecMetadata {
exit_code,
exit_code: *exit_code,
duration_seconds,
},
};
@@ -2546,3 +2914,209 @@ async fn drain_to_completed(
}
}
}
fn convert_call_tool_result_to_function_call_output_payload(
call_tool_result: &CallToolResult,
) -> FunctionCallOutputPayload {
let CallToolResult {
content,
is_error,
structured_content,
} = call_tool_result;
// In terms of what to send back to the model, we prefer structured_content,
// if available, and fallback to content, otherwise.
let mut is_success = is_error != &Some(true);
let content = if let Some(structured_content) = structured_content
&& structured_content != &serde_json::Value::Null
&& let Ok(serialized_structured_content) = serde_json::to_string(&structured_content)
{
serialized_structured_content
} else {
match serde_json::to_string(&content) {
Ok(serialized_content) => serialized_content,
Err(err) => {
// If we could not serialize either content or structured_content to
// JSON, flag this as an error.
is_success = false;
err.to_string()
}
}
};
FunctionCallOutputPayload {
content,
success: Some(is_success),
}
}
#[cfg(test)]
mod tests {
use super::*;
use mcp_types::ContentBlock;
use mcp_types::TextContent;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::time::Duration as StdDuration;
fn text_block(s: &str) -> ContentBlock {
ContentBlock::TextContent(TextContent {
annotations: None,
text: s.to_string(),
r#type: "text".to_string(),
})
}
#[test]
fn prefers_structured_content_when_present() {
let ctr = CallToolResult {
// Content present but should be ignored because structured_content is set.
content: vec![text_block("ignored")],
is_error: None,
structured_content: Some(json!({
"ok": true,
"value": 42
})),
};
let got = convert_call_tool_result_to_function_call_output_payload(&ctr);
let expected = FunctionCallOutputPayload {
content: serde_json::to_string(&json!({
"ok": true,
"value": 42
}))
.unwrap(),
success: Some(true),
};
assert_eq!(expected, got);
}
#[test]
fn model_truncation_head_tail_by_lines() {
// Build 400 short lines so line-count limit, not byte budget, triggers truncation
let lines: Vec<String> = (1..=400).map(|i| format!("line{i}")).collect();
let full = lines.join("\n");
let exec = ExecToolCallOutput {
exit_code: 0,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(String::new()),
aggregated_output: StreamOutput::new(full.clone()),
duration: StdDuration::from_secs(1),
};
let out = format_exec_output_str(&exec);
// Expect elision marker with correct counts
let omitted = 400 - MODEL_FORMAT_MAX_LINES; // 144
let marker = format!("\n[... omitted {omitted} of 400 lines ...]\n\n");
assert!(out.contains(&marker), "missing marker: {out}");
// Validate head and tail
let parts: Vec<&str> = out.split(&marker).collect();
assert_eq!(parts.len(), 2, "expected one marker split");
let head = parts[0];
let tail = parts[1];
let expected_head: String = (1..=MODEL_FORMAT_HEAD_LINES)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
assert!(head.starts_with(&expected_head), "head mismatch");
let expected_tail: String = ((400 - MODEL_FORMAT_TAIL_LINES + 1)..=400)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
assert!(tail.ends_with(&expected_tail), "tail mismatch");
}
#[test]
fn model_truncation_respects_byte_budget() {
// Construct a large output (about 100kB) so byte budget dominates
let big_line = "x".repeat(100);
let full = std::iter::repeat_n(big_line.clone(), 1000)
.collect::<Vec<_>>()
.join("\n");
let exec = ExecToolCallOutput {
exit_code: 0,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(String::new()),
aggregated_output: StreamOutput::new(full.clone()),
duration: StdDuration::from_secs(1),
};
let out = format_exec_output_str(&exec);
assert!(out.len() <= MODEL_FORMAT_MAX_BYTES, "exceeds byte budget");
assert!(out.contains("omitted"), "should contain elision marker");
// Ensure head and tail are drawn from the original
assert!(full.starts_with(out.chars().take(8).collect::<String>().as_str()));
assert!(
full.ends_with(
out.chars()
.rev()
.take(8)
.collect::<String>()
.chars()
.rev()
.collect::<String>()
.as_str()
)
);
}
#[test]
fn falls_back_to_content_when_structured_is_null() {
let ctr = CallToolResult {
content: vec![text_block("hello"), text_block("world")],
is_error: None,
structured_content: Some(serde_json::Value::Null),
};
let got = convert_call_tool_result_to_function_call_output_payload(&ctr);
let expected = FunctionCallOutputPayload {
content: serde_json::to_string(&vec![text_block("hello"), text_block("world")])
.unwrap(),
success: Some(true),
};
assert_eq!(expected, got);
}
#[test]
fn success_flag_reflects_is_error_true() {
let ctr = CallToolResult {
content: vec![text_block("unused")],
is_error: Some(true),
structured_content: Some(json!({ "message": "bad" })),
};
let got = convert_call_tool_result_to_function_call_output_payload(&ctr);
let expected = FunctionCallOutputPayload {
content: serde_json::to_string(&json!({ "message": "bad" })).unwrap(),
success: Some(false),
};
assert_eq!(expected, got);
}
#[test]
fn success_flag_true_with_no_error_and_content_used() {
let ctr = CallToolResult {
content: vec![text_block("alpha")],
is_error: Some(false),
structured_content: None,
};
let got = convert_call_tool_result_to_function_call_output_payload(&ctr);
let expected = FunctionCallOutputPayload {
content: serde_json::to_string(&vec![text_block("alpha")]).unwrap(),
success: Some(true),
};
assert_eq!(expected, got);
}
}

View File

@@ -6,6 +6,8 @@ use crate::config_types::ShellEnvironmentPolicy;
use crate::config_types::ShellEnvironmentPolicyToml;
use crate::config_types::Tui;
use crate::config_types::UriBasedFileOpener;
use crate::config_types::Verbosity;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::model_family::ModelFamily;
use crate::model_family::find_family_for_model;
use crate::model_provider_info::ModelProviderInfo;
@@ -150,6 +152,9 @@ pub struct Config {
/// request using the Responses API.
pub model_reasoning_summary: ReasoningSummary,
/// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`).
pub model_verbosity: Option<Verbosity>,
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
pub chatgpt_base_url: String,
@@ -164,6 +169,9 @@ pub struct Config {
/// model family's default preference.
pub include_apply_patch_tool: bool,
/// Include the `subagent.run` tool allowing the model to invoke configured subagents.
pub include_subagent_tool: bool,
/// The value for the `originator` header included with Responses API requests.
pub responses_originator_header: String,
@@ -259,10 +267,61 @@ pub fn set_project_trusted(codex_home: &Path, project_path: &Path) -> anyhow::Re
Err(e) => return Err(e.into()),
};
// Mark the project as trusted. toml_edit is very good at handling
// missing properties
// Ensure we render a human-friendly structure:
//
// [projects]
// [projects."/path/to/project"]
// trust_level = "trusted"
//
// rather than inline tables like:
//
// [projects]
// "/path/to/project" = { trust_level = "trusted" }
let project_key = project_path.to_string_lossy().to_string();
doc["projects"][project_key.as_str()]["trust_level"] = toml_edit::value("trusted");
// Ensure top-level `projects` exists as a non-inline, explicit table. If it
// exists but was previously represented as a non-table (e.g., inline),
// replace it with an explicit table.
let mut created_projects_table = false;
{
let root = doc.as_table_mut();
let needs_table = !root.contains_key("projects")
|| root.get("projects").and_then(|i| i.as_table()).is_none();
if needs_table {
root.insert("projects", toml_edit::table());
created_projects_table = true;
}
}
let Some(projects_tbl) = doc["projects"].as_table_mut() else {
return Err(anyhow::anyhow!(
"projects table missing after initialization"
));
};
// If we created the `projects` table ourselves, keep it implicit so we
// don't render a standalone `[projects]` header.
if created_projects_table {
projects_tbl.set_implicit(true);
}
// Ensure the per-project entry is its own explicit table. If it exists but
// is not a table (e.g., an inline table), replace it with an explicit table.
let needs_proj_table = !projects_tbl.contains_key(project_key.as_str())
|| projects_tbl
.get(project_key.as_str())
.and_then(|i| i.as_table())
.is_none();
if needs_proj_table {
projects_tbl.insert(project_key.as_str(), toml_edit::table());
}
let Some(proj_tbl) = projects_tbl
.get_mut(project_key.as_str())
.and_then(|i| i.as_table_mut())
else {
return Err(anyhow::anyhow!("project table missing for {}", project_key));
};
proj_tbl.set_implicit(false);
proj_tbl["trust_level"] = toml_edit::value("trusted");
// ensure codex_home exists
std::fs::create_dir_all(codex_home)?;
@@ -398,6 +457,8 @@ pub struct ConfigToml {
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
/// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`).
pub model_verbosity: Option<Verbosity>,
/// Override to force-enable reasoning summaries for the configured model.
pub model_supports_reasoning_summaries: Option<bool>,
@@ -418,6 +479,9 @@ pub struct ConfigToml {
/// If set to `true`, the API key will be signed with the `originator` header.
pub preferred_auth_method: Option<AuthMode>,
/// Include the `subagent.run` tool allowing the model to invoke configured subagents.
pub include_subagent_tool: Option<bool>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
@@ -454,10 +518,27 @@ impl ConfigToml {
pub fn is_cwd_trusted(&self, resolved_cwd: &Path) -> bool {
let projects = self.projects.clone().unwrap_or_default();
projects
.get(&resolved_cwd.to_string_lossy().to_string())
.map(|p| p.trust_level.clone().unwrap_or("".to_string()) == "trusted")
.unwrap_or(false)
let is_path_trusted = |path: &Path| {
let path_str = path.to_string_lossy().to_string();
projects
.get(&path_str)
.map(|p| p.trust_level.as_deref() == Some("trusted"))
.unwrap_or(false)
};
// Fast path: exact cwd match
if is_path_trusted(resolved_cwd) {
return true;
}
// If cwd lives inside a git worktree, check whether the root git project
// (the primary repository working directory) is trusted. This lets
// worktrees inherit trust from the main project.
if let Some(root_project) = resolve_root_git_project_for_trust(resolved_cwd) {
return is_path_trusted(&root_project);
}
false
}
pub fn get_config_profile(
@@ -495,6 +576,7 @@ pub struct ConfigOverrides {
pub base_instructions: Option<String>,
pub include_plan_tool: Option<bool>,
pub include_apply_patch_tool: Option<bool>,
pub include_subagent_tool: Option<bool>,
pub disable_response_storage: Option<bool>,
pub show_raw_agent_reasoning: Option<bool>,
}
@@ -521,6 +603,7 @@ impl Config {
base_instructions,
include_plan_tool,
include_apply_patch_tool,
include_subagent_tool,
disable_response_storage,
show_raw_agent_reasoning,
} = overrides;
@@ -597,7 +680,7 @@ impl Config {
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries,
uses_local_shell_tool: false,
uses_apply_patch_tool: false,
apply_patch_tool_type: None,
}
});
@@ -624,9 +707,6 @@ impl Config {
Self::get_base_instructions(experimental_instructions_path, &resolved_cwd)?;
let base_instructions = base_instructions.or(file_base_instructions);
let include_apply_patch_tool_val =
include_apply_patch_tool.unwrap_or(model_family.uses_apply_patch_tool);
let responses_originator_header: String = cfg
.responses_originator_header_internal_override
.unwrap_or(DEFAULT_RESPONSES_ORIGINATOR_HEADER.to_owned());
@@ -675,7 +755,7 @@ impl Config {
.model_reasoning_summary
.or(cfg.model_reasoning_summary)
.unwrap_or_default(),
model_verbosity: config_profile.model_verbosity.or(cfg.model_verbosity),
chatgpt_base_url: config_profile
.chatgpt_base_url
.or(cfg.chatgpt_base_url)
@@ -683,7 +763,12 @@ impl Config {
experimental_resume,
include_plan_tool: include_plan_tool.unwrap_or(false),
include_apply_patch_tool: include_apply_patch_tool_val,
include_apply_patch_tool: include_apply_patch_tool.unwrap_or(false),
include_subagent_tool: config_profile
.include_subagent_tool
.or(cfg.include_subagent_tool)
.or(include_subagent_tool)
.unwrap_or(false),
responses_originator_header,
preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT),
};
@@ -1044,11 +1129,13 @@ disable_response_storage = true
show_raw_agent_reasoning: false,
model_reasoning_effort: ReasoningEffort::High,
model_reasoning_summary: ReasoningSummary::Detailed,
model_verbosity: None,
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
experimental_resume: None,
base_instructions: None,
include_plan_tool: false,
include_apply_patch_tool: false,
include_subagent_tool: false,
responses_originator_header: "codex_cli_rs".to_string(),
preferred_auth_method: AuthMode::ChatGPT,
},
@@ -1097,11 +1184,13 @@ disable_response_storage = true
show_raw_agent_reasoning: false,
model_reasoning_effort: ReasoningEffort::default(),
model_reasoning_summary: ReasoningSummary::default(),
model_verbosity: None,
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
experimental_resume: None,
base_instructions: None,
include_plan_tool: false,
include_apply_patch_tool: false,
include_subagent_tool: false,
responses_originator_header: "codex_cli_rs".to_string(),
preferred_auth_method: AuthMode::ChatGPT,
};
@@ -1165,11 +1254,13 @@ disable_response_storage = true
show_raw_agent_reasoning: false,
model_reasoning_effort: ReasoningEffort::default(),
model_reasoning_summary: ReasoningSummary::default(),
model_verbosity: None,
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
experimental_resume: None,
base_instructions: None,
include_plan_tool: false,
include_apply_patch_tool: false,
include_subagent_tool: false,
responses_originator_header: "codex_cli_rs".to_string(),
preferred_auth_method: AuthMode::ChatGPT,
};
@@ -1178,4 +1269,74 @@ disable_response_storage = true
Ok(())
}
#[test]
fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> {
let codex_home = TempDir::new().unwrap();
let project_dir = TempDir::new().unwrap();
// Call the function under test
set_project_trusted(codex_home.path(), project_dir.path())?;
// Read back the generated config.toml and assert exact contents
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let contents = std::fs::read_to_string(&config_path)?;
let raw_path = project_dir.path().to_string_lossy();
let path_str = if raw_path.contains('\\') {
format!("'{}'", raw_path)
} else {
format!("\"{}\"", raw_path)
};
let expected = format!(
r#"[projects.{path_str}]
trust_level = "trusted"
"#
);
assert_eq!(contents, expected);
Ok(())
}
#[test]
fn test_set_project_trusted_converts_inline_to_explicit() -> anyhow::Result<()> {
let codex_home = TempDir::new().unwrap();
let project_dir = TempDir::new().unwrap();
// Seed config.toml with an inline project entry under [projects]
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let raw_path = project_dir.path().to_string_lossy();
let path_str = if raw_path.contains('\\') {
format!("'{}'", raw_path)
} else {
format!("\"{}\"", raw_path)
};
// Use a quoted key so backslashes don't require escaping on Windows
let initial = format!(
r#"[projects]
{path_str} = {{ trust_level = "untrusted" }}
"#
);
std::fs::create_dir_all(codex_home.path())?;
std::fs::write(&config_path, initial)?;
// Run the function; it should convert to explicit tables and set trusted
set_project_trusted(codex_home.path(), project_dir.path())?;
let contents = std::fs::read_to_string(&config_path)?;
// Assert exact output after conversion to explicit table
let expected = format!(
r#"[projects]
[projects.{path_str}]
trust_level = "trusted"
"#
);
assert_eq!(contents, expected);
Ok(())
}
// No test enforcing the presence of a standalone [projects] header.
}

View File

@@ -1,6 +1,7 @@
use serde::Deserialize;
use std::path::PathBuf;
use crate::config_types::Verbosity;
use crate::protocol::AskForApproval;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
@@ -17,6 +18,9 @@ pub struct ConfigProfile {
pub disable_response_storage: Option<bool>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
pub chatgpt_base_url: Option<String>,
pub experimental_instructions_file: Option<PathBuf>,
/// Include the `subagent.run` tool allowing the model to invoke configured subagents.
pub include_subagent_tool: Option<bool>,
}

View File

@@ -8,6 +8,8 @@ use std::path::PathBuf;
use wildmatch::WildMatchPattern;
use serde::Deserialize;
use serde::Serialize;
use strum_macros::Display;
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct McpServerConfig {
@@ -183,3 +185,43 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
}
}
}
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ReasoningEffort {
Low,
#[default]
Medium,
High,
/// Option to disable reasoning.
None,
}
/// A summary of the reasoning performed by the model. This can be useful for
/// debugging and understanding the model's reasoning process.
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ReasoningSummary {
#[default]
Auto,
Concise,
Detailed,
/// Option to disable reasoning summaries.
None,
}
/// Controls output length/detail on GPT-5 models via the Responses API.
/// Serialized with lowercase values to match the OpenAI API.
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum Verbosity {
Low,
#[default]
Medium,
High,
}

View File

@@ -1,4 +1,4 @@
use crate::models::ResponseItem;
use codex_protocol::models::ResponseItem;
/// Transcript of conversation history
#[derive(Debug, Clone, Default)]
@@ -66,7 +66,7 @@ impl ConversationHistory {
self.items.push(ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![crate::models::ContentItem::OutputText {
content: vec![codex_protocol::models::ContentItem::OutputText {
text: delta.to_string(),
}],
});
@@ -110,6 +110,8 @@ fn is_api_message(message: &ResponseItem) -> bool {
ResponseItem::Message { role, .. } => role.as_str() != "system",
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::Reasoning { .. } => true,
ResponseItem::Other => false,
@@ -118,11 +120,11 @@ fn is_api_message(message: &ResponseItem) -> bool {
/// Helper to append the textual content from `src` into `dst` in place.
fn append_text_content(
dst: &mut Vec<crate::models::ContentItem>,
src: &Vec<crate::models::ContentItem>,
dst: &mut Vec<codex_protocol::models::ContentItem>,
src: &Vec<codex_protocol::models::ContentItem>,
) {
for c in src {
if let crate::models::ContentItem::OutputText { text } = c {
if let codex_protocol::models::ContentItem::OutputText { text } = c {
append_text_delta(dst, text);
}
}
@@ -130,15 +132,15 @@ fn append_text_content(
/// Append a single text delta to the last OutputText item in `content`, or
/// push a new OutputText item if none exists.
fn append_text_delta(content: &mut Vec<crate::models::ContentItem>, delta: &str) {
if let Some(crate::models::ContentItem::OutputText { text }) = content
fn append_text_delta(content: &mut Vec<codex_protocol::models::ContentItem>, delta: &str) {
if let Some(codex_protocol::models::ContentItem::OutputText { text }) = content
.iter_mut()
.rev()
.find(|c| matches!(c, crate::models::ContentItem::OutputText { .. }))
.find(|c| matches!(c, codex_protocol::models::ContentItem::OutputText { .. }))
{
text.push_str(delta);
} else {
content.push(crate::models::ContentItem::OutputText {
content.push(codex_protocol::models::ContentItem::OutputText {
text: delta.to_string(),
});
}
@@ -147,7 +149,7 @@ fn append_text_delta(content: &mut Vec<crate::models::ContentItem>, delta: &str)
#[cfg(test)]
mod tests {
use super::*;
use crate::models::ContentItem;
use codex_protocol::models::ContentItem;
fn assistant_msg(text: &str) -> ResponseItem {
ResponseItem::Message {

View File

@@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use tokio::sync::RwLock;
use uuid::Uuid;
@@ -15,6 +16,7 @@ use crate::error::Result as CodexResult;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::SessionConfiguredEvent;
use codex_protocol::models::ResponseItem;
/// Represents a newly created Codex conversation, including the first event
/// (which is [`EventMsg::SessionConfigured`]).
@@ -28,34 +30,48 @@ pub struct NewConversation {
/// maintaining them in memory.
pub struct ConversationManager {
conversations: Arc<RwLock<HashMap<Uuid, Arc<CodexConversation>>>>,
}
impl Default for ConversationManager {
fn default() -> Self {
Self {
conversations: Arc::new(RwLock::new(HashMap::new())),
}
}
auth_manager: Arc<AuthManager>,
}
impl ConversationManager {
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
let auth = CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method)?;
self.new_conversation_with_auth(config, auth).await
pub fn new(auth_manager: Arc<AuthManager>) -> Self {
Self {
conversations: Arc::new(RwLock::new(HashMap::new())),
auth_manager,
}
}
/// Used for integration tests: should not be used by ordinary business
/// logic.
pub async fn new_conversation_with_auth(
/// Construct with a dummy AuthManager containing the provided CodexAuth.
/// Used for integration tests: should not be used by ordinary business logic.
pub fn with_auth(auth: CodexAuth) -> Self {
Self::new(codex_login::AuthManager::from_auth_for_testing(auth))
}
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
self.spawn_conversation(config, self.auth_manager.clone())
.await
}
async fn spawn_conversation(
&self,
config: Config,
auth: Option<CodexAuth>,
auth_manager: Arc<AuthManager>,
) -> CodexResult<NewConversation> {
let CodexSpawnOk {
codex,
session_id: conversation_id,
} = Codex::spawn(config, auth).await?;
} = {
let initial_history = None;
Codex::spawn(config, auth_manager, initial_history).await?
};
self.finalize_spawn(codex, conversation_id).await
}
async fn finalize_spawn(
&self,
codex: Codex,
conversation_id: Uuid,
) -> CodexResult<NewConversation> {
// The first event must be `SessionInitialized`. Validate and forward it
// to the caller so that they can display it in the conversation
// history.
@@ -93,4 +109,120 @@ impl ConversationManager {
.cloned()
.ok_or_else(|| CodexErr::ConversationNotFound(conversation_id))
}
/// Fork an existing conversation by dropping the last `drop_last_messages`
/// user/assistant messages from its transcript and starting a new
/// conversation with identical configuration (unless overridden by the
/// caller's `config`). The new conversation will have a fresh id.
pub async fn fork_conversation(
&self,
conversation_history: Vec<ResponseItem>,
num_messages_to_drop: usize,
config: Config,
) -> CodexResult<NewConversation> {
// Compute the prefix up to the cut point.
let truncated_history =
truncate_after_dropping_last_messages(conversation_history, num_messages_to_drop);
// Spawn a new conversation with the computed initial history.
let auth_manager = self.auth_manager.clone();
let CodexSpawnOk {
codex,
session_id: conversation_id,
} = Codex::spawn(config, auth_manager, Some(truncated_history)).await?;
self.finalize_spawn(codex, conversation_id).await
}
}
/// Return a prefix of `items` obtained by dropping the last `n` user messages
/// and all items that follow them.
fn truncate_after_dropping_last_messages(items: Vec<ResponseItem>, n: usize) -> Vec<ResponseItem> {
if n == 0 || items.is_empty() {
return items;
}
// Walk backwards counting only `user` Message items, find cut index.
let mut count = 0usize;
let mut cut_index = 0usize;
for (idx, item) in items.iter().enumerate().rev() {
if let ResponseItem::Message { role, .. } = item
&& role == "user"
{
count += 1;
if count == n {
// Cut everything from this user message to the end.
cut_index = idx;
break;
}
}
}
if count < n {
// If fewer than n messages exist, drop everything.
Vec::new()
} else {
items.into_iter().take(cut_index).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::models::ResponseItem;
fn user_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
}
}
fn assistant_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
}
}
#[test]
fn drops_from_last_user_only() {
let items = vec![
user_msg("u1"),
assistant_msg("a1"),
assistant_msg("a2"),
user_msg("u2"),
assistant_msg("a3"),
ResponseItem::Reasoning {
id: "r1".to_string(),
summary: vec![ReasoningItemReasoningSummary::SummaryText {
text: "s".to_string(),
}],
content: None,
encrypted_content: None,
},
ResponseItem::FunctionCall {
id: None,
name: "tool".to_string(),
arguments: "{}".to_string(),
call_id: "c1".to_string(),
},
assistant_msg("a4"),
];
let truncated = truncate_after_dropping_last_messages(items.clone(), 1);
assert_eq!(
truncated,
vec![items[0].clone(), items[1].clone(), items[2].clone()]
);
let truncated2 = truncate_after_dropping_last_messages(items, 2);
assert!(truncated2.is_empty());
}
}

View File

@@ -2,12 +2,12 @@ use serde::Deserialize;
use serde::Serialize;
use strum_macros::Display as DeriveDisplay;
use crate::models::ContentItem;
use crate::models::ResponseItem;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
use crate::shell::Shell;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use std::path::PathBuf;
/// wraps environment context message in a tag for the model to parse more easily.

View File

@@ -28,18 +28,17 @@ use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use serde_bytes::ByteBuf;
// Maximum we send for each stream, which is either:
// - 10KiB OR
// - 256 lines
const MAX_STREAM_OUTPUT: usize = 10 * 1024;
const MAX_STREAM_OUTPUT_LINES: usize = 256;
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
// Hardcode these since it does not seem worth including the libc crate just
// for these.
const SIGKILL_CODE: i32 = 9;
const TIMEOUT_CODE: i32 = 64;
const EXIT_CODE_SIGNAL_BASE: i32 = 128; // conventional shell: 128 + signal
// I/O buffer sizing
const READ_CHUNK_SIZE: usize = 8192; // bytes per read
const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB
#[derive(Debug, Clone)]
pub struct ExecParams {
@@ -153,6 +152,7 @@ pub async fn process_exec_tool_call(
exit_code,
stdout,
stderr,
aggregated_output: raw_output.aggregated_output.from_utf8_lossy(),
duration,
})
}
@@ -189,10 +189,11 @@ pub struct StreamOutput<T> {
pub truncated_after_lines: Option<u32>,
}
#[derive(Debug)]
pub struct RawExecToolCallOutput {
struct RawExecToolCallOutput {
pub exit_status: ExitStatus,
pub stdout: StreamOutput<Vec<u8>>,
pub stderr: StreamOutput<Vec<u8>>,
pub aggregated_output: StreamOutput<Vec<u8>>,
}
impl StreamOutput<String> {
@@ -213,11 +214,17 @@ impl StreamOutput<Vec<u8>> {
}
}
#[inline]
fn append_all(dst: &mut Vec<u8>, src: &[u8]) {
dst.extend_from_slice(src);
}
#[derive(Debug)]
pub struct ExecToolCallOutput {
pub exit_code: i32,
pub stdout: StreamOutput<String>,
pub stderr: StreamOutput<String>,
pub aggregated_output: StreamOutput<String>,
pub duration: Duration,
}
@@ -253,7 +260,7 @@ async fn exec(
/// Consumes the output of a child process, truncating it so it is suitable for
/// use as the output of a `shell` tool call. Also enforces specified timeout.
pub(crate) async fn consume_truncated_output(
async fn consume_truncated_output(
mut child: Child,
timeout: Duration,
stdout_stream: Option<StdoutStream>,
@@ -273,19 +280,19 @@ pub(crate) async fn consume_truncated_output(
))
})?;
let (agg_tx, agg_rx) = async_channel::unbounded::<Vec<u8>>();
let stdout_handle = tokio::spawn(read_capped(
BufReader::new(stdout_reader),
MAX_STREAM_OUTPUT,
MAX_STREAM_OUTPUT_LINES,
stdout_stream.clone(),
false,
Some(agg_tx.clone()),
));
let stderr_handle = tokio::spawn(read_capped(
BufReader::new(stderr_reader),
MAX_STREAM_OUTPUT,
MAX_STREAM_OUTPUT_LINES,
stdout_stream.clone(),
true,
Some(agg_tx.clone()),
));
let exit_status = tokio::select! {
@@ -297,38 +304,48 @@ pub(crate) async fn consume_truncated_output(
// timeout
child.start_kill()?;
// Debatable whether `child.wait().await` should be called here.
synthetic_exit_status(128 + TIMEOUT_CODE)
synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE)
}
}
}
_ = tokio::signal::ctrl_c() => {
child.start_kill()?;
synthetic_exit_status(128 + SIGKILL_CODE)
synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + SIGKILL_CODE)
}
};
let stdout = stdout_handle.await??;
let stderr = stderr_handle.await??;
drop(agg_tx);
let mut combined_buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY);
while let Ok(chunk) = agg_rx.recv().await {
append_all(&mut combined_buf, &chunk);
}
let aggregated_output = StreamOutput {
text: combined_buf,
truncated_after_lines: None,
};
Ok(RawExecToolCallOutput {
exit_status,
stdout,
stderr,
aggregated_output,
})
}
async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
mut reader: R,
max_output: usize,
max_lines: usize,
stream: Option<StdoutStream>,
is_stderr: bool,
aggregate_tx: Option<Sender<Vec<u8>>>,
) -> io::Result<StreamOutput<Vec<u8>>> {
let mut buf = Vec::with_capacity(max_output.min(8 * 1024));
let mut tmp = [0u8; 8192];
let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY);
let mut tmp = [0u8; READ_CHUNK_SIZE];
let mut remaining_bytes = max_output;
let mut remaining_lines = max_lines;
// No caps: append all bytes
loop {
let n = reader.read(&mut tmp).await?;
@@ -355,33 +372,17 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
let _ = stream.tx_event.send(event).await;
}
// Copy into the buffer only while we still have byte and line budget.
if remaining_bytes > 0 && remaining_lines > 0 {
let mut copy_len = 0;
for &b in &tmp[..n] {
if remaining_bytes == 0 || remaining_lines == 0 {
break;
}
copy_len += 1;
remaining_bytes -= 1;
if b == b'\n' {
remaining_lines -= 1;
}
}
buf.extend_from_slice(&tmp[..copy_len]);
if let Some(tx) = &aggregate_tx {
let _ = tx.send(tmp[..n].to_vec()).await;
}
// Continue reading to EOF to avoid back-pressure, but discard once caps are hit.
}
let truncated = remaining_lines == 0 || remaining_bytes == 0;
append_all(&mut buf, &tmp[..n]);
// Continue reading to EOF to avoid back-pressure
}
Ok(StreamOutput {
text: buf,
truncated_after_lines: if truncated {
Some((max_lines - remaining_lines) as u32)
} else {
None
},
truncated_after_lines: None,
})
}

View File

@@ -1,5 +1,6 @@
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::mcp_protocol::GitSha;
use futures::future::join_all;
@@ -425,6 +426,38 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
Some(diff)
}
/// Resolve the path that should be used for trust checks. Similar to
/// `[utils::is_inside_git_repo]`, but resolves to the root of the main
/// repository. Handles worktrees.
pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };
// TODO: we should make this async, but it's primarily used deep in
// callstacks of sync code, and should almost always be fast
let git_dir_out = std::process::Command::new("git")
.args(["rev-parse", "--git-common-dir"])
.current_dir(base)
.output()
.ok()?;
if !git_dir_out.status.success() {
return None;
}
let git_dir_s = String::from_utf8(git_dir_out.stdout)
.ok()?
.trim()
.to_string();
let git_dir_path_raw = if Path::new(&git_dir_s).is_absolute() {
PathBuf::from(&git_dir_s)
} else {
base.join(&git_dir_s)
};
// Normalize to handle macOS /var vs /private/var and resolve ".." segments.
let git_dir_path = std::fs::canonicalize(&git_dir_path_raw).unwrap_or(git_dir_path_raw);
git_dir_path.parent().map(Path::to_path_buf)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -732,6 +765,80 @@ mod tests {
assert_eq!(state.sha, GitSha::new(&remote_sha));
}
#[test]
fn resolve_root_git_project_for_trust_returns_none_outside_repo() {
let tmp = TempDir::new().expect("tempdir");
assert!(resolve_root_git_project_for_trust(tmp.path()).is_none());
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
let expected = std::fs::canonicalize(&repo_path).unwrap().to_path_buf();
assert_eq!(
resolve_root_git_project_for_trust(&repo_path),
Some(expected.clone())
);
let nested = repo_path.join("sub/dir");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(
resolve_root_git_project_for_trust(&nested),
Some(expected.clone())
);
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_root() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
// Create a linked worktree
let wt_root = temp_dir.path().join("wt");
let _ = std::process::Command::new("git")
.args([
"worktree",
"add",
wt_root.to_str().unwrap(),
"-b",
"feature/x",
])
.current_dir(&repo_path)
.output()
.expect("git worktree add");
let expected = std::fs::canonicalize(&repo_path).ok();
let got = resolve_root_git_project_for_trust(&wt_root)
.and_then(|p| std::fs::canonicalize(p).ok());
assert_eq!(got, expected);
let nested = wt_root.join("nested/sub");
std::fs::create_dir_all(&nested).unwrap();
let got_nested =
resolve_root_git_project_for_trust(&nested).and_then(|p| std::fs::canonicalize(p).ok());
assert_eq!(got_nested, expected);
}
#[test]
fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() {
let tmp = TempDir::new().expect("tempdir");
let proj = tmp.path().join("proj");
std::fs::create_dir_all(proj.join("nested")).unwrap();
// `.git` is a file but does not point to a worktrees path
std::fs::write(
proj.join(".git"),
format!(
"gitdir: {}\n",
tmp.path().join("some/other/location").display()
),
)
.unwrap();
assert!(resolve_root_git_project_for_trust(&proj).is_none());
assert!(resolve_root_git_project_for_trust(&proj.join("nested")).is_none());
}
#[tokio::test]
async fn test_get_git_working_tree_state_unpushed_commit() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");

View File

@@ -39,7 +39,6 @@ mod conversation_manager;
pub use conversation_manager::ConversationManager;
pub use conversation_manager::NewConversation;
pub mod model_family;
mod models;
mod openai_model_info;
mod openai_tools;
pub mod plan_tool;
@@ -50,6 +49,7 @@ pub mod seatbelt;
pub mod shell;
pub mod spawn;
pub mod terminal;
mod tool_apply_patch;
pub mod turn_diff_tracker;
pub mod user_agent;
mod user_notification;
@@ -62,3 +62,4 @@ pub use codex_protocol::protocol;
// Re-export protocol config enums to ensure call sites can use the same types
// as those in the protocol crate when constructing protocol messages.
pub use codex_protocol::config_types as protocol_config_types;
pub mod subagents;

View File

@@ -4,13 +4,13 @@ use std::time::Instant;
use tracing::error;
use crate::codex::Session;
use crate::models::FunctionCallOutputPayload;
use crate::models::ResponseInputItem;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::McpInvocation;
use crate::protocol::McpToolCallBeginEvent;
use crate::protocol::McpToolCallEndEvent;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
/// Handles the specified tool call dispatches the appropriate
/// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`.

View File

@@ -1,3 +1,5 @@
use crate::tool_apply_patch::ApplyPatchToolType;
/// A model family is a group of models that share certain characteristics.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ModelFamily {
@@ -24,9 +26,9 @@ pub struct ModelFamily {
// See https://platform.openai.com/docs/guides/tools-local-shell
pub uses_local_shell_tool: bool,
/// True if the model performs better when `apply_patch` is provided as
/// a tool call instead of just a bash command.
pub uses_apply_patch_tool: bool,
/// Present if the model performs better when `apply_patch` is provided as
/// a tool call instead of just a bash command
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
}
macro_rules! model_family {
@@ -40,7 +42,7 @@ macro_rules! model_family {
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
uses_local_shell_tool: false,
uses_apply_patch_tool: false,
apply_patch_tool_type: None,
};
// apply overrides
$(
@@ -60,7 +62,7 @@ macro_rules! simple_model_family {
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
uses_local_shell_tool: false,
uses_apply_patch_tool: false,
apply_patch_tool_type: None,
})
}};
}
@@ -88,6 +90,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
model_family!(
slug, slug,
supports_reasoning_summaries: true,
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
)
} else if slug.starts_with("gpt-4.1") {
model_family!(
@@ -95,7 +98,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
needs_special_apply_patch_instructions: true,
)
} else if slug.starts_with("gpt-oss") {
model_family!(slug, "gpt-oss", uses_apply_patch_tool: true)
model_family!(slug, "gpt-oss", apply_patch_tool_type: Some(ApplyPatchToolType::Function))
} else if slug.starts_with("gpt-4o") {
simple_model_family!(slug, "gpt-4o")
} else if slug.starts_with("gpt-3.5") {
@@ -104,6 +107,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
model_family!(
slug, "gpt-5",
supports_reasoning_summaries: true,
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
)
} else {
None

View File

@@ -9,6 +9,9 @@ use crate::model_family::ModelFamily;
use crate::plan_tool::PLAN_TOOL;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
use crate::tool_apply_patch::ApplyPatchToolType;
use crate::tool_apply_patch::create_apply_patch_freeform_tool;
use crate::tool_apply_patch::create_apply_patch_json_tool;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ResponsesApiTool {
@@ -21,6 +24,20 @@ pub struct ResponsesApiTool {
pub(crate) parameters: JsonSchema,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FreeformTool {
pub(crate) name: String,
pub(crate) description: String,
pub(crate) format: FreeformToolFormat,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FreeformToolFormat {
pub(crate) r#type: String,
pub(crate) syntax: String,
pub(crate) definition: String,
}
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
/// Responses API.
#[derive(Debug, Clone, Serialize, PartialEq)]
@@ -30,6 +47,8 @@ pub(crate) enum OpenAiTool {
Function(ResponsesApiTool),
#[serde(rename = "local_shell")]
LocalShell {},
#[serde(rename = "custom")]
Freeform(FreeformTool),
}
#[derive(Debug, Clone)]
@@ -43,7 +62,8 @@ pub enum ConfigShellToolType {
pub struct ToolsConfig {
pub shell_type: ConfigShellToolType,
pub plan_tool: bool,
pub apply_patch_tool: bool,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub subagent_tool: bool,
}
impl ToolsConfig {
@@ -53,6 +73,7 @@ impl ToolsConfig {
sandbox_policy: SandboxPolicy,
include_plan_tool: bool,
include_apply_patch_tool: bool,
include_subagent_tool: bool,
) -> Self {
let mut shell_type = if model_family.uses_local_shell_tool {
ConfigShellToolType::LocalShell
@@ -65,10 +86,23 @@ impl ToolsConfig {
}
}
let apply_patch_tool_type = match model_family.apply_patch_tool_type {
Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform),
Some(ApplyPatchToolType::Function) => Some(ApplyPatchToolType::Function),
None => {
if include_apply_patch_tool {
Some(ApplyPatchToolType::Freeform)
} else {
None
}
}
};
Self {
shell_type,
plan_tool: include_plan_tool,
apply_patch_tool: include_apply_patch_tool || model_family.uses_apply_patch_tool,
apply_patch_tool_type,
subagent_tool: include_subagent_tool,
}
}
}
@@ -115,16 +149,20 @@ fn create_shell_tool() -> OpenAiTool {
"command".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: None,
description: Some("The command to execute".to_string()),
},
);
properties.insert(
"workdir".to_string(),
JsonSchema::String { description: None },
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
);
properties.insert(
"timeout".to_string(),
JsonSchema::Number { description: None },
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
@@ -155,7 +193,7 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool {
},
);
properties.insert(
"timeout".to_string(),
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
@@ -171,7 +209,7 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool {
properties.insert(
"justification".to_string(),
JsonSchema::String {
description: Some("Only set if ask_for_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
},
);
}
@@ -237,92 +275,16 @@ The shell tool is used to execute shell commands.
},
})
}
/// TODO(dylan): deprecate once we get rid of json tool
#[derive(Serialize, Deserialize)]
pub(crate) struct ApplyPatchToolArgs {
pub(crate) input: String,
}
fn create_apply_patch_tool() -> OpenAiTool {
// Minimal schema: one required string argument containing the patch body
let mut properties = BTreeMap::new();
properties.insert(
"input".to_string(),
JsonSchema::String {
description: Some(r#"The entire contents of the apply_patch command"#.to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "apply_patch".to_string(),
description: r#"Use this tool to edit files.
Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
**_ Begin Patch
[ one or more file sections ]
_** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
**_ Add File: <path> - create a new file. Every following line is a + line (the initial contents).
_** Delete File: <path> - remove an existing file. Nothing follows.
\*\*\* Update File: <path> - patch an existing file in place (optionally with a rename).
May be immediately followed by \*\*\* Move to: <new path> if you want to rename the file.
Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).
Within a hunk each line starts with:
- for inserted text,
* for removed text, or
space ( ) for context.
At the end of a truncated hunk you can emit \*\*\* End of File.
Patch := Begin { FileOp } End
Begin := "**_ Begin Patch" NEWLINE
End := "_** End Patch" NEWLINE
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "**_ Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile := "_** Delete File: " path NEWLINE
UpdateFile := "**_ Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "_** Move to: " newPath NEWLINE
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
HunkLine := (" " | "-" | "+") text NEWLINE
A full patch can combine several operations:
**_ Begin Patch
_** Add File: hello.txt
+Hello world
**_ Update File: src/app.py
_** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
**_ Delete File: obsolete.txt
_** End Patch
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file
"#
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["input".to_string()]),
additional_properties: Some(false),
},
})
}
/// Returns JSON values that are compatible with Function Calling in the
/// Responses API:
/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
pub(crate) fn create_tools_json_for_responses_api(
pub fn create_tools_json_for_responses_api(
tools: &Vec<OpenAiTool>,
) -> crate::error::Result<Vec<serde_json::Value>> {
let mut tools_json = Vec::new();
@@ -539,8 +501,21 @@ pub(crate) fn get_openai_tools(
tools.push(PLAN_TOOL.clone());
}
if config.apply_patch_tool {
tools.push(create_apply_patch_tool());
if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type {
match apply_patch_tool_type {
ApplyPatchToolType::Freeform => {
tools.push(create_apply_patch_freeform_tool());
}
ApplyPatchToolType::Function => {
tools.push(create_apply_patch_json_tool());
}
}
}
if config.subagent_tool {
tracing::trace!("Adding subagent tool");
tools.push(crate::subagents::SUBAGENT_TOOL.clone());
tools.push(crate::subagents::SUBAGENT_LIST_TOOL.clone());
}
if let Some(mcp_tools) = mcp_tools {
@@ -554,6 +529,7 @@ pub(crate) fn get_openai_tools(
}
}
tracing::trace!("Tools: {tools:?}");
tools
}
@@ -571,6 +547,7 @@ mod tests {
.map(|tool| match tool {
OpenAiTool::Function(ResponsesApiTool { name, .. }) => name,
OpenAiTool::LocalShell {} => "local_shell",
OpenAiTool::Freeform(FreeformTool { name, .. }) => name,
})
.collect::<Vec<_>>();
@@ -596,7 +573,8 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
true,
model_family.uses_apply_patch_tool,
false,
false,
);
let tools = get_openai_tools(&config, Some(HashMap::new()));
@@ -611,7 +589,8 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
true,
model_family.uses_apply_patch_tool,
false,
false,
);
let tools = get_openai_tools(&config, Some(HashMap::new()));
@@ -626,7 +605,8 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
model_family.uses_apply_patch_tool,
false,
false,
);
let tools = get_openai_tools(
&config,
@@ -720,7 +700,8 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
model_family.uses_apply_patch_tool,
false,
false,
);
let tools = get_openai_tools(
@@ -776,7 +757,8 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
model_family.uses_apply_patch_tool,
false,
false,
);
let tools = get_openai_tools(
@@ -827,7 +809,8 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
model_family.uses_apply_patch_tool,
false,
false,
);
let tools = get_openai_tools(
@@ -881,7 +864,8 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
model_family.uses_apply_patch_tool,
false,
false,
);
let tools = get_openai_tools(

View File

@@ -2,13 +2,13 @@ use std::collections::BTreeMap;
use std::sync::LazyLock;
use crate::codex::Session;
use crate::models::FunctionCallOutputPayload;
use crate::models::ResponseInputItem;
use crate::openai_tools::JsonSchema;
use crate::openai_tools::OpenAiTool;
use crate::openai_tools::ResponsesApiTool;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
// Use the canonical plan tool types from the protocol crate to ensure
// type-identity matches events transported via `codex_protocol`.

View File

@@ -22,7 +22,7 @@ use uuid::Uuid;
use crate::config::Config;
use crate::git_info::GitInfo;
use crate::git_info::collect_git_info;
use crate::models::ResponseItem;
use codex_protocol::models::ResponseItem;
const SESSIONS_SUBDIR: &str = "sessions";
@@ -132,6 +132,8 @@ impl RolloutRecorder {
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => filtered.push(item.clone()),
ResponseItem::Other => {
// These should never be serialized.
@@ -194,6 +196,8 @@ impl RolloutRecorder {
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => items.push(item),
ResponseItem::Other => {}
},
@@ -317,6 +321,8 @@ async fn rollout_writer(
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => {
writer.write_line(&item).await?;
}

View File

@@ -0,0 +1,32 @@
use serde::Deserialize;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Deserialize)]
pub struct SubagentDefinition {
pub name: String,
pub description: String,
/// Base instructions for this subagent.
pub instructions: String,
/// When not set, inherits the parent agent's tool set. When set to an
/// empty list, no tools are available to the subagent.
#[serde(default)]
pub tools: Option<Vec<String>>, // None => inherit; Some(vec) => allow-list
}
impl SubagentDefinition {
pub fn from_json_str(s: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str::<Self>(s)
}
pub fn from_file(path: &Path) -> std::io::Result<Self> {
let contents = fs::read_to_string(path)?;
// Surface JSON parsing error with file context
serde_json::from_str::<Self>(&contents).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("invalid subagent JSON at {}: {e}", path.display()),
)
})
}
}

View File

@@ -0,0 +1,6 @@
pub mod definition;
pub mod registry;
pub mod runner;
pub mod tool;
pub(crate) use tool::{SUBAGENT_LIST_TOOL, SUBAGENT_TOOL};

View File

@@ -0,0 +1,92 @@
use super::definition::SubagentDefinition;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, Default, Clone)]
pub struct SubagentRegistry {
/// Directory under the project (cwd/.codex/agents).
project_dir: Option<PathBuf>,
/// Directory under CODEX_HOME (~/.codex/agents).
user_dir: Option<PathBuf>,
/// Merged map: project definitions override user ones.
map: HashMap<String, SubagentDefinition>,
}
impl SubagentRegistry {
pub fn new(project_dir: Option<PathBuf>, user_dir: Option<PathBuf>) -> Self {
Self {
project_dir,
user_dir,
map: HashMap::new(),
}
}
/// Loads JSON files from user_dir then project_dir (project wins on conflict).
pub fn load(&mut self) {
let mut map: HashMap<String, SubagentDefinition> = HashMap::new();
// Load user definitions first
if let Some(dir) = &self.user_dir {
Self::load_from_dir_into(dir, &mut map);
}
// Then load project definitions which override on conflicts
if let Some(dir) = &self.project_dir {
Self::load_from_dir_into(dir, &mut map);
}
// Ensure a simple builtin test subagent exists to validate wiring endtoend.
// Users can override this by providing their own definition named "hello".
if !map.contains_key("hello") {
map.insert(
"hello".to_string(),
SubagentDefinition {
name: "hello".to_string(),
description: "Builtin test subagent that replies with a greeting".to_string(),
// Keep instructions narrow so models reliably output the intended text.
instructions:
"Reply with exactly this text and nothing else: Hello from subagent"
.to_string(),
// Disallow tool usage for the hello subagent.
tools: Some(Vec::new()),
},
);
}
self.map = map;
}
pub fn get(&self, name: &str) -> Option<&SubagentDefinition> {
self.map.get(name)
}
pub fn all_names(&self) -> Vec<String> {
self.map.keys().cloned().collect()
}
fn load_from_dir_into(dir: &Path, out: &mut HashMap<String, SubagentDefinition>) {
let Ok(iter) = fs::read_dir(dir) else {
return;
};
for entry in iter.flatten() {
let path = entry.path();
if path.is_file()
&& path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("json"))
.unwrap_or(false)
{
match SubagentDefinition::from_file(&path) {
Ok(def) => {
out.insert(def.name.clone(), def);
}
Err(e) => {
tracing::warn!("Failed to load subagent from {}: {}", path.display(), e);
}
}
}
}
}
}

View File

@@ -0,0 +1,142 @@
use crate::codex::Codex;
use crate::error::Result as CodexResult;
use super::definition::SubagentDefinition;
use super::registry::SubagentRegistry;
/// Arguments expected for the `subagent.run` tool.
#[derive(serde::Deserialize)]
pub struct RunSubagentArgs {
pub name: String,
pub input: String,
#[serde(default)]
pub context: Option<String>,
}
/// Run a subagent in a nested Codex session and return the final message.
pub(crate) async fn run(
sess: &crate::codex::Session,
turn_context: &crate::codex::TurnContext,
registry: &SubagentRegistry,
args: RunSubagentArgs,
_parent_sub_id: &str,
) -> CodexResult<String> {
let def: &SubagentDefinition = registry.get(&args.name).ok_or_else(|| {
crate::error::CodexErr::Stream(format!("unknown subagent: {}", args.name), None)
})?;
let mut nested_cfg = (*sess.base_config()).clone();
nested_cfg.base_instructions = Some(def.instructions.clone());
nested_cfg.user_instructions = None;
nested_cfg.approval_policy = turn_context.approval_policy;
nested_cfg.sandbox_policy = turn_context.sandbox_policy.clone();
nested_cfg.cwd = turn_context.cwd.clone();
nested_cfg.include_subagent_tool = false;
let nested = Codex::spawn(nested_cfg, sess.auth_manager(), None).await?;
let nested_codex = nested.codex;
let subagent_id = uuid::Uuid::new_v4().to_string();
forward_begin(sess, _parent_sub_id, &subagent_id, &def.name).await;
let text = match args.context {
Some(ctx) if !ctx.trim().is_empty() => format!("{ctx}\n\n{input}", input = args.input),
_ => args.input,
};
nested_codex
.submit(crate::protocol::Op::UserInput {
items: vec![crate::protocol::InputItem::Text { text }],
})
.await
.map_err(|e| {
crate::error::CodexErr::Stream(format!("failed to submit to subagent: {e}"), None)
})?;
let mut last_message: Option<String> = None;
loop {
let ev = nested_codex.next_event().await?;
match ev.msg.clone() {
crate::protocol::EventMsg::AgentMessage(m) => {
last_message = Some(m.message);
}
crate::protocol::EventMsg::TaskComplete(t) => {
let _ = nested_codex.submit(crate::protocol::Op::Shutdown).await;
forward_forwarded(sess, _parent_sub_id, &subagent_id, &def.name, ev.msg).await;
forward_end(
sess,
_parent_sub_id,
&subagent_id,
&def.name,
true,
t.last_agent_message.clone(),
)
.await;
return Ok(t
.last_agent_message
.unwrap_or_else(|| last_message.unwrap_or_default()));
}
_ => {}
}
forward_forwarded(sess, _parent_sub_id, &subagent_id, &def.name, ev.msg).await;
}
}
async fn forward_begin(
sess: &crate::codex::Session,
parent_sub_id: &str,
subagent_id: &str,
name: &str,
) {
sess
.send_event(crate::protocol::Event {
id: parent_sub_id.to_string(),
msg: crate::protocol::EventMsg::SubagentBegin(crate::protocol::SubagentBeginEvent {
subagent_id: subagent_id.to_string(),
name: name.to_string(),
}),
})
.await;
}
async fn forward_forwarded(
sess: &crate::codex::Session,
parent_sub_id: &str,
subagent_id: &str,
name: &str,
msg: crate::protocol::EventMsg,
) {
sess
.send_event(crate::protocol::Event {
id: parent_sub_id.to_string(),
msg: crate::protocol::EventMsg::SubagentForwarded(
crate::protocol::SubagentForwardedEvent {
subagent_id: subagent_id.to_string(),
name: name.to_string(),
event: Box::new(msg),
},
),
})
.await;
}
async fn forward_end(
sess: &crate::codex::Session,
parent_sub_id: &str,
subagent_id: &str,
name: &str,
success: bool,
last_agent_message: Option<String>,
) {
sess
.send_event(crate::protocol::Event {
id: parent_sub_id.to_string(),
msg: crate::protocol::EventMsg::SubagentEnd(crate::protocol::SubagentEndEvent {
subagent_id: subagent_id.to_string(),
name: name.to_string(),
success,
last_agent_message,
}),
})
.await;
}

View File

@@ -0,0 +1,54 @@
use std::collections::BTreeMap;
use std::sync::LazyLock;
use crate::openai_tools::JsonSchema;
use crate::openai_tools::OpenAiTool;
use crate::openai_tools::ResponsesApiTool;
pub(crate) static SUBAGENT_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
let mut properties = BTreeMap::new();
properties.insert(
"name".to_string(),
JsonSchema::String {
description: Some("Registered subagent name".to_string()),
},
);
properties.insert(
"input".to_string(),
JsonSchema::String {
description: Some("Task or instruction for the subagent".to_string()),
},
);
properties.insert(
"context".to_string(),
JsonSchema::String {
description: Some("Optional extra context to aid the task".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "subagent_run".to_string(),
description: "Invoke a named subagent with isolated context and return its result"
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["name".to_string(), "input".to_string()]),
additional_properties: Some(false),
},
})
});
pub(crate) static SUBAGENT_LIST_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
let properties = BTreeMap::new();
OpenAiTool::Function(ResponsesApiTool {
name: "subagent_list".to_string(),
description: "List available subagents (name and description). Call before subagent_run if unsure.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false),
},
})
});

View File

@@ -0,0 +1,145 @@
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use crate::openai_tools::FreeformTool;
use crate::openai_tools::FreeformToolFormat;
use crate::openai_tools::JsonSchema;
use crate::openai_tools::OpenAiTool;
use crate::openai_tools::ResponsesApiTool;
#[derive(Serialize, Deserialize)]
pub(crate) struct ApplyPatchToolArgs {
pub(crate) input: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ApplyPatchToolType {
Freeform,
Function,
}
/// Returns a custom tool that can be used to edit files. Well-suited for GPT-5 models
/// https://platform.openai.com/docs/guides/function-calling#custom-tools
pub(crate) fn create_apply_patch_freeform_tool() -> OpenAiTool {
OpenAiTool::Freeform(FreeformTool {
name: "apply_patch".to_string(),
description: "Use the `apply_patch` tool to edit files".to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: r#"start: begin_patch hunk+ end_patch
begin_patch: "*** Begin Patch" LF
end_patch: "*** End Patch" LF?
hunk: add_hunk | delete_hunk | update_hunk
add_hunk: "*** Add File: " filename LF add_line+
delete_hunk: "*** Delete File: " filename LF
update_hunk: "*** Update File: " filename LF change_move? change?
filename: /(.+)/
add_line: "+" /(.+)/ LF -> line
change_move: "*** Move to: " filename LF
change: (change_context | change_line)+ eof_line?
change_context: ("@@" | "@@ " /(.+)/) LF
change_line: ("+" | "-" | " ") /(.+)/ LF
eof_line: "*** End of File" LF
%import common.LF
"#
.to_string(),
},
})
}
/// Returns a json tool that can be used to edit files. Should only be used with gpt-oss models
pub(crate) fn create_apply_patch_json_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"input".to_string(),
JsonSchema::String {
description: Some(r#"The entire contents of the apply_patch command"#.to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "apply_patch".to_string(),
description: r#"Use the `apply_patch` tool to edit files.
Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
*** Begin Patch
[ one or more file sections ]
*** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
*** Update File: <path> - patch an existing file in place (optionally with a rename).
May be immediately followed by *** Move to: <new path> if you want to rename the file.
Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).
Within a hunk each line starts with:
For instructions on [context_before] and [context_after]:
- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first changes [context_after] lines in the second changes [context_before] lines.
- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
@@ class BaseClass
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
@@ class BaseClass
@@ def method():
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
The full grammar definition is below:
Patch := Begin { FileOp } End
Begin := "*** Begin Patch" NEWLINE
End := "*** End Patch" NEWLINE
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile := "*** Delete File: " path NEWLINE
UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "*** Move to: " newPath NEWLINE
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
HunkLine := (" " | "-" | "+") text NEWLINE
A full patch can combine several operations:
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file
- File references can only be relative, NEVER ABSOLUTE.
"#
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["input".to_string()]),
additional_properties: Some(false),
},
})
}

View File

@@ -142,13 +142,14 @@ async fn includes_session_id_and_model_headers_in_request() {
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
let conversation_manager = ConversationManager::default();
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let NewConversation {
conversation: codex,
conversation_id,
session_configured: _,
} = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.new_conversation(config)
.await
.expect("create new conversation");
@@ -207,9 +208,10 @@ async fn includes_base_instructions_override_in_request() {
config.base_instructions = Some("test instructions".to_string());
config.model_provider = model_provider;
let conversation_manager = ConversationManager::default();
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.new_conversation(config)
.await
.expect("create new conversation")
.conversation;
@@ -262,9 +264,10 @@ async fn originator_config_override_is_used() {
config.model_provider = model_provider;
config.responses_originator_header = "my_override".to_owned();
let conversation_manager = ConversationManager::default();
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.new_conversation(config)
.await
.expect("create new conversation")
.conversation;
@@ -318,13 +321,13 @@ async fn chatgpt_auth_sends_correct_request() {
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
let conversation_manager = ConversationManager::default();
let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth());
let NewConversation {
conversation: codex,
conversation_id,
session_configured: _,
} = conversation_manager
.new_conversation_with_auth(config, Some(create_dummy_codex_auth()))
.new_conversation(config)
.await
.expect("create new conversation");
@@ -411,7 +414,13 @@ async fn prefers_chatgpt_token_when_config_prefers_chatgpt() {
config.model_provider = model_provider;
config.preferred_auth_method = AuthMode::ChatGPT;
let conversation_manager = ConversationManager::default();
let auth_manager =
match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) {
Ok(Some(auth)) => codex_login::AuthManager::from_auth_for_testing(auth),
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {}", e),
};
let conversation_manager = ConversationManager::new(auth_manager);
let NewConversation {
conversation: codex,
..
@@ -486,7 +495,13 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
config.model_provider = model_provider;
config.preferred_auth_method = AuthMode::ApiKey;
let conversation_manager = ConversationManager::default();
let auth_manager =
match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) {
Ok(Some(auth)) => codex_login::AuthManager::from_auth_for_testing(auth),
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {}", e),
};
let conversation_manager = ConversationManager::new(auth_manager);
let NewConversation {
conversation: codex,
..
@@ -540,9 +555,10 @@ async fn includes_user_instructions_message_in_request() {
config.model_provider = model_provider;
config.user_instructions = Some("be nice".to_string());
let conversation_manager = ConversationManager::default();
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.new_conversation(config)
.await
.expect("create new conversation")
.conversation;
@@ -632,9 +648,9 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = provider;
let conversation_manager = ConversationManager::default();
let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth());
let codex = conversation_manager
.new_conversation_with_auth(config, None)
.new_conversation(config)
.await
.expect("create new conversation")
.conversation;
@@ -708,9 +724,9 @@ async fn env_var_overrides_loaded_auth() {
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = provider;
let conversation_manager = ConversationManager::default();
let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth());
let codex = conversation_manager
.new_conversation_with_auth(config, Some(create_dummy_codex_auth()))
.new_conversation(config)
.await
.expect("create new conversation")
.conversation;

View File

@@ -141,9 +141,9 @@ async fn summarize_context_three_requests_and_instructions() {
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home);
config.model_provider = model_provider;
let conversation_manager = ConversationManager::default();
let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy"));
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("dummy")))
.new_conversation(config)
.await
.unwrap()
.conversation;

View File

@@ -70,12 +70,12 @@ async fn truncates_output_lines() {
let output = run_test_cmd(tmp, cmd).await.unwrap();
let expected_output = (1..=256)
let expected_output = (1..=300)
.map(|i| format!("{i}\n"))
.collect::<Vec<_>>()
.join("");
assert_eq!(output.stdout.text, expected_output);
assert_eq!(output.stdout.truncated_after_lines, Some(256));
assert_eq!(output.stdout.truncated_after_lines, None);
}
/// Command succeeds with exit code 0 normally
@@ -91,8 +91,8 @@ async fn truncates_output_bytes() {
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));
assert!(output.stdout.text.len() >= 15000);
assert_eq!(output.stdout.truncated_after_lines, None);
}
/// Command not found returns exit code 127, this is not considered a sandbox error

View File

@@ -139,3 +139,34 @@ async fn test_exec_stderr_stream_events_echo() {
}
assert_eq!(String::from_utf8_lossy(&err), "oops\n");
}
#[tokio::test]
async fn test_aggregated_output_interleaves_in_order() {
// Spawn a shell that alternates stdout and stderr with sleeps to enforce order.
let cmd = vec![
"/bin/sh".to_string(),
"-c".to_string(),
"printf 'O1\\n'; sleep 0.01; printf 'E1\\n' 1>&2; sleep 0.01; printf 'O2\\n'; sleep 0.01; printf 'E2\\n' 1>&2".to_string(),
];
let params = ExecParams {
command: cmd,
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
timeout_ms: Some(5_000),
env: HashMap::new(),
with_escalated_permissions: None,
justification: None,
};
let policy = SandboxPolicy::new_read_only_policy();
let result = process_exec_tool_call(params, SandboxType::None, &policy, &None, None)
.await
.expect("process_exec_tool_call");
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.text, "O1\nO2\n");
assert_eq!(result.stderr.text, "E1\nE2\n");
assert_eq!(result.aggregated_output.text, "O1\nE1\nO2\nE2\n");
assert_eq!(result.aggregated_output.truncated_after_lines, None);
}

View File

@@ -1,6 +1,9 @@
#![allow(clippy::unwrap_used)]
use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
use codex_core::built_in_model_providers;
use codex_core::model_family::find_family_for_model;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
@@ -25,6 +28,185 @@ fn sse_completed(id: &str) -> String {
load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
}
fn assert_tool_names(body: &serde_json::Value, expected_names: &[&str]) {
assert_eq!(
body["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap().to_string())
.collect::<Vec<_>>(),
expected_names
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn codex_mini_latest_tools() {
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 conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
config.include_apply_patch_tool = false;
config.model = "codex-mini-latest".to_string();
config.model_family = find_family_for_model("codex-mini-latest").unwrap();
let codex = conversation_manager
.new_conversation(config)
.await
.expect("create new conversation")
.conversation;
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_instructions = [
include_str!("../prompt.md"),
include_str!("../../apply-patch/apply_patch_tool_instructions.md"),
]
.join("\n");
let body0 = requests[0].body_json::<serde_json::Value>().unwrap();
assert_eq!(
body0["instructions"],
serde_json::json!(expected_instructions),
);
let body1 = requests[1].body_json::<serde_json::Value>().unwrap();
assert_eq!(
body1["instructions"],
serde_json::json!(expected_instructions),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn prompt_tools_are_consistent_across_requests() {
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());
config.include_apply_patch_tool = true;
config.include_plan_tool = true;
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = conversation_manager
.new_conversation(config)
.await
.expect("create new conversation")
.conversation;
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_instructions: &str = include_str!("../prompt.md");
// our internal implementation is responsible for keeping tools in sync
// with the OpenAI schema, so we just verify the tool presence here
let expected_tools_names: &[&str] = &["shell", "update_plan", "apply_patch"];
let body0 = requests[0].body_json::<serde_json::Value>().unwrap();
assert_eq!(
body0["instructions"],
serde_json::json!(expected_instructions),
);
assert_tool_names(&body0, expected_tools_names);
let body1 = requests[1].body_json::<serde_json::Value>().unwrap();
assert_eq!(
body1["instructions"],
serde_json::json!(expected_instructions),
);
assert_tool_names(&body1, expected_tools_names);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn prefixes_context_and_instructions_once_and_consistently_across_requests() {
use pretty_assertions::assert_eq;
@@ -56,9 +238,10 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
config.model_provider = model_provider;
config.user_instructions = Some("be consistent and helpful".to_string());
let conversation_manager = ConversationManager::default();
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.new_conversation(config)
.await
.expect("create new conversation")
.conversation;
@@ -177,9 +360,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
config.model_provider = model_provider;
config.user_instructions = Some("be consistent and helpful".to_string());
let conversation_manager = ConversationManager::default();
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.new_conversation(config)
.await
.expect("create new conversation")
.conversation;
@@ -299,9 +483,10 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() {
config.model_provider = model_provider;
config.user_instructions = Some("be consistent and helpful".to_string());
let conversation_manager = ConversationManager::default();
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.new_conversation(config)
.await
.expect("create new conversation")
.conversation;

View File

@@ -88,9 +88,10 @@ async fn continue_after_stream_error() {
config.base_instructions = Some("You are a helpful assistant".to_string());
config.model_provider = provider;
let conversation_manager = ConversationManager::default();
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.new_conversation(config)
.await
.unwrap()
.conversation;

View File

@@ -93,9 +93,10 @@ async fn retries_on_early_close() {
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
let conversation_manager = ConversationManager::default();
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = conversation_manager
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
.new_conversation(config)
.await
.unwrap()
.conversation;

View File

@@ -25,6 +25,7 @@ codex-common = { path = "../common", features = [
"sandbox_summary",
] }
codex-core = { path = "../core" }
codex-login = { path = "../login" }
codex-ollama = { path = "../ollama" }
codex-protocol = { path = "../protocol" }
owo-colors = "4.2.0"

View File

@@ -168,6 +168,15 @@ impl EventProcessor for EventProcessorWithHumanOutput {
fn process_event(&mut self, event: Event) -> CodexStatus {
let Event { id: _, msg } = event;
match msg {
EventMsg::SubagentBegin(_) => {
// Ignore in human output for now.
}
EventMsg::SubagentForwarded(_) => {
// Ignore; TUI will render forwarded events.
}
EventMsg::SubagentEnd(_) => {
// Ignore in human output for now.
}
EventMsg::Error(ErrorEvent { message }) => {
let prefix = "ERROR:".style(self.red);
ts_println!(self, "{prefix} {message}");
@@ -287,10 +296,10 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::ExecCommandOutputDelta(_) => {}
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
stdout,
stderr,
aggregated_output,
duration,
exit_code,
..
}) => {
let exec_command = self.call_id_to_command.remove(&call_id);
let (duration, call) = if let Some(ExecCommandBegin { command, .. }) = exec_command
@@ -303,8 +312,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
("".to_string(), format!("exec('{call_id}')"))
};
let output = if exit_code == 0 { stdout } else { stderr };
let truncated_output = output
let truncated_output = aggregated_output
.lines()
.take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL)
.collect::<Vec<_>>()
@@ -539,6 +547,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
},
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
EventMsg::ConversationHistory(_) => {}
}
CodexStatus::Running
}

View File

@@ -41,6 +41,12 @@ impl EventProcessor for EventProcessorWithJsonOutput {
fn process_event(&mut self, event: Event) -> CodexStatus {
match event.msg {
EventMsg::SubagentBegin(_)
| EventMsg::SubagentForwarded(_)
| EventMsg::SubagentEnd(_) => {
// Ignored for JSON output in exec for now.
CodexStatus::Running
}
EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) => {
// Suppress streaming events in JSON mode.
CodexStatus::Running

View File

@@ -20,6 +20,7 @@ use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::util::is_inside_git_repo;
use codex_login::AuthManager;
use codex_ollama::DEFAULT_OSS_MODEL;
use codex_protocol::config_types::SandboxMode;
use event_processor_with_human_output::EventProcessorWithHumanOutput;
@@ -145,6 +146,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
model_provider,
codex_linux_sandbox_exe,
base_instructions: None,
include_subagent_tool: None,
include_plan_tool: None,
include_apply_patch_tool: None,
disable_response_storage: oss.then_some(true),
@@ -185,7 +187,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
std::process::exit(1);
}
let conversation_manager = ConversationManager::default();
let conversation_manager = ConversationManager::new(AuthManager::shared(
config.codex_home.clone(),
config.preferred_auth_method,
));
let NewConversation {
conversation_id: _,
conversation,

View File

@@ -123,6 +123,155 @@ async fn test_apply_patch_tool() -> anyhow::Result<()> {
// Start a mock model server
let server = MockServer::start().await;
// First response: model calls apply_patch to create test.md
let first = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(
load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_ADD, "call1"),
"text/event-stream",
);
Mock::given(method("POST"))
// .and(path("/v1/responses"))
.respond_with(first)
.up_to_n_times(1)
.mount(&server)
.await;
// Second response: model calls apply_patch to update test.md
let second = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(
load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_UPDATE, "call2"),
"text/event-stream",
);
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(second)
.up_to_n_times(1)
.mount(&server)
.await;
let final_completed = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(
load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_COMPLETED, "resp3"),
"text/event-stream",
);
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(final_completed)
.expect(1)
.mount(&server)
.await;
let tmp_cwd = TempDir::new().unwrap();
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.current_dir(tmp_cwd.path())
.env("CODEX_HOME", tmp_cwd.path())
.env("OPENAI_API_KEY", "dummy")
.env("OPENAI_BASE_URL", format!("{}/v1", server.uri()))
.arg("--skip-git-repo-check")
.arg("-s")
.arg("workspace-write")
.arg("foo")
.assert()
.success();
// Verify final file contents
let final_path = tmp_cwd.path().join("test.md");
let contents = std::fs::read_to_string(&final_path)
.unwrap_or_else(|e| panic!("failed reading {}: {e}", final_path.display()));
assert_eq!(contents, "Final text\n");
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[tokio::test]
async fn test_apply_patch_freeform_tool() -> anyhow::Result<()> {
use core_test_support::load_sse_fixture_with_id_from_str;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const SSE_TOOL_CALL_ADD: &str = r#"[
{
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"name": "apply_patch",
"input": "*** Begin Patch\n*** Add File: test.md\n+Hello world\n*** End Patch",
"call_id": "__ID__"
}
},
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]"#;
const SSE_TOOL_CALL_UPDATE: &str = r#"[
{
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"name": "apply_patch",
"input": "*** Begin Patch\n*** Update File: test.md\n@@\n-Hello world\n+Final text\n*** End Patch",
"call_id": "__ID__"
}
},
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]"#;
const SSE_TOOL_CALL_COMPLETED: &str = r#"[
{
"type": "response.completed",
"response": {
"id": "__ID__",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
}
]"#;
// Start a mock model server
let server = MockServer::start().await;
// First response: model calls apply_patch to create test.md
let first = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")

View File

@@ -26,7 +26,7 @@ multimap = "0.10.0"
path-absolutize = "3.1.1"
regex-lite = "0.1"
serde = { version = "1.0.194", features = ["derive"] }
serde_json = "1.0.142"
serde_json = "1.0.143"
serde_with = { version = "3", features = ["macros"] }
[dev-dependencies]

View File

@@ -17,5 +17,5 @@ clap = { version = "4", features = ["derive"] }
ignore = "0.4.23"
nucleo-matcher = "0.3.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0.142"
serde_json = "1.0.143"
tokio = { version = "1", features = ["full"] }

View File

@@ -0,0 +1,129 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use crate::AuthMode;
use crate::CodexAuth;
/// Internal cached auth state.
#[derive(Clone, Debug)]
struct CachedAuth {
preferred_auth_mode: AuthMode,
auth: Option<CodexAuth>,
}
/// Central manager providing a single source of truth for auth.json derived
/// authentication data. It loads once (or on preference change) and then
/// hands out cloned `CodexAuth` values so the rest of the program has a
/// consistent snapshot.
///
/// External modifications to `auth.json` will NOT be observed until
/// `reload()` is called explicitly. This matches the design goal of avoiding
/// different parts of the program seeing inconsistent auth data midrun.
#[derive(Debug)]
pub struct AuthManager {
codex_home: PathBuf,
inner: RwLock<CachedAuth>,
}
impl AuthManager {
/// Create a new manager loading the initial auth using the provided
/// preferred auth method. Errors loading auth are swallowed; `auth()` will
/// simply return `None` in that case so callers can treat it as an
/// unauthenticated state.
pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Self {
let auth = crate::CodexAuth::from_codex_home(&codex_home, preferred_auth_mode)
.ok()
.flatten();
Self {
codex_home,
inner: RwLock::new(CachedAuth {
preferred_auth_mode,
auth,
}),
}
}
/// Create an AuthManager with a specific CodexAuth, for testing only.
pub fn from_auth_for_testing(auth: CodexAuth) -> Arc<Self> {
let preferred_auth_mode = auth.mode;
let cached = CachedAuth {
preferred_auth_mode,
auth: Some(auth),
};
Arc::new(Self {
codex_home: PathBuf::new(),
inner: RwLock::new(cached),
})
}
/// Current cached auth (clone). May be `None` if not logged in or load failed.
pub fn auth(&self) -> Option<CodexAuth> {
self.inner.read().ok().and_then(|c| c.auth.clone())
}
/// Preferred auth method used when (re)loading.
pub fn preferred_auth_method(&self) -> AuthMode {
self.inner
.read()
.map(|c| c.preferred_auth_mode)
.unwrap_or(AuthMode::ApiKey)
}
/// Force a reload using the existing preferred auth method. Returns
/// whether the auth value changed.
pub fn reload(&self) -> bool {
let preferred = self.preferred_auth_method();
let new_auth = crate::CodexAuth::from_codex_home(&self.codex_home, preferred)
.ok()
.flatten();
if let Ok(mut guard) = self.inner.write() {
let changed = !AuthManager::auths_equal(&guard.auth, &new_auth);
guard.auth = new_auth;
changed
} else {
false
}
}
fn auths_equal(a: &Option<CodexAuth>, b: &Option<CodexAuth>) -> bool {
match (a, b) {
(None, None) => true,
(Some(a), Some(b)) => a == b,
_ => false,
}
}
/// Convenience constructor returning an `Arc` wrapper.
pub fn shared(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Arc<Self> {
Arc::new(Self::new(codex_home, preferred_auth_mode))
}
/// Attempt to refresh the current auth token (if any). On success, reload
/// the auth state from disk so other components observe refreshed token.
pub async fn refresh_token(&self) -> std::io::Result<Option<String>> {
let auth = match self.auth() {
Some(a) => a,
None => return Ok(None),
};
match auth.refresh_token().await {
Ok(token) => {
// Reload to pick up persisted changes.
self.reload();
Ok(Some(token))
}
Err(e) => Err(e),
}
}
/// Log out by deleting the ondisk auth.json (if present). Returns Ok(true)
/// if a file was removed, Ok(false) if no auth file existed. On success,
/// reloads the inmemory auth cache so callers immediately observe the
/// unauthenticated state.
pub fn logout(&self) -> std::io::Result<bool> {
let removed = crate::logout(&self.codex_home)?;
// Always reload to clear any cached auth (even if file absent).
self.reload();
Ok(removed)
}
}

View File

@@ -23,12 +23,14 @@ pub use crate::server::run_login_server;
pub use crate::token_data::TokenData;
use crate::token_data::parse_id_token;
mod auth_manager;
mod pkce;
mod server;
mod token_data;
pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
pub use auth_manager::AuthManager;
pub use codex_protocol::mcp_protocol::AuthMode;
#[derive(Debug, Clone)]

View File

@@ -14,6 +14,7 @@ use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ReviewDecision;
use codex_login::AuthManager;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::GitDiffToRemoteResponse;
use mcp_types::JSONRPCErrorError;
@@ -31,10 +32,8 @@ use crate::outgoing_message::OutgoingNotification;
use codex_core::protocol::InputItem as CoreInputItem;
use codex_core::protocol::Op;
use codex_login::CLIENT_ID;
use codex_login::CodexAuth;
use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::logout;
use codex_login::run_login_server;
use codex_protocol::mcp_protocol::APPLY_PATCH_APPROVAL_METHOD;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
@@ -78,6 +77,7 @@ impl ActiveLogin {
/// Handles JSON-RPC messages for Codex conversations.
pub(crate) struct CodexMessageProcessor {
auth_manager: Arc<AuthManager>,
conversation_manager: Arc<ConversationManager>,
outgoing: Arc<OutgoingMessageSender>,
codex_linux_sandbox_exe: Option<PathBuf>,
@@ -90,12 +90,14 @@ pub(crate) struct CodexMessageProcessor {
impl CodexMessageProcessor {
pub fn new(
auth_manager: Arc<AuthManager>,
conversation_manager: Arc<ConversationManager>,
outgoing: Arc<OutgoingMessageSender>,
codex_linux_sandbox_exe: Option<PathBuf>,
config: Arc<Config>,
) -> Self {
Self {
auth_manager,
conversation_manager,
outgoing,
codex_linux_sandbox_exe,
@@ -129,6 +131,9 @@ impl CodexMessageProcessor {
ClientRequest::RemoveConversationListener { request_id, params } => {
self.remove_conversation_listener(request_id, params).await;
}
ClientRequest::GitDiffToRemote { request_id, params } => {
self.git_diff_to_origin(request_id, params.cwd).await;
}
ClientRequest::LoginChatGpt { request_id } => {
self.login_chatgpt(request_id).await;
}
@@ -138,11 +143,8 @@ impl CodexMessageProcessor {
ClientRequest::LogoutChatGpt { request_id } => {
self.logout_chatgpt(request_id).await;
}
ClientRequest::GetAuthStatus { request_id } => {
self.get_auth_status(request_id).await;
}
ClientRequest::GitDiffToRemote { request_id, params } => {
self.git_diff_to_origin(request_id, params.cwd).await;
ClientRequest::GetAuthStatus { request_id, params } => {
self.get_auth_status(request_id, params).await;
}
}
}
@@ -185,6 +187,7 @@ impl CodexMessageProcessor {
// Spawn background task to monitor completion.
let outgoing_clone = self.outgoing.clone();
let active_login = self.active_login.clone();
let auth_manager = self.auth_manager.clone();
tokio::spawn(async move {
let (success, error_msg) = match tokio::time::timeout(
LOGIN_CHATGPT_TIMEOUT,
@@ -211,8 +214,13 @@ impl CodexMessageProcessor {
// Send an auth status change notification.
if success {
// Update in-memory auth cache now that login completed.
auth_manager.reload();
// Notify clients with the actual current auth mode.
let current_auth_method = auth_manager.auth().map(|a| a.mode);
let payload = AuthStatusChangeNotification {
auth_method: Some(AuthMode::ChatGPT),
auth_method: current_auth_method,
};
outgoing_clone
.send_server_notification(ServerNotification::AuthStatusChange(payload))
@@ -276,10 +284,7 @@ impl CodexMessageProcessor {
}
}
// Load config to locate codex_home for persistent logout.
let config = self.config.as_ref();
if let Err(err) = logout(&config.codex_home) {
if let Err(err) = self.auth_manager.logout() {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("logout failed: {err}"),
@@ -296,45 +301,55 @@ impl CodexMessageProcessor {
)
.await;
// Send auth status change notification.
let payload = AuthStatusChangeNotification { auth_method: None };
// Send auth status change notification reflecting the current auth mode
// after logout (which may fall back to API key via env var).
let current_auth_method = self.auth_manager.auth().map(|auth| auth.mode);
let payload = AuthStatusChangeNotification {
auth_method: current_auth_method,
};
self.outgoing
.send_server_notification(ServerNotification::AuthStatusChange(payload))
.await;
}
async fn get_auth_status(&self, request_id: RequestId) {
// Load config to determine codex_home and preferred auth method.
let config = self.config.as_ref();
async fn get_auth_status(
&self,
request_id: RequestId,
params: codex_protocol::mcp_protocol::GetAuthStatusParams,
) {
let preferred_auth_method: AuthMode = self.auth_manager.preferred_auth_method();
let include_token = params.include_token.unwrap_or(false);
let do_refresh = params.refresh_token.unwrap_or(false);
let preferred_auth_method: AuthMode = config.preferred_auth_method;
let response =
match CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method) {
Ok(Some(auth)) => {
// Verify that the current auth mode has a valid, non-empty token.
// If token acquisition fails or is empty, treat as unauthenticated.
let reported_auth_method = match auth.get_token().await {
Ok(token) if !token.is_empty() => Some(auth.mode),
Ok(_) => None, // Empty token
Err(err) => {
tracing::warn!("failed to get token for auth status: {err}");
None
}
};
codex_protocol::mcp_protocol::GetAuthStatusResponse {
auth_method: reported_auth_method,
preferred_auth_method,
if do_refresh && let Err(err) = self.auth_manager.refresh_token().await {
tracing::warn!("failed to refresh token while getting auth status: {err}");
}
let response = match self.auth_manager.auth() {
Some(auth) => {
let (reported_auth_method, token_opt) = match auth.get_token().await {
Ok(token) if !token.is_empty() => {
let tok = if include_token { Some(token) } else { None };
(Some(auth.mode), tok)
}
Ok(_) => (None, None),
Err(err) => {
tracing::warn!("failed to get token for auth status: {err}");
(None, None)
}
};
codex_protocol::mcp_protocol::GetAuthStatusResponse {
auth_method: reported_auth_method,
preferred_auth_method,
auth_token: token_opt,
}
Ok(None) => codex_protocol::mcp_protocol::GetAuthStatusResponse {
auth_method: None,
preferred_auth_method,
},
Err(_) => codex_protocol::mcp_protocol::GetAuthStatusResponse {
auth_method: None,
preferred_auth_method,
},
};
}
None => codex_protocol::mcp_protocol::GetAuthStatusResponse {
auth_method: None,
preferred_auth_method,
auth_token: None,
},
};
self.outgoing.send_response(request_id, response).await;
}
@@ -721,6 +736,7 @@ fn derive_config_from_params(
base_instructions,
include_plan_tool,
include_apply_patch_tool,
include_subagent_tool: None,
disable_response_storage: None,
show_raw_agent_reasoning: None,
};

View File

@@ -161,6 +161,7 @@ impl CodexToolCallParam {
base_instructions,
include_plan_tool,
include_apply_patch_tool: None,
include_subagent_tool: None,
disable_response_storage: None,
show_raw_agent_reasoning: None,
};

View File

@@ -174,6 +174,11 @@ async fn run_codex_tool_session_inner(
.await;
match event.msg {
EventMsg::SubagentBegin(_)
| EventMsg::SubagentForwarded(_)
| EventMsg::SubagentEnd(_) => {
// Ignore subagent orchestration for MCP echoing.
}
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
command,
cwd,
@@ -275,6 +280,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::PlanUpdate(_)
| EventMsg::TurnAborted(_)
| EventMsg::ConversationHistory(_)
| EventMsg::ShutdownComplete => {
// For now, we do not do anything extra for these
// events. Note that

View File

@@ -13,6 +13,7 @@ use codex_protocol::mcp_protocol::ClientRequest;
use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::protocol::Submission;
use codex_login::AuthManager;
use mcp_types::CallToolRequestParams;
use mcp_types::CallToolResult;
use mcp_types::ClientRequest as McpClientRequest;
@@ -52,8 +53,11 @@ impl MessageProcessor {
config: Arc<Config>,
) -> Self {
let outgoing = Arc::new(outgoing);
let conversation_manager = Arc::new(ConversationManager::default());
let auth_manager =
AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method);
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
let codex_message_processor = CodexMessageProcessor::new(
auth_manager,
conversation_manager.clone(),
outgoing.clone(),
codex_linux_sandbox_exe.clone(),

View File

@@ -0,0 +1,142 @@
use std::path::Path;
use codex_login::login_with_api_key;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::GetAuthStatusResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
// Helper to create a config.toml; mirrors create_conversation.rs
fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "http://127.0.0.1:0/v1"
wire_api = "chat"
request_max_retries = 0
stream_max_retries = 0
"#,
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_auth_status_no_auth() {
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(codex_home.path()).expect("write config.toml");
let mut mcp = McpProcess::new(codex_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let request_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(false),
})
.await
.expect("send getAuthStatus");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getAuthStatus timeout")
.expect("getAuthStatus response");
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
assert_eq!(status.auth_method, None, "expected no auth method");
assert_eq!(status.auth_token, None, "expected no token");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_auth_status_with_api_key() {
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(codex_home.path()).expect("write config.toml");
login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key");
let mut mcp = McpProcess::new(codex_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let request_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(false),
})
.await
.expect("send getAuthStatus");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getAuthStatus timeout")
.expect("getAuthStatus response");
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
assert_eq!(status.auth_method, Some(AuthMode::ApiKey));
assert_eq!(status.auth_token, Some("sk-test-key".to_string()));
assert_eq!(status.preferred_auth_method, AuthMode::ChatGPT);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_auth_status_with_api_key_no_include_token() {
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(codex_home.path()).expect("write config.toml");
login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key");
let mut mcp = McpProcess::new(codex_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
// Build params via struct so None field is omitted in wire JSON.
let params = GetAuthStatusParams {
include_token: None,
refresh_token: Some(false),
};
let request_id = mcp
.send_get_auth_status_request(params)
.await
.expect("send getAuthStatus");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getAuthStatus timeout")
.expect("getAuthStatus response");
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
assert_eq!(status.auth_method, Some(AuthMode::ApiKey));
assert!(status.auth_token.is_none(), "token must be omitted");
assert_eq!(status.preferred_auth_method, AuthMode::ChatGPT);
}

View File

@@ -86,9 +86,7 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
)
.await??;
// This is the first request from the server, so the id should be 0 given
// how things are currently implemented.
let elicitation_request_id = RequestId::Integer(0);
let elicitation_request_id = elicitation_request.id.clone();
let params = serde_json::from_value::<ExecApprovalElicitRequestParams>(
elicitation_request
.params

View File

@@ -13,6 +13,8 @@ use anyhow::Context;
use assert_cmd::prelude::*;
use codex_mcp_server::CodexToolCallParam;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::InterruptConversationParams;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
@@ -217,6 +219,34 @@ impl McpProcess {
self.send_request("interruptConversation", params).await
}
/// Send a `getAuthStatus` JSON-RPC request.
pub async fn send_get_auth_status_request(
&mut self,
params: GetAuthStatusParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("getAuthStatus", params).await
}
/// Send a `loginChatGpt` JSON-RPC request.
pub async fn send_login_chat_gpt_request(&mut self) -> anyhow::Result<i64> {
self.send_request("loginChatGpt", None).await
}
/// Send a `cancelLoginChatGpt` JSON-RPC request.
pub async fn send_cancel_login_chat_gpt_request(
&mut self,
params: CancelLoginChatGptParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("cancelLoginChatGpt", params).await
}
/// Send a `logoutChatGpt` JSON-RPC request.
pub async fn send_logout_chat_gpt_request(&mut self) -> anyhow::Result<i64> {
self.send_request("logoutChatGpt", None).await
}
async fn send_request(
&mut self,
method: &str,

View File

@@ -0,0 +1,146 @@
use std::path::Path;
use std::time::Duration;
use codex_login::login_with_api_key;
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
use codex_protocol::mcp_protocol::CancelLoginChatGptResponse;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::GetAuthStatusResponse;
use codex_protocol::mcp_protocol::LoginChatGptResponse;
use codex_protocol::mcp_protocol::LogoutChatGptResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
// Helper to create a config.toml; mirrors create_conversation.rs
fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "http://127.0.0.1:0/v1"
wire_api = "chat"
request_max_retries = 0
stream_max_retries = 0
"#,
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn logout_chatgpt_removes_auth() {
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(codex_home.path()).expect("write config.toml");
login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key");
assert!(codex_home.path().join("auth.json").exists());
let mut mcp = McpProcess::new(codex_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let id = mcp
.send_logout_chat_gpt_request()
.await
.expect("send logoutChatGpt");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(id)),
)
.await
.expect("logoutChatGpt timeout")
.expect("logoutChatGpt response");
let _ok: LogoutChatGptResponse = to_response(resp).expect("deserialize logout response");
assert!(
!codex_home.path().join("auth.json").exists(),
"auth.json should be deleted"
);
// Verify status reflects signed-out state.
let status_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(false),
})
.await
.expect("send getAuthStatus");
let status_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(status_id)),
)
.await
.expect("getAuthStatus timeout")
.expect("getAuthStatus response");
let status: GetAuthStatusResponse = to_response(status_resp).expect("deserialize status");
assert_eq!(status.auth_method, None);
assert_eq!(status.auth_token, None);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn login_and_cancel_chatgpt() {
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(codex_home.path()).expect("write config.toml");
let mut mcp = McpProcess::new(codex_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let login_id = mcp
.send_login_chat_gpt_request()
.await
.expect("send loginChatGpt");
let login_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(login_id)),
)
.await
.expect("loginChatGpt timeout")
.expect("loginChatGpt response");
let login: LoginChatGptResponse = to_response(login_resp).expect("deserialize login resp");
let cancel_id = mcp
.send_cancel_login_chat_gpt_request(CancelLoginChatGptParams {
login_id: login.login_id,
})
.await
.expect("send cancelLoginChatGpt");
let cancel_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)),
)
.await
.expect("cancelLoginChatGpt timeout")
.expect("cancelLoginChatGpt response");
let _ok: CancelLoginChatGptResponse =
to_response(cancel_resp).expect("deserialize cancel response");
// Optionally observe the completion notification; do not fail if it races.
let maybe_note = timeout(
Duration::from_secs(2),
mcp.read_stream_until_notification_message("codex/event/login_chat_gpt_complete"),
)
.await;
if maybe_note.is_err() {
eprintln!("warning: did not observe login_chat_gpt_complete notification after cancel");
}
}

View File

@@ -32,11 +32,16 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
codex_protocol::mcp_protocol::SendUserTurnResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::InterruptConversationParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::InterruptConversationResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GitDiffToRemoteParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GitDiffToRemoteResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LoginChatGptResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LoginChatGptCompleteNotification::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::CancelLoginChatGptParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GitDiffToRemoteParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LogoutChatGptParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LogoutChatGptResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GetAuthStatusParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GetAuthStatusResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ApplyPatchApprovalParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?;

View File

@@ -11,12 +11,15 @@ path = "src/lib.rs"
workspace = true
[dependencies]
base64 = "0.22.1"
mcp-types = { path = "../mcp-types" }
mime_guess = "2.0.5"
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
serde_json = "1"
strum = "0.27.2"
strum_macros = "0.27.2"
tracing = "0.1.41"
ts-rs = { version = "11", features = ["uuid-impl", "serde-json-impl"] }
uuid = { version = "1", features = ["serde", "v4"] }

View File

@@ -1,6 +1,7 @@
pub mod config_types;
pub mod mcp_protocol;
pub mod message_history;
pub mod models;
pub mod parse_command;
pub mod plan_tool;
pub mod protocol;

View File

@@ -78,6 +78,11 @@ pub enum ClientRequest {
request_id: RequestId,
params: RemoveConversationListenerParams,
},
GitDiffToRemote {
#[serde(rename = "id")]
request_id: RequestId,
params: GitDiffToRemoteParams,
},
LoginChatGpt {
#[serde(rename = "id")]
request_id: RequestId,
@@ -94,11 +99,7 @@ pub enum ClientRequest {
GetAuthStatus {
#[serde(rename = "id")]
request_id: RequestId,
},
GitDiffToRemote {
#[serde(rename = "id")]
request_id: RequestId,
params: GitDiffToRemoteParams,
params: GetAuthStatusParams,
},
}
@@ -195,9 +196,7 @@ pub struct CancelLoginChatGptResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct LogoutChatGptParams {
pub login_id: Uuid,
}
pub struct LogoutChatGptParams {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
@@ -206,7 +205,12 @@ pub struct LogoutChatGptResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct GetAuthStatusParams {
pub login_id: Uuid,
/// If true, include the current auth token (if available) in the response.
#[serde(skip_serializing_if = "Option::is_none")]
pub include_token: Option<bool>,
/// If true, attempt to refresh the token before returning status.
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
@@ -215,6 +219,8 @@ pub struct GetAuthStatusResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_method: Option<AuthMode>,
pub preferred_auth_method: AuthMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_token: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]

View File

@@ -24,6 +24,10 @@ pub enum ResponseInputItem {
call_id: String,
result: Result<CallToolResult, String>,
},
CustomToolCallOutput {
call_id: String,
output: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -77,6 +81,20 @@ pub enum ResponseItem {
call_id: String,
output: FunctionCallOutputPayload,
},
CustomToolCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<String>,
call_id: String,
name: String,
input: String,
},
CustomToolCallOutput {
call_id: String,
output: String,
},
#[serde(other)]
Other,
}
@@ -114,6 +132,9 @@ impl From<ResponseInputItem> for ResponseItem {
),
},
},
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
Self::CustomToolCallOutput { call_id, output }
}
}
}
}
@@ -183,7 +204,6 @@ impl From<Vec<InputItem>> for ResponseInputItem {
None
}
},
_ => None,
})
.collect::<Vec<ContentItem>>(),
}
@@ -197,10 +217,8 @@ pub struct ShellToolCallParams {
pub command: Vec<String>,
pub workdir: Option<String>,
/// This is the maximum time in seconds that the command is allowed to run.
#[serde(rename = "timeout")]
// The wire format uses `timeout`, which has ambiguous units, so we use
// `timeout_ms` as the field name so it is clear in code.
/// This is the maximum time in milliseconds that the command is allowed to run.
#[serde(alias = "timeout")]
pub timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub with_escalated_permissions: Option<bool>,

View File

@@ -22,6 +22,7 @@ use uuid::Uuid;
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::message_history::HistoryEntry;
use crate::models::ResponseItem;
use crate::parse_command::ParsedCommand;
use crate::plan_tool::UpdatePlanArgs;
@@ -137,6 +138,10 @@ pub enum Op {
/// Request a single history entry identified by `log_id` + `offset`.
GetHistoryEntryRequest { offset: usize, log_id: u64 },
/// Request the full in-memory conversation transcript for the current session.
/// Reply is delivered via `EventMsg::ConversationHistory`.
GetHistory,
/// Request the list of MCP tools available across all configured servers.
/// Reply is delivered via `EventMsg::McpListToolsResponse`.
ListMcpTools,
@@ -471,6 +476,16 @@ pub enum EventMsg {
/// Notification that the agent is shutting down.
ShutdownComplete,
ConversationHistory(ConversationHistoryResponseEvent),
// --- Subagent orchestration events ---
/// Emitted when a subagent starts.
SubagentBegin(SubagentBeginEvent),
/// Forwards a nested event produced by a running subagent.
SubagentForwarded(SubagentForwardedEvent),
/// Emitted when a subagent finishes.
SubagentEnd(SubagentEndEvent),
}
// Individual event payload types matching each `EventMsg` variant.
@@ -494,6 +509,28 @@ pub struct TokenUsage {
pub total_tokens: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SubagentBeginEvent {
pub subagent_id: String,
pub name: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SubagentEndEvent {
pub subagent_id: String,
pub name: String,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_agent_message: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SubagentForwardedEvent {
pub subagent_id: String,
pub name: String,
pub event: Box<EventMsg>,
}
impl TokenUsage {
pub fn is_zero(&self) -> bool {
self.total_tokens == 0
@@ -651,6 +688,14 @@ impl McpToolCallEndEvent {
}
}
/// Response payload for `Op::GetHistory` containing the current session's
/// in-memory transcript.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ConversationHistoryResponseEvent {
pub conversation_id: Uuid,
pub entries: Vec<ResponseItem>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ExecCommandBeginEvent {
/// Identifier so this can be paired with the ExecCommandEnd event.
@@ -670,10 +715,15 @@ pub struct ExecCommandEndEvent {
pub stdout: String,
/// Captured stderr
pub stderr: String,
/// Captured aggregated output
#[serde(default)]
pub aggregated_output: String,
/// The command's exit code.
pub exit_code: i32,
/// The duration of the command execution.
pub duration: Duration,
/// Formatted output from the command, as seen by the model.
pub formatted_output: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]

View File

@@ -22,6 +22,7 @@ workspace = true
[dependencies]
anyhow = "1"
arboard = "3"
async-stream = "0.3.6"
base64 = "0.22.1"
chrono = { version = "0.4", features = ["serde"] }
@@ -41,7 +42,10 @@ codex-protocol = { path = "../protocol" }
color-eyre = "0.6.3"
crossterm = { version = "0.28.1", features = ["bracketed-paste", "event-stream"] }
diffy = "0.4.2"
image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
image = { version = "^0.25.6", default-features = false, features = [
"jpeg",
"png",
] }
lazy_static = "1"
mcp-types = { path = "../mcp-types" }
once_cell = "1"
@@ -61,6 +65,7 @@ shlex = "1.3.0"
strum = "0.27.2"
strum_macros = "0.27.2"
supports-color = "3.0.2"
tempfile = "3"
textwrap = "0.16.2"
tokio = { version = "1", features = [
"io-std",

View File

@@ -5,18 +5,17 @@ use crate::file_search::FileSearchManager;
use crate::transcript_app::TranscriptApp;
use crate::tui;
use crate::tui::TuiEvent;
use codex_ansi_escape::ansi_escape_line;
use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::protocol::TokenUsage;
use codex_login::AuthManager;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::execute;
use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen;
use crossterm::terminal::supports_keyboard_enhancement;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use std::path::PathBuf;
use std::sync::Arc;
@@ -42,7 +41,6 @@ pub(crate) struct App {
// Transcript overlay state
transcript_overlay: Option<TranscriptApp>,
deferred_history_lines: Vec<Line<'static>>,
transcript_saved_viewport: Option<Rect>,
enhanced_keys_supported: bool,
@@ -53,6 +51,7 @@ pub(crate) struct App {
impl App {
pub async fn run(
tui: &mut tui::Tui,
auth_manager: Arc<AuthManager>,
config: Config,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
@@ -61,7 +60,7 @@ impl App {
let (app_event_tx, mut app_event_rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(app_event_tx);
let conversation_manager = Arc::new(ConversationManager::default());
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
@@ -87,7 +86,6 @@ impl App {
transcript_lines: Vec::new(),
transcript_overlay: None,
deferred_history_lines: Vec::new(),
transcript_saved_viewport: None,
commit_anim_running: Arc::new(AtomicBool::new(false)),
};
@@ -117,17 +115,14 @@ impl App {
overlay.handle_event(tui, event)?;
if overlay.is_done {
// Exit alternate screen and restore viewport.
let _ = execute!(tui.terminal.backend_mut(), LeaveAlternateScreen);
if let Some(saved) = self.transcript_saved_viewport.take() {
tui.terminal.set_viewport_area(saved);
}
let _ = tui.leave_alt_screen();
if !self.deferred_history_lines.is_empty() {
let lines = std::mem::take(&mut self.deferred_history_lines);
tui.insert_history_lines(lines);
}
self.transcript_overlay = None;
tui.frame_requester().schedule_frame();
}
tui.frame_requester().schedule_frame();
} else {
match event {
TuiEvent::Key(key_event) => {
@@ -152,15 +147,14 @@ impl App {
},
)?;
}
#[cfg(unix)]
TuiEvent::ResumeFromSuspend => {
let cursor_pos = tui.terminal.get_cursor_position()?;
tui.terminal.set_viewport_area(ratatui::layout::Rect::new(
0,
cursor_pos.y,
0,
0,
));
TuiEvent::AttachImage {
path,
width,
height,
format_label,
} => {
self.chat_widget
.attach_image(path, width, height, format_label);
}
}
}
@@ -238,7 +232,20 @@ impl App {
}
AppEvent::CodexOp(op) => self.chat_widget.submit_op(op),
AppEvent::DiffResult(text) => {
self.chat_widget.add_diff_output(text);
// Clear the in-progress state in the bottom pane
self.chat_widget.on_diff_complete();
// Enter alternate screen using TUI helper and build pager lines
let _ = tui.enter_alt_screen();
let pager_lines: Vec<ratatui::text::Line<'static>> = if text.trim().is_empty() {
vec!["No changes detected.".italic().into()]
} else {
text.lines().map(ansi_escape_line).collect()
};
self.transcript_overlay = Some(TranscriptApp::with_title(
pager_lines,
"D I F F".to_string(),
));
tui.frame_requester().schedule_frame();
}
AppEvent::StartFileSearch(query) => {
if !query.is_empty() {
@@ -293,14 +300,7 @@ impl App {
..
} => {
// Enter alternate screen and set viewport to full size.
let _ = execute!(tui.terminal.backend_mut(), EnterAlternateScreen);
if let Ok(size) = tui.terminal.size() {
self.transcript_saved_viewport = Some(tui.terminal.viewport_area);
tui.terminal
.set_viewport_area(Rect::new(0, 0, size.width, size.height));
let _ = tui.terminal.clear();
}
let _ = tui.enter_alt_screen();
self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone()));
tui.frame_requester().schedule_frame();
}

View File

@@ -31,6 +31,16 @@ use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use codex_file_search::FileMatch;
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
// Heuristic thresholds for detecting paste-like input bursts.
const PASTE_BURST_MIN_CHARS: u16 = 3;
const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
@@ -43,6 +53,12 @@ pub enum InputResult {
None,
}
#[derive(Clone, Debug, PartialEq)]
struct AttachedImage {
placeholder: String,
path: PathBuf,
}
struct TokenUsageInfo {
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
@@ -71,7 +87,15 @@ pub(crate) struct ChatComposer {
pending_pastes: Vec<(String, String)>,
token_usage_info: Option<TokenUsageInfo>,
has_focus: bool,
attached_images: Vec<AttachedImage>,
placeholder_text: String,
// Heuristic state to detect non-bracketed paste bursts.
last_plain_char_time: Option<Instant>,
consecutive_plain_char_burst: u16,
paste_burst_until: Option<Instant>,
// Buffer to accumulate characters during a detected non-bracketed paste burst.
paste_burst_buffer: String,
in_paste_burst_mode: bool,
}
/// Popup state at most one can be visible at any time.
@@ -103,7 +127,13 @@ impl ChatComposer {
pending_pastes: Vec::new(),
token_usage_info: None,
has_focus: has_input_focus,
attached_images: Vec::new(),
placeholder_text,
last_plain_char_time: None,
consecutive_plain_char_burst: 0,
paste_burst_until: None,
paste_burst_buffer: String::new(),
in_paste_burst_mode: false,
}
}
@@ -191,11 +221,29 @@ impl ChatComposer {
} else {
self.textarea.insert_str(&pasted);
}
// Explicit paste events should not trigger Enter suppression.
self.last_plain_char_time = None;
self.consecutive_plain_char_burst = 0;
self.paste_burst_until = None;
self.sync_command_popup();
self.sync_file_search_popup();
true
}
pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, format_label: &str) {
let placeholder = format!("[image {width}x{height} {format_label}]");
// Insert as an element to match large paste placeholder behavior:
// styled distinctly and treated atomically for cursor/mutations.
self.textarea.insert_element(&placeholder);
self.attached_images
.push(AttachedImage { placeholder, path });
}
pub fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
let images = std::mem::take(&mut self.attached_images);
images.into_iter().map(|img| img.path).collect()
}
/// Integrate results from an asynchronous file search.
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
// Only apply if user is still editing a token starting with `query`.
@@ -346,19 +394,74 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
..
} => {
if let Some(sel) = popup.selected_match() {
let sel_path = sel.to_string();
// Drop popup borrow before using self mutably again.
self.insert_selected_path(&sel_path);
let Some(sel) = popup.selected_match() else {
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
};
let sel_path = sel.to_string();
// If selected path looks like an image (png/jpeg), attach as image instead of inserting text.
let is_image = Self::is_image_path(&sel_path);
if is_image {
// Determine dimensions; if that fails fall back to normal path insertion.
let path_buf = PathBuf::from(&sel_path);
if let Ok((w, h)) = image::image_dimensions(&path_buf) {
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
// using the flat text and byte-offset cursor API.
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
let before_cursor = &text[..cursor_offset];
let after_cursor = &text[cursor_offset..];
// Determine token boundaries in the full text.
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = cursor_offset + end_rel_idx;
self.textarea.replace_range(start_idx..end_idx, "");
self.textarea.set_cursor(start_idx);
let format_label = match Path::new(&sel_path)
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase())
{
Some(ext) if ext == "png" => "PNG",
Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG",
_ => "IMG",
};
self.attach_image(path_buf.clone(), w, h, format_label);
// Add a trailing space to keep typing fluid.
self.textarea.insert_str(" ");
} else {
// Fallback to plain path insertion if metadata read fails.
self.insert_selected_path(&sel_path);
}
} else {
// Non-image: inserting file path.
self.insert_selected_path(&sel_path);
}
(InputResult::None, false)
// No selection: treat Enter as closing the popup/session.
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
input => self.handle_input_basic(input),
}
}
fn is_image_path(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg")
}
/// Extract the `@token` that the cursor is currently positioned on, if any.
///
/// The returned string **does not** include the leading `@`.
@@ -534,6 +637,60 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
..
} => {
// If we're in a paste-like burst capture, treat Enter as part of the burst
// and accumulate it rather than submitting or inserting immediately.
// Do not treat Enter as paste inside a slash-command context.
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|| self
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/');
if (self.in_paste_burst_mode || !self.paste_burst_buffer.is_empty())
&& !in_slash_context
{
self.paste_burst_buffer.push('\n');
let now = Instant::now();
// Keep the window alive so subsequent lines are captured too.
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return (InputResult::None, true);
}
// If we have pending placeholder pastes, submit immediately to expand them.
if !self.pending_pastes.is_empty() {
let mut text = self.textarea.text().to_string();
self.textarea.set_text("");
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
}
}
self.pending_pastes.clear();
if text.is_empty() {
return (InputResult::None, true);
}
self.history.record_local_submission(&text);
return (InputResult::Submitted(text), true);
}
// During a paste-like burst, treat Enter as a newline instead of submit.
let now = Instant::now();
let tight_after_char = self
.last_plain_char_time
.is_some_and(|t| now.duration_since(t) <= PASTE_BURST_CHAR_INTERVAL);
let recent_after_char = self
.last_plain_char_time
.is_some_and(|t| now.duration_since(t) <= PASTE_ENTER_SUPPRESS_WINDOW);
let burst_by_count =
recent_after_char && self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS;
let in_burst_window = self.paste_burst_until.is_some_and(|until| now <= until);
if tight_after_char || burst_by_count || in_burst_window {
self.textarea.insert_str("\n");
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return (InputResult::None, true);
}
let mut text = self.textarea.text().to_string();
self.textarea.set_text("");
@@ -545,12 +702,19 @@ impl ChatComposer {
}
self.pending_pastes.clear();
if text.is_empty() {
(InputResult::None, true)
} else {
self.history.record_local_submission(&text);
(InputResult::Submitted(text), true)
// Strip image placeholders from the submitted text; images are retrieved via take_recent_submission_images()
for img in &self.attached_images {
if text.contains(&img.placeholder) {
text = text.replace(&img.placeholder, "");
}
}
text = text.trim().to_string();
if !text.is_empty() {
self.history.record_local_submission(&text);
}
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
(InputResult::Submitted(text), true)
}
input => self.handle_input_basic(input),
}
@@ -558,17 +722,302 @@ impl ChatComposer {
/// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// If we have a buffered non-bracketed paste burst and enough time has
// elapsed since the last char, flush it before handling a new input.
let now = Instant::now();
let timed_out = self
.last_plain_char_time
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
if timed_out && (!self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode) {
let pasted = std::mem::take(&mut self.paste_burst_buffer);
self.in_paste_burst_mode = false;
// Reuse normal paste path (handles large-paste placeholders).
self.handle_paste(pasted);
}
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
if matches!(input.code, KeyCode::Enter)
&& (self.in_paste_burst_mode || !self.paste_burst_buffer.is_empty())
{
self.paste_burst_buffer.push('\n');
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return (InputResult::None, true);
}
// Intercept plain Char inputs to optionally accumulate into a burst buffer.
if let KeyEvent {
code: KeyCode::Char(ch),
modifiers,
..
} = input
{
let has_ctrl_or_alt =
modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT);
if !has_ctrl_or_alt {
// Update burst heuristics.
match self.last_plain_char_time {
Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
self.consecutive_plain_char_burst =
self.consecutive_plain_char_burst.saturating_add(1);
}
_ => {
self.consecutive_plain_char_burst = 1;
}
}
self.last_plain_char_time = Some(now);
// If we're already buffering, capture the char into the buffer.
if self.in_paste_burst_mode {
self.paste_burst_buffer.push(ch);
// Keep the window alive while we receive the burst.
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return (InputResult::None, true);
} else if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
// Do not start burst buffering while typing a slash command (first line starts with '/').
let first_line = self.textarea.text().lines().next().unwrap_or("");
if first_line.starts_with('/') {
// Keep heuristics but do not buffer.
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
// Insert normally.
self.textarea.input(input);
let text_after = self.textarea.text();
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
return (InputResult::None, true);
}
// Begin buffering from this character onward.
self.paste_burst_buffer.push(ch);
self.in_paste_burst_mode = true;
// Keep the window alive to continue capturing.
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return (InputResult::None, true);
}
// Not buffering: insert normally and continue.
self.textarea.input(input);
let text_after = self.textarea.text();
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
return (InputResult::None, true);
} else {
// Modified char ends any burst: flush buffered content before applying.
if !self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode {
let pasted = std::mem::take(&mut self.paste_burst_buffer);
self.in_paste_burst_mode = false;
self.handle_paste(pasted);
}
}
}
// For non-char inputs (or after flushing), handle normally.
// Special handling for backspace on placeholders
if let KeyEvent {
code: KeyCode::Backspace,
..
} = input
&& self.try_remove_any_placeholder_at_cursor()
{
return (InputResult::None, true);
}
// Normal input handling
self.textarea.input(input);
let text_after = self.textarea.text();
// Update paste-burst heuristic for plain Char (no Ctrl/Alt) events.
let crossterm::event::KeyEvent {
code, modifiers, ..
} = input;
match code {
KeyCode::Char(_) => {
let has_ctrl_or_alt = modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT);
if has_ctrl_or_alt {
// Modified char: clear burst window.
self.consecutive_plain_char_burst = 0;
self.last_plain_char_time = None;
self.paste_burst_until = None;
self.in_paste_burst_mode = false;
self.paste_burst_buffer.clear();
}
// Plain chars handled above.
}
KeyCode::Enter => {
// Keep burst window alive (supports blank lines in paste).
}
_ => {
// Other keys: clear burst window and any buffer (after flushing earlier).
self.consecutive_plain_char_burst = 0;
self.last_plain_char_time = None;
self.paste_burst_until = None;
self.in_paste_burst_mode = false;
// Do not clear paste_burst_buffer here; it should have been flushed above.
}
}
// Check if any placeholders were removed and remove their corresponding pending pastes
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
// Keep attached images in proportion to how many matching placeholders exist in the text.
// This handles duplicate placeholders that share the same visible label.
if !self.attached_images.is_empty() {
let mut needed: HashMap<String, usize> = HashMap::new();
for img in &self.attached_images {
needed
.entry(img.placeholder.clone())
.or_insert_with(|| text_after.matches(&img.placeholder).count());
}
let mut used: HashMap<String, usize> = HashMap::new();
let mut kept: Vec<AttachedImage> = Vec::with_capacity(self.attached_images.len());
for img in self.attached_images.drain(..) {
let total_needed = *needed.get(&img.placeholder).unwrap_or(&0);
let used_count = used.entry(img.placeholder.clone()).or_insert(0);
if *used_count < total_needed {
kept.push(img);
*used_count += 1;
}
}
self.attached_images = kept;
}
(InputResult::None, true)
}
/// Attempts to remove an image or paste placeholder if the cursor is at the end of one.
/// Returns true if a placeholder was removed.
fn try_remove_any_placeholder_at_cursor(&mut self) -> bool {
let p = self.textarea.cursor();
let text = self.textarea.text();
// Try image placeholders first
let mut out: Option<(usize, String)> = None;
// Detect if the cursor is at the end of any image placeholder.
// If duplicates exist, remove the specific occurrence's mapping.
for (i, img) in self.attached_images.iter().enumerate() {
let ph = &img.placeholder;
if p < ph.len() {
continue;
}
let start = p - ph.len();
if text[start..p] != *ph {
continue;
}
// Count the number of occurrences of `ph` before `start`.
let mut occ_before = 0usize;
let mut search_pos = 0usize;
while search_pos < start {
if let Some(found) = text[search_pos..start].find(ph) {
occ_before += 1;
search_pos += found + ph.len();
} else {
break;
}
}
// Remove the occ_before-th attached image that shares this placeholder label.
out = if let Some((remove_idx, _)) = self
.attached_images
.iter()
.enumerate()
.filter(|(_, img2)| img2.placeholder == *ph)
.nth(occ_before)
{
Some((remove_idx, ph.clone()))
} else {
Some((i, ph.clone()))
};
break;
}
if let Some((idx, placeholder)) = out {
self.textarea.replace_range(p - placeholder.len()..p, "");
self.attached_images.remove(idx);
return true;
}
// Also handle when the cursor is at the START of an image placeholder.
// let result = 'out: {
let out: Option<(usize, String)> = 'out: {
for (i, img) in self.attached_images.iter().enumerate() {
let ph = &img.placeholder;
if p + ph.len() > text.len() {
continue;
}
if &text[p..p + ph.len()] != ph {
continue;
}
// Count occurrences of `ph` before `p`.
let mut occ_before = 0usize;
let mut search_pos = 0usize;
while search_pos < p {
if let Some(found) = text[search_pos..p].find(ph) {
occ_before += 1;
search_pos += found + ph.len();
} else {
break 'out None;
}
}
if let Some((remove_idx, _)) = self
.attached_images
.iter()
.enumerate()
.filter(|(_, img2)| img2.placeholder == *ph)
.nth(occ_before)
{
break 'out Some((remove_idx, ph.clone()));
} else {
break 'out Some((i, ph.clone()));
}
}
None
};
if let Some((idx, placeholder)) = out {
self.textarea.replace_range(p..p + placeholder.len(), "");
self.attached_images.remove(idx);
return true;
}
// Then try pasted-content placeholders
if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| {
if p < ph.len() {
return None;
}
let start = p - ph.len();
if text[start..p] == *ph {
Some(ph.clone())
} else {
None
}
}) {
self.textarea.replace_range(p - placeholder.len()..p, "");
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
return true;
}
// Also handle when the cursor is at the START of a pasted-content placeholder.
if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| {
if p + ph.len() > text.len() {
return None;
}
if &text[p..p + ph.len()] == ph {
Some(ph.clone())
} else {
None
}
}) {
self.textarea.replace_range(p..p + placeholder.len(), "");
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
return true;
}
false
}
/// Synchronize `self.command_popup` with the current text in the
/// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate.
@@ -746,10 +1195,14 @@ impl WidgetRef for &ChatComposer {
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::bottom_pane::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::AttachedImage;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use crate::bottom_pane::textarea::TextArea;
use tokio::sync::mpsc::unbounded_channel;
@@ -1312,4 +1765,112 @@ mod tests {
]
);
}
// --- Image attachment tests ---
#[test]
fn attach_image_and_submit_includes_image_paths() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
let path = PathBuf::from("/tmp/image1.png");
composer.attach_image(path.clone(), 32, 16, "PNG");
composer.handle_paste(" hi".into());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "hi"),
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
assert_eq!(vec![path], imgs);
}
#[test]
fn attach_image_without_text_submits_empty_text_and_images() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
let path = PathBuf::from("/tmp/image2.png");
composer.attach_image(path.clone(), 10, 5, "PNG");
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert!(text.is_empty()),
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
assert_eq!(imgs.len(), 1);
assert_eq!(imgs[0], path);
assert!(composer.attached_images.is_empty());
}
#[test]
fn image_placeholder_backspace_behaves_like_text_placeholder() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
let path = PathBuf::from("/tmp/image3.png");
composer.attach_image(path.clone(), 20, 10, "PNG");
let placeholder = composer.attached_images[0].placeholder.clone();
// Case 1: backspace at end
composer.textarea.move_cursor_to_end_of_line(false);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(!composer.textarea.text().contains(&placeholder));
assert!(composer.attached_images.is_empty());
// Re-add and test backspace in middle: should break the placeholder string
// and drop the image mapping (same as text placeholder behavior).
composer.attach_image(path.clone(), 20, 10, "PNG");
let placeholder2 = composer.attached_images[0].placeholder.clone();
// Move cursor to roughly middle of placeholder
if let Some(start_pos) = composer.textarea.text().find(&placeholder2) {
let mid_pos = start_pos + (placeholder2.len() / 2);
composer.textarea.set_cursor(mid_pos);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(!composer.textarea.text().contains(&placeholder2));
assert!(composer.attached_images.is_empty());
} else {
panic!("Placeholder not found in textarea");
}
}
#[test]
fn deleting_one_of_duplicate_image_placeholders_removes_matching_entry() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer =
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
let path1 = PathBuf::from("/tmp/image_dup1.png");
let path2 = PathBuf::from("/tmp/image_dup2.png");
composer.attach_image(path1.clone(), 10, 5, "PNG");
// separate placeholders with a space for clarity
composer.handle_paste(" ".into());
composer.attach_image(path2.clone(), 10, 5, "PNG");
let ph = composer.attached_images[0].placeholder.clone();
let text = composer.textarea.text().to_string();
let start1 = text.find(&ph).expect("first placeholder present");
let end1 = start1 + ph.len();
composer.textarea.set_cursor(end1);
// Backspace should delete the first placeholder and its mapping.
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let new_text = composer.textarea.text().to_string();
assert_eq!(1, new_text.matches(&ph).count(), "one placeholder remains");
assert_eq!(
vec![AttachedImage {
path: path2,
placeholder: "[image 10x5 PNG]".to_string()
}],
composer.attached_images,
"one image mapping remains"
);
}
}

View File

@@ -1,4 +1,5 @@
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use crate::tui::FrameRequester;
@@ -8,6 +9,8 @@ use codex_core::protocol::TokenUsage;
use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
@@ -94,8 +97,31 @@ impl BottomPane {
} else {
self.composer.desired_height(width)
};
let top_pad = if self.active_view.is_none() || self.status_view_active {
1
} else {
0
};
view_height
.saturating_add(Self::BOTTOM_PAD_LINES)
.saturating_add(top_pad)
}
view_height.saturating_add(Self::BOTTOM_PAD_LINES)
fn layout(&self, area: Rect) -> Rect {
let top = if self.active_view.is_none() || self.status_view_active {
1
} else {
0
};
let [_, content, _] = Layout::vertical([
Constraint::Max(top),
Constraint::Min(1),
Constraint::Max(BottomPane::BOTTOM_PAD_LINES),
])
.areas(area);
content
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
@@ -103,10 +129,11 @@ impl BottomPane {
// status indicator shown while a task is running, or approval modal).
// In these states the textarea is not interactable, so we should not
// show its caret.
if self.active_view.is_some() {
if self.active_view.is_some() || self.status_view_active {
None
} else {
self.composer.cursor_pos(area)
let content = self.layout(area);
self.composer.cursor_pos(content)
}
}
@@ -342,35 +369,34 @@ impl BottomPane {
self.composer.on_file_search_result(query, matches);
self.request_redraw();
}
pub(crate) fn attach_image(
&mut self,
path: PathBuf,
width: u32,
height: u32,
format_label: &str,
) {
if self.active_view.is_none() {
self.composer
.attach_image(path, width, height, format_label);
self.request_redraw();
}
}
pub(crate) fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
self.composer.take_recent_submission_images()
}
}
impl WidgetRef for &BottomPane {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let content = self.layout(area);
if let Some(view) = &self.active_view {
// Reserve bottom padding lines; keep at least 1 line for the view.
let avail = area.height;
if avail > 0 {
let pad = BottomPane::BOTTOM_PAD_LINES.min(avail.saturating_sub(1));
let view_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: avail - pad,
};
view.render(view_rect, buf);
}
view.render(content, buf);
} else {
let avail = area.height;
if avail > 0 {
let composer_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
// Reserve bottom padding
height: avail - BottomPane::BOTTOM_PAD_LINES.min(avail.saturating_sub(1)),
};
(&self.composer).render_ref(composer_rect, buf);
}
(&self.composer).render_ref(content, buf);
}
}
}
@@ -473,18 +499,16 @@ mod tests {
assert!(pane.active_view.is_some(), "active view should be present");
// Render and ensure the top row includes the Working header instead of the composer.
// Give the animation thread a moment to tick.
std::thread::sleep(std::time::Duration::from_millis(120));
let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
let mut row0 = String::new();
let mut row1 = String::new();
for x in 0..area.width {
row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
row1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert!(
row0.contains("Working"),
"expected Working header after denial: {row0:?}"
row1.contains("Working"),
"expected Working header after denial on row 1: {row1:?}"
);
// Drain the channel to avoid unused warnings.
@@ -506,17 +530,13 @@ mod tests {
// Begin a task: show initial status.
pane.set_task_running(true);
// Allow some frames so the animation thread ticks.
std::thread::sleep(std::time::Duration::from_millis(120));
// Render and confirm the line contains the "Working" header.
let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
let mut row0 = String::new();
for x in 0..area.width {
row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
row0.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert!(
row0.contains("Working"),
@@ -549,12 +569,12 @@ mod tests {
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
// Top row contains the status header
// Row 1 contains the status header (row 0 is the spacer)
let mut top = String::new();
for x in 0..area.width {
top.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
top.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert_eq!(buf[(0, 0)].symbol().chars().next().unwrap_or(' '), '▌');
assert_eq!(buf[(0, 1)].symbol().chars().next().unwrap_or(' '), '▌');
assert!(
top.contains("Working"),
"expected Working header on top row: {top:?}"
@@ -591,7 +611,7 @@ mod tests {
pane.set_task_running(true);
// Height=2 → pad shrinks to 1; bottom row is blank, top row has spinner.
// Height=2 → with spacer, spinner on row 1; no bottom padding.
let area2 = Rect::new(0, 0, 20, 2);
let mut buf2 = Buffer::empty(area2);
(&pane).render_ref(area2, &mut buf2);
@@ -601,13 +621,10 @@ mod tests {
row0.push(buf2[(x, 0)].symbol().chars().next().unwrap_or(' '));
row1.push(buf2[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert!(row0.trim().is_empty(), "expected spacer on row 0: {row0:?}");
assert!(
row0.contains("Working"),
"expected Working header on row 0: {row0:?}"
);
assert!(
row1.trim().is_empty(),
"expected bottom padding on row 1: {row1:?}"
row1.contains("Working"),
"expected Working on row 1: {row1:?}"
);
// Height=1 → no padding; single row is the spinner.

View File

@@ -1473,7 +1473,7 @@ mod tests {
.timestamp() as u64;
let mut rng = rand::rngs::StdRng::seed_from_u64(pst_today_seed);
for _case in 0..10_000 {
for _case in 0..500 {
let mut ta = TextArea::new();
let mut state = TextAreaState::default();
// Track element payloads we insert. Payloads use characters '[' and ']' which
@@ -1497,7 +1497,7 @@ mod tests {
let mut width: u16 = rng.random_range(1..=12);
let mut height: u16 = rng.random_range(1..=4);
for _step in 0..200 {
for _step in 0..60 {
// Mostly stable width/height, occasionally change
if rng.random_bool(0.1) {
width = rng.random_range(1..=12);

View File

@@ -105,6 +105,7 @@ pub(crate) struct ChatWidget {
full_reasoning_buffer: String,
session_id: Option<Uuid>,
frame_requester: FrameRequester,
last_history_was_exec: bool,
}
struct UserMessage {
@@ -376,6 +377,9 @@ impl ChatWidget {
self.bottom_pane.set_task_running(false);
self.task_complete_pending = false;
}
// A completed stream indicates non-exec content was just inserted.
// Reset the exec header grouping so the next exec shows its header.
self.last_history_was_exec = false;
self.flush_interrupt_queue();
}
}
@@ -401,6 +405,7 @@ impl ChatWidget {
exit_code: ev.exit_code,
stdout: ev.stdout.clone(),
stderr: ev.stderr.clone(),
formatted_output: ev.formatted_output.clone(),
},
));
@@ -408,9 +413,16 @@ impl ChatWidget {
self.active_exec_cell = None;
let pending = std::mem::take(&mut self.pending_exec_completions);
for (command, parsed, output) in pending {
self.add_to_history(history_cell::new_completed_exec_command(
command, parsed, output,
));
let include_header = !self.last_history_was_exec;
let cell = history_cell::new_completed_exec_command(
command,
parsed,
output,
include_header,
ev.duration,
);
self.add_to_history(cell);
self.last_history_was_exec = true;
}
}
}
@@ -473,9 +485,11 @@ impl ChatWidget {
exec.parsed.extend(ev.parsed_cmd);
}
_ => {
let include_header = !self.last_history_was_exec;
self.active_exec_cell = Some(history_cell::new_active_exec_command(
ev.command,
ev.parsed_cmd,
include_header,
));
}
}
@@ -565,6 +579,7 @@ impl ChatWidget {
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
session_id: None,
last_history_was_exec: false,
}
}
@@ -583,7 +598,11 @@ impl ChatWidget {
match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted(text) => {
self.submit_user_message(text.into());
let images = self.bottom_pane.take_recent_submission_images();
self.submit_user_message(UserMessage {
text,
image_paths: images,
});
}
InputResult::Command(cmd) => {
self.dispatch_command(cmd);
@@ -592,6 +611,21 @@ impl ChatWidget {
}
}
pub(crate) fn attach_image(
&mut self,
path: PathBuf,
width: u32,
height: u32,
format_label: &str,
) {
tracing::info!(
"attach_image path={path:?} width={width} height={height} format={format_label}",
);
self.bottom_pane
.attach_image(path.clone(), width, height, format_label);
self.request_redraw();
}
fn dispatch_command(&mut self, cmd: SlashCommand) {
match cmd {
SlashCommand::New => {
@@ -694,13 +728,19 @@ impl ChatWidget {
fn flush_active_exec_cell(&mut self) {
if let Some(active) = self.active_exec_cell.take() {
self.last_history_was_exec = true;
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(active)));
}
}
fn add_to_history(&mut self, cell: impl HistoryCell + 'static) {
// Only break exec grouping if the cell renders visible lines.
let has_display_lines = !cell.display_lines().is_empty();
self.flush_active_exec_cell();
if has_display_lines {
self.last_history_was_exec = false;
}
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
}
@@ -796,9 +836,41 @@ impl ChatWidget {
EventMsg::ShutdownComplete => self.on_shutdown_complete(),
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff),
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
// Also show background logs in the transcript for visibility.
self.add_to_history(history_cell::new_log_line(message.clone()));
self.on_background_event(message)
}
EventMsg::SubagentBegin(ev) => {
let msg = format!("subagent begin: {} ({})", ev.name, ev.subagent_id);
self.add_to_history(history_cell::new_log_line(msg));
}
EventMsg::SubagentForwarded(ev) => {
// Summarize forwarded event type; include message text when it is AgentMessage.
match *ev.event {
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
let msg = format!("subagent {}: {}", ev.name, message);
self.add_to_history(history_cell::new_log_line(msg));
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { ref delta }) => {
let msg = format!("subagent {}: {}", ev.name, delta);
self.add_to_history(history_cell::new_log_line(msg));
}
ref other => {
let msg = format!("subagent {} forwarded: {:?}", ev.name, other);
self.add_to_history(history_cell::new_log_line(msg));
}
}
}
EventMsg::SubagentEnd(ev) => {
let summary = ev.last_agent_message.as_deref().unwrap_or("");
let msg = format!(
"subagent end: {} ({}) success={} {}",
ev.name, ev.subagent_id, ev.success, summary
);
self.add_to_history(history_cell::new_log_line(msg));
}
EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message),
EventMsg::ConversationHistory(_) => {}
}
// Coalesce redraws: issue at most one after handling the event
if self.needs_redraw {
@@ -818,9 +890,8 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
pub(crate) fn on_diff_complete(&mut self) {
self.bottom_pane.set_task_running(false);
self.add_to_history(history_cell::new_diff_output(diff_output));
self.mark_needs_redraw();
}

View File

@@ -21,6 +21,7 @@ use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_login::CodexAuth;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -43,6 +44,31 @@ fn test_config() -> Config {
.expect("config")
}
// Backward-compat shim for older session logs that predate the
// `formatted_output` field on ExecCommandEnd events.
fn upgrade_event_payload_for_tests(mut payload: serde_json::Value) -> serde_json::Value {
if let Some(obj) = payload.as_object_mut()
&& let Some(msg) = obj.get_mut("msg")
&& let Some(m) = msg.as_object_mut()
{
let ty = m.get("type").and_then(|v| v.as_str()).unwrap_or("");
if ty == "exec_command_end" && !m.contains_key("formatted_output") {
let stdout = m.get("stdout").and_then(|v| v.as_str()).unwrap_or("");
let stderr = m.get("stderr").and_then(|v| v.as_str()).unwrap_or("");
let formatted = if stderr.is_empty() {
stdout.to_string()
} else {
format!("{stdout}{stderr}")
};
m.insert(
"formatted_output".to_string(),
serde_json::Value::String(formatted),
);
}
}
payload
}
#[test]
fn final_answer_without_newline_is_flushed_immediately() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
@@ -104,7 +130,9 @@ async fn helpers_are_available_and_do_not_panic() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let cfg = test_config();
let conversation_manager = Arc::new(ConversationManager::default());
let conversation_manager = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key(
"test",
)));
let mut w = ChatWidget::new(
cfg,
conversation_manager,
@@ -154,6 +182,7 @@ fn make_chatwidget_manual() -> (
full_reasoning_buffer: String::new(),
session_id: None,
frame_requester: crate::tui::FrameRequester::test_dummy(),
last_history_was_exec: false,
};
(widget, rx, op_rx)
}
@@ -234,8 +263,10 @@ fn exec_history_cell_shows_working_then_completed() {
call_id: "call-1".into(),
stdout: "done".into(),
stderr: String::new(),
aggregated_output: "done".into(),
exit_code: 0,
duration: std::time::Duration::from_millis(5),
formatted_output: "done".into(),
}),
});
@@ -247,8 +278,12 @@ fn exec_history_cell_shows_working_then_completed() {
);
let blob = lines_to_single_string(&cells[0]);
assert!(
blob.contains("Completed"),
"expected completed exec cell to show Completed header: {blob:?}"
blob.contains('✓'),
"expected completed exec cell to show success marker: {blob:?}"
);
assert!(
blob.contains("echo done"),
"expected command text to be present: {blob:?}"
);
}
@@ -279,8 +314,10 @@ fn exec_history_cell_shows_working_then_failed() {
call_id: "call-2".into(),
stdout: String::new(),
stderr: "error".into(),
aggregated_output: "error".into(),
exit_code: 2,
duration: std::time::Duration::from_millis(7),
formatted_output: "".into(),
}),
});
@@ -292,9 +329,82 @@ fn exec_history_cell_shows_working_then_failed() {
);
let blob = lines_to_single_string(&cells[0]);
assert!(
blob.contains("Failed (exit 2)"),
"expected completed exec cell to show Failed header with exit code: {blob:?}"
blob.contains('✗'),
"expected failure marker present: {blob:?}"
);
assert!(
blob.contains("false"),
"expected command text present: {blob:?}"
);
}
#[test]
fn exec_history_extends_previous_when_consecutive() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
// First command
chat.handle_codex_event(Event {
id: "call-a".into(),
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: "call-a".into(),
command: vec!["bash".into(), "-lc".into(), "echo one".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
parsed_cmd: vec![
codex_core::parse_command::ParsedCommand::Unknown {
cmd: "echo one".into(),
}
.into(),
],
}),
});
chat.handle_codex_event(Event {
id: "call-a".into(),
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "call-a".into(),
stdout: "one".into(),
stderr: String::new(),
aggregated_output: "one".into(),
exit_code: 0,
duration: std::time::Duration::from_millis(5),
formatted_output: "one".into(),
}),
});
let first_cells = drain_insert_history(&mut rx);
assert_eq!(first_cells.len(), 1, "first exec should insert history");
// Second command
chat.handle_codex_event(Event {
id: "call-b".into(),
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: "call-b".into(),
command: vec!["bash".into(), "-lc".into(), "echo two".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
parsed_cmd: vec![
codex_core::parse_command::ParsedCommand::Unknown {
cmd: "echo two".into(),
}
.into(),
],
}),
});
chat.handle_codex_event(Event {
id: "call-b".into(),
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "call-b".into(),
stdout: "two".into(),
stderr: String::new(),
aggregated_output: "two".into(),
exit_code: 0,
duration: std::time::Duration::from_millis(5),
formatted_output: "two".into(),
}),
});
let second_cells = drain_insert_history(&mut rx);
assert_eq!(second_cells.len(), 1, "second exec should extend history");
let first_blob = lines_to_single_string(&first_cells[0]);
let second_blob = lines_to_single_string(&second_cells[0]);
assert!(first_blob.contains('✓'));
assert!(second_blob.contains("echo two"));
}
#[tokio::test(flavor = "current_thread")]
@@ -337,7 +447,9 @@ async fn binary_size_transcript_matches_ideal_fixture() {
match kind {
"codex_event" => {
if let Some(payload) = v.get("payload") {
let ev: Event = serde_json::from_value(payload.clone()).expect("parse");
let ev: Event =
serde_json::from_value(upgrade_event_payload_for_tests(payload.clone()))
.expect("parse");
chat.handle_codex_event(ev);
while let Ok(app_ev) = rx.try_recv() {
match app_ev {

View File

@@ -0,0 +1,97 @@
use std::path::PathBuf;
use tempfile::Builder;
#[derive(Debug)]
pub enum PasteImageError {
ClipboardUnavailable(String),
NoImage(String),
EncodeFailed(String),
IoError(String),
}
impl std::fmt::Display for PasteImageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PasteImageError::ClipboardUnavailable(msg) => write!(f, "clipboard unavailable: {msg}"),
PasteImageError::NoImage(msg) => write!(f, "no image on clipboard: {msg}"),
PasteImageError::EncodeFailed(msg) => write!(f, "could not encode image: {msg}"),
PasteImageError::IoError(msg) => write!(f, "io error: {msg}"),
}
}
}
impl std::error::Error for PasteImageError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncodedImageFormat {
Png,
}
impl EncodedImageFormat {
pub fn label(self) -> &'static str {
match self {
EncodedImageFormat::Png => "PNG",
}
}
}
#[derive(Debug, Clone)]
pub struct PastedImageInfo {
pub width: u32,
pub height: u32,
pub encoded_format: EncodedImageFormat, // Always PNG for now.
}
/// Capture image from system clipboard, encode to PNG, and return bytes + info.
pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {
tracing::debug!("attempting clipboard image read");
let mut cb = arboard::Clipboard::new()
.map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()))?;
let img = cb
.get_image()
.map_err(|e| PasteImageError::NoImage(e.to_string()))?;
let w = img.width as u32;
let h = img.height as u32;
let mut png: Vec<u8> = Vec::new();
let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else {
return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into()));
};
let dyn_img = image::DynamicImage::ImageRgba8(rgba_img);
tracing::debug!("clipboard image decoded RGBA {w}x{h}");
{
let mut cursor = std::io::Cursor::new(&mut png);
dyn_img
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?;
}
tracing::debug!(
"clipboard image encoded to PNG ({len} bytes)",
len = png.len()
);
Ok((
png,
PastedImageInfo {
width: w,
height: h,
encoded_format: EncodedImageFormat::Png,
},
))
}
/// Convenience: write to a temp file and return its path + info.
pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> {
let (png, info) = paste_image_as_png()?;
// Create a unique temporary file with a .png suffix to avoid collisions.
let tmp = Builder::new()
.prefix("codex-clipboard-")
.suffix(".png")
.tempfile()
.map_err(|e| PasteImageError::IoError(e.to_string()))?;
std::fs::write(tmp.path(), &png).map_err(|e| PasteImageError::IoError(e.to_string()))?;
// Persist the file (so it remains after the handle is dropped) and return its PathBuf.
let (_file, path) = tmp
.keep()
.map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
Ok((path, info))
}

View File

@@ -70,20 +70,6 @@ impl Frame<'_> {
/// Usually the area argument is the size of the current frame or a sub-area of the current
/// frame (which can be obtained using [`Layout`] to split the total area).
///
/// # Example
///
/// ```rust
/// # use ratatui::{backend::TestBackend, Terminal};
/// # let backend = TestBackend::new(5, 5);
/// # let mut terminal = Terminal::new(backend).unwrap();
/// # let mut frame = terminal.get_frame();
/// use ratatui::{layout::Rect, widgets::Block};
///
/// let block = Block::new();
/// let area = Rect::new(0, 0, 5, 5);
/// frame.render_widget(block, area);
/// ```
///
/// [`Layout`]: crate::layout::Layout
pub fn render_widget<W: Widget>(&mut self, widget: W, area: Rect) {
widget.render(area, self.buffer);
@@ -93,22 +79,6 @@ impl Frame<'_> {
///
/// Usually the area argument is the size of the current frame or a sub-area of the current
/// frame (which can be obtained using [`Layout`] to split the total area).
///
/// # Example
///
/// ```rust
/// # #[cfg(feature = "unstable-widget-ref")] {
/// # use ratatui::{backend::TestBackend, Terminal};
/// # let backend = TestBackend::new(5, 5);
/// # let mut terminal = Terminal::new(backend).unwrap();
/// # let mut frame = terminal.get_frame();
/// use ratatui::{layout::Rect, widgets::Block};
///
/// let block = Block::new();
/// let area = Rect::new(0, 0, 5, 5);
/// frame.render_widget_ref(block, area);
/// # }
/// ```
#[allow(clippy::needless_pass_by_value)]
pub fn render_widget_ref<W: WidgetRef>(&mut self, widget: W, area: Rect) {
widget.render_ref(area, self.buffer);
@@ -122,24 +92,6 @@ impl Frame<'_> {
/// The last argument should be an instance of the [`StatefulWidget::State`] associated to the
/// given [`StatefulWidget`].
///
/// # Example
///
/// ```rust
/// # use ratatui::{backend::TestBackend, Terminal};
/// # let backend = TestBackend::new(5, 5);
/// # let mut terminal = Terminal::new(backend).unwrap();
/// # let mut frame = terminal.get_frame();
/// use ratatui::{
/// layout::Rect,
/// widgets::{List, ListItem, ListState},
/// };
///
/// let mut state = ListState::default().with_selected(Some(1));
/// let list = List::new(vec![ListItem::new("Item 1"), ListItem::new("Item 2")]);
/// let area = Rect::new(0, 0, 5, 5);
/// frame.render_stateful_widget(list, area, &mut state);
/// ```
///
/// [`Layout`]: crate::layout::Layout
pub fn render_stateful_widget<W>(&mut self, widget: W, area: Rect, state: &mut W::State)
where
@@ -156,26 +108,6 @@ impl Frame<'_> {
///
/// The last argument should be an instance of the [`StatefulWidgetRef::State`] associated to
/// the given [`StatefulWidgetRef`].
///
/// # Example
///
/// ```rust
/// # #[cfg(feature = "unstable-widget-ref")] {
/// # use ratatui::{backend::TestBackend, Terminal};
/// # let backend = TestBackend::new(5, 5);
/// # let mut terminal = Terminal::new(backend).unwrap();
/// # let mut frame = terminal.get_frame();
/// use ratatui::{
/// layout::Rect,
/// widgets::{List, ListItem, ListState},
/// };
///
/// let mut state = ListState::default().with_selected(Some(1));
/// let list = List::new(vec![ListItem::new("Item 1"), ListItem::new("Item 2")]);
/// let area = Rect::new(0, 0, 5, 5);
/// frame.render_stateful_widget_ref(list, area, &mut state);
/// # }
/// ```
#[allow(clippy::needless_pass_by_value)]
pub fn render_stateful_widget_ref<W>(&mut self, widget: W, area: Rect, state: &mut W::State)
where
@@ -216,17 +148,6 @@ impl Frame<'_> {
/// This count is particularly useful when dealing with dynamic content or animations where the
/// state of the display changes over time. By tracking the frame count, developers can
/// synchronize updates or changes to the content with the rendering process.
///
/// # Examples
///
/// ```rust
/// # use ratatui::{backend::TestBackend, Terminal};
/// # let backend = TestBackend::new(5, 5);
/// # let mut terminal = Terminal::new(backend).unwrap();
/// # let mut frame = terminal.get_frame();
/// let current_count = frame.count();
/// println!("Current frame count: {}", current_count);
/// ```
pub const fn count(&self) -> usize {
self.count
}
@@ -277,19 +198,6 @@ where
B: Backend,
{
/// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`].
///
/// # Example
///
/// ```rust
/// use std::io::stdout;
///
/// use ratatui::{backend::CrosstermBackend, layout::Rect, Terminal, TerminalOptions, Viewport};
///
/// let backend = CrosstermBackend::new(stdout());
/// let viewport = Viewport::Fixed(Rect::new(0, 0, 10, 10));
/// let terminal = Terminal::with_options(backend, TerminalOptions { viewport })?;
/// # std::io::Result::Ok(())
/// ```
pub fn with_options(mut backend: B) -> io::Result<Self> {
let screen_size = backend.size()?;
let cursor_pos = backend.get_cursor_position()?;
@@ -394,29 +302,6 @@ where
/// previous frame to determine what has changed, and only the changes are written to the
/// terminal. If the render callback does not fully render the frame, the terminal will not be
/// in a consistent state.
///
/// # Examples
///
/// ```
/// # let backend = ratatui::backend::TestBackend::new(10, 10);
/// # let mut terminal = ratatui::Terminal::new(backend)?;
/// use ratatui::{layout::Position, widgets::Paragraph};
///
/// // with a closure
/// terminal.draw(|frame| {
/// let area = frame.area();
/// frame.render_widget(Paragraph::new("Hello World!"), area);
/// frame.set_cursor_position(Position { x: 0, y: 0 });
/// })?;
///
/// // or with a function
/// terminal.draw(render)?;
///
/// fn render(frame: &mut ratatui::Frame) {
/// frame.render_widget(Paragraph::new("Hello World!"), frame.area());
/// }
/// # std::io::Result::Ok(())
/// ```
pub fn draw<F>(&mut self, render_callback: F) -> io::Result<()>
where
F: FnOnce(&mut Frame),
@@ -462,36 +347,6 @@ where
/// previous frame to determine what has changed, and only the changes are written to the
/// terminal. If the render function does not fully render the frame, the terminal will not be
/// in a consistent state.
///
/// # Examples
///
/// ```should_panic
/// # use ratatui::layout::Position;;
/// # let backend = ratatui::backend::TestBackend::new(10, 10);
/// # let mut terminal = ratatui::Terminal::new(backend)?;
/// use std::io;
///
/// use ratatui::widgets::Paragraph;
///
/// // with a closure
/// terminal.try_draw(|frame| {
/// let value: u8 = "not a number".parse().map_err(io::Error::other)?;
/// let area = frame.area();
/// frame.render_widget(Paragraph::new("Hello World!"), area);
/// frame.set_cursor_position(Position { x: 0, y: 0 });
/// io::Result::Ok(())
/// })?;
///
/// // or with a function
/// terminal.try_draw(render)?;
///
/// fn render(frame: &mut ratatui::Frame) -> io::Result<()> {
/// let value: u8 = "not a number".parse().map_err(io::Error::other)?;
/// frame.render_widget(Paragraph::new("Hello World!"), frame.area());
/// Ok(())
/// }
/// # io::Result::Ok(())
/// ```
pub fn try_draw<F, E>(&mut self, render_callback: F) -> io::Result<()>
where
F: FnOnce(&mut Frame) -> Result<(), E>,

View File

@@ -9,13 +9,7 @@ pub(crate) fn escape_command(command: &[String]) -> String {
pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String {
match command {
// exactly three items
[first, second, third]
// first two must be "bash", "-lc"
if first == "bash" && second == "-lc" =>
{
third.clone() // borrow `third`
}
[first, second, third] if first == "bash" && second == "-lc" => third.clone(),
_ => escape_command(command),
}
}

View File

@@ -29,10 +29,10 @@ use ratatui::prelude::*;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use shlex::try_join as shlex_try_join;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::PathBuf;
@@ -46,6 +46,7 @@ pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
pub(crate) stdout: String,
pub(crate) stderr: String,
pub(crate) formatted_output: String,
}
pub(crate) enum PatchEventType {
@@ -104,6 +105,8 @@ pub(crate) struct ExecCell {
pub(crate) parsed: Vec<ParsedCommand>,
pub(crate) output: Option<CommandOutput>,
start_time: Option<Instant>,
duration: Option<Duration>,
include_header: bool,
}
impl HistoryCell for ExecCell {
fn display_lines(&self) -> Vec<Line<'static>> {
@@ -112,15 +115,63 @@ impl HistoryCell for ExecCell {
&self.parsed,
self.output.as_ref(),
self.start_time,
self.include_header,
)
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = vec!["".into()];
let cmd_display = strip_bash_lc_and_escape(&self.command);
for (i, part) in cmd_display.lines().enumerate() {
if i == 0 {
lines.push(Line::from(vec!["$ ".magenta(), part.to_string().into()]));
} else {
lines.push(Line::from(vec![" ".into(), part.to_string().into()]));
}
}
// Command output: include full stdout and stderr (no truncation)
if let Some(output) = self.output.as_ref() {
lines.extend(output.formatted_output.lines().map(ansi_escape_line));
}
if let Some(output) = self.output.as_ref() {
let duration = self
.duration
.map(format_duration)
.unwrap_or_else(|| "unknown".to_string());
let mut result = if output.exit_code == 0 {
Line::from("".green().bold())
} else {
Line::from(vec![
"".red().bold(),
format!(" ({})", output.exit_code).into(),
])
};
result.push_span(format!("{duration}").dim());
lines.push(result);
}
lines
}
}
impl WidgetRef for &ExecCell {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let content_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height,
};
Paragraph::new(Text::from(self.display_lines()))
.wrap(Wrap { trim: false })
.render(area, buf);
.render(content_area, buf);
}
}
@@ -131,8 +182,8 @@ struct CompletedMcpToolCallWithImageOutput {
impl HistoryCell for CompletedMcpToolCallWithImageOutput {
fn display_lines(&self) -> Vec<Line<'static>> {
vec![
Line::from("tool result (image output omitted)"),
Line::from(""),
Line::from("tool result (image output omitted)"),
]
}
}
@@ -179,6 +230,7 @@ pub(crate) fn new_session_info(
};
let lines: Vec<Line<'static>> = vec![
Line::from(Span::from("")),
Line::from(vec![
Span::raw(">_ ").dim(),
Span::styled(
@@ -194,17 +246,16 @@ pub(crate) fn new_session_info(
Line::from(format!(" /status - {}", SlashCommand::Status.description()).dim()),
Line::from(format!(" /approvals - {}", SlashCommand::Approvals.description()).dim()),
Line::from(format!(" /model - {}", SlashCommand::Model.description()).dim()),
Line::from("".dim()),
];
PlainHistoryCell { lines }
} else if config.model == model {
PlainHistoryCell { lines: Vec::new() }
} else {
let lines = vec![
Line::from(""),
Line::from("model changed:".magenta().bold()),
Line::from(format!("requested: {}", config.model)),
Line::from(format!("used: {model}")),
Line::from(""),
];
PlainHistoryCell { lines }
}
@@ -212,9 +263,9 @@ pub(crate) fn new_session_info(
pub(crate) fn new_user_prompt(message: String) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(""));
lines.push(Line::from("user".cyan().bold()));
lines.extend(message.lines().map(|l| Line::from(l.to_string())));
lines.push(Line::from(""));
PlainHistoryCell { lines }
}
@@ -222,12 +273,15 @@ pub(crate) fn new_user_prompt(message: String) -> PlainHistoryCell {
pub(crate) fn new_active_exec_command(
command: Vec<String>,
parsed: Vec<ParsedCommand>,
include_header: bool,
) -> ExecCell {
ExecCell {
command,
parsed,
output: None,
start_time: Some(Instant::now()),
duration: None,
include_header,
}
}
@@ -235,76 +289,61 @@ pub(crate) fn new_completed_exec_command(
command: Vec<String>,
parsed: Vec<ParsedCommand>,
output: CommandOutput,
include_header: bool,
duration: Duration,
) -> ExecCell {
ExecCell {
command,
parsed,
output: Some(output),
start_time: None,
duration: Some(duration),
include_header,
}
}
fn exec_duration(start: Instant) -> String {
format!("{}s", start.elapsed().as_secs())
}
fn exec_command_lines(
command: &[String],
parsed: &[ParsedCommand],
output: Option<&CommandOutput>,
start_time: Option<Instant>,
include_header: bool,
) -> Vec<Line<'static>> {
match parsed.is_empty() {
true => new_exec_command_generic(command, output, start_time),
false => new_parsed_command(command, parsed, output, start_time),
true => new_exec_command_generic(command, output, start_time, include_header),
false => new_parsed_command(command, parsed, output, start_time, include_header),
}
}
fn new_parsed_command(
command: &[String],
_command: &[String],
parsed_commands: &[ParsedCommand],
output: Option<&CommandOutput>,
start_time: Option<Instant>,
include_header: bool,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line> = Vec::new();
match output {
None => {
let mut spans = vec!["⚙︎ Working".magenta().bold()];
if let Some(st) = start_time {
let dur = exec_duration(st);
spans.push(format!("{dur}").dim());
}
lines.push(Line::from(spans));
}
Some(o) if o.exit_code == 0 => {
lines.push(Line::from(vec!["".green(), " Completed".into()]));
}
Some(o) => {
lines.push(Line::from(vec![
"".red(),
format!(" Failed (exit {})", o.exit_code).into(),
]));
}
};
// Optionally include the complete, unaltered command from the model.
if std::env::var("SHOW_FULL_COMMANDS")
.map(|v| !v.is_empty())
.unwrap_or(false)
{
let full_cmd = shlex_try_join(command.iter().map(|s| s.as_str()))
.unwrap_or_else(|_| command.join(" "));
lines.push(Line::from(vec![
Span::styled("", Style::default().add_modifier(Modifier::DIM)),
Span::styled(
full_cmd,
Style::default()
.add_modifier(Modifier::DIM)
.add_modifier(Modifier::ITALIC),
),
]));
// Leading spacer and header line above command list
if include_header {
lines.push(Line::from(""));
lines.push(Line::from(">_".magenta()));
}
for (i, parsed) in parsed_commands.iter().enumerate() {
// Determine the leading status marker: spinner while running, ✓ on success, ✗ on failure.
let status_marker: Span<'static> = match output {
None => {
// Animated braille spinner choose frame based on elapsed time.
const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let idx = start_time
.map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len())
.unwrap_or(0);
let ch = FRAMES[idx];
Span::raw(format!("{ch}"))
}
Some(o) if o.exit_code == 0 => Span::styled("", Style::default().fg(Color::Green)),
Some(_) => Span::styled("", Style::default().fg(Color::Red)),
};
for parsed in parsed_commands.iter() {
let text = match parsed {
ParsedCommand::Read { name, .. } => format!("📖 {name}"),
ParsedCommand::ListFiles { cmd, path } => match path {
@@ -323,19 +362,25 @@ fn new_parsed_command(
ParsedCommand::Unknown { cmd } => format!("⌨️ {cmd}"),
ParsedCommand::Noop { cmd } => format!("🔄 {cmd}"),
};
let first_prefix = if i == 0 { "" } else { " " };
// Prefix: two spaces, marker, space. Continuations align under the text block.
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)),
line_text.to_string().dim(),
]));
if j == 0 {
lines.push(Line::from(vec![
" ".into(),
status_marker.clone(),
" ".into(),
line_text.to_string().light_blue(),
]));
} else {
lines.push(Line::from(vec![
" ".into(),
line_text.to_string().light_blue(),
]));
}
}
}
lines.extend(output_lines(output, true, false));
lines.push(Line::from(""));
lines
}
@@ -344,29 +389,44 @@ fn new_exec_command_generic(
command: &[String],
output: Option<&CommandOutput>,
start_time: Option<Instant>,
include_header: bool,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let command_escaped = strip_bash_lc_and_escape(command);
let mut cmd_lines = command_escaped.lines();
if let Some(first) = cmd_lines.next() {
let mut spans: Vec<Span> = vec!["⚡ Running".magenta()];
if let Some(st) = start_time {
let dur = exec_duration(st);
spans.push(format!("{dur}").dim());
}
spans.push(" ".into());
spans.push(first.to_string().into());
lines.push(Line::from(spans));
} else {
let mut spans: Vec<Span> = vec!["⚡ Running".magenta()];
if let Some(st) = start_time {
let dur = exec_duration(st);
spans.push(format!("{dur}").dim());
}
lines.push(Line::from(spans));
// Leading spacer and header line above command list
if include_header {
lines.push(Line::from(""));
lines.push(Line::from(">_".magenta()));
}
for cont in cmd_lines {
lines.push(Line::from(cont.to_string()));
let command_escaped = strip_bash_lc_and_escape(command);
// Determine marker: spinner while running, ✓/✗ when completed
let status_marker: Span<'static> = match output {
None => {
const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let idx = start_time
.map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len())
.unwrap_or(0);
let ch = FRAMES[idx];
Span::raw(format!("{ch}"))
}
Some(o) if o.exit_code == 0 => Span::styled("", Style::default().fg(Color::Green)),
Some(_) => Span::styled("", Style::default().fg(Color::Red)),
};
for (i, line) in command_escaped.lines().enumerate() {
if i == 0 {
lines.push(Line::from(vec![
Span::raw(" "),
status_marker.clone(),
Span::raw(" "),
Span::raw(line.to_string()),
]));
} else {
lines.push(Line::from(vec![
Span::styled(" ", Style::default().add_modifier(Modifier::DIM)),
Span::raw(line.to_string()),
]));
}
}
lines.extend(output_lines(output, false, true));
@@ -377,9 +437,9 @@ fn new_exec_command_generic(
pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> PlainHistoryCell {
let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]);
let lines: Vec<Line> = vec![
Line::from(""),
title_line,
format_mcp_invocation(invocation.clone()),
Line::from(""),
];
PlainHistoryCell { lines }
@@ -489,8 +549,6 @@ pub(crate) fn new_completed_mcp_tool_call(
));
}
}
lines.push(Line::from(""));
}
Err(e) => {
lines.push(Line::from(vec![
@@ -503,29 +561,18 @@ pub(crate) fn new_completed_mcp_tool_call(
}
};
// Leading blank separator at the start of this cell
lines.insert(0, Line::from(""));
Box::new(PlainHistoryCell { lines })
}
pub(crate) fn new_diff_output(message: String) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("/diff".magenta()));
if message.trim().is_empty() {
lines.push(Line::from("No changes detected.".italic()));
} else {
lines.extend(message.lines().map(ansi_escape_line));
}
lines.push(Line::from(""));
PlainHistoryCell { lines }
}
pub(crate) fn new_status_output(
config: &Config,
usage: &TokenUsage,
session_id: &Option<Uuid>,
) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(""));
lines.push(Line::from("/status".magenta()));
let config_entries = create_config_summary_entries(config);
@@ -610,8 +657,6 @@ pub(crate) fn new_status_output(
]));
}
lines.push(Line::from(""));
// 👤 Account (only if ChatGPT tokens exist), shown under the first block
let auth_file = get_auth_file(&config.codex_home);
if let Ok(auth) = try_read_auth_json(&auth_file)
@@ -702,13 +747,19 @@ pub(crate) fn new_status_output(
usage.blended_total().to_string().into(),
]));
lines.push(Line::from(""));
PlainHistoryCell { lines }
}
/// Simple one-line log entry (dim) to surface traces and diagnostics in the transcript.
pub(crate) fn new_log_line(message: String) -> TranscriptOnlyHistoryCell {
let lines: Vec<Line<'static>> = vec![Line::from(""), Line::from(message).dim()];
TranscriptOnlyHistoryCell { lines }
}
/// Render a summary of configured MCP servers from the current `Config`.
pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
let lines: Vec<Line<'static>> = vec![
Line::from(""),
Line::from("/mcp".magenta()),
Line::from(""),
Line::from(vec!["🔌 ".into(), "MCP Tools".bold()]),
@@ -723,7 +774,6 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
" to configure them.".into(),
])
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
];
PlainHistoryCell { lines }
@@ -796,7 +846,7 @@ pub(crate) fn new_mcp_tools_output(
}
pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
let lines: Vec<Line<'static>> = vec![vec!["🖐 ".red().bold(), message.into()].into(), "".into()];
let lines: Vec<Line<'static>> = vec!["".into(), vec!["🖐 ".red().bold(), message.into()].into()];
PlainHistoryCell { lines }
}
@@ -811,6 +861,8 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlainHistoryCell {
let UpdatePlanArgs { explanation, plan } = update;
let mut lines: Vec<Line<'static>> = Vec::new();
// Leading blank for separation
lines.push(Line::from(""));
// Header with progress summary
let total = plan.len();
let completed = plan
@@ -901,8 +953,6 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlainHistoryCell {
}
}
lines.push(Line::from(""));
PlainHistoryCell { lines }
}
@@ -922,16 +972,16 @@ pub(crate) fn new_patch_event(
auto_approved: false,
} => {
let lines: Vec<Line<'static>> = vec![
Line::from("✏️ Applying patch".magenta().bold()),
Line::from(""),
Line::from("✏️ Applying patch".magenta().bold()),
];
return PlainHistoryCell { lines };
}
};
let mut lines: Vec<Line<'static>> = create_diff_summary(title, &changes, event_type);
lines.push(Line::from(""));
// Add leading blank separator for the cell
lines.insert(0, Line::from(""));
PlainHistoryCell { lines }
}
@@ -948,14 +998,15 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
exit_code: 1,
stdout: String::new(),
stderr,
formatted_output: String::new(),
}),
true,
true,
));
}
lines.push(Line::from(""));
// Leading blank separator
lines.insert(0, Line::from(""));
PlainHistoryCell { lines }
}
@@ -1002,9 +1053,8 @@ pub(crate) fn new_patch_apply_success(stdout: String) -> PlainHistoryCell {
lines.push(Line::from(format!("... +{remaining} lines")).dim());
}
}
lines.push(Line::from(""));
// Leading blank separator
lines.insert(0, Line::from(""));
PlainHistoryCell { lines }
}
@@ -1013,9 +1063,9 @@ pub(crate) fn new_reasoning_block(
config: &Config,
) -> TranscriptOnlyHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(""));
lines.push(Line::from("thinking".magenta().italic()));
append_markdown(&full_reasoning_buffer, &mut lines, config);
lines.push(Line::from(""));
TranscriptOnlyHistoryCell { lines }
}
@@ -1028,6 +1078,7 @@ fn output_lines(
exit_code,
stdout,
stderr,
..
} = match output {
Some(output) if only_err && output.exit_code == 0 => return vec![],
Some(output) => output,
@@ -1110,9 +1161,14 @@ mod tests {
let parsed = vec![ParsedCommand::Unknown {
cmd: "printf 'foo\nbar'".to_string(),
}];
let lines = exec_command_lines(&[], &parsed, None, None);
assert!(lines.len() >= 3);
assert_eq!(lines[1].spans[0].content, "");
assert_eq!(lines[2].spans[0].content, " ");
let lines = exec_command_lines(&[], &parsed, None, None, true);
assert!(lines.len() >= 4);
// Leading spacer then header line
assert!(lines[0].spans.is_empty() || lines[0].spans[0].content.is_empty());
assert_eq!(lines[1].spans[0].content, ">_");
// First rendered command line starts with two-space + marker.
assert_eq!(lines[2].spans[0].content, " ");
// Continuation lines align under the text block.
assert_eq!(lines[3].spans[0].content, " ");
}
}

View File

@@ -12,6 +12,7 @@ use codex_core::config::find_codex_home;
use codex_core::config::load_config_as_toml_with_cli_overrides;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_login::AuthManager;
use codex_login::AuthMode;
use codex_login::CodexAuth;
use codex_ollama::DEFAULT_OSS_MODEL;
@@ -30,6 +31,7 @@ mod bottom_pane;
mod chatwidget;
mod citation_regex;
mod cli;
mod clipboard_paste;
mod common;
pub mod custom_terminal;
mod diff_render;
@@ -122,6 +124,7 @@ pub async fn run_main(
config_profile: cli.config_profile.clone(),
codex_linux_sandbox_exe,
base_instructions: None,
include_subagent_tool: None,
include_plan_tool: Some(true),
include_apply_patch_tool: None,
disable_response_storage: cli.oss.then_some(true),
@@ -299,6 +302,7 @@ async fn run_ratatui_app(
let Cli { prompt, images, .. } = cli;
let auth_manager = AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method);
let login_status = get_login_status(&config);
let should_show_onboarding =
should_show_onboarding(login_status, &config, should_show_trust_screen);
@@ -311,6 +315,7 @@ async fn run_ratatui_app(
show_trust_screen: should_show_trust_screen,
login_status,
preferred_auth_method: config.preferred_auth_method,
auth_manager: auth_manager.clone(),
},
&mut tui,
)
@@ -321,7 +326,7 @@ async fn run_ratatui_app(
}
}
let app_result = App::run(&mut tui, config, prompt, images).await;
let app_result = App::run(&mut tui, auth_manager, config, prompt, images).await;
restore();
// Mark the end of the recorded session.

View File

@@ -1,5 +1,6 @@
#![allow(clippy::unwrap_used)]
use codex_login::AuthManager;
use codex_login::CLIENT_ID;
use codex_login::ServerOptions;
use codex_login::ShutdownHandle;
@@ -112,6 +113,7 @@ pub(crate) struct AuthModeWidget {
pub codex_home: PathBuf,
pub login_status: LoginStatus,
pub preferred_auth_method: AuthMode,
pub auth_manager: Arc<AuthManager>,
}
impl AuthModeWidget {
@@ -338,6 +340,7 @@ impl AuthModeWidget {
Ok(child) => {
let sign_in_state = self.sign_in_state.clone();
let request_frame = self.request_frame.clone();
let auth_manager = self.auth_manager.clone();
tokio::spawn(async move {
let auth_url = child.auth_url.clone();
{
@@ -351,6 +354,9 @@ impl AuthModeWidget {
let r = child.block_until_done().await;
match r {
Ok(()) => {
// Force the auth manager to reload the new auth information.
auth_manager.reload();
*sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage;
request_frame.schedule_frame();
}

View File

@@ -1,4 +1,5 @@
use codex_core::util::is_inside_git_repo;
use codex_login::AuthManager;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -58,6 +59,7 @@ pub(crate) struct OnboardingScreenArgs {
pub show_login_screen: bool,
pub login_status: LoginStatus,
pub preferred_auth_method: AuthMode,
pub auth_manager: Arc<AuthManager>,
}
impl OnboardingScreen {
@@ -69,6 +71,7 @@ impl OnboardingScreen {
show_login_screen,
login_status,
preferred_auth_method,
auth_manager,
} = args;
let mut steps: Vec<Step> = vec![Step::Welcome(WelcomeWidget {
is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated),
@@ -82,6 +85,7 @@ impl OnboardingScreen {
codex_home: codex_home.clone(),
login_status,
preferred_auth_method,
auth_manager,
}))
}
let is_git_repo = is_inside_git_repo(&cwd);

View File

@@ -1,6 +1,7 @@
use std::path::PathBuf;
use codex_core::config::set_project_trusted;
use codex_core::git_info::resolve_root_git_project_for_trust;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
@@ -144,10 +145,11 @@ impl StepStateProvider for TrustDirectoryWidget {
impl TrustDirectoryWidget {
fn handle_trust(&mut self) {
if let Err(e) = set_project_trusted(&self.codex_home, &self.cwd) {
let target =
resolve_root_git_project_for_trust(&self.cwd).unwrap_or_else(|| self.cwd.clone());
if let Err(e) = set_project_trusted(&self.codex_home, &target) {
tracing::error!("Failed to set project trusted: {e:?}");
self.error = Some(e.to_string());
// self.error = Some("Failed to set project trusted".to_string());
self.error = Some(format!("Failed to set trust for {}: {e}", target.display()));
}
self.selection = Some(TrustDirectorySelection::Trust);

View File

@@ -34,12 +34,3 @@ pub fn is_blank_line_spaces_only(line: &Line<'_>) -> bool {
.iter()
.all(|s| s.content.is_empty() || s.content.chars().all(|c| c == ' '))
}
/// Consider a line blank if its spans are empty or all span contents are
/// whitespace when trimmed.
pub fn is_blank_line_trim(line: &Line<'_>) -> bool {
if line.spans.is_empty() {
return true;
}
line.spans.iter().all(|s| s.content.trim().is_empty())
}

View File

@@ -146,6 +146,15 @@ impl StatusIndicatorWidget {
let since_start = self.start_time.elapsed();
(since_start.as_millis() / 100) as usize
}
/// Test-only helper to fast-forward the internal clock so animations
/// advance without sleeping.
#[cfg(test)]
pub(crate) fn test_fast_forward_frames(&mut self, frames: usize) {
let advance_ms = (frames as u64).saturating_mul(100);
// Move the start time into the past so `current_frame()` advances.
self.start_time = std::time::Instant::now() - std::time::Duration::from_millis(advance_ms);
}
}
impl WidgetRef for StatusIndicatorWidget {
@@ -236,8 +245,8 @@ mod tests {
w.restart_with_text("Hello".to_string());
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
// Allow a short delay so the typewriter reveals the first character.
std::thread::sleep(std::time::Duration::from_millis(120));
// Advance animation without sleeping.
w.test_fast_forward_frames(2);
let mut buf = ratatui::buffer::Buffer::empty(area);
w.render_ref(area, &mut buf);
@@ -252,8 +261,8 @@ mod tests {
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
w.restart_with_text("Hi".to_string());
// Ensure some frames elapse so we get a stable state.
std::thread::sleep(std::time::Duration::from_millis(120));
// Advance animation without sleeping.
w.test_fast_forward_frames(2);
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
let mut buf = ratatui::buffer::Buffer::empty(area);
@@ -273,7 +282,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
w.restart_with_text("Hello".to_string());
std::thread::sleep(std::time::Duration::from_millis(120));
w.test_fast_forward_frames(2);
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
let mut buf = ratatui::buffer::Buffer::empty(area);

View File

@@ -70,17 +70,6 @@ impl StreamController {
self.header.maybe_emit(out_lines)
}
#[inline]
fn ensure_single_trailing_blank(lines: &mut Lines) {
if lines
.last()
.map(|l| !crate::render::line_utils::is_blank_line_trim(l))
.unwrap_or(true)
{
lines.push(Line::from(""));
}
}
/// Begin an answer stream. Does not emit header yet; it is emitted on first commit.
pub(crate) fn begin(&mut self, _sink: &impl HistorySink) {
// Starting a new stream cancels any pending finish-from-previous-stream animation.
@@ -138,7 +127,6 @@ impl StreamController {
let mut lines_with_header: Lines = Vec::new();
self.emit_header_if_needed(&mut lines_with_header);
lines_with_header.extend(out_lines);
Self::ensure_single_trailing_blank(&mut lines_with_header);
sink.insert_history(lines_with_header);
}

View File

@@ -64,6 +64,8 @@ impl HeaderEmitter {
pub(crate) fn maybe_emit(&mut self, out_lines: &mut Vec<ratatui::text::Line<'static>>) -> bool {
if !self.emitted_in_stream && !self.emitted_this_turn {
// Add a leading blank line before the header for visual spacing
out_lines.push(ratatui::text::Line::from(""));
out_lines.push(render_header_line());
self.emitted_in_stream = true;
self.emitted_this_turn = true;

View File

@@ -21,14 +21,25 @@ pub(crate) struct TranscriptApp {
pub(crate) transcript_lines: Vec<Line<'static>>,
pub(crate) scroll_offset: usize,
pub(crate) is_done: bool,
title: String,
}
impl TranscriptApp {
pub(crate) fn new(transcript_lines: Vec<Line<'static>>) -> Self {
Self {
transcript_lines,
scroll_offset: usize::MAX,
is_done: false,
title: "T R A N S C R I P T".to_string(),
}
}
pub(crate) fn with_title(transcript_lines: Vec<Line<'static>>, title: String) -> Self {
Self {
transcript_lines,
scroll_offset: 0,
is_done: false,
title,
}
}
@@ -51,6 +62,7 @@ impl TranscriptApp {
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
match key_event {
// Ctrl+Z is handled at the App level when transcript overlay is active
KeyEvent {
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
@@ -93,7 +105,7 @@ impl TranscriptApp {
self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize);
}
KeyEvent {
code: KeyCode::PageDown,
code: KeyCode::PageDown | KeyCode::Char(' '),
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
@@ -114,8 +126,11 @@ impl TranscriptApp {
} => {
self.scroll_offset = usize::MAX;
}
_ => {}
_ => {
return;
}
}
tui.frame_requester().schedule_frame();
}
fn scroll_area(&self, area: Rect) -> Rect {
@@ -130,9 +145,8 @@ impl TranscriptApp {
Span::from("/ ".repeat(area.width as usize / 2))
.dim()
.render_ref(area, buf);
Span::from("/ T R A N S C R I P T")
.dim()
.render_ref(area, buf);
let header = format!("/ {}", self.title);
Span::from(header).dim().render_ref(area, buf);
// Main content area (excludes header and bottom status section)
let content_area = self.scroll_area(area);

View File

@@ -1,7 +1,13 @@
use std::io::Result;
use std::io::Stdout;
use std::io::stdout;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
#[cfg(unix)]
use std::sync::atomic::AtomicU8;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
@@ -10,12 +16,16 @@ use crossterm::cursor;
use crossterm::cursor::MoveTo;
use crossterm::event::DisableBracketedPaste;
use crossterm::event::EnableBracketedPaste;
use crossterm::event::Event;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use crossterm::event::KeyboardEnhancementFlags;
use crossterm::event::PopKeyboardEnhancementFlags;
use crossterm::event::PushKeyboardEnhancementFlags;
use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen;
use crossterm::terminal::ScrollUp;
use ratatui::backend::Backend;
use ratatui::backend::CrosstermBackend;
@@ -25,6 +35,7 @@ use ratatui::crossterm::terminal::enable_raw_mode;
use ratatui::layout::Offset;
use ratatui::text::Line;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::custom_terminal;
use crate::custom_terminal::Terminal as CustomTerminal;
use tokio::select;
@@ -98,8 +109,12 @@ pub enum TuiEvent {
Key(KeyEvent),
Paste(String),
Draw,
#[cfg(unix)]
ResumeFromSuspend,
AttachImage {
path: PathBuf,
width: u32,
height: u32,
format_label: &'static str,
},
}
pub struct Tui {
@@ -107,6 +122,35 @@ pub struct Tui {
draw_tx: tokio::sync::broadcast::Sender<()>,
pub(crate) terminal: Terminal,
pending_history_lines: Vec<Line<'static>>,
alt_saved_viewport: Option<ratatui::layout::Rect>,
#[cfg(unix)]
resume_pending: Arc<AtomicU8>, // Stores a ResumeAction
// True when overlay alt-screen UI is active
alt_screen_active: Arc<AtomicBool>,
}
#[cfg(unix)]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[repr(u8)]
enum ResumeAction {
None = 0,
RealignInline = 1,
RestoreAlt = 2,
}
#[cfg(unix)]
enum PreparedResumeAction {
RestoreAltScreen,
RealignViewport(ratatui::layout::Rect),
}
#[cfg(unix)]
fn take_resume_action(pending: &AtomicU8) -> ResumeAction {
match pending.swap(ResumeAction::None as u8, Ordering::Relaxed) {
1 => ResumeAction::RealignInline,
2 => ResumeAction::RestoreAlt,
_ => ResumeAction::None,
}
}
#[derive(Clone, Debug)]
@@ -184,6 +228,10 @@ impl Tui {
draw_tx,
terminal,
pending_history_lines: vec![],
alt_saved_viewport: None,
#[cfg(unix)]
resume_pending: Arc::new(AtomicU8::new(0)),
alt_screen_active: Arc::new(AtomicBool::new(false)),
}
}
@@ -197,31 +245,67 @@ impl Tui {
use tokio_stream::StreamExt;
let mut crossterm_events = crossterm::event::EventStream::new();
let mut draw_rx = self.draw_tx.subscribe();
#[cfg(unix)]
let resume_pending = self.resume_pending.clone();
#[cfg(unix)]
let alt_screen_active = self.alt_screen_active.clone();
let event_stream = async_stream::stream! {
loop {
select! {
Some(Ok(event)) = crossterm_events.next() => {
match event {
crossterm::event::Event::Key(KeyEvent {
code: KeyCode::Char('z'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
// Detect Ctrl+V to attach an image from the clipboard.
Event::Key(key_event @ KeyEvent {
code: KeyCode::Char('v'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
}) => {
#[cfg(unix)]
{
let _ = Tui::suspend();
yield TuiEvent::ResumeFromSuspend;
yield TuiEvent::Draw;
match paste_image_to_temp_png() {
Ok((path, info)) => {
yield TuiEvent::AttachImage {
path,
width: info.width,
height: info.height,
format_label: info.encoded_format.label(),
};
}
Err(_) => {
// Fall back to normal key handling if no image is available.
yield TuiEvent::Key(key_event);
}
}
}
crossterm::event::Event::Key(key_event) => {
#[cfg(unix)]
if matches!(
key_event,
crossterm::event::KeyEvent {
code: crossterm::event::KeyCode::Char('z'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: crossterm::event::KeyEventKind::Press,
..
}
)
{
if alt_screen_active.load(Ordering::Relaxed) {
let _ = execute!(stdout(), LeaveAlternateScreen);
resume_pending.store(ResumeAction::RestoreAlt as u8, Ordering::Relaxed);
} else {
resume_pending.store(ResumeAction::RealignInline as u8, Ordering::Relaxed);
}
let _ = execute!(stdout(), crossterm::cursor::Show);
let _ = Tui::suspend();
yield TuiEvent::Draw;
continue;
}
yield TuiEvent::Key(key_event);
}
crossterm::event::Event::Resize(_, _) => {
Event::Resize(_, _) => {
yield TuiEvent::Draw;
}
crossterm::event::Event::Paste(pasted) => {
Event::Paste(pasted) => {
yield TuiEvent::Paste(pasted);
}
_ => {}
@@ -246,7 +330,6 @@ impl Tui {
};
Box::pin(event_stream)
}
#[cfg(unix)]
fn suspend() -> Result<()> {
restore()?;
@@ -255,6 +338,80 @@ impl Tui {
Ok(())
}
#[cfg(unix)]
fn prepare_resume_action(
&mut self,
action: ResumeAction,
) -> Result<Option<PreparedResumeAction>> {
match action {
ResumeAction::RealignInline => {
let cursor_pos = self.terminal.get_cursor_position()?;
Ok(Some(PreparedResumeAction::RealignViewport(
ratatui::layout::Rect::new(0, cursor_pos.y, 0, 0),
)))
}
ResumeAction::RestoreAlt => {
if let Ok((_x, y)) = crossterm::cursor::position()
&& let Some(saved) = self.alt_saved_viewport.as_mut()
{
saved.y = y;
}
Ok(Some(PreparedResumeAction::RestoreAltScreen))
}
ResumeAction::None => Ok(None),
}
}
#[cfg(unix)]
fn apply_prepared_resume_action(&mut self, prepared: PreparedResumeAction) -> Result<()> {
match prepared {
PreparedResumeAction::RealignViewport(area) => {
self.terminal.set_viewport_area(area);
}
PreparedResumeAction::RestoreAltScreen => {
execute!(self.terminal.backend_mut(), EnterAlternateScreen)?;
if let Ok(size) = self.terminal.size() {
self.terminal.set_viewport_area(ratatui::layout::Rect::new(
0,
0,
size.width,
size.height,
));
self.terminal.clear()?;
}
}
}
Ok(())
}
/// Enter alternate screen and expand the viewport to full terminal size, saving the current
/// inline viewport for restoration when leaving.
pub fn enter_alt_screen(&mut self) -> Result<()> {
let _ = execute!(self.terminal.backend_mut(), EnterAlternateScreen);
if let Ok(size) = self.terminal.size() {
self.alt_saved_viewport = Some(self.terminal.viewport_area);
self.terminal.set_viewport_area(ratatui::layout::Rect::new(
0,
0,
size.width,
size.height,
));
let _ = self.terminal.clear();
}
self.alt_screen_active.store(true, Ordering::Relaxed);
Ok(())
}
/// Leave alternate screen and restore the previously saved inline viewport, if any.
pub fn leave_alt_screen(&mut self) -> Result<()> {
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
if let Some(saved) = self.alt_saved_viewport.take() {
self.terminal.set_viewport_area(saved);
}
self.alt_screen_active.store(false, Ordering::Relaxed);
Ok(())
}
pub fn insert_history_lines(&mut self, lines: Vec<Line<'static>>) {
self.pending_history_lines.extend(lines);
self.frame_requester().schedule_frame();
@@ -265,7 +422,13 @@ impl Tui {
height: u16,
draw_fn: impl FnOnce(&mut custom_terminal::Frame),
) -> Result<()> {
std::io::stdout().sync_update(|_| {
// Precompute any viewport updates that need a cursor-position query before entering
// the synchronized update, to avoid racing with the event reader.
let mut pending_viewport_area: Option<ratatui::layout::Rect> = None;
#[cfg(unix)]
let mut prepared_resume =
self.prepare_resume_action(take_resume_action(&self.resume_pending))?;
{
let terminal = &mut self.terminal;
let screen_size = terminal.size()?;
let last_known_screen_size = terminal.last_known_screen_size;
@@ -274,15 +437,27 @@ impl Tui {
let last_known_cursor_pos = terminal.last_known_cursor_pos;
if cursor_pos.y != last_known_cursor_pos.y {
let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32;
let new_viewport_area = terminal.viewport_area.offset(Offset {
x: 0,
y: cursor_delta,
});
terminal.set_viewport_area(new_viewport_area);
terminal.clear()?;
pending_viewport_area = Some(new_viewport_area);
}
}
}
std::io::stdout().sync_update(|_| {
#[cfg(unix)]
{
if let Some(prepared) = prepared_resume.take() {
self.apply_prepared_resume_action(prepared)?;
}
}
let terminal = &mut self.terminal;
if let Some(new_area) = pending_viewport_area.take() {
terminal.set_viewport_area(new_area);
terminal.clear()?;
}
let size = terminal.size()?;

View File

@@ -258,7 +258,7 @@ impl UserApprovalWidget {
}
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut lines: Vec<Line<'static>> = vec![Line::from("")];
match &self.approval_request {
ApprovalRequest::Exec { command, .. } => {
let cmd = strip_bash_lc_and_escape(command);
@@ -327,7 +327,6 @@ impl UserApprovalWidget {
lines.push(Line::from(l.to_string()));
}
}
lines.push(Line::from(""));
self.app_event_tx.send(AppEvent::InsertHistoryLines(lines));
let op = match &self.approval_request {

View File

@@ -9,7 +9,8 @@ codex
Im going to scan the workspace and Cargo manifests to see build profiles and
dependencies that impact binary size. Then Ill summarize the main causes.
⚡ Running ls -la
_
✓ ls -la
└ total 6696
drwxr-xr-x@ 39 easong staff 1248 Aug 9 08:49 .
drwxr-xr-x@ 29 easong staff 928 Aug 9 08:50 ..
@@ -21,7 +22,7 @@ dependencies that impact binary size. Then Ill summarize the main causes.
drwxr-xr-x@ 6 easong staff 192 Aug 7 19:20 tui
-rw-r--r--@ 1 easong staff 8639 Aug 6 23:30 typewriter.md
-rw-r--r--@ 1 easong staff 7673 Aug 6 21:58 wrap-plan.md
Running sed -n '1,200p' Cargo.toml
sed -n '1,200p' Cargo.toml
└ [workspace]
members = [
"ansi-escape",
@@ -34,7 +35,7 @@ dependencies that impact binary size. Then Ill summarize the main causes.
# ratatui = { path = "../../ratatui" }
ratatui = { git = "https://github.com/nornagon/ratatui", branch =
"nornagon-v0.29.0-patch" }
Running for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama tui; do
echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
└ --- ansi-escape/Cargo.toml
@@ -48,7 +49,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
"signal",
] }
tracing = { version
Running sed -n '1,200p' execpolicy/Cargo.toml
sed -n '1,200p' execpolicy/Cargo.toml
└ [package]
name = "codex-execpolicy"
version = { workspace = true }
@@ -60,7 +61,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
[dev-dependencies]
tempfile = "3.13.0"
Running sed -n '1,200p' file-search/Cargo.toml
sed -n '1,200p' file-search/Cargo.toml
└ [package]
edition = "2024"
name = "codex-file-search"
@@ -72,7 +73,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
serde = { version = "1", features = ["derive"] }
serde_json = "1.0.142"
tokio = { version = "1", features = ["full"] }
Running sed -n '1,200p' linux-sandbox/Cargo.toml
sed -n '1,200p' linux-sandbox/Cargo.toml
└ [package]
edition = "2024"
name = "codex-linux-sandbox"
@@ -84,7 +85,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
"rt-multi-thread",
"signal",
] }
Running sed -n '1,200p' login/Cargo.toml
sed -n '1,200p' login/Cargo.toml
└ [package]
edition = "2024"
name = "codex-login"
@@ -96,7 +97,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
[dev-dependencies]
pretty_assertions = "1.4.1"
tempfile = "3"
Running sed -n '1,200p' mcp-client/Cargo.toml
sed -n '1,200p' mcp-client/Cargo.toml
└ [package]
name = "codex-mcp-client"
version = { workspace = true }
@@ -108,7 +109,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
"sync",
"time",
] }
Running sed -n '1,200p' mcp-server/Cargo.toml
sed -n '1,200p' mcp-server/Cargo.toml
└ [package]
edition = "2024"
name = "codex-mcp-server"
@@ -120,7 +121,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
tempfile = "3"
tokio-test = "0.4"
wiremock = "0.6"
Running sed -n '1,200p' mcp-types/Cargo.toml
sed -n '1,200p' mcp-types/Cargo.toml
└ [package]
edition = "2024"
name = "mcp-types"
@@ -132,7 +133,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Running sed -n '1,200p' ollama/Cargo.toml
sed -n '1,200p' ollama/Cargo.toml
└ [package]
edition = "2024"
name = "codex-ollama"
@@ -144,7 +145,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
[dev-dependencies]
tempfile = "3"
Running sed -n '1,200p' tui/Cargo.toml
sed -n '1,200p' tui/Cargo.toml
└ [package]
edition = "2024"
name = "codex-tui"
@@ -156,7 +157,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
pretty_assertions = "1"
rand = "0.8"
vt100 = "0.16.2"
Running sed -n '1,200p' chatgpt/Cargo.toml
sed -n '1,200p' chatgpt/Cargo.toml
└ [package]
edition = "2024"
name = "codex-chatgpt"
@@ -168,6 +169,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
[dev-dependencies]
tempfile = "3"
codex
Heres whats driving size in this workspaces binaries.
@@ -203,4 +205,4 @@ assertions—outputs are much larger than cargo build --release.
If you want, I can outline targeted trims (e.g., strip = "debuginfo", opt-level
= "z", panic abort, tighter tokio/reqwest features) and estimate impact per
binary.
binary.