Compare commits

...

50 Commits

Author SHA1 Message Date
jimmyfraiture
a9def0eac1 Drop old shells 2025-09-08 09:05:28 -07:00
jimmyfraiture
d0d21550b9 NIT windows 2025-09-07 19:18:47 -07:00
jimmyfraiture
f661af0647 V12 2025-09-07 19:07:00 -07:00
jimmyfraiture
e71a533ec1 V11 2025-09-07 18:53:54 -07:00
jimmyfraiture
95eeb3d1a1 V10 2025-09-07 18:43:54 -07:00
jimmyfraiture
c4c673164e V9 2025-09-07 18:28:54 -07:00
jimmyfraiture
2de51d4e30 V8 2025-09-07 17:51:23 -07:00
jimmyfraiture
85b505afb8 V7 2025-09-07 17:45:26 -07:00
jimmyfraiture
80eea492ff V6 2025-09-07 17:22:08 -07:00
jimmyfraiture
e1809a8f13 V5 2025-09-07 17:20:34 -07:00
jimmyfraiture
16236b699e V4 2025-09-07 16:53:29 -07:00
jimmyfraiture
f00c6819d1 V3 2025-09-07 15:48:58 -07:00
jimmyfraiture
909666b03d V2 2025-09-07 15:22:24 -07:00
jimmyfraiture
4bd40bd27f V1 2025-09-07 14:47:25 -07:00
Anton Panasenko
ba9620aea7 [codex] respect overrides for model family configuration from toml file (#3176) 2025-09-05 16:56:58 -07:00
Eric Traut
45c3b20041 Added CLI version to /status output (#3223)
This PR adds the CLI version to the `/status` output.

This addresses feature request #2767
2025-09-05 16:27:31 -07:00
Enrique Moreno Tent
6cfc012e9d feat(tui): show minutes/hours in thinking timer (#3220)
What
  
- Show compact elapsed time in the TUI status indicator: Xs, MmSSs,
HhMMmSSs.
  - Add private helper fmt_elapsed_compact with a unit test.
  
  Why
  
- Seconds‑only becomes hard to read during longer runs; minutes/hours
improve clarity without extra noise.
  
  How
  
  - Implemented in codex-rs/tui/src/status_indicator_widget.rs only.
- The helper is used when rendering the existing “Working/Thinking”
timer.
- No changes to codex-common::elapsed::format_duration or other crates.
  
  Scope/Impact
  
  - TUI‑only; no public API changes; minimal risk.
  - Snapshot tests should remain unchanged (most show “0s”).
  
  Before/After
  
- Working (65s • Esc to interrupt) → Working (1m05s • Esc to interrupt)
  - Working (3723s • …) → Working (1h02m03s • …)
  
  Tests
  
  - Unit: fmt_elapsed_compact_formats_seconds_minutes_hours.
- Local checks: cargo fmt --all, cargo clippy -p codex-tui -- -D
warnings, cargo test -p codex-tui.
  
  Notes
  
- Open to adjusting the exact format or moving the helper if maintainers
prefer a shared location.

Signed-off-by: Enrique Moreno Tent <enriquemorenotent@gmail.com>
2025-09-05 22:06:36 +00:00
Eric Traut
17a80d43c8 Added logic to cancel pending oauth login to free up localhost port (#3217)
This PR addresses an issue that several users have reported. If the
local oauth login server in one codex instance is left running (e.g. the
user abandons the oauth flow), a subsequent codex instance will receive
an error when attempting to log in because the localhost port is already
in use by the dangling web server from the first instance.

This PR adds a cancelation mechanism that the second instance can use to
abort the first login attempt and free up the port.
2025-09-05 14:29:00 -07:00
Ahmed Ibrahim
c11696f6b1 hide resume until it's complete (#3218)
Hide resume functionality until it's fully done.
2025-09-05 13:12:46 -07:00
pakrym-oai
5775174ec2 Never store requests (#3212)
When item ids are sent to Responses API it will load them from the
database ignoring the provided values. This adds extra latency.

Not having the mode to store requests also allows us to simplify the
code.

## Breaking change

The `disable_response_storage` configuration option is removed.
2025-09-05 10:41:47 -07:00
jif-oai
ba631e7928 ZSH on UNIX system and better detection (#3187) 2025-09-05 09:51:01 -07:00
pakrym-oai
db3834733a [BREAKING] Stop loading project .env files (#3184)
Loading project local .env often loads settings that break codex cli.

Fixes: https://github.com/openai/codex/issues/3174
2025-09-05 09:10:41 -07:00
Jeremy Rose
d6182becbe syntax-highlight bash lines (#3142)
i'm not yet convinced i have the best heuristics for what to highlight,
but this feels like a useful step towards something a bit easier to
read, esp. when the model is producing large commands.

<img width="669" height="589" alt="Screenshot 2025-09-03 at 8 21 56 PM"
src="https://github.com/user-attachments/assets/b9cbcc43-80e8-4d41-93c8-daa74b84b331"
/>

also a fairly significant refactor of our line wrapping logic.
2025-09-05 14:10:32 +00:00
Jeremy Rose
323a5cb7e7 refactor: remove AttachImage tui event (#3191)
TuiEvent is supposed to be purely events that come from the "driver",
i.e. events from the terminal. Everything app-specific should be an
AppEvent. In this case, it didn't need to be an event at all.
2025-09-05 07:02:11 -07:00
Michael Bolin
3f40fbc0a8 chore: improve serialization of ServerNotification (#3193)
This PR introduces introduces a new
`OutgoingMessage::AppServerNotification` variant that is designed to
wrap a `ServerNotification`, which makes the serialization more
straightforward compared to
`OutgoingMessage::Notification(OutgoingNotification)`. We still use the
latter for serializing an `Event` as a `JSONRPCMessage::Notification`,
but I will try to get away from that in the near future.

With this change, now the generated TypeScript type for
`ServerNotification` is:

```typescript
export type ServerNotification =
  | { "method": "authStatusChange", "params": AuthStatusChangeNotification }
  | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification };
```

whereas before it was:

```typescript
export type ServerNotification =
  | { type: "auth_status_change"; data: AuthStatusChangeNotification }
  | { type: "login_chat_gpt_complete"; data: LoginChatGptCompleteNotification };
```

Once the `Event`s are migrated to the `ServerNotification` enum in Rust,
it should be considerably easier to work with notifications on the
TypeScript side, as it will be possible to `switch (message.method)` and
check for exhaustiveness.

Though we will probably need to introduce:

```typescript
export type ServerMessage = ServerRequest | ServerNotification;
```

and then we still need to group all of the `ServerResponse` types
together, as well.
2025-09-04 17:49:50 -07:00
Jeremy Rose
742feaf40f tui: fix approval dialog for large commands (#3087)
#### Summary
- Emit a “Proposed Command” history cell when an ExecApprovalRequest
arrives (parity with proposed patches).
- Simplify the approval dialog: show only the reason/instructions; move
the command preview into history.
- Make approval/abort decision history concise:
  - Single line snippet; if multiline, show first line + " ...".
  - Truncate to 80 graphemes with ellipsis for very long commands.

#### Details
- History
- Add `new_proposed_command` to render a header and indented command
preview.
  - Use shared `prefix_lines` helper for first/subsequent line prefixes.
- Approval UI
- `UserApprovalWidget` no longer renders the command in the modal; shows
optional `reason` text only.
  - Decision history renders an inline, dimmed snippet per rules above.
- Tests (snapshot-based)
  - Proposed/decision flow for short command.
  - Proposed multi-line + aborted decision snippet with “ ...”.
  - Very long one-line command -> truncated snippet with “…”.
  - Updated existing exec approval snapshots and test reasons.

<img width="1053" height="704" alt="Screenshot 2025-09-03 at 11 57
35 AM"
src="https://github.com/user-attachments/assets/9ed4c316-9daf-4ac1-80ff-7ae1f481dd10"
/>

after approving:

<img width="1053" height="704" alt="Screenshot 2025-09-03 at 11 58
18 AM"
src="https://github.com/user-attachments/assets/a44e243f-eb9d-42ea-87f4-171b3fb481e7"
/>

rejection:

<img width="1053" height="207" alt="Screenshot 2025-09-03 at 11 58
45 AM"
src="https://github.com/user-attachments/assets/a022664b-ae0e-4b70-a388-509208707934"
/>

big command:


https://github.com/user-attachments/assets/2dd976e5-799f-4af7-9682-a046e66cc494
2025-09-04 23:54:53 +00:00
Ahmed Ibrahim
907d3dd348 MCP: add session resume + history listing; (#3185)
# External (non-OpenAI) Pull Request Requirements

Before opening this Pull Request, please read the dedicated
"Contributing" markdown file or your PR may be closed:
https://github.com/openai/codex/blob/main/docs/contributing.md

If your PR conforms to our contribution guidelines, replace this text
with a detailed and high quality description of your changes.
2025-09-04 23:44:18 +00:00
pakrym-oai
7df9e9c664 Correctly calculate remaining context size (#3190)
We had multiple issues with context size calculation:
1. `initial_prompt_tokens` calculation based on cache size is not
reliable, cache misses might set it to much higher value. For now
hardcoded to a safer constant.
2. Input context size for GPT-5 is 272k (that's where 33% came from).

Fixes.
2025-09-04 23:34:14 +00:00
dependabot[bot]
b795fbe244 chore(deps): bump uuid from 1.17.0 to 1.18.0 in /codex-rs (#2493)
Bumps [uuid](https://github.com/uuid-rs/uuid) from 1.17.0 to 1.18.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/uuid-rs/uuid/releases">uuid's
releases</a>.</em></p>
<blockquote>
<h2>v1.18.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix up mismatched_lifetime_syntaxes lint by <a
href="https://github.com/KodrAus"><code>@​KodrAus</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/837">uuid-rs/uuid#837</a></li>
<li>Conversions between <code>Timestamp</code> and
<code>std::time::SystemTime</code> by <a
href="https://github.com/dcormier"><code>@​dcormier</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/835">uuid-rs/uuid#835</a></li>
<li>Wrap the error type used in time conversions by <a
href="https://github.com/KodrAus"><code>@​KodrAus</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/838">uuid-rs/uuid#838</a></li>
<li>Prepare for 1.18.0 release by <a
href="https://github.com/KodrAus"><code>@​KodrAus</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/839">uuid-rs/uuid#839</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/dcormier"><code>@​dcormier</code></a>
made their first contribution in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/835">uuid-rs/uuid#835</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/uuid-rs/uuid/compare/v1.17.0...v1.18.0">https://github.com/uuid-rs/uuid/compare/v1.17.0...v1.18.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="60a49eb94f"><code>60a49eb</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/839">#839</a> from
uuid-rs/cargo/v1.18.0</li>
<li><a
href="eb8c697083"><code>eb8c697</code></a>
prepare for 1.18.0 release</li>
<li><a
href="281f26fcd9"><code>281f26f</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/838">#838</a> from
uuid-rs/chore/time-conversion</li>
<li><a
href="2d67ab2b5e"><code>2d67ab2</code></a>
don't use allocated values in errors</li>
<li><a
href="c284ed562f"><code>c284ed5</code></a>
wrap the error type used in time conversions</li>
<li><a
href="87a4359f25"><code>87a4359</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/835">#835</a> from
dcormier/main</li>
<li><a
href="8927396625"><code>8927396</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/837">#837</a> from
uuid-rs/fix/lifetime-syntaxes</li>
<li><a
href="6dfb4b135c"><code>6dfb4b1</code></a>
Conversions between <code>Timestamp</code> and
<code>std::time::SystemTime</code></li>
<li><a
href="b508383aff"><code>b508383</code></a>
fix up mismatched_lifetime_syntaxes lint</li>
<li>See full diff in <a
href="https://github.com/uuid-rs/uuid/compare/v1.17.0...v1.18.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=uuid&package-manager=cargo&previous-version=1.17.0&new-version=1.18.0)](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-09-04 16:30:34 -07:00
Dylan
82ed7bd285 [mcp-server] Update read config interface (#3093)
## Summary
Follow-up to #3056

This PR updates the mcp-server interface for reading the config settings
saved by the user. At risk of introducing _another_ Config struct, I
think it makes sense to avoid tying our protocol to ConfigToml, as its
become a bit unwieldy. GetConfigTomlResponse was a de-facto struct for
this already - better to make it explicit, in my opinion.

This is technically a breaking change of the mcp-server protocol, but
given the previous interface was introduced so recently in #2725, and we
have not yet even started to call it, I propose proceeding with the
breaking change - but am open to preserving the old endpoint.

## Testing
- [x] Added additional integration test coverage
2025-09-04 16:26:41 -07:00
Jeremy Rose
1c04e1314d AGENTS.md: clarify test approvals for codex-rs (#3132)
Clarifies codex-rs testing approvals in AGENTS.md:

- Allow running project-specific or individual tests without asking.
- Require asking before running the complete test suite.
- Keep `just fmt` always allowed without approval.
2025-09-04 13:36:12 -07:00
Jeremy Rose
bef7ed0ccc prompt to read AGENTS.md files (#3122) 2025-09-04 13:30:12 -07:00
Jeremy Rose
be23fe1353 Pause status timer while modals are open (#3131)
Summary:
- pause the status timer while waiting on approval modals
- expose deterministic pause/resume helpers to avoid sleep-based tests
- simplify bottom pane timer handling now that the widget owns the clock
2025-09-04 12:37:43 -07:00
Jeremy Rose
2073fa7139 tui: pager pins scroll to bottom (#3167)
when the pager is scrolled to the bottom of the buffer, keep it there.

this should make transcript mode feel a bit more "alive". i've also seen
some confusion about what transcript mode does/doesn't show that i think
has been related to it not pinning scroll.
2025-09-04 11:50:49 -07:00
Anton Panasenko
e60a44cbab [codex] move configuration for reasoning summary format to model family config type (#3171) 2025-09-04 11:00:01 -07:00
Jeremy Rose
075e385969 Use ⌥⇧⌃ glyphs for key hints on mac (#3143)
#### Summary
- render the edit queued message shortcut with the ⌥ modifier on macOS
builds
- add a helper for status indicator snapshot suffixes
- record macOS-specific snapshots for the status indicator widget
2025-09-04 10:55:50 -07:00
Michael Bolin
aa083b795d chore: add rust-lang.rust-analyzer and vadimcn.vscode-lldb to the list of recommended extensions (#3172)
`rust-lang.rust-analyzer` is clearly something all contributors should
install.

`vadimcn.vscode-lldb` is maybe debatable, but I think this is often
better that print-debugging.
2025-09-04 10:47:46 -07:00
Michael Bolin
91708bb031 fix: fix serde_as annotation and verify with test (#3170)
I didn't do https://github.com/openai/codex/pull/3163 correctly the
first time: now verified with a test.
2025-09-04 10:38:00 -07:00
Anton Panasenko
82dfec5b10 [codex] improve handling of reasoning summary (#3138)
<img width="1474" height="289" alt="Screenshot 2025-09-03 at 5 27 19 PM"
src="https://github.com/user-attachments/assets/d6febcdd-fd9c-488c-9e82-348600b1f757"
/>

Fallback to standard behavior when there is no summary in cot, and also
added tests to codify this behavior.
2025-09-04 09:45:14 -07:00
Jeremy Rose
1e82bf9d98 tui: avoid panic when active exec cell area is zero height (#3133)
#### Summary
Avoid a potential panic when rendering the active execution cell when
the allocated area has zero height.

#### Changes
- Guard rendering with `active_cell_area.height > 0` and presence of
`active_exec_cell`.
- Use `saturating_add(1)` for the Y offset to avoid overflow.
- Render via `active_exec_cell.as_ref().unwrap().render_ref(...)` after
the explicit `is_some` check.
2025-09-04 15:51:02 +00:00
Michael Bolin
0a83db5512 fix: use a more efficient wire format for ExecCommandOutputDeltaEvent.chunk (#3163)
When serializing to JSON, the existing solution created an enormous
array of ints, which is far more bytes on the wire than a base64-encoded
string would be.
2025-09-04 08:21:58 -07:00
Michael Bolin
bd4fa85507 fix: add callback to map before sending request to fix race condition (#3146)
Last week, I thought I found the smoking gun in our flaky integration
tests where holding these locks could have led to potential deadlock:

- https://github.com/openai/codex/pull/2876
- https://github.com/openai/codex/pull/2878

Yet even after those PRs went in, we continued to see flakinees in our
integration tests! Though with the additional logging added as part of
debugging those tests, I now saw things like:

```
read message from stdout: Notification(JSONRPCNotification { jsonrpc: "2.0", method: "codex/event/exec_approval_request", params: Some(Object {"id": String("0"), "msg": Object {"type": String("exec_approval_request"), "call_id": String("call1"), "command": Array [String("python3"), String("-c"), String("print(42)")], "cwd": String("/tmp/.tmpFj2zwi/workdir")}, "conversationId": String("c67b32c5-9475-41bf-8680-f4b4834ebcc6")}) })
notification: Notification(JSONRPCNotification { jsonrpc: "2.0", method: "codex/event/exec_approval_request", params: Some(Object {"id": String("0"), "msg": Object {"type": String("exec_approval_request"), "call_id": String("call1"), "command": Array [String("python3"), String("-c"), String("print(42)")], "cwd": String("/tmp/.tmpFj2zwi/workdir")}, "conversationId": String("c67b32c5-9475-41bf-8680-f4b4834ebcc6")}) })
read message from stdout: Request(JSONRPCRequest { id: Integer(0), jsonrpc: "2.0", method: "execCommandApproval", params: Some(Object {"conversation_id": String("c67b32c5-9475-41bf-8680-f4b4834ebcc6"), "call_id": String("call1"), "command": Array [String("python3"), String("-c"), String("print(42)")], "cwd": String("/tmp/.tmpFj2zwi/workdir")}) })
writing message to stdin: Response(JSONRPCResponse { id: Integer(0), jsonrpc: "2.0", result: Object {"decision": String("approved")} })
in read_stream_until_notification_message(codex/event/task_complete)
[mcp stderr] 2025-09-04T00:00:59.738585Z  INFO codex_mcp_server::message_processor: <- response: JSONRPCResponse { id: Integer(0), jsonrpc: "2.0", result: Object {"decision": String("approved")} }
[mcp stderr] 2025-09-04T00:00:59.738740Z DEBUG codex_core::codex: Submission sub=Submission { id: "1", op: ExecApproval { id: "0", decision: Approved } }
[mcp stderr] 2025-09-04T00:00:59.738832Z  WARN codex_core::codex: No pending approval found for sub_id: 0
```

That is, a response was sent for a request, but no callback was in place
to handle the response!

This time, I think I may have found the underlying issue (though the
fixes for holding locks for too long may have also been part of it),
which is I found cases where we were sending the request:


234c0a0469/codex-rs/core/src/codex.rs (L597)

before inserting the `Sender` into the `pending_approvals` map (which
has to wait on acquiring a mutex):


234c0a0469/codex-rs/core/src/codex.rs (L598-L601)

so it is possible the request could go out and the client could respond
before `pending_approvals` was updated!

Note this was happening in both `request_command_approval()` and
`request_patch_approval()`, which maps to the sorts of errors we have
been seeing when these integration tests have been flaking on us.

While here, I am also adding some extra logging that prints if inserting
into `pending_approvals` overwrites an entry as opposed to purely
inserting one. Today, a conversation can have only one pending request
at a time, but as we are planning to support parallel tool calls, this
invariant may not continue to hold, in which case we need to revisit
this abstraction.
2025-09-04 07:38:28 -07:00
Ahmed Ibrahim
234c0a0469 TUI: Add session resume picker (--resume) and quick resume (--continue) (#3135)
Adds a TUI resume flow with an interactive picker and quick resume.

- CLI: 
  - --resume / -r: open picker to resume a prior session
  - --continue   / -l: resume the most recent session (no picker)
- Behavior on resume: initial history is replayed, welcome banner
hidden, and the first redraw is suppressed to avoid flicker.
- Implementation:
- New tui/src/resume_picker.rs (paginated listing via
RolloutRecorder::list_conversations)
  - App::run accepts ResumeSelection; resumes from disk when requested
- ChatWidget refactor with ChatWidgetInit and new_from_existing; replays
initial messages
- Tests: cover picker sorting/preview extraction and resumed-history
rendering.
- Docs: getting-started updated with flags and picker usage.



https://github.com/user-attachments/assets/1bb6469b-e5d1-42f6-bec6-b1ae6debda3b
2025-09-04 06:20:40 +00:00
dependabot[bot]
0f4ae1b5b0 chore(deps): bump wiremock from 0.6.4 to 0.6.5 in /codex-rs (#2666)
Bumps [wiremock](https://github.com/LukeMathWalker/wiremock-rs) from
0.6.4 to 0.6.5.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="6b193047bf"><code>6b19304</code></a>
chore: Release wiremock version 0.6.5</li>
<li><a
href="ebaa70b024"><code>ebaa70b</code></a>
feat: Make method and MethodExactMatcher case in-sensitive (<a
href="https://redirect.github.com/LukeMathWalker/wiremock-rs/issues/165">#165</a>)</li>
<li><a
href="613b4f9135"><code>613b4f9</code></a>
Make <code>BodyPrintLimit</code> public (<a
href="https://redirect.github.com/LukeMathWalker/wiremock-rs/issues/167">#167</a>)</li>
<li><a
href="abfafd2227"><code>abfafd2</code></a>
chore: Upgrade all deps to their latest version (<a
href="https://redirect.github.com/LukeMathWalker/wiremock-rs/issues/170">#170</a>)</li>
<li><a
href="60688cfdde"><code>60688cf</code></a>
ci: Upgrade actions. Upgrade dependencies. (<a
href="https://redirect.github.com/LukeMathWalker/wiremock-rs/issues/169">#169</a>)</li>
<li>See full diff in <a
href="https://github.com/LukeMathWalker/wiremock-rs/compare/v0.6.4...v0.6.5">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=wiremock&package-manager=cargo&previous-version=0.6.4&new-version=0.6.5)](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-09-03 23:18:42 -07:00
Ahmed Ibrahim
2b96f9f569 Dividing UserMsgs into categories to send it back to the tui (#3127)
This PR does the following:

- divides user msgs into 3 categories: plain, user instructions, and
environment context
- Centralizes adding user instructions and environment context to a
degree
- Improve the integration testing

Building on top of #3123

Specifically this
[comment](https://github.com/openai/codex/pull/3123#discussion_r2319885089).
We need to send the user message while ignoring the User Instructions
and Environment Context we attach.
2025-09-04 05:34:50 +00:00
Ahmed Ibrahim
f2036572b6 Replay EventMsgs from Response Items when resuming a session with history. (#3123)
### Overview

This PR introduces the following changes:
	1.	Adds a unified mechanism to convert ResponseItem into EventMsg.
2. Ensures that when a session is initialized with initial history, a
vector of EventMsg is sent along with the session configuration. This
allows clients to re-render the UI accordingly.
	3. 	Added integration testing

### Caveats

This implementation does not send every EventMsg that was previously
dispatched to clients. The excluded events fall into two categories:
	•	“Arguably” rolled-out events
Examples include tool calls and apply-patch calls. While these events
are conceptually rolled out, we currently only roll out ResponseItems.
These events are already being handled elsewhere and transformed into
EventMsg before being sent.
	•	Non-rolled-out events
Certain events such as TurnDiff, Error, and TokenCount are not rolled
out at all.

### Future Directions

At present, resuming a session involves maintaining two states:
	•	UI State
Clients can replay most of the important UI from the provided EventMsg
history.
	•	Model State
The model receives the complete session history to reconstruct its
internal state.

This design provides a solid foundation. If, in the future, more precise
UI reconstruction is needed, we have two potential paths:
1. Introduce a third data structure that allows us to derive both
ResponseItems and EventMsgs.
2. Clearly divide responsibilities: the core system ensures the
integrity of the model state, while clients are responsible for
reconstructing the UI.
2025-09-04 04:47:00 +00:00
jif-oai
bea64569c1 MCP sandbox call (#3128)
I have read the CLA Document and I hereby sign the CLA
2025-09-03 17:05:03 -07:00
pakrym-oai
e83c5f429c Include originator in authentication URL parameters (#3117)
Associates the client with an authentication session.
2025-09-03 16:51:00 -07:00
Dylan
ed0d23d560 [tui] Update /mcp output (#3134)
# Summary
Quick update to clean up MCP output

## Testing
- [x] Ran locally, confirmed output looked good
2025-09-03 23:38:09 +00:00
Jeremy Rose
4ae45a6c8d remove bold the keyword from prompt (#3121)
the model was often including the literal text "Bold the keyword" in
lists.
this guidance doesn't seem particularly useful to me, so just drop it.
2025-09-03 16:00:33 -07:00
106 changed files with 6152 additions and 2137 deletions

View File

@@ -1,5 +1,11 @@
{
"recommendations": [
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml",
"vadimcn.vscode-lldb",
// Useful if touching files in .github/workflows, though most
// contributors will not be doing that?
// "github.vscode-github-actions",
]
}

View File

@@ -11,7 +11,7 @@ In the codex-rs folder where the rust code lives:
Run `just fmt` (in `codex-rs` directory) automatically after making Rust code changes; do not ask for approval to run it. Before finalizing a change to `codex-rs`, run `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspacewide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests:
1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`.
2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`.
When running interactively, ask the user before running `just fix` and tests to finalize; `just fmt` does not require approval.
When running interactively, ask the user before running `just fix` to finalize. `just fmt` does not require approval. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite.
## TUI style conventions
@@ -37,7 +37,15 @@ See `codex-rs/tui/styles.md`.
- Avoid churn: dont refactor between equivalent forms (Span::styled ↔ set_style, Line::from ↔ .into()) without a clear readability or functional gain; follow filelocal conventions and do not introduce type annotations solely to satisfy .into().
- Compactness: prefer the form that stays on one line after rustfmt; if only one of Line::from(vec![…]) or vec![…].into() avoids wrapping, choose that. If both wrap, pick the one with fewer wrapped lines.
## Snapshot tests
### Text wrapping
- Always use textwrap::wrap to wrap plain strings.
- If you have a ratatui Line and you want to wrap it, use the helpers in tui/src/wrapping.rs, e.g. word_wrap_lines / word_wrap_line.
- If you need to indent wrapped lines, use the initial_indent / subsequent_indent options from RtOptions if you can, rather than writing custom logic.
- If you have a list of lines and you need to prefix them all with some prefix (optionally different on the first vs subsequent lines), use the `prefix_lines` helper from line_utils.
## Tests
### Snapshot tests
This repo uses snapshot tests (via `insta`), especially in `codex-rs/tui`, to validate rendered output. When UI or text output changes intentionally, update the snapshots as follows:
@@ -52,3 +60,7 @@ This repo uses snapshot tests (via `insta`), especially in `codex-rs/tui`, to va
If you dont have the tool:
- `cargo install cargo-insta`
### Test assertions
- Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already.

35
codex-rs/Cargo.lock generated
View File

@@ -298,17 +298,6 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "async-trait"
version = "0.1.88"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -934,6 +923,7 @@ dependencies = [
"serde",
"serde_bytes",
"serde_json",
"serde_with",
"strum 0.27.2",
"strum_macros 0.27.2",
"tracing",
@@ -1279,12 +1269,12 @@ dependencies = [
[[package]]
name = "deadpool"
version = "0.10.0"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490"
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
dependencies = [
"async-trait",
"deadpool-runtime",
"lazy_static",
"num_cpus",
"tokio",
]
@@ -2131,13 +2121,14 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.6.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-util",
"futures-core",
"h2",
"http",
"http-body",
@@ -2145,6 +2136,7 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@@ -5543,9 +5535,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.17.0"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
dependencies = [
"getrandom 0.3.3",
"js-sys",
@@ -6272,12 +6264,11 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]]
name = "wiremock"
version = "0.6.4"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b8b99d4cdbf36b239a9532e31fe4fb8acc38d1897c1761e161550a7dc78e6a"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"async-trait",
"base64 0.22.1",
"deadpool",
"futures",

View File

@@ -21,8 +21,7 @@ const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch";
/// `codex-linux-sandbox` we *directly* execute
/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we:
///
/// 1. Use [`dotenvy::from_path`] and [`dotenvy::dotenv`] to modify the
/// environment before creating any threads.
/// 1. Load `.env` values from `~/.codex/.env` before creating any threads.
/// 2. Construct a Tokio multi-thread runtime.
/// 3. Derive the path to the current executable (so children can re-invoke the
/// sandbox) when running on Linux.
@@ -106,7 +105,7 @@ where
const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_";
/// Load env vars from ~/.codex/.env and `$(pwd)/.env`.
/// Load env vars from ~/.codex/.env.
///
/// Security: Do not allow `.env` files to create or modify any variables
/// with names starting with `CODEX_`.
@@ -116,10 +115,6 @@ fn load_dotenv() {
{
set_filtered(iter);
}
if let Ok(iter) = dotenvy::dotenv_iter() {
set_filtered(iter);
}
}
/// Helper to set vars from a dotenvy iterator while filtering out `CODEX_` keys.

View File

@@ -12,8 +12,8 @@ use codex_protocol::mcp_protocol::AuthMode;
use std::env;
use std::path::PathBuf;
pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> {
let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string());
pub async fn login_with_chatgpt(codex_home: PathBuf, originator: String) -> std::io::Result<()> {
let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string(), originator);
let server = run_login_server(opts)?;
eprintln!(
@@ -27,7 +27,12 @@ pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> {
pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides);
match login_with_chatgpt(config.codex_home).await {
match login_with_chatgpt(
config.codex_home,
config.responses_originator_header.clone(),
)
.await
{
Ok(_) => {
eprintln!("Successfully logged in");
std::process::exit(0);

View File

@@ -14,6 +14,18 @@ Within this context, Codex refers to the open-source agentic coding interface (n
Your default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.
# AGENTS.md spec
- Repos often contain AGENTS.md files. These files can appear anywhere within the repository.
- These files are a way for humans to give you (the agent) instructions or tips for working within the container.
- Some examples might be: coding conventions, info about how code is organized, or instructions for how to run or test code.
- Instructions in AGENTS.md files:
- The scope of an AGENTS.md file is the entire directory tree rooted at the folder that contains it.
- For every file you touch in the final patch, you must obey instructions in any AGENTS.md file whose scope includes that file.
- Instructions about code style, structure, naming, etc. apply only to code within the AGENTS.md file's scope, unless the file states otherwise.
- More-deeply-nested AGENTS.md files take precedence in the case of conflicting instructions.
- Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions.
- The contents of the AGENTS.md file at the root of the repo and any directories from the CWD up to the root are included with the developer message and don't need to be re-read. When working in a subdirectory of CWD, or a directory outside the CWD, check for any AGENTS.md files that may be applicable.
## Responsiveness
### Preamble messages
@@ -228,7 +240,6 @@ You are producing plain text that will later be styled by the CLI. Follow these
**Bullets**
- Use `-` followed by a space for every bullet.
- Bold the keyword, then colon + concise description.
- Merge related points when possible; avoid a bullet for every trivial detail.
- Keep bullets to one line unless breaking for clarity is unavoidable.
- Group into short lists (46 bullets) ordered by importance.

View File

@@ -56,9 +56,9 @@ pub(crate) async fn stream_chat_completions(
for item in &input {
match item {
ResponseItem::Message { role, .. } => last_emitted_role = Some(role.as_str()),
ResponseItem::FunctionCall { .. } | ResponseItem::LocalShellCall { .. } => {
last_emitted_role = Some("assistant")
}
ResponseItem::FunctionCall { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::UnifiedExec { .. } => last_emitted_role = Some("assistant"),
ResponseItem::FunctionCallOutput { .. } => last_emitted_role = Some("tool"),
ResponseItem::Reasoning { .. } | ResponseItem::Other => {}
ResponseItem::CustomToolCall { .. } => {}
@@ -119,7 +119,9 @@ pub(crate) async fn stream_chat_completions(
// Otherwise, attach to immediate next assistant anchor (tool-calls or assistant message)
if !attached && idx + 1 < input.len() {
match &input[idx + 1] {
ResponseItem::FunctionCall { .. } | ResponseItem::LocalShellCall { .. } => {
ResponseItem::FunctionCall { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::UnifiedExec { .. } => {
reasoning_by_anchor_index
.entry(idx + 1)
.and_modify(|v| v.push_str(&text))
@@ -224,6 +226,12 @@ pub(crate) async fn stream_chat_completions(
}
messages.push(msg);
}
ResponseItem::UnifiedExec { .. } => {
// The interactive shell tool is currently exposed only via the
// Responses API. Ignore these items when using Chat
// Completions to maintain feature parity with existing
// behaviour.
}
ResponseItem::FunctionCallOutput { call_id, output } => {
messages.push(json!({
"role": "tool",

View File

@@ -157,14 +157,6 @@ impl ModelClient {
let auth_manager = self.auth_manager.clone();
let auth_mode = auth_manager
.as_ref()
.and_then(|m| m.auth())
.as_ref()
.map(|a| a.mode);
let store = prompt.store && auth_mode != Some(AuthMode::ChatGPT);
let full_instructions = prompt.get_full_instructions(&self.config.model_family);
let tools_json = create_tools_json_for_responses_api(&prompt.tools)?;
let reasoning = create_reasoning_param_for_request(
@@ -173,9 +165,7 @@ impl ModelClient {
self.summary,
);
// Request encrypted COT if we are not storing responses,
// otherwise reasoning items will be referenced by ID
let include: Vec<String> = if !store && reasoning.is_some() {
let include: Vec<String> = if reasoning.is_some() {
vec!["reasoning.encrypted_content".to_string()]
} else {
vec![]
@@ -204,7 +194,7 @@ impl ModelClient {
tool_choice: "auto",
parallel_tool_calls: false,
reasoning,
store,
store: false,
stream: true,
include,
prompt_cache_key: Some(self.session_id.to_string()),

View File

@@ -6,7 +6,6 @@ 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::config_types::Verbosity as VerbosityConfig;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use futures::Stream;
use serde::Serialize;
@@ -20,19 +19,12 @@ use tokio::sync::mpsc;
/// with this content.
const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md");
/// wraps user instructions message in a tag for the model to parse more easily.
const USER_INSTRUCTIONS_START: &str = "<user_instructions>\n\n";
const USER_INSTRUCTIONS_END: &str = "\n\n</user_instructions>";
/// API request payload for a single model turn
#[derive(Default, Debug, Clone)]
pub struct Prompt {
/// Conversation context input items.
pub input: Vec<ResponseItem>,
/// Whether to store response on server side (disable_response_storage = !store).
pub store: bool,
/// Tools available to the model, including additional tools sourced from
/// external MCP servers.
pub(crate) tools: Vec<OpenAiTool>,
@@ -68,17 +60,6 @@ impl Prompt {
pub(crate) fn get_formatted_input(&self) -> Vec<ResponseItem> {
self.input.clone()
}
/// Creates a formatted user instructions message from a string
pub(crate) fn format_user_instructions_message(ui: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("{USER_INSTRUCTIONS_START}{ui}{USER_INSTRUCTIONS_END}"),
}],
}
}
}
#[derive(Debug)]
@@ -144,7 +125,6 @@ pub(crate) struct ResponsesApiRequest<'a> {
pub(crate) tool_choice: &'static str,
pub(crate) parallel_tool_calls: bool,
pub(crate) reasoning: Option<Reasoning>,
/// true when using the Responses API.
pub(crate) store: bool,
pub(crate) stream: bool,
pub(crate) include: Vec<String>,
@@ -215,7 +195,7 @@ mod tests {
tool_choice: "auto",
parallel_tool_calls: false,
reasoning: None,
store: true,
store: false,
stream: true,
include: vec![],
prompt_cache_key: None,
@@ -245,7 +225,7 @@ mod tests {
tool_choice: "auto",
parallel_tool_calls: false,
reasoning: None,
store: true,
store: false,
stream: true,
include: vec![],
prompt_cache_key: None,

View File

@@ -9,6 +9,7 @@ use std::sync::atomic::AtomicU64;
use std::time::Duration;
use crate::AuthManager;
use crate::event_mapping::map_response_item_to_event_messages;
use async_channel::Receiver;
use async_channel::Sender;
use codex_apply_patch::ApplyPatchAction;
@@ -20,6 +21,7 @@ use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use futures::prelude::*;
use mcp_types::CallToolResult;
use serde::Deserialize;
use serde::Serialize;
use serde_json;
use tokio::sync::oneshot;
@@ -75,9 +77,7 @@ use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageDeltaEvent;
use crate::protocol::AgentMessageEvent;
use crate::protocol::AgentReasoningDeltaEvent;
use crate::protocol::AgentReasoningEvent;
use crate::protocol::AgentReasoningRawContentDeltaEvent;
use crate::protocol::AgentReasoningRawContentEvent;
use crate::protocol::AgentReasoningSectionBreakEvent;
use crate::protocol::ApplyPatchApprovalRequestEvent;
use crate::protocol::AskForApproval;
@@ -102,13 +102,14 @@ use crate::protocol::Submission;
use crate::protocol::TaskCompleteEvent;
use crate::protocol::TurnDiffEvent;
use crate::protocol::WebSearchBeginEvent;
use crate::protocol::WebSearchEndEvent;
use crate::rollout::RolloutRecorder;
use crate::safety::SafetyCheck;
use crate::safety::assess_command_safety;
use crate::safety::assess_safety_for_untrusted_command;
use crate::shell;
use crate::turn_diff_tracker::TurnDiffTracker;
use crate::unified_exec::UnifiedExecSessionManager;
use crate::user_instructions::UserInstructions;
use crate::user_notification::UserNotification;
use crate::util::backoff;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
@@ -117,12 +118,9 @@ use codex_protocol::custom_prompts::CustomPrompt;
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;
use codex_protocol::models::WebSearchAction;
// A convenience extension trait for acquiring mutex locks where poisoning is
// unrecoverable and should abort the program. This avoids scattered `.unwrap()`
@@ -188,7 +186,6 @@ impl Codex {
base_instructions: config.base_instructions.clone(),
approval_policy: config.approval_policy,
sandbox_policy: config.sandbox_policy.clone(),
disable_response_storage: config.disable_response_storage,
notify: config.notify.clone(),
cwd: config.cwd.clone(),
};
@@ -199,6 +196,7 @@ impl Codex {
config.clone(),
auth_manager.clone(),
tx_event.clone(),
conversation_history.clone(),
)
.await
.map_err(|e| {
@@ -277,6 +275,7 @@ pub(crate) struct Session {
/// Manager for external MCP servers/tools.
mcp_connection_manager: McpConnectionManager,
session_manager: ExecSessionManager,
unified_exec_manager: UnifiedExecSessionManager,
/// External notifier command (will be passed as args to exec()). When
/// `None` this feature is disabled.
@@ -304,7 +303,6 @@ pub(crate) struct TurnContext {
pub(crate) approval_policy: AskForApproval,
pub(crate) sandbox_policy: SandboxPolicy,
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
pub(crate) disable_response_storage: bool,
pub(crate) tools_config: ToolsConfig,
}
@@ -337,8 +335,6 @@ struct ConfigureSession {
approval_policy: AskForApproval,
/// How to sandbox commands executed in the system
sandbox_policy: SandboxPolicy,
/// Disable server-side response storage (send full context each request)
disable_response_storage: bool,
/// Optional external notifier command tokens. Present only when the
/// client wants the agent to spawn a program after each completed
@@ -361,6 +357,7 @@ impl Session {
config: Arc<Config>,
auth_manager: Arc<AuthManager>,
tx_event: Sender<Event>,
initial_history: InitialHistory,
) -> anyhow::Result<(Arc<Self>, TurnContext)> {
let session_id = Uuid::new_v4();
let ConfigureSession {
@@ -372,7 +369,6 @@ impl Session {
base_instructions,
approval_policy,
sandbox_policy,
disable_response_storage,
notify,
cwd,
} = configure_session;
@@ -464,13 +460,13 @@ impl Session {
sandbox_policy,
shell_environment_policy: config.shell_environment_policy.clone(),
cwd,
disable_response_storage,
};
let sess = Arc::new(Session {
session_id,
tx_event: tx_event.clone(),
mcp_connection_manager,
session_manager: ExecSessionManager::default(),
unified_exec_manager: UnifiedExecSessionManager::default(),
notify,
state: Mutex::new(state),
rollout: Mutex::new(Some(rollout_recorder)),
@@ -480,6 +476,12 @@ impl Session {
});
// Dispatch the SessionConfiguredEvent first and then report any errors.
// If resuming, include converted initial messages in the payload so UIs can render them immediately.
let initial_messages = match &initial_history {
InitialHistory::New => None,
InitialHistory::Resumed(items) => Some(sess.build_initial_messages(items)),
};
let events = std::iter::once(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
@@ -487,6 +489,7 @@ impl Session {
model,
history_log_id,
history_entry_count,
initial_messages,
}),
})
.chain(post_session_configured_error_events.into_iter());
@@ -537,7 +540,7 @@ impl Session {
// TODO: Those items shouldn't be "user messages" IMO. Maybe developer messages.
let mut conversation_items = Vec::<ResponseItem>::with_capacity(2);
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
conversation_items.push(Prompt::format_user_instructions_message(user_instructions));
conversation_items.push(UserInstructions::new(user_instructions.to_string()).into());
}
conversation_items.push(ResponseItem::from(EnvironmentContext::new(
Some(turn_context.cwd.clone()),
@@ -552,6 +555,17 @@ impl Session {
self.record_conversation_items(&items).await;
}
/// build the initial messages vector for SessionConfigured by converting
/// ResponseItems into EventMsg.
fn build_initial_messages(&self, items: &[ResponseItem]) -> Vec<EventMsg> {
items
.iter()
.flat_map(|item| {
map_response_item_to_event_messages(item, self.show_raw_agent_reasoning)
})
.collect()
}
/// 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) {
@@ -568,9 +582,19 @@ impl Session {
cwd: PathBuf,
reason: Option<String>,
) -> oneshot::Receiver<ReviewDecision> {
// Add the tx_approve callback to the map before sending the request.
let (tx_approve, rx_approve) = oneshot::channel();
let event_id = sub_id.clone();
let prev_entry = {
let mut state = self.state.lock_unchecked();
state.pending_approvals.insert(sub_id, tx_approve)
};
if prev_entry.is_some() {
warn!("Overwriting existing pending approval for sub_id: {event_id}");
}
let event = Event {
id: sub_id.clone(),
id: event_id,
msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
call_id,
command,
@@ -579,10 +603,6 @@ impl Session {
}),
};
let _ = self.tx_event.send(event).await;
{
let mut state = self.state.lock_unchecked();
state.pending_approvals.insert(sub_id, tx_approve);
}
rx_approve
}
@@ -594,9 +614,19 @@ impl Session {
reason: Option<String>,
grant_root: Option<PathBuf>,
) -> oneshot::Receiver<ReviewDecision> {
// Add the tx_approve callback to the map before sending the request.
let (tx_approve, rx_approve) = oneshot::channel();
let event_id = sub_id.clone();
let prev_entry = {
let mut state = self.state.lock_unchecked();
state.pending_approvals.insert(sub_id, tx_approve)
};
if prev_entry.is_some() {
warn!("Overwriting existing pending approval for sub_id: {event_id}");
}
let event = Event {
id: sub_id.clone(),
id: event_id,
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id,
changes: convert_apply_patch_to_protocol(action),
@@ -605,10 +635,6 @@ impl Session {
}),
};
let _ = self.tx_event.send(event).await;
{
let mut state = self.state.lock_unchecked();
state.pending_approvals.insert(sub_id, tx_approve);
}
rx_approve
}
@@ -1089,7 +1115,6 @@ async fn submission_loop(
sandbox_policy: new_sandbox_policy.clone(),
shell_environment_policy: prev.shell_environment_policy.clone(),
cwd: new_cwd.clone(),
disable_response_storage: prev.disable_response_storage,
};
// Install the new persistent context for subsequent tasks/turns.
@@ -1171,7 +1196,6 @@ async fn submission_loop(
sandbox_policy,
shell_environment_policy: turn_context.shell_environment_policy.clone(),
cwd,
disable_response_storage: turn_context.disable_response_storage,
};
// TODO: record the new environment context in the conversation history
// no current task, spawn a new one with the perturn context
@@ -1576,7 +1600,6 @@ async fn run_turn(
let prompt = Prompt {
input,
store: !turn_context.disable_response_storage,
tools,
base_instructions_override: turn_context.base_instructions.clone(),
};
@@ -1830,7 +1853,6 @@ async fn run_compact_task(
let prompt = Prompt {
input: turn_input,
store: !turn_context.disable_response_storage,
tools: Vec::new(),
base_instructions_override: Some(compact_instructions.clone()),
};
@@ -1903,53 +1925,6 @@ async fn handle_response_item(
) -> CodexResult<Option<ResponseInputItem>> {
debug!(?item, "Output item");
let output = match item {
ResponseItem::Message { content, .. } => {
for item in content {
if let ContentItem::OutputText { text } = item {
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::AgentMessage(AgentMessageEvent { message: text }),
};
sess.tx_event.send(event).await.ok();
}
}
None
}
ResponseItem::Reasoning {
id: _,
summary,
content,
encrypted_content: _,
} => {
for item in summary {
let text = match item {
ReasoningItemReasoningSummary::SummaryText { text } => text,
};
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::AgentReasoning(AgentReasoningEvent { text }),
};
sess.tx_event.send(event).await.ok();
}
if sess.show_raw_agent_reasoning
&& let Some(content) = content
{
for item in content {
let text = match item {
ReasoningItemContent::ReasoningText { text } => text,
ReasoningItemContent::Text { text } => text,
};
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent {
text,
}),
};
sess.tx_event.send(event).await.ok();
}
}
None
}
ResponseItem::FunctionCall {
name,
arguments,
@@ -2013,6 +1988,27 @@ async fn handle_response_item(
.await,
)
}
ResponseItem::UnifiedExec {
id,
session_id,
arguments,
timeout_ms,
} => {
let call_id = id.clone().unwrap_or_else(|| match &session_id {
Some(session_id) => format!("unified_exec:{session_id}:{}", Uuid::new_v4()),
None => format!("unified_exec:{}", Uuid::new_v4()),
});
Some(
handle_unified_exec_tool_call(
sess,
call_id,
session_id.clone(),
arguments,
timeout_ms,
)
.await,
)
}
ResponseItem::CustomToolCall {
id: _,
call_id,
@@ -2039,12 +2035,14 @@ async fn handle_response_item(
debug!("unexpected CustomToolCallOutput from stream");
None
}
ResponseItem::WebSearchCall { id, action, .. } => {
if let WebSearchAction::Search { query } = action {
let call_id = id.unwrap_or_else(|| "".to_string());
ResponseItem::Message { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::WebSearchCall { .. } => {
let msgs = map_response_item_to_event_messages(&item, sess.show_raw_agent_reasoning);
for msg in msgs {
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::WebSearchEnd(WebSearchEndEvent { call_id, query }),
msg,
};
sess.tx_event.send(event).await.ok();
}
@@ -2055,6 +2053,72 @@ async fn handle_response_item(
Ok(output)
}
async fn handle_unified_exec_tool_call(
sess: &Session,
call_id: String,
session_id: Option<String>,
arguments: Vec<String>,
timeout_ms: Option<u64>,
) -> ResponseInputItem {
let parsed_session_id = if let Some(session_id) = session_id {
match session_id.parse::<i32>() {
Ok(parsed) => Some(parsed),
Err(output) => {
return ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_string(),
output: FunctionCallOutputPayload {
content: format!("invalid session_id: {session_id} due to error {output}"),
success: Some(false),
},
};
}
}
} else {
None
};
let request = crate::unified_exec::UnifiedExecRequest {
session_id: parsed_session_id,
input_chunks: &arguments,
timeout_ms,
};
let result = sess.unified_exec_manager.handle_request(request).await;
let output_payload = match result {
Ok(value) => {
#[derive(Serialize)]
struct SerializedUnifiedExecResult<'a> {
session_id: Option<String>,
output: &'a str,
}
match serde_json::to_string(&SerializedUnifiedExecResult {
session_id: value.session_id.map(|id| id.to_string()),
output: &value.output,
}) {
Ok(serialized) => FunctionCallOutputPayload {
content: serialized,
success: Some(true),
},
Err(err) => FunctionCallOutputPayload {
content: format!("failed to serialize unified exec output: {err}"),
success: Some(false),
},
}
}
Err(err) => FunctionCallOutputPayload {
content: format!("unified exec failed: {err}"),
success: Some(false),
},
};
ResponseInputItem::FunctionCallOutput {
call_id,
output: output_payload,
}
}
async fn handle_function_call(
sess: &Session,
turn_context: &TurnContext,
@@ -2082,6 +2146,38 @@ async fn handle_function_call(
)
.await
}
"unified_exec" => {
#[derive(Deserialize)]
struct UnifiedExecArgs {
input: Vec<String>,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
timeout_ms: Option<u64>,
}
let args = match serde_json::from_str::<UnifiedExecArgs>(&arguments) {
Ok(args) => args,
Err(err) => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("failed to parse function arguments: {err}"),
success: Some(false),
},
};
}
};
handle_unified_exec_tool_call(
sess,
call_id,
args.session_id,
args.input,
args.timeout_ms,
)
.await
}
"view_image" => {
#[derive(serde::Deserialize)]
struct SeeImageArgs {

View File

@@ -1,6 +1,7 @@
use crate::config_profile::ConfigProfile;
use crate::config_types::History;
use crate::config_types::McpServerConfig;
use crate::config_types::ReasoningSummaryFormat;
use crate::config_types::SandboxWorkspaceWrite;
use crate::config_types::ShellEnvironmentPolicy;
use crate::config_types::ShellEnvironmentPolicyToml;
@@ -19,6 +20,8 @@ use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::Verbosity;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::Tools;
use codex_protocol::mcp_protocol::UserSavedConfig;
use dirs::home_dir;
use serde::Deserialize;
use std::collections::HashMap;
@@ -75,11 +78,6 @@ pub struct Config {
/// Defaults to `false`.
pub show_raw_agent_reasoning: bool,
/// Disable server-side response storage (sends the full conversation
/// context with every request). Currently necessary for OpenAI customers
/// who have opted into Zero Data Retention (ZDR).
pub disable_response_storage: bool,
/// User-provided instructions from AGENTS.md.
pub user_instructions: Option<String>,
@@ -185,8 +183,6 @@ pub struct Config {
/// All characters are inserted as they are received, and no buffering
/// or placeholder replacement will occur for fast keypress bursts.
pub disable_paste_burst: bool,
pub use_experimental_reasoning_summary: bool,
}
impl Config {
@@ -416,11 +412,6 @@ pub struct ConfigToml {
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
/// Disable server-side response storage (sends the full conversation
/// context with every request). Currently necessary for OpenAI customers
/// who have opted into Zero Data Retention (ZDR).
pub disable_response_storage: Option<bool>,
/// Optional external command to spawn for end-user notifications.
#[serde(default)]
pub notify: Option<Vec<String>>,
@@ -473,6 +464,9 @@ pub struct ConfigToml {
/// Override to force-enable reasoning summaries for the configured model.
pub model_supports_reasoning_summaries: Option<bool>,
/// Override to force reasoning summary format for the configured model.
pub model_reasoning_summary_format: Option<ReasoningSummaryFormat>,
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
pub chatgpt_base_url: Option<String>,
@@ -484,8 +478,6 @@ pub struct ConfigToml {
pub experimental_use_exec_command_tool: Option<bool>,
pub use_experimental_reasoning_summary: Option<bool>,
/// The value for the `originator` header included with Responses API requests.
pub responses_originator_header_internal_override: Option<String>,
@@ -503,6 +495,29 @@ pub struct ConfigToml {
pub disable_paste_burst: Option<bool>,
}
impl From<ConfigToml> for UserSavedConfig {
fn from(config_toml: ConfigToml) -> Self {
let profiles = config_toml
.profiles
.into_iter()
.map(|(k, v)| (k, v.into()))
.collect();
Self {
approval_policy: config_toml.approval_policy,
sandbox_mode: config_toml.sandbox_mode,
sandbox_settings: config_toml.sandbox_workspace_write.map(From::from),
model: config_toml.model,
model_reasoning_effort: config_toml.model_reasoning_effort,
model_reasoning_summary: config_toml.model_reasoning_summary,
model_verbosity: config_toml.model_verbosity,
tools: config_toml.tools.map(From::from),
profile: config_toml.profile,
profiles,
}
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct ProjectConfig {
pub trust_level: Option<String>,
@@ -518,6 +533,15 @@ pub struct ToolsToml {
pub view_image: Option<bool>,
}
impl From<ToolsToml> for Tools {
fn from(tools_toml: ToolsToml) -> Self {
Self {
web_search: tools_toml.web_search,
view_image: tools_toml.view_image,
}
}
}
impl ConfigToml {
/// Derive the effective sandbox policy from the configuration.
fn derive_sandbox_policy(&self, sandbox_mode_override: Option<SandboxMode>) -> SandboxPolicy {
@@ -606,7 +630,6 @@ pub struct ConfigOverrides {
pub include_plan_tool: Option<bool>,
pub include_apply_patch_tool: Option<bool>,
pub include_view_image_tool: Option<bool>,
pub disable_response_storage: Option<bool>,
pub show_raw_agent_reasoning: Option<bool>,
pub tools_web_search_request: Option<bool>,
}
@@ -634,7 +657,6 @@ impl Config {
include_plan_tool,
include_apply_patch_tool,
include_view_image_tool,
disable_response_storage,
show_raw_agent_reasoning,
tools_web_search_request: override_tools_web_search_request,
} = overrides;
@@ -710,19 +732,24 @@ impl Config {
.or(config_profile.model)
.or(cfg.model)
.unwrap_or_else(default_model);
let model_family = find_family_for_model(&model).unwrap_or_else(|| {
let supports_reasoning_summaries =
cfg.model_supports_reasoning_summaries.unwrap_or(false);
ModelFamily {
slug: model.clone(),
family: model.clone(),
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries,
uses_local_shell_tool: false,
apply_patch_tool_type: None,
}
let mut model_family = find_family_for_model(&model).unwrap_or_else(|| ModelFamily {
slug: model.clone(),
family: model.clone(),
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
reasoning_summary_format: ReasoningSummaryFormat::None,
uses_local_shell_tool: false,
apply_patch_tool_type: None,
});
if let Some(supports_reasoning_summaries) = cfg.model_supports_reasoning_summaries {
model_family.supports_reasoning_summaries = supports_reasoning_summaries;
}
if let Some(model_reasoning_summary_format) = cfg.model_reasoning_summary_format {
model_family.reasoning_summary_format = model_reasoning_summary_format;
}
let openai_model_info = get_model_info(&model_family);
let model_context_window = cfg
.model_context_window
@@ -764,11 +791,6 @@ impl Config {
.unwrap_or_else(AskForApproval::default),
sandbox_policy,
shell_environment_policy,
disable_response_storage: config_profile
.disable_response_storage
.or(cfg.disable_response_storage)
.or(disable_response_storage)
.unwrap_or(false),
notify: cfg.notify,
user_instructions,
base_instructions,
@@ -811,9 +833,6 @@ impl Config {
.unwrap_or(false),
include_view_image_tool,
disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false),
use_experimental_reasoning_summary: cfg
.use_experimental_reasoning_summary
.unwrap_or(false),
};
Ok(config)
}
@@ -1036,7 +1055,6 @@ exclude_slash_tmp = true
let toml = r#"
model = "o3"
approval_policy = "untrusted"
disable_response_storage = false
# Can be used to determine which profile to use if not specified by
# `ConfigOverrides`.
@@ -1066,7 +1084,6 @@ model_provider = "openai-chat-completions"
model = "o3"
model_provider = "openai"
approval_policy = "on-failure"
disable_response_storage = true
[profiles.gpt5]
model = "gpt-5"
@@ -1164,7 +1181,6 @@ model_verbosity = "high"
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: false,
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -1192,7 +1208,6 @@ model_verbosity = "high"
use_experimental_streamable_shell_tool: false,
include_view_image_tool: true,
disable_paste_burst: false,
use_experimental_reasoning_summary: false,
},
o3_profile_config
);
@@ -1223,7 +1238,6 @@ model_verbosity = "high"
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: false,
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -1251,7 +1265,6 @@ model_verbosity = "high"
use_experimental_streamable_shell_tool: false,
include_view_image_tool: true,
disable_paste_burst: false,
use_experimental_reasoning_summary: false,
};
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
@@ -1297,7 +1310,6 @@ model_verbosity = "high"
approval_policy: AskForApproval::OnFailure,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: true,
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -1325,7 +1337,6 @@ model_verbosity = "high"
use_experimental_streamable_shell_tool: false,
include_view_image_tool: true,
disable_paste_burst: false,
use_experimental_reasoning_summary: false,
};
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
@@ -1350,14 +1361,13 @@ model_verbosity = "high"
let expected_gpt5_profile_config = Config {
model: "gpt-5".to_string(),
model_family: find_family_for_model("gpt-5").expect("known model slug"),
model_context_window: Some(400_000),
model_context_window: Some(272_000),
model_max_output_tokens: Some(128_000),
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
approval_policy: AskForApproval::OnFailure,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: false,
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -1385,7 +1395,6 @@ model_verbosity = "high"
use_experimental_streamable_shell_tool: false,
include_view_image_tool: true,
disable_paste_burst: false,
use_experimental_reasoning_summary: false,
};
assert_eq!(expected_gpt5_profile_config, gpt5_profile_config);

View File

@@ -15,10 +15,23 @@ pub struct ConfigProfile {
/// [`ModelProviderInfo`] to use.
pub model_provider: Option<String>,
pub approval_policy: Option<AskForApproval>,
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>,
}
impl From<ConfigProfile> for codex_protocol::mcp_protocol::Profile {
fn from(config_profile: ConfigProfile) -> Self {
Self {
model: config_profile.model,
model_provider: config_profile.model_provider,
approval_policy: config_profile.approval_policy,
model_reasoning_effort: config_profile.model_reasoning_effort,
model_reasoning_summary: config_profile.model_reasoning_summary,
model_verbosity: config_profile.model_verbosity,
chatgpt_base_url: config_profile.chatgpt_base_url,
}
}
}

View File

@@ -88,6 +88,17 @@ pub struct SandboxWorkspaceWrite {
pub exclude_slash_tmp: bool,
}
impl From<SandboxWorkspaceWrite> for codex_protocol::mcp_protocol::SandboxSettings {
fn from(sandbox_workspace_write: SandboxWorkspaceWrite) -> Self {
Self {
writable_roots: sandbox_workspace_write.writable_roots,
network_access: Some(sandbox_workspace_write.network_access),
exclude_tmpdir_env_var: Some(sandbox_workspace_write.exclude_tmpdir_env_var),
exclude_slash_tmp: Some(sandbox_workspace_write.exclude_slash_tmp),
}
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ShellEnvironmentPolicyInherit {
@@ -183,3 +194,11 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
}
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Default, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum ReasoningSummaryFormat {
#[default]
None,
Experimental,
}

View File

@@ -71,6 +71,7 @@ fn is_api_message(message: &ResponseItem) -> bool {
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::UnifiedExec { .. }
| ResponseItem::Reasoning { .. } => true,
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => false,
}

View File

@@ -8,12 +8,10 @@ use crate::shell::Shell;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
use std::path::PathBuf;
/// wraps environment context message in a tag for the model to parse more easily.
pub(crate) const ENVIRONMENT_CONTEXT_START: &str = "<environment_context>";
pub(crate) const ENVIRONMENT_CONTEXT_END: &str = "</environment_context>";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, DeriveDisplay)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
@@ -79,7 +77,7 @@ impl EnvironmentContext {
/// </environment_context>
/// ```
pub fn serialize_to_xml(self) -> String {
let mut lines = vec![ENVIRONMENT_CONTEXT_START.to_string()];
let mut lines = vec![ENVIRONMENT_CONTEXT_OPEN_TAG.to_string()];
if let Some(cwd) = self.cwd {
lines.push(format!(" <cwd>{}</cwd>", cwd.to_string_lossy()));
}
@@ -101,7 +99,7 @@ impl EnvironmentContext {
{
lines.push(format!(" <shell>{shell_name}</shell>"));
}
lines.push(ENVIRONMENT_CONTEXT_END.to_string());
lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string());
lines.join("\n")
}
}

View File

@@ -0,0 +1,99 @@
use crate::protocol::AgentMessageEvent;
use crate::protocol::AgentReasoningEvent;
use crate::protocol::AgentReasoningRawContentEvent;
use crate::protocol::EventMsg;
use crate::protocol::InputMessageKind;
use crate::protocol::UserMessageEvent;
use crate::protocol::WebSearchEndEvent;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::WebSearchAction;
/// Convert a `ResponseItem` into zero or more `EventMsg` values that the UI can render.
///
/// When `show_raw_agent_reasoning` is false, raw reasoning content events are omitted.
pub(crate) fn map_response_item_to_event_messages(
item: &ResponseItem,
show_raw_agent_reasoning: bool,
) -> Vec<EventMsg> {
match item {
ResponseItem::Message { role, content, .. } => {
// Do not surface system messages as user events.
if role == "system" {
return Vec::new();
}
let events: Vec<EventMsg> = content
.iter()
.filter_map(|content_item| match content_item {
ContentItem::OutputText { text } => {
Some(EventMsg::AgentMessage(AgentMessageEvent {
message: text.clone(),
}))
}
ContentItem::InputText { text } => {
let trimmed = text.trim_start();
let kind = if trimmed.starts_with("<environment_context>") {
Some(InputMessageKind::EnvironmentContext)
} else if trimmed.starts_with("<user_instructions>") {
Some(InputMessageKind::UserInstructions)
} else {
Some(InputMessageKind::Plain)
};
Some(EventMsg::UserMessage(UserMessageEvent {
message: text.clone(),
kind,
}))
}
_ => None,
})
.collect();
events
}
ResponseItem::Reasoning {
summary, content, ..
} => {
let mut events = Vec::new();
for ReasoningItemReasoningSummary::SummaryText { text } in summary {
events.push(EventMsg::AgentReasoning(AgentReasoningEvent {
text: text.clone(),
}));
}
if let Some(items) = content.as_ref().filter(|_| show_raw_agent_reasoning) {
for c in items {
let text = match c {
ReasoningItemContent::ReasoningText { text }
| ReasoningItemContent::Text { text } => text,
};
events.push(EventMsg::AgentReasoningRawContent(
AgentReasoningRawContentEvent { text: text.clone() },
));
}
}
events
}
ResponseItem::WebSearchCall { id, action, .. } => match action {
WebSearchAction::Search { query } => {
let call_id = id.clone().unwrap_or_else(|| "".to_string());
vec![EventMsg::WebSearchEnd(WebSearchEndEvent {
call_id,
query: query.clone(),
})]
}
WebSearchAction::Other => Vec::new(),
},
// Variants that require side effects are handled by higher layers and do not emit events here.
ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::UnifiedExec { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Other => Vec::new(),
}
}

View File

@@ -26,7 +26,6 @@ use crate::protocol::SandboxPolicy;
use crate::seatbelt::spawn_command_under_seatbelt;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use serde_bytes::ByteBuf;
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
@@ -369,7 +368,7 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
} else {
ExecOutputStream::Stdout
},
chunk: ByteBuf::from(chunk),
chunk,
});
let event = Event {
id: stream.sub_id.clone(),

View File

@@ -24,6 +24,9 @@ pub(crate) struct ExecCommandSession {
/// JoinHandle for the child wait task.
wait_handle: StdMutex<Option<JoinHandle<()>>>,
/// Tracks whether the underlying process has exited.
exit_status: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl ExecCommandSession {
@@ -34,6 +37,7 @@ impl ExecCommandSession {
reader_handle: JoinHandle<()>,
writer_handle: JoinHandle<()>,
wait_handle: JoinHandle<()>,
exit_status: std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> Self {
Self {
writer_tx,
@@ -42,6 +46,7 @@ impl ExecCommandSession {
reader_handle: StdMutex::new(Some(reader_handle)),
writer_handle: StdMutex::new(Some(writer_handle)),
wait_handle: StdMutex::new(Some(wait_handle)),
exit_status,
}
}
@@ -52,6 +57,10 @@ impl ExecCommandSession {
pub(crate) fn output_receiver(&self) -> broadcast::Receiver<Vec<u8>> {
self.output_tx.subscribe()
}
pub(crate) fn has_exited(&self) -> bool {
self.exit_status.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl Drop for ExecCommandSession {

View File

@@ -6,6 +6,7 @@ mod session_manager;
pub use exec_command_params::ExecCommandParams;
pub use exec_command_params::WriteStdinParams;
pub(crate) use exec_command_session::ExecCommandSession;
pub use responses_api::EXEC_COMMAND_TOOL_NAME;
pub use responses_api::WRITE_STDIN_TOOL_NAME;
pub use responses_api::create_exec_command_tool_for_responses_api;

View File

@@ -19,6 +19,7 @@ use crate::exec_command::exec_command_params::ExecCommandParams;
use crate::exec_command::exec_command_params::WriteStdinParams;
use crate::exec_command::exec_command_session::ExecCommandSession;
use crate::exec_command::session_id::SessionId;
use crate::truncate::truncate_middle;
use codex_protocol::models::FunctionCallOutputPayload;
#[derive(Debug, Default)]
@@ -327,11 +328,14 @@ async fn create_exec_command_session(
// Keep the child alive until it exits, then signal exit code.
let (exit_tx, exit_rx) = oneshot::channel::<i32>();
let exit_status = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let wait_exit_status = std::sync::Arc::clone(&exit_status);
let wait_handle = tokio::task::spawn_blocking(move || {
let code = match child.wait() {
Ok(status) => status.exit_code() as i32,
Err(_) => -1,
};
wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = exit_tx.send(code);
});
@@ -343,116 +347,11 @@ async fn create_exec_command_session(
reader_handle,
writer_handle,
wait_handle,
exit_status,
);
Ok((session, exit_rx))
}
/// Truncate the middle of a UTF-8 string to at most `max_bytes` bytes,
/// preserving the beginning and the end. Returns the possibly truncated
/// string and `Some(original_token_count)` (estimated at 4 bytes/token)
/// if truncation occurred; otherwise returns the original string and `None`.
fn truncate_middle(s: &str, max_bytes: usize) -> (String, Option<u64>) {
// No truncation needed
if s.len() <= max_bytes {
return (s.to_string(), None);
}
let est_tokens = (s.len() as u64).div_ceil(4);
if max_bytes == 0 {
// Cannot keep any content; still return a full marker (never truncated).
return (format!("{est_tokens} tokens truncated…"), Some(est_tokens));
}
// Helper to truncate a string to a given byte length on a char boundary.
fn truncate_on_boundary(input: &str, max_len: usize) -> &str {
if input.len() <= max_len {
return input;
}
let mut end = max_len;
while end > 0 && !input.is_char_boundary(end) {
end -= 1;
}
&input[..end]
}
// Given a left/right budget, prefer newline boundaries; otherwise fall back
// to UTF-8 char boundaries.
fn pick_prefix_end(s: &str, left_budget: usize) -> usize {
if let Some(head) = s.get(..left_budget)
&& let Some(i) = head.rfind('\n')
{
return i + 1; // keep the newline so suffix starts on a fresh line
}
truncate_on_boundary(s, left_budget).len()
}
fn pick_suffix_start(s: &str, right_budget: usize) -> usize {
let start_tail = s.len().saturating_sub(right_budget);
if let Some(tail) = s.get(start_tail..)
&& let Some(i) = tail.find('\n')
{
return start_tail + i + 1; // start after newline
}
// Fall back to a char boundary at or after start_tail.
let mut idx = start_tail.min(s.len());
while idx < s.len() && !s.is_char_boundary(idx) {
idx += 1;
}
idx
}
// Refine marker length and budgets until stable. Marker is never truncated.
let mut guess_tokens = est_tokens; // worst-case: everything truncated
for _ in 0..4 {
let marker = format!("{guess_tokens} tokens truncated…");
let marker_len = marker.len();
let keep_budget = max_bytes.saturating_sub(marker_len);
if keep_budget == 0 {
// No room for any content within the cap; return a full, untruncated marker
// that reflects the entire truncated content.
return (format!("{est_tokens} tokens truncated…"), Some(est_tokens));
}
let left_budget = keep_budget / 2;
let right_budget = keep_budget - left_budget;
let prefix_end = pick_prefix_end(s, left_budget);
let mut suffix_start = pick_suffix_start(s, right_budget);
if suffix_start < prefix_end {
suffix_start = prefix_end;
}
let kept_content_bytes = prefix_end + (s.len() - suffix_start);
let truncated_content_bytes = s.len().saturating_sub(kept_content_bytes);
let new_tokens = (truncated_content_bytes as u64).div_ceil(4);
if new_tokens == guess_tokens {
let mut out = String::with_capacity(marker_len + kept_content_bytes + 1);
out.push_str(&s[..prefix_end]);
out.push_str(&marker);
// Place marker on its own line for symmetry when we keep line boundaries.
out.push('\n');
out.push_str(&s[suffix_start..]);
return (out, Some(est_tokens));
}
guess_tokens = new_tokens;
}
// Fallback: use last guess to build output.
let marker = format!("{guess_tokens} tokens truncated…");
let marker_len = marker.len();
let keep_budget = max_bytes.saturating_sub(marker_len);
if keep_budget == 0 {
return (format!("{est_tokens} tokens truncated…"), Some(est_tokens));
}
let left_budget = keep_budget / 2;
let right_budget = keep_budget - left_budget;
let prefix_end = pick_prefix_end(s, left_budget);
let suffix_start = pick_suffix_start(s, right_budget);
let mut out = String::with_capacity(marker_len + prefix_end + (s.len() - suffix_start) + 1);
out.push_str(&s[..prefix_end]);
out.push_str(&marker);
out.push('\n');
out.push_str(&s[suffix_start..]);
(out, Some(est_tokens))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -616,50 +515,4 @@ Output:
abc"#;
assert_eq!(expected, text);
}
#[test]
fn truncate_middle_no_newlines_fallback() {
// A long string with no newlines that exceeds the cap.
let s = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
let max_bytes = 16; // force truncation
let (out, original) = truncate_middle(s, max_bytes);
// For very small caps, we return the full, untruncated marker,
// even if it exceeds the cap.
assert_eq!(out, "…16 tokens truncated…");
// Original string length is 62 bytes => ceil(62/4) = 16 tokens.
assert_eq!(original, Some(16));
}
#[test]
fn truncate_middle_prefers_newline_boundaries() {
// Build a multi-line string of 20 numbered lines (each "NNN\n").
let mut s = String::new();
for i in 1..=20 {
s.push_str(&format!("{i:03}\n"));
}
// Total length: 20 lines * 4 bytes per line = 80 bytes.
assert_eq!(s.len(), 80);
// Choose a cap that forces truncation while leaving room for
// a few lines on each side after accounting for the marker.
let max_bytes = 64;
// Expect exact output: first 4 lines, marker, last 4 lines, and correct token estimate (80/4 = 20).
assert_eq!(
truncate_middle(&s, max_bytes),
(
r#"001
002
003
004
…12 tokens truncated…
017
018
019
020
"#
.to_string(),
Some(20)
)
);
}
}

View File

@@ -7,7 +7,7 @@
mod apply_patch;
pub mod auth;
mod bash;
pub mod bash;
mod chat_completions;
mod client;
mod client_common;
@@ -34,12 +34,16 @@ mod mcp_tool_call;
mod message_history;
mod model_provider_info;
pub mod parse_command;
mod truncate;
mod unified_exec;
mod user_instructions;
pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;
pub use model_provider_info::ModelProviderInfo;
pub use model_provider_info::WireApi;
pub use model_provider_info::built_in_model_providers;
pub use model_provider_info::create_oss_provider_with_base_url;
mod conversation_manager;
mod event_mapping;
pub use conversation_manager::ConversationManager;
pub use conversation_manager::NewConversation;
// Re-export common auth types for workspace consumers
@@ -59,7 +63,10 @@ pub mod spawn;
pub mod terminal;
mod tool_apply_patch;
pub mod turn_diff_tracker;
pub use rollout::RolloutRecorder;
pub use rollout::list::ConversationItem;
pub use rollout::list::ConversationsPage;
pub use rollout::list::Cursor;
mod user_notification;
pub mod util;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;

View File

@@ -1,3 +1,4 @@
use crate::config_types::ReasoningSummaryFormat;
use crate::tool_apply_patch::ApplyPatchToolType;
/// A model family is a group of models that share certain characteristics.
@@ -20,6 +21,9 @@ pub struct ModelFamily {
// `summary` is optional).
pub supports_reasoning_summaries: bool,
// Define if we need a special handling of reasoning summary
pub reasoning_summary_format: ReasoningSummaryFormat,
// This should be set to true when the model expects a tool named
// "local_shell" to be provided. Its contract must be understood natively by
// the model such that its description can be omitted.
@@ -41,6 +45,7 @@ macro_rules! model_family {
family: $family.to_string(),
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
reasoning_summary_format: ReasoningSummaryFormat::None,
uses_local_shell_tool: false,
apply_patch_tool_type: None,
};
@@ -61,6 +66,7 @@ macro_rules! simple_model_family {
family: $family.to_string(),
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
reasoning_summary_format: ReasoningSummaryFormat::None,
uses_local_shell_tool: false,
apply_patch_tool_type: None,
})
@@ -90,6 +96,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
model_family!(
slug, slug,
supports_reasoning_summaries: true,
reasoning_summary_format: ReasoningSummaryFormat::Experimental,
)
} else if slug.starts_with("gpt-4.1") {
model_family!(

View File

@@ -79,12 +79,12 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
}),
"gpt-5" => Some(ModelInfo {
context_window: 400_000,
context_window: 272_000,
max_output_tokens: 128_000,
}),
_ if slug.starts_with("codex-") => Some(ModelInfo {
context_window: 400_000,
context_window: 272_000,
max_output_tokens: 128_000,
}),

View File

@@ -200,6 +200,53 @@ fn create_shell_tool() -> OpenAiTool {
})
}
fn create_unified_exec_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"input".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"When no session_id is provided, treat the array as the command and arguments \
to launch. When session_id is set, concatenate the strings (in order) and write \
them to the session's stdin."
.to_string(),
),
},
);
properties.insert(
"session_id".to_string(),
JsonSchema::String {
description: Some(
"Identifier for an existing interactive session. If omitted, a new command \
is spawned."
.to_string(),
),
},
);
properties.insert(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some(
"Maximum time in milliseconds to wait for output after writing the input."
.to_string(),
),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "unified_exec".to_string(),
description:
"Runs a command in a PTY. Provide a session_id to reuse an existing interactive session.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["input".to_string()]),
additional_properties: Some(false),
},
})
}
fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
@@ -534,25 +581,27 @@ pub(crate) fn get_openai_tools(
) -> Vec<OpenAiTool> {
let mut tools: Vec<OpenAiTool> = Vec::new();
match &config.shell_type {
ConfigShellToolType::DefaultShell => {
tools.push(create_shell_tool());
}
ConfigShellToolType::ShellWithRequest { sandbox_policy } => {
tools.push(create_shell_tool_for_sandbox(sandbox_policy));
}
ConfigShellToolType::LocalShell => {
tools.push(OpenAiTool::LocalShell {});
}
ConfigShellToolType::StreamableShell => {
tools.push(OpenAiTool::Function(
crate::exec_command::create_exec_command_tool_for_responses_api(),
));
tools.push(OpenAiTool::Function(
crate::exec_command::create_write_stdin_tool_for_responses_api(),
));
}
}
// match &config.shell_type {
// ConfigShellToolType::DefaultShell => {
// tools.push(create_shell_tool());
// }
// ConfigShellToolType::ShellWithRequest { sandbox_policy } => {
// tools.push(create_shell_tool_for_sandbox(sandbox_policy));
// }
// ConfigShellToolType::LocalShell => {
// tools.push(OpenAiTool::LocalShell {});
// }
// ConfigShellToolType::StreamableShell => {
// tools.push(OpenAiTool::Function(
// crate::exec_command::create_exec_command_tool_for_responses_api(),
// ));
// tools.push(OpenAiTool::Function(
// crate::exec_command::create_write_stdin_tool_for_responses_api(),
// ));
// }
// }
tools.push(create_unified_exec_tool());
if config.plan_tool {
tools.push(PLAN_TOOL.clone());
@@ -577,10 +626,7 @@ pub(crate) fn get_openai_tools(
if config.include_view_image_tool {
tools.push(create_view_image_tool());
}
if let Some(mcp_tools) = mcp_tools {
// Ensure deterministic ordering to maximize prompt cache hits.
// HashMap iteration order is non-deterministic, so sort by fully-qualified tool name.
let mut entries: Vec<(String, mcp_types::Tool)> = mcp_tools.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
@@ -647,7 +693,13 @@ mod tests {
assert_eq_tool_names(
&tools,
&["local_shell", "update_plan", "web_search", "view_image"],
&[
"local_shell",
"unified_exec",
"update_plan",
"web_search",
"view_image",
],
);
}
@@ -668,7 +720,13 @@ mod tests {
assert_eq_tool_names(
&tools,
&["shell", "update_plan", "web_search", "view_image"],
&[
"shell",
"unified_exec",
"update_plan",
"web_search",
"view_image",
],
);
}
@@ -727,6 +785,7 @@ mod tests {
&tools,
&[
"shell",
"unified_exec",
"web_search",
"view_image",
"test_server/do_something_cool",
@@ -734,7 +793,7 @@ mod tests {
);
assert_eq!(
tools[3],
tools[4],
OpenAiTool::Function(ResponsesApiTool {
name: "test_server/do_something_cool".to_string(),
parameters: JsonSchema::Object {
@@ -846,6 +905,7 @@ mod tests {
&tools,
&[
"shell",
"unified_exec",
"view_image",
"test_server/cool",
"test_server/do",
@@ -893,11 +953,17 @@ mod tests {
assert_eq_tool_names(
&tools,
&["shell", "web_search", "view_image", "dash/search"],
&[
"shell",
"unified_exec",
"web_search",
"view_image",
"dash/search",
],
);
assert_eq!(
tools[3],
tools[4],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/search".to_string(),
parameters: JsonSchema::Object {
@@ -953,10 +1019,16 @@ mod tests {
assert_eq_tool_names(
&tools,
&["shell", "web_search", "view_image", "dash/paginate"],
&[
"shell",
"unified_exec",
"web_search",
"view_image",
"dash/paginate",
],
);
assert_eq!(
tools[3],
tools[4],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/paginate".to_string(),
parameters: JsonSchema::Object {
@@ -1008,9 +1080,18 @@ mod tests {
)])),
);
assert_eq_tool_names(&tools, &["shell", "web_search", "view_image", "dash/tags"]);
assert_eq_tool_names(
&tools,
&[
"shell",
"unified_exec",
"web_search",
"view_image",
"dash/tags",
],
);
assert_eq!(
tools[3],
tools[4],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/tags".to_string(),
parameters: JsonSchema::Object {
@@ -1065,9 +1146,18 @@ mod tests {
)])),
);
assert_eq_tool_names(&tools, &["shell", "web_search", "view_image", "dash/value"]);
assert_eq_tool_names(
&tools,
&[
"shell",
"unified_exec",
"web_search",
"view_image",
"dash/value",
],
);
assert_eq!(
tools[3],
tools[4],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/value".to_string(),
parameters: JsonSchema::Object {

View File

@@ -34,7 +34,8 @@ pub struct ConversationItem {
}
/// Hard cap to bound worstcase work per request.
const MAX_SCAN_FILES: usize = 50_000;
const MAX_SCAN_FILES: usize = 10_000;
const HEAD_RECORD_LIMIT: usize = 10;
/// Pagination cursor identifying a file by timestamp and UUID.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -166,7 +167,9 @@ async fn traverse_directories_for_paths(
if items.len() == page_size {
break 'outer;
}
let head = read_first_jsonl_records(&path, 5).await.unwrap_or_default();
let head = read_first_jsonl_records(&path, HEAD_RECORD_LIMIT)
.await
.unwrap_or_default();
items.push(ConversationItem { path, head });
}
}

View File

@@ -7,6 +7,7 @@ pub(crate) fn is_persisted_response_item(item: &ResponseItem) -> bool {
ResponseItem::Message { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::UnifiedExec { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }

View File

@@ -107,6 +107,7 @@ impl RolloutRecorder {
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
let timestamp = timestamp
.to_offset(time::UtcOffset::UTC)
.format(timestamp_format)
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;

View File

@@ -9,6 +9,12 @@ pub struct ZshShell {
zshrc_path: String,
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct BashShell {
shell_path: String,
bashrc_path: String,
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct PowerShellConfig {
exe: String, // Executable name or path, e.g. "pwsh" or "powershell.exe".
@@ -18,6 +24,7 @@ pub struct PowerShellConfig {
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub enum Shell {
Zsh(ZshShell),
Bash(BashShell),
PowerShell(PowerShellConfig),
Unknown,
}
@@ -26,22 +33,10 @@ impl Shell {
pub fn format_default_shell_invocation(&self, command: Vec<String>) -> Option<Vec<String>> {
match self {
Shell::Zsh(zsh) => {
if !std::path::Path::new(&zsh.zshrc_path).exists() {
return None;
}
let mut result = vec![zsh.shell_path.clone()];
result.push("-lc".to_string());
let joined = strip_bash_lc(&command)
.or_else(|| shlex::try_join(command.iter().map(|s| s.as_str())).ok());
if let Some(joined) = joined {
result.push(format!("source {} && ({joined})", zsh.zshrc_path));
} else {
return None;
}
Some(result)
format_shell_invocation_with_rc(&command, &zsh.shell_path, &zsh.zshrc_path)
}
Shell::Bash(bash) => {
format_shell_invocation_with_rc(&command, &bash.shell_path, &bash.bashrc_path)
}
Shell::PowerShell(ps) => {
// If model generated a bash command, prefer a detected bash fallback
@@ -97,12 +92,32 @@ impl Shell {
Shell::Zsh(zsh) => std::path::Path::new(&zsh.shell_path)
.file_name()
.map(|s| s.to_string_lossy().to_string()),
Shell::Bash(bash) => std::path::Path::new(&bash.shell_path)
.file_name()
.map(|s| s.to_string_lossy().to_string()),
Shell::PowerShell(ps) => Some(ps.exe.clone()),
Shell::Unknown => None,
}
}
}
fn format_shell_invocation_with_rc(
command: &Vec<String>,
shell_path: &str,
rc_path: &str,
) -> Option<Vec<String>> {
let joined = strip_bash_lc(command)
.or_else(|| shlex::try_join(command.iter().map(|s| s.as_str())).ok())?;
let rc_command = if std::path::Path::new(rc_path).exists() {
format!("source {rc_path} && ({joined})")
} else {
joined
};
Some(vec![shell_path.to_string(), "-lc".to_string(), rc_command])
}
fn strip_bash_lc(command: &Vec<String>) -> Option<String> {
match command.as_slice() {
// exactly three items
@@ -116,44 +131,43 @@ fn strip_bash_lc(command: &Vec<String>) -> Option<String> {
}
}
#[cfg(target_os = "macos")]
pub async fn default_user_shell() -> Shell {
use tokio::process::Command;
use whoami;
#[cfg(unix)]
fn detect_default_user_shell() -> Shell {
use libc::getpwuid;
use libc::getuid;
use std::ffi::CStr;
let user = whoami::username();
let home = format!("/Users/{user}");
let output = Command::new("dscl")
.args([".", "-read", &home, "UserShell"])
.output()
.await
.ok();
match output {
Some(o) => {
if !o.status.success() {
return Shell::Unknown;
}
let stdout = String::from_utf8_lossy(&o.stdout);
for line in stdout.lines() {
if let Some(shell_path) = line.strip_prefix("UserShell: ")
&& shell_path.ends_with("/zsh")
{
return Shell::Zsh(ZshShell {
shell_path: shell_path.to_string(),
zshrc_path: format!("{home}/.zshrc"),
});
}
unsafe {
let uid = getuid();
let pw = getpwuid(uid);
if !pw.is_null() {
let shell_path = CStr::from_ptr((*pw).pw_shell)
.to_string_lossy()
.into_owned();
let home_path = CStr::from_ptr((*pw).pw_dir).to_string_lossy().into_owned();
if shell_path.ends_with("/zsh") {
return Shell::Zsh(ZshShell {
shell_path,
zshrc_path: format!("{home_path}/.zshrc"),
});
}
Shell::Unknown
if shell_path.ends_with("/bash") {
return Shell::Bash(BashShell {
shell_path,
bashrc_path: format!("{home_path}/.bashrc"),
});
}
}
_ => Shell::Unknown,
}
Shell::Unknown
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
#[cfg(unix)]
pub async fn default_user_shell() -> Shell {
Shell::Unknown
detect_default_user_shell()
}
#[cfg(target_os = "windows")]
@@ -196,8 +210,13 @@ pub async fn default_user_shell() -> Shell {
}
}
#[cfg(all(not(target_os = "windows"), not(unix)))]
pub async fn default_user_shell() -> Shell {
Shell::Unknown
}
#[cfg(test)]
#[cfg(target_os = "macos")]
#[cfg(unix)]
mod tests {
use super::*;
use std::process::Command;
@@ -230,9 +249,127 @@ mod tests {
zshrc_path: "/does/not/exist/.zshrc".to_string(),
});
let actual_cmd = shell.format_default_shell_invocation(vec!["myecho".to_string()]);
assert_eq!(actual_cmd, None);
assert_eq!(
actual_cmd,
Some(vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
"myecho".to_string()
])
);
}
#[tokio::test]
async fn test_run_with_profile_bashrc_not_exists() {
let shell = Shell::Bash(BashShell {
shell_path: "/bin/bash".to_string(),
bashrc_path: "/does/not/exist/.bashrc".to_string(),
});
let actual_cmd = shell.format_default_shell_invocation(vec!["myecho".to_string()]);
assert_eq!(
actual_cmd,
Some(vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"myecho".to_string()
])
);
}
#[tokio::test]
async fn test_run_with_profile_bash_escaping_and_execution() {
let shell_path = "/bin/bash";
let cases = vec![
(
vec!["myecho"],
vec![shell_path, "-lc", "source BASHRC_PATH && (myecho)"],
Some("It works!\n"),
),
(
vec!["bash", "-lc", "echo 'single' \"double\""],
vec![
shell_path,
"-lc",
"source BASHRC_PATH && (echo 'single' \"double\")",
],
Some("single double\n"),
),
];
for (input, expected_cmd, expected_output) in cases {
use std::collections::HashMap;
use crate::exec::ExecParams;
use crate::exec::SandboxType;
use crate::exec::process_exec_tool_call;
use crate::protocol::SandboxPolicy;
let temp_home = tempfile::tempdir().unwrap();
let bashrc_path = temp_home.path().join(".bashrc");
std::fs::write(
&bashrc_path,
r#"
set -x
function myecho {
echo 'It works!'
}
"#,
)
.unwrap();
let shell = Shell::Bash(BashShell {
shell_path: shell_path.to_string(),
bashrc_path: bashrc_path.to_str().unwrap().to_string(),
});
let actual_cmd = shell
.format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect());
let expected_cmd = expected_cmd
.iter()
.map(|s| {
s.replace("BASHRC_PATH", bashrc_path.to_str().unwrap())
.to_string()
})
.collect();
assert_eq!(actual_cmd, Some(expected_cmd));
let output = process_exec_tool_call(
ExecParams {
command: actual_cmd.unwrap(),
cwd: PathBuf::from(temp_home.path()),
timeout_ms: None,
env: HashMap::from([(
"HOME".to_string(),
temp_home.path().to_str().unwrap().to_string(),
)]),
with_escalated_permissions: None,
justification: None,
},
SandboxType::None,
&SandboxPolicy::DangerFullAccess,
&None,
None,
)
.await
.unwrap();
assert_eq!(output.exit_code, 0, "input: {input:?} output: {output:?}");
if let Some(expected) = expected_output {
assert_eq!(
output.stdout.text, expected,
"input: {input:?} output: {output:?}"
);
}
}
}
}
#[cfg(test)]
#[cfg(target_os = "macos")]
mod macos_tests {
use super::*;
#[tokio::test]
async fn test_run_with_profile_escaping_and_execution() {
let shell_path = "/bin/zsh";

View File

@@ -0,0 +1,180 @@
//! Utilities for truncating large chunks of output while preserving a prefix
//! and suffix on UTF-8 boundaries.
/// Truncate the middle of a UTF-8 string to at most `max_bytes` bytes,
/// preserving the beginning and the end. Returns the possibly truncated
/// string and `Some(original_token_count)` (estimated at 4 bytes/token)
/// if truncation occurred; otherwise returns the original string and `None`.
pub(crate) fn truncate_middle(s: &str, max_bytes: usize) -> (String, Option<u64>) {
if s.len() <= max_bytes {
return (s.to_string(), None);
}
let est_tokens = (s.len() as u64).div_ceil(4);
if max_bytes == 0 {
return (format!("{est_tokens} tokens truncated…"), Some(est_tokens));
}
fn truncate_on_boundary(input: &str, max_len: usize) -> &str {
if input.len() <= max_len {
return input;
}
let mut end = max_len;
while end > 0 && !input.is_char_boundary(end) {
end -= 1;
}
&input[..end]
}
fn pick_prefix_end(s: &str, left_budget: usize) -> usize {
if let Some(head) = s.get(..left_budget)
&& let Some(i) = head.rfind('\n')
{
return i + 1;
}
truncate_on_boundary(s, left_budget).len()
}
fn pick_suffix_start(s: &str, right_budget: usize) -> usize {
let start_tail = s.len().saturating_sub(right_budget);
if let Some(tail) = s.get(start_tail..)
&& let Some(i) = tail.find('\n')
{
return start_tail + i + 1;
}
let mut idx = start_tail.min(s.len());
while idx < s.len() && !s.is_char_boundary(idx) {
idx += 1;
}
idx
}
let mut guess_tokens = est_tokens;
for _ in 0..4 {
let marker = format!("{guess_tokens} tokens truncated…");
let marker_len = marker.len();
let keep_budget = max_bytes.saturating_sub(marker_len);
if keep_budget == 0 {
return (format!("{est_tokens} tokens truncated…"), Some(est_tokens));
}
let left_budget = keep_budget / 2;
let right_budget = keep_budget - left_budget;
let prefix_end = pick_prefix_end(s, left_budget);
let mut suffix_start = pick_suffix_start(s, right_budget);
if suffix_start < prefix_end {
suffix_start = prefix_end;
}
let kept_content_bytes = prefix_end + (s.len() - suffix_start);
let truncated_content_bytes = s.len().saturating_sub(kept_content_bytes);
let new_tokens = (truncated_content_bytes as u64).div_ceil(4);
if new_tokens == guess_tokens {
let mut out = String::with_capacity(marker_len + kept_content_bytes + 1);
out.push_str(&s[..prefix_end]);
out.push_str(&marker);
out.push('\n');
out.push_str(&s[suffix_start..]);
return (out, Some(est_tokens));
}
guess_tokens = new_tokens;
}
let marker = format!("{guess_tokens} tokens truncated…");
let marker_len = marker.len();
let keep_budget = max_bytes.saturating_sub(marker_len);
if keep_budget == 0 {
return (format!("{est_tokens} tokens truncated…"), Some(est_tokens));
}
let left_budget = keep_budget / 2;
let right_budget = keep_budget - left_budget;
let prefix_end = pick_prefix_end(s, left_budget);
let suffix_start = pick_suffix_start(s, right_budget);
let mut out = String::with_capacity(marker_len + prefix_end + (s.len() - suffix_start) + 1);
out.push_str(&s[..prefix_end]);
out.push_str(&marker);
out.push('\n');
out.push_str(&s[suffix_start..]);
(out, Some(est_tokens))
}
#[cfg(test)]
mod tests {
use super::truncate_middle;
#[test]
fn truncate_middle_no_newlines_fallback() {
let s = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ*";
let max_bytes = 32;
let (out, original) = truncate_middle(s, max_bytes);
assert!(out.starts_with("abc"));
assert!(out.contains("tokens truncated"));
assert!(out.ends_with("XYZ*"));
assert_eq!(original, Some((s.len() as u64).div_ceil(4)));
}
#[test]
fn truncate_middle_prefers_newline_boundaries() {
let mut s = String::new();
for i in 1..=20 {
s.push_str(&format!("{i:03}\n"));
}
assert_eq!(s.len(), 80);
let max_bytes = 64;
let (out, tokens) = truncate_middle(&s, max_bytes);
assert!(out.starts_with("001\n002\n003\n004\n"));
assert!(out.contains("tokens truncated"));
assert!(out.ends_with("017\n018\n019\n020\n"));
assert_eq!(tokens, Some(20));
}
#[test]
fn truncate_middle_handles_utf8_content() {
let s = "😀😀😀😀😀😀😀😀😀😀\nsecond line with ascii text\n";
let max_bytes = 32;
let (out, tokens) = truncate_middle(s, max_bytes);
assert!(out.contains("tokens truncated"));
assert!(!out.contains('\u{fffd}'));
assert_eq!(tokens, Some((s.len() as u64).div_ceil(4)));
}
#[test]
fn truncate_middle_prefers_newline_boundaries_2() {
// Build a multi-line string of 20 numbered lines (each "NNN\n").
let mut s = String::new();
for i in 1..=20 {
s.push_str(&format!("{i:03}\n"));
}
// Total length: 20 lines * 4 bytes per line = 80 bytes.
assert_eq!(s.len(), 80);
// Choose a cap that forces truncation while leaving room for
// a few lines on each side after accounting for the marker.
let max_bytes = 64;
// Expect exact output: first 4 lines, marker, last 4 lines, and correct token estimate (80/4 = 20).
assert_eq!(
truncate_middle(&s, max_bytes),
(
r#"001
002
003
004
…12 tokens truncated…
017
018
019
020
"#
.to_string(),
Some(20)
)
);
}
}

View File

@@ -0,0 +1,26 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub(crate) enum UnifiedExecError {
#[error("Failed to create unified exec session: {pty_error}")]
CreateSession {
#[source]
pty_error: anyhow::Error,
},
#[error("Unknown session id {session_id}")]
UnknownSessionId { session_id: i32 },
#[error("failed to write to stdin")]
WriteToStdin,
#[error("missing command line for unified exec request")]
MissingCommandLine,
#[error("invalid command line: {command_line}")]
InvalidCommandLine { command_line: String },
#[error("command not found: {command}")]
CommandNotFound { command: String },
}
impl UnifiedExecError {
pub(crate) fn create_session(error: anyhow::Error) -> Self {
Self::CreateSession { pty_error: error }
}
}

View File

@@ -0,0 +1,643 @@
use portable_pty::CommandBuilder;
use portable_pty::PtySize;
use portable_pty::native_pty_system;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::io::ErrorKind;
use std::io::Read;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicI32;
use std::sync::atomic::Ordering;
use tokio::sync::Mutex;
use tokio::sync::Notify;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::time::Duration;
use tokio::time::Instant;
use crate::exec_command::ExecCommandSession;
use crate::truncate::truncate_middle;
mod errors;
mod path;
pub(crate) use errors::UnifiedExecError;
use path::command_from_chunks;
use path::join_input_chunks;
use path::resolve_command_path;
const DEFAULT_TIMEOUT_MS: u64 = 1_000;
const MAX_TIMEOUT_MS: u64 = 60_000;
const UNIFIED_EXEC_OUTPUT_MAX_BYTES: usize = 128 * 1024; // 128 KiB
#[derive(Debug)]
pub(crate) struct UnifiedExecRequest<'a> {
pub session_id: Option<i32>,
pub input_chunks: &'a [String],
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct UnifiedExecResult {
pub session_id: Option<i32>,
pub output: String,
}
#[derive(Debug, Default)]
pub(crate) struct UnifiedExecSessionManager {
next_session_id: AtomicI32,
sessions: Mutex<HashMap<i32, ManagedUnifiedExecSession>>,
}
#[derive(Debug)]
struct ManagedUnifiedExecSession {
session: ExecCommandSession,
output_buffer: OutputBuffer,
output_notify: Arc<Notify>,
output_task: JoinHandle<()>,
}
#[derive(Debug, Default)]
struct OutputBufferState {
chunks: VecDeque<Vec<u8>>,
total_bytes: usize,
}
impl OutputBufferState {
fn push_chunk(&mut self, chunk: Vec<u8>) {
self.total_bytes = self.total_bytes.saturating_add(chunk.len());
self.chunks.push_back(chunk);
while self.total_bytes > UNIFIED_EXEC_OUTPUT_MAX_BYTES {
match self.chunks.pop_front() {
Some(removed) => {
self.total_bytes = self.total_bytes.saturating_sub(removed.len());
}
None => break,
}
}
}
fn drain(&mut self) -> Vec<Vec<u8>> {
let drained: Vec<Vec<u8>> = self.chunks.drain(..).collect();
self.total_bytes = 0;
drained
}
}
type OutputBuffer = Arc<Mutex<OutputBufferState>>;
type OutputHandles = (OutputBuffer, Arc<Notify>);
impl ManagedUnifiedExecSession {
fn new(session: ExecCommandSession) -> Self {
let output_buffer = Arc::new(Mutex::new(OutputBufferState::default()));
let output_notify = Arc::new(Notify::new());
let mut receiver = session.output_receiver();
let buffer_clone = Arc::clone(&output_buffer);
let notify_clone = Arc::clone(&output_notify);
let output_task = tokio::spawn(async move {
while let Ok(chunk) = receiver.recv().await {
let mut guard = buffer_clone.lock().await;
guard.push_chunk(chunk);
drop(guard);
notify_clone.notify_waiters();
}
});
Self {
session,
output_buffer,
output_notify,
output_task,
}
}
fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
self.session.writer_sender()
}
fn output_handles(&self) -> OutputHandles {
(
Arc::clone(&self.output_buffer),
Arc::clone(&self.output_notify),
)
}
fn has_exited(&self) -> bool {
self.session.has_exited()
}
}
impl Drop for ManagedUnifiedExecSession {
fn drop(&mut self) {
self.output_task.abort();
}
}
impl UnifiedExecSessionManager {
pub async fn handle_request(
&self,
request: UnifiedExecRequest<'_>,
) -> Result<UnifiedExecResult, UnifiedExecError> {
let (timeout_ms, timeout_warning) = match request.timeout_ms {
Some(requested) if requested > MAX_TIMEOUT_MS => (
MAX_TIMEOUT_MS,
Some(format!(
"Warning: requested timeout {requested}ms exceeds maximum of {MAX_TIMEOUT_MS}ms; clamping to {MAX_TIMEOUT_MS}ms.\n"
)),
),
Some(requested) => (requested, None),
None => (DEFAULT_TIMEOUT_MS, None),
};
let mut new_session: Option<ManagedUnifiedExecSession> = None;
let session_id;
let writer_tx;
let output_buffer;
let output_notify;
if let Some(existing_id) = request.session_id {
let mut sessions = self.sessions.lock().await;
match sessions.get(&existing_id) {
Some(session) => {
if session.has_exited() {
sessions.remove(&existing_id);
return Err(UnifiedExecError::UnknownSessionId {
session_id: existing_id,
});
}
let (buffer, notify) = session.output_handles();
session_id = existing_id;
writer_tx = session.writer_sender();
output_buffer = buffer;
output_notify = notify;
}
None => {
return Err(UnifiedExecError::UnknownSessionId {
session_id: existing_id,
});
}
}
drop(sessions);
} else {
let command = command_from_chunks(request.input_chunks)?;
let new_id = self.next_session_id.fetch_add(1, Ordering::SeqCst);
let session = create_unified_exec_session(&command).await?;
let managed_session = ManagedUnifiedExecSession::new(session);
let (buffer, notify) = managed_session.output_handles();
writer_tx = managed_session.writer_sender();
output_buffer = buffer;
output_notify = notify;
session_id = new_id;
new_session = Some(managed_session);
};
if request.session_id.is_some() {
let joined_input = join_input_chunks(request.input_chunks);
if !joined_input.is_empty() && writer_tx.send(joined_input.into_bytes()).await.is_err()
{
return Err(UnifiedExecError::WriteToStdin);
}
}
let mut collected: Vec<u8> = Vec::with_capacity(4096);
let start = Instant::now();
let deadline = start + Duration::from_millis(timeout_ms);
loop {
let drained_chunks;
let mut wait_for_output = None;
{
let mut guard = output_buffer.lock().await;
drained_chunks = guard.drain();
if drained_chunks.is_empty() {
wait_for_output = Some(output_notify.notified());
}
}
if drained_chunks.is_empty() {
if Instant::now() >= deadline {
break;
}
let remaining = deadline.saturating_duration_since(Instant::now());
let notified = wait_for_output.unwrap_or_else(|| output_notify.notified());
tokio::pin!(notified);
tokio::select! {
_ = &mut notified => {}
_ = tokio::time::sleep(remaining) => break,
}
continue;
}
for chunk in drained_chunks {
collected.extend_from_slice(&chunk);
}
if Instant::now() >= deadline {
break;
}
}
let (output, _maybe_tokens) = truncate_middle(
&String::from_utf8_lossy(&collected),
UNIFIED_EXEC_OUTPUT_MAX_BYTES,
);
let output = if let Some(warning) = timeout_warning {
format!("{warning}{output}")
} else {
output
};
let should_store_session = if let Some(session) = new_session.as_ref() {
!session.has_exited()
} else if request.session_id.is_some() {
let mut sessions = self.sessions.lock().await;
if let Some(existing) = sessions.get(&session_id) {
if existing.has_exited() {
sessions.remove(&session_id);
false
} else {
true
}
} else {
false
}
} else {
true
};
if should_store_session {
if let Some(session) = new_session {
self.sessions.lock().await.insert(session_id, session);
}
Ok(UnifiedExecResult {
session_id: Some(session_id),
output,
})
} else {
Ok(UnifiedExecResult {
session_id: None,
output,
})
}
}
}
async fn create_unified_exec_session(
command: &[String],
) -> Result<ExecCommandSession, UnifiedExecError> {
if command.is_empty() {
return Err(UnifiedExecError::MissingCommandLine);
}
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.map_err(UnifiedExecError::create_session)?;
let resolved_command = resolve_command_path(&command[0])?;
let mut command_builder = CommandBuilder::new(&resolved_command);
for arg in &command[1..] {
command_builder.arg(arg);
}
let mut child = pair
.slave
.spawn_command(command_builder)
.map_err(UnifiedExecError::create_session)?;
let killer = child.clone_killer();
let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(128);
let (output_tx, _) = tokio::sync::broadcast::channel::<Vec<u8>>(256);
let mut reader = pair
.master
.try_clone_reader()
.map_err(UnifiedExecError::create_session)?;
let output_tx_clone = output_tx.clone();
let reader_handle = tokio::task::spawn_blocking(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let _ = output_tx_clone.send(buf[..n].to_vec());
}
Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
Err(ref e) if e.kind() == ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
continue;
}
Err(_) => break,
}
}
});
let writer = pair
.master
.take_writer()
.map_err(UnifiedExecError::create_session)?;
let writer = Arc::new(StdMutex::new(writer));
let writer_handle = tokio::spawn({
let writer = writer.clone();
async move {
while let Some(bytes) = writer_rx.recv().await {
let writer = writer.clone();
let _ = tokio::task::spawn_blocking(move || {
if let Ok(mut guard) = writer.lock() {
use std::io::Write;
let _ = guard.write_all(&bytes);
let _ = guard.flush();
}
})
.await;
}
}
});
let exit_status = Arc::new(AtomicBool::new(false));
let wait_exit_status = Arc::clone(&exit_status);
let wait_handle = tokio::task::spawn_blocking(move || {
let _ = child.wait();
wait_exit_status.store(true, Ordering::SeqCst);
});
Ok(ExecCommandSession::new(
writer_tx,
output_tx,
killer,
reader_handle,
writer_handle,
wait_handle,
exit_status,
))
}
#[cfg(test)]
mod tests {
use super::path::parse_command_line;
use super::*;
#[test]
fn parse_command_line_splits_words() {
assert_eq!(
parse_command_line("echo codex").unwrap(),
vec!["echo".to_string(), "codex".to_string()]
);
}
#[test]
fn parse_command_line_trims_whitespace() {
assert_eq!(
parse_command_line(" ls -la \n").unwrap(),
vec!["ls".to_string(), "-la".to_string()]
);
}
#[test]
fn parse_command_line_rejects_empty() {
let err = parse_command_line(" ").expect_err("expected error");
assert!(matches!(err, UnifiedExecError::MissingCommandLine));
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_persists_across_requests() -> Result<(), UnifiedExecError> {
let manager = UnifiedExecSessionManager::default();
let open_shell = manager
.handle_request(UnifiedExecRequest {
session_id: None,
input_chunks: &["bash".to_string(), "-i".to_string()],
timeout_ms: Some(1_500),
})
.await?;
let session_id = open_shell.session_id.expect("expected session_id");
manager
.handle_request(UnifiedExecRequest {
session_id: Some(session_id),
input_chunks: &["export CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string()],
timeout_ms: Some(1_500),
})
.await?;
let out_2 = manager
.handle_request(UnifiedExecRequest {
session_id: Some(session_id),
input_chunks: &["echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()],
timeout_ms: Some(1_500),
})
.await?;
assert!(out_2.output.contains("codex"));
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn multi_unified_exec_sessions() -> Result<(), UnifiedExecError> {
let manager = UnifiedExecSessionManager::default();
let shell_a = manager
.handle_request(UnifiedExecRequest {
session_id: None,
input_chunks: &["/bin/bash".to_string(), "-i".to_string()],
timeout_ms: Some(1_500),
})
.await?;
let session_a = shell_a.session_id.expect("expected session id");
manager
.handle_request(UnifiedExecRequest {
session_id: Some(session_a),
input_chunks: &["export CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string()],
timeout_ms: Some(1_500),
})
.await?;
let out_2 = manager
.handle_request(UnifiedExecRequest {
session_id: None,
input_chunks: &["/bin/echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()],
timeout_ms: Some(1_500),
})
.await?;
assert!(!out_2.output.contains("codex"));
let out_3 = manager
.handle_request(UnifiedExecRequest {
session_id: Some(session_a),
input_chunks: &["echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()],
timeout_ms: Some(1_500),
})
.await?;
assert!(out_3.output.contains("codex"));
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn unified_exec_timeouts() -> Result<(), UnifiedExecError> {
let manager = UnifiedExecSessionManager::default();
let open_shell = manager
.handle_request(UnifiedExecRequest {
session_id: None,
input_chunks: &["/bin/bash".to_string(), "-i".to_string()],
timeout_ms: Some(1_500),
})
.await?;
let session_id = open_shell.session_id.expect("expected session id");
manager
.handle_request(UnifiedExecRequest {
session_id: Some(session_id),
input_chunks: &["export CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string()],
timeout_ms: Some(1_500),
})
.await?;
let out_2 = manager
.handle_request(UnifiedExecRequest {
session_id: Some(session_id),
input_chunks: &["sleep 5 && echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()],
timeout_ms: Some(10),
})
.await?;
assert!(!out_2.output.contains("codex"));
tokio::time::sleep(Duration::from_secs(7)).await;
let empty = Vec::new();
let out_3 = manager
.handle_request(UnifiedExecRequest {
session_id: Some(session_id),
input_chunks: &empty,
timeout_ms: Some(100),
})
.await?;
assert!(out_3.output.contains("codex"));
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn requests_with_large_timeout_are_capped() -> Result<(), UnifiedExecError> {
let manager = UnifiedExecSessionManager::default();
let result = manager
.handle_request(UnifiedExecRequest {
session_id: None,
input_chunks: &["/bin/echo".to_string(), "codex".to_string()],
timeout_ms: Some(120_000),
})
.await?;
assert!(result.output.starts_with(
"Warning: requested timeout 120000ms exceeds maximum of 60000ms; clamping to 60000ms.\n"
));
assert!(result.output.contains("codex"));
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn completed_commands_do_not_persist_sessions() -> Result<(), UnifiedExecError> {
let manager = UnifiedExecSessionManager::default();
let result = manager
.handle_request(UnifiedExecRequest {
session_id: None,
input_chunks: &["/bin/echo".to_string(), "codex".to_string()],
timeout_ms: Some(1_500),
})
.await?;
assert!(result.session_id.is_none());
assert!(result.output.contains("codex"));
assert!(manager.sessions.lock().await.is_empty());
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn correct_path_resolution() -> Result<(), UnifiedExecError> {
let manager = UnifiedExecSessionManager::default();
let result = manager
.handle_request(UnifiedExecRequest {
session_id: None,
input_chunks: &["echo".to_string(), "codex".to_string()],
timeout_ms: Some(1_500),
})
.await?;
assert!(result.session_id.is_none());
assert!(result.output.contains("codex"));
assert!(manager.sessions.lock().await.is_empty());
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reusing_completed_session_returns_unknown_session() -> Result<(), UnifiedExecError> {
let manager = UnifiedExecSessionManager::default();
let open_shell = manager
.handle_request(UnifiedExecRequest {
session_id: None,
input_chunks: &["/bin/bash".to_string(), "-i".to_string()],
timeout_ms: Some(1_500),
})
.await?;
let session_id = open_shell.session_id.expect("expected session id");
manager
.handle_request(UnifiedExecRequest {
session_id: Some(session_id),
input_chunks: &["exit\n".to_string()],
timeout_ms: Some(1_500),
})
.await?;
tokio::time::sleep(Duration::from_millis(200)).await;
let err = manager
.handle_request(UnifiedExecRequest {
session_id: Some(session_id),
input_chunks: &[],
timeout_ms: Some(100),
})
.await
.expect_err("expected unknown session error");
match err {
UnifiedExecError::UnknownSessionId { session_id: err_id } => {
assert_eq!(err_id, session_id);
}
other => panic!("expected UnknownSessionId, got {other:?}"),
}
assert!(!manager.sessions.lock().await.contains_key(&session_id));
Ok(())
}
}

View File

@@ -0,0 +1,194 @@
use std::env;
use std::path::Path;
use std::path::PathBuf;
use super::errors::UnifiedExecError;
pub(crate) fn parse_command_line(line: &str) -> Result<Vec<String>, UnifiedExecError> {
let trimmed = line.trim();
if trimmed.is_empty() {
return Err(UnifiedExecError::MissingCommandLine);
}
match parse_command_line_impl(trimmed) {
Some(parts) if !parts.is_empty() => Ok(parts),
_ => Err(UnifiedExecError::InvalidCommandLine {
command_line: trimmed.to_string(),
}),
}
}
#[cfg(not(windows))]
fn parse_command_line_impl(trimmed: &str) -> Option<Vec<String>> {
shlex::split(trimmed)
}
#[cfg(windows)]
fn parse_command_line_impl(trimmed: &str) -> Option<Vec<String>> {
windows_split_command_line(trimmed)
}
#[cfg(windows)]
fn windows_split_command_line(input: &str) -> Option<Vec<String>> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut backslashes = 0usize;
for ch in input.chars() {
match ch {
'\\' => {
backslashes += 1;
}
'"' => {
current.extend(std::iter::repeat_n('\\', backslashes / 2));
if backslashes % 2 == 0 {
in_quotes = !in_quotes;
} else {
current.push('"');
}
backslashes = 0;
}
c if c.is_whitespace() && !in_quotes => {
current.extend(std::iter::repeat_n('\\', backslashes));
backslashes = 0;
if !current.is_empty() {
args.push(std::mem::take(&mut current));
}
}
other => {
current.extend(std::iter::repeat_n('\\', backslashes));
backslashes = 0;
current.push(other);
}
}
}
if in_quotes {
return None;
}
current.extend(std::iter::repeat_n('\\', backslashes));
if !current.is_empty() {
args.push(current);
}
if args.is_empty() { None } else { Some(args) }
}
pub(crate) fn command_from_chunks(chunks: &[String]) -> Result<Vec<String>, UnifiedExecError> {
match chunks {
[] => Err(UnifiedExecError::MissingCommandLine),
[single] => parse_command_line(single),
_ => Ok(chunks.to_vec()),
}
}
pub(crate) fn join_input_chunks(chunks: &[String]) -> String {
match chunks {
[] => String::new(),
[single] => single.clone(),
_ => chunks.concat(),
}
}
pub(crate) fn resolve_command_path(command: &str) -> Result<String, UnifiedExecError> {
if command.is_empty() {
return Err(UnifiedExecError::MissingCommandLine);
}
if is_explicit_path(command) {
return ensure_executable(Path::new(command))
.then_some(command.to_string())
.ok_or_else(|| UnifiedExecError::CommandNotFound {
command: command.to_string(),
});
}
if let Some(resolved) = find_in_path(command) {
return Ok(resolved.to_string_lossy().to_string());
}
Err(UnifiedExecError::CommandNotFound {
command: command.to_string(),
})
}
fn is_explicit_path(command: &str) -> bool {
let path = Path::new(command);
path.is_absolute() || path.components().count() > 1
}
fn find_in_path(command: &str) -> Option<PathBuf> {
let path_var = env::var_os("PATH")?;
env::split_paths(&path_var)
.flat_map(|dir| candidate_paths(dir, command))
.find(|candidate| ensure_executable(candidate))
}
fn candidate_paths(dir: PathBuf, command: &str) -> Vec<PathBuf> {
build_platform_candidates(dir.join(command))
}
#[cfg(unix)]
fn build_platform_candidates(candidate: PathBuf) -> Vec<PathBuf> {
vec![candidate]
}
#[cfg(windows)]
fn build_platform_candidates(candidate: PathBuf) -> Vec<PathBuf> {
if candidate.extension().is_some() {
return vec![candidate];
}
let pathext = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
let mut candidates = Vec::new();
for ext in pathext.split(';') {
if ext.is_empty() {
continue;
}
let mut path_with_ext = candidate.clone();
let new_ext = ext.trim_start_matches('.');
path_with_ext.set_extension(new_ext);
candidates.push(path_with_ext);
}
if candidates.is_empty() {
candidates.push(candidate);
}
candidates
}
fn ensure_executable(path: &Path) -> bool {
match path.metadata() {
Ok(metadata) => metadata.is_file() && is_executable(&metadata),
Err(_) => false,
}
}
#[cfg(unix)]
fn is_executable(metadata: &std::fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
fn is_executable(metadata: &std::fs::Metadata) -> bool {
metadata.is_file()
}
#[cfg(all(test, windows))]
mod tests {
use super::parse_command_line;
#[test]
fn parses_windows_command_with_spaces() {
let out = parse_command_line(r#""C:\Program Files\Git\bin\bash.exe" -i"#).unwrap();
assert_eq!(
out,
vec![
"C:\\Program Files\\Git\\bin\\bash.exe".to_string(),
"-i".to_string(),
]
);
}
}

View File

@@ -0,0 +1,42 @@
use serde::Deserialize;
use serde::Serialize;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::USER_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::USER_INSTRUCTIONS_OPEN_TAG;
/// Wraps user instructions in a tag so the model can classify them easily.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename = "user_instructions", rename_all = "snake_case")]
pub(crate) struct UserInstructions {
text: String,
}
impl UserInstructions {
pub fn new<T: Into<String>>(text: T) -> Self {
Self { text: text.into() }
}
/// Serializes the user instructions to an XML-like tagged block that starts
/// with <user_instructions> so clients can classify it.
pub fn serialize_to_xml(self) -> String {
format!(
"{USER_INSTRUCTIONS_OPEN_TAG}\n\n{}\n\n{USER_INSTRUCTIONS_CLOSE_TAG}",
self.text
)
}
}
impl From<UserInstructions> for ResponseItem {
fn from(ui: UserInstructions) -> Self {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: ui.serialize_to_xml(),
}],
}
}
}

View File

@@ -13,6 +13,7 @@ use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::wait_for_event;
use serde_json::json;
use std::io::Write;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
@@ -108,6 +109,138 @@ fn write_auth_json(
fake_jwt
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_includes_initial_messages_and_sends_prior_items() {
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
);
return;
}
// Create a fake rollout session file with prior user + system + assistant messages.
let tmpdir = TempDir::new().unwrap();
let session_path = tmpdir.path().join("resume-session.jsonl");
let mut f = std::fs::File::create(&session_path).unwrap();
// First line: meta (content not used by reader other than non-empty)
writeln!(
f,
"{}",
serde_json::json!({"meta":"test","instructions":"be nice"})
)
.unwrap();
// Prior item: user message (should be delivered)
let prior_user = codex_protocol::models::ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![codex_protocol::models::ContentItem::InputText {
text: "resumed user message".to_string(),
}],
};
writeln!(f, "{}", serde_json::to_string(&prior_user).unwrap()).unwrap();
// Prior item: system message (excluded from API history)
let prior_system = codex_protocol::models::ResponseItem::Message {
id: None,
role: "system".to_string(),
content: vec![codex_protocol::models::ContentItem::OutputText {
text: "resumed system instruction".to_string(),
}],
};
writeln!(f, "{}", serde_json::to_string(&prior_system).unwrap()).unwrap();
// Prior item: assistant message
let prior_item = codex_protocol::models::ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![codex_protocol::models::ContentItem::OutputText {
text: "resumed assistant message".to_string(),
}],
};
writeln!(f, "{}", serde_json::to_string(&prior_item).unwrap()).unwrap();
drop(f);
// Mock server that will receive the resumed request
let server = MockServer::start().await;
let first = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(sse_completed("resp1"), "text/event-stream");
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(first)
.expect(1)
.mount(&server)
.await;
// Configure Codex to resume from our file
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
config.experimental_resume = Some(session_path.clone());
// Also configure user instructions to ensure they are NOT delivered on resume.
config.user_instructions = Some("be nice".to_string());
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let NewConversation {
conversation: codex,
session_configured,
..
} = conversation_manager
.new_conversation(config)
.await
.expect("create new conversation");
// 1) Assert initial_messages contains the prior user + assistant messages as EventMsg entries
let initial_msgs = session_configured
.initial_messages
.clone()
.expect("expected initial messages for resumed session");
let initial_json = serde_json::to_value(&initial_msgs).unwrap();
let expected_initial_json = serde_json::json!([
{ "type": "user_message", "message": "resumed user message", "kind": "plain" },
{ "type": "agent_message", "message": "resumed assistant message" }
]);
assert_eq!(initial_json, expected_initial_json);
// 2) Submit new input; the request body must include the prior item followed by the new user input.
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "hello".into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let request = &server.received_requests().await.unwrap()[0];
let request_body = request.body_json::<serde_json::Value>().unwrap();
let expected_input = serde_json::json!([
{
"type": "message",
"role": "user",
"content": [{ "type": "input_text", "text": "resumed user message" }]
},
{
"type": "message",
"role": "assistant",
"content": [{ "type": "output_text", "text": "resumed assistant message" }]
},
{
"type": "message",
"role": "user",
"content": [{ "type": "input_text", "text": "hello" }]
}
]);
assert_eq!(request_body["input"], expected_input);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn includes_session_id_and_model_headers_in_request() {
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
@@ -360,7 +493,6 @@ async fn chatgpt_auth_sends_correct_request() {
"Bearer Access Token"
);
assert_eq!(request_chatgpt_account_id.to_str().unwrap(), "account_id");
assert!(!request_body["store"].as_bool().unwrap());
assert!(request_body["stream"].as_bool().unwrap());
assert_eq!(
request_body["include"][0].as_str().unwrap(),
@@ -442,14 +574,6 @@ async fn prefers_chatgpt_token_when_config_prefers_chatgpt() {
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// verify request body flags
let request = &server.received_requests().await.unwrap()[0];
let request_body = request.body_json::<serde_json::Value>().unwrap();
assert!(
!request_body["store"].as_bool().unwrap(),
"store should be false for ChatGPT auth"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -526,14 +650,6 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// verify request body flags
let request = &server.received_requests().await.unwrap()[0];
let request_body = request.body_json::<serde_json::Value>().unwrap();
assert!(
request_body["store"].as_bool().unwrap(),
"store should be true for API key auth"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -854,31 +970,26 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
let r3_tail_expected = serde_json::json!([
{
"type": "message",
"id": null,
"role": "user",
"content": [{"type":"input_text","text":"U1"}]
},
{
"type": "message",
"id": null,
"role": "assistant",
"content": [{"type":"output_text","text":"Hey there!\n"}]
},
{
"type": "message",
"id": null,
"role": "user",
"content": [{"type":"input_text","text":"U2"}]
},
{
"type": "message",
"id": null,
"role": "assistant",
"content": [{"type":"output_text","text":"Hey there!\n"}]
},
{
"type": "message",
"id": null,
"role": "user",
"content": [{"type":"input_text","text":"U3"}]
}

View File

@@ -191,7 +191,13 @@ async fn prompt_tools_are_consistent_across_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", "view_image"];
let expected_tools_names: &[&str] = &[
"shell",
"unified_exec",
"update_plan",
"apply_patch",
"view_image",
];
let body0 = requests[0].body_json::<serde_json::Value>().unwrap();
assert_eq!(
body0["instructions"],
@@ -289,20 +295,17 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
let expected_env_msg = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": expected_env_text } ]
});
let expected_ui_msg = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": expected_ui_text } ]
});
let expected_user_message_1 = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": "hello 1" } ]
});
@@ -314,7 +317,6 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
let expected_user_message_2 = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": "hello 2" } ]
});
@@ -424,7 +426,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
// as the prefix of the second request, ensuring cache hit potential.
let expected_user_message_2 = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": "hello 2" } ]
});
@@ -438,7 +439,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
</environment_context>"#;
let expected_env_msg_2 = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": expected_env_text_2 } ]
});
@@ -543,7 +543,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() {
// as the prefix of the second request.
let expected_user_message_2 = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": "hello 2" } ]
});

View File

@@ -515,6 +515,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
model,
history_log_id: _,
history_entry_count: _,
initial_messages: _,
} = session_configured_event;
ts_println!(
@@ -551,6 +552,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
},
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
EventMsg::ConversationHistory(_) => {}
EventMsg::UserMessage(_) => {}
}
CodexStatus::Running
}

View File

@@ -149,7 +149,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
include_plan_tool: None,
include_apply_patch_tool: None,
include_view_image_tool: None,
disable_response_storage: oss.then_some(true),
show_raw_agent_reasoning: oss.then_some(true),
tools_web_search_request: None,
};

View File

@@ -30,3 +30,7 @@ fix *args:
install:
rustup show active-toolchain
cargo fetch
# Run the MCP server
mcp-server-run *args:
cargo run -p codex-mcp-server -- "$@"

View File

@@ -1,9 +1,14 @@
use std::io::Cursor;
use std::io::Read;
use std::io::Write;
use std::io::{self};
use std::net::SocketAddr;
use std::net::TcpStream;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use crate::pkce::PkceCodes;
use crate::pkce::generate_pkce;
@@ -30,10 +35,11 @@ pub struct ServerOptions {
pub port: u16,
pub open_browser: bool,
pub force_state: Option<String>,
pub originator: String,
}
impl ServerOptions {
pub fn new(codex_home: PathBuf, client_id: String) -> Self {
pub fn new(codex_home: PathBuf, client_id: String, originator: String) -> Self {
Self {
codex_home,
client_id: client_id.to_string(),
@@ -41,6 +47,7 @@ impl ServerOptions {
port: DEFAULT_PORT,
open_browser: true,
force_state: None,
originator,
}
}
}
@@ -83,7 +90,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let pkce = generate_pkce();
let state = opts.force_state.clone().unwrap_or_else(generate_state);
let server = Server::http(format!("127.0.0.1:{}", opts.port)).map_err(io::Error::other)?;
let server = bind_server(opts.port)?;
let actual_port = match server.server_addr().to_ip() {
Some(addr) => addr.port(),
None => {
@@ -96,7 +103,14 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let server = Arc::new(server);
let redirect_uri = format!("http://localhost:{actual_port}/auth/callback");
let auth_url = build_authorize_url(&opts.issuer, &opts.client_id, &redirect_uri, &pkce, &state);
let auth_url = build_authorize_url(
&opts.issuer,
&opts.client_id,
&redirect_uri,
&pkce,
&state,
&opts.originator,
);
if opts.open_browser {
let _ = webbrowser::open(&auth_url);
@@ -136,19 +150,24 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let response =
process_request(&url_raw, &opts, &redirect_uri, &pkce, actual_port, &state).await;
let is_login_complete = matches!(response, HandledRequest::ResponseAndExit(_));
match response {
HandledRequest::Response(r) | HandledRequest::ResponseAndExit(r) => {
let _ = tokio::task::spawn_blocking(move || req.respond(r)).await;
let exit_result = match response {
HandledRequest::Response(response) => {
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
None
}
HandledRequest::ResponseAndExit { response, result } => {
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
Some(result)
}
HandledRequest::RedirectWithHeader(header) => {
let redirect = Response::empty(302).with_header(header);
let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await;
None
}
}
};
if is_login_complete {
break Ok(());
if let Some(result) = exit_result {
break result;
}
}
}
@@ -172,7 +191,10 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
enum HandledRequest {
Response(Response<Cursor<Vec<u8>>>),
RedirectWithHeader(Header),
ResponseAndExit(Response<Cursor<Vec<u8>>>),
ResponseAndExit {
response: Response<Cursor<Vec<u8>>>,
result: io::Result<()>,
},
}
async fn process_request(
@@ -267,8 +289,18 @@ async fn process_request(
) {
resp.add_header(h);
}
HandledRequest::ResponseAndExit(resp)
HandledRequest::ResponseAndExit {
response: resp,
result: Ok(()),
}
}
"/cancel" => HandledRequest::ResponseAndExit {
response: Response::from_string("Login cancelled"),
result: Err(io::Error::new(
io::ErrorKind::Interrupted,
"Login cancelled",
)),
},
_ => HandledRequest::Response(Response::from_string("Not Found").with_status_code(404)),
}
}
@@ -279,6 +311,7 @@ fn build_authorize_url(
redirect_uri: &str,
pkce: &PkceCodes,
state: &str,
originator: &str,
) -> String {
let query = vec![
("response_type", "code"),
@@ -290,6 +323,7 @@ fn build_authorize_url(
("id_token_add_organizations", "true"),
("codex_cli_simplified_flow", "true"),
("state", state),
("originator", originator),
];
let qs = query
.into_iter()
@@ -305,6 +339,68 @@ fn generate_state() -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn send_cancel_request(port: u16) -> io::Result<()> {
let addr: SocketAddr = format!("127.0.0.1:{port}")
.parse()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(2))?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
stream.write_all(b"GET /cancel HTTP/1.1\r\n")?;
stream.write_all(format!("Host: 127.0.0.1:{port}\r\n").as_bytes())?;
stream.write_all(b"Connection: close\r\n\r\n")?;
let mut buf = [0u8; 64];
let _ = stream.read(&mut buf);
Ok(())
}
fn bind_server(port: u16) -> io::Result<Server> {
let bind_address = format!("127.0.0.1:{port}");
let mut cancel_attempted = false;
let mut attempts = 0;
const MAX_ATTEMPTS: u32 = 10;
const RETRY_DELAY: Duration = Duration::from_millis(200);
loop {
match Server::http(&bind_address) {
Ok(server) => return Ok(server),
Err(err) => {
attempts += 1;
let is_addr_in_use = err
.downcast_ref::<io::Error>()
.map(|io_err| io_err.kind() == io::ErrorKind::AddrInUse)
.unwrap_or(false);
// If the address is in use, there is probably another instance of the login server
// running. Attempt to cancel it and retry.
if is_addr_in_use {
if !cancel_attempted {
cancel_attempted = true;
if let Err(cancel_err) = send_cancel_request(port) {
eprintln!("Failed to cancel previous login server: {cancel_err}");
}
}
thread::sleep(RETRY_DELAY);
if attempts >= MAX_ATTEMPTS {
return Err(io::Error::new(
io::ErrorKind::AddrInUse,
format!("Port {bind_address} is already in use"),
));
}
continue;
}
return Err(io::Error::other(err));
}
}
}
}
struct ExchangedTokens {
id_token: String,
access_token: String,

View File

@@ -1,7 +1,9 @@
#![allow(clippy::unwrap_used)]
use std::io;
use std::net::SocketAddr;
use std::net::TcpListener;
use std::thread;
use std::time::Duration;
use base64::Engine;
use codex_login::ServerOptions;
@@ -100,6 +102,7 @@ async fn end_to_end_login_flow_persists_auth_json() {
port: 0,
open_browser: false,
force_state: Some(state),
originator: "test_originator".to_string(),
};
let server = run_login_server(opts).unwrap();
let login_port = server.actual_port;
@@ -158,6 +161,7 @@ async fn creates_missing_codex_home_dir() {
port: 0,
open_browser: false,
force_state: Some(state),
originator: "test_originator".to_string(),
};
let server = run_login_server(opts).unwrap();
let login_port = server.actual_port;
@@ -175,3 +179,67 @@ async fn creates_missing_codex_home_dir() {
"auth.json should be created even if parent dir was missing"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cancels_previous_login_server_when_port_is_in_use() {
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
);
return;
}
let (issuer_addr, _issuer_handle) = start_mock_issuer();
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
let first_tmp = tempdir().unwrap();
let first_codex_home = first_tmp.path().to_path_buf();
let first_opts = ServerOptions {
codex_home: first_codex_home,
client_id: codex_login::CLIENT_ID.to_string(),
issuer: issuer.clone(),
port: 0,
open_browser: false,
force_state: Some("cancel_state".to_string()),
originator: "test_originator".to_string(),
};
let first_server = run_login_server(first_opts).unwrap();
let login_port = first_server.actual_port;
let first_server_task = tokio::spawn(async move { first_server.block_until_done().await });
tokio::time::sleep(Duration::from_millis(100)).await;
let second_tmp = tempdir().unwrap();
let second_codex_home = second_tmp.path().to_path_buf();
let second_opts = ServerOptions {
codex_home: second_codex_home,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: login_port,
open_browser: false,
force_state: Some("cancel_state_2".to_string()),
originator: "test_originator".to_string(),
};
let second_server = run_login_server(second_opts).unwrap();
assert_eq!(second_server.actual_port, login_port);
let cancel_result = first_server_task
.await
.expect("first login server task panicked")
.expect_err("login server should report cancellation");
assert_eq!(cancel_result.kind(), io::ErrorKind::Interrupted);
let client = reqwest::Client::new();
let cancel_url = format!("http://127.0.0.1:{login_port}/cancel");
let resp = client.get(cancel_url).send().await.unwrap();
assert!(resp.status().is_success());
second_server
.block_until_done()
.await
.expect_err("second login server should report cancellation");
}

View File

@@ -3,37 +3,33 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use codex_core::AuthManager;
use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::config::load_config_as_toml;
use codex_core::git_info::git_diff_to_remote;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ReviewDecision;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::GitDiffToRemoteResponse;
use mcp_types::JSONRPCErrorError;
use mcp_types::RequestId;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
use tracing::error;
use uuid::Uuid;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::json_to_toml::json_to_toml;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotification;
use codex_core::AuthManager;
use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::Cursor as RolloutCursor;
use codex_core::NewConversation;
use codex_core::RolloutRecorder;
use codex_core::auth::CLIENT_ID;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::config::load_config_as_toml;
use codex_core::exec::ExecParams;
use codex_core::exec_env::create_env;
use codex_core::get_platform_sandbox;
use codex_core::git_info::git_diff_to_remote;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::InputItem as CoreInputItem;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::run_login_server;
@@ -42,27 +38,42 @@ use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::ApplyPatchApprovalParams;
use codex_protocol::mcp_protocol::ApplyPatchApprovalResponse;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::AuthStatusChangeNotification;
use codex_protocol::mcp_protocol::ClientRequest;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::mcp_protocol::ConversationSummary;
use codex_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD;
use codex_protocol::mcp_protocol::ExecArbitraryCommandResponse;
use codex_protocol::mcp_protocol::ExecCommandApprovalParams;
use codex_protocol::mcp_protocol::ExecCommandApprovalResponse;
use codex_protocol::mcp_protocol::GetConfigTomlResponse;
use codex_protocol::mcp_protocol::ExecOneOffCommandParams;
use codex_protocol::mcp_protocol::GetUserSavedConfigResponse;
use codex_protocol::mcp_protocol::GitDiffToRemoteResponse;
use codex_protocol::mcp_protocol::InputItem as WireInputItem;
use codex_protocol::mcp_protocol::InterruptConversationParams;
use codex_protocol::mcp_protocol::InterruptConversationResponse;
use codex_protocol::mcp_protocol::ListConversationsParams;
use codex_protocol::mcp_protocol::ListConversationsResponse;
use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification;
use codex_protocol::mcp_protocol::LoginChatGptResponse;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
use codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::ResumeConversationParams;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserMessageResponse;
use codex_protocol::mcp_protocol::SendUserTurnParams;
use codex_protocol::mcp_protocol::SendUserTurnResponse;
use codex_protocol::mcp_protocol::ServerNotification;
use codex_protocol::mcp_protocol::UserSavedConfig;
use mcp_types::JSONRPCErrorError;
use mcp_types::RequestId;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
use tracing::error;
use uuid::Uuid;
// Duration before a ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
@@ -119,6 +130,12 @@ impl CodexMessageProcessor {
// created before processing any subsequent messages.
self.process_new_conversation(request_id, params).await;
}
ClientRequest::ListConversations { request_id, params } => {
self.handle_list_conversations(request_id, params).await;
}
ClientRequest::ResumeConversation { request_id, params } => {
self.handle_resume_conversation(request_id, params).await;
}
ClientRequest::SendUserMessage { request_id, params } => {
self.send_user_message(request_id, params).await;
}
@@ -149,8 +166,11 @@ impl CodexMessageProcessor {
ClientRequest::GetAuthStatus { request_id, params } => {
self.get_auth_status(request_id, params).await;
}
ClientRequest::GetConfigToml { request_id } => {
self.get_config_toml(request_id).await;
ClientRequest::GetUserSavedConfig { request_id } => {
self.get_user_saved_config(request_id).await;
}
ClientRequest::ExecOneOffCommand { request_id, params } => {
self.exec_one_off_command(request_id, params).await;
}
}
}
@@ -160,7 +180,11 @@ impl CodexMessageProcessor {
let opts = LoginServerOptions {
open_browser: false,
..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string())
..LoginServerOptions::new(
config.codex_home.clone(),
CLIENT_ID.to_string(),
config.responses_originator_header.clone(),
)
};
enum LoginChatGptReply {
@@ -360,7 +384,7 @@ impl CodexMessageProcessor {
self.outgoing.send_response(request_id, response).await;
}
async fn get_config_toml(&self, request_id: RequestId) {
async fn get_user_saved_config(&self, request_id: RequestId) {
let toml_value = match load_config_as_toml(&self.config.codex_home) {
Ok(val) => val,
Err(err) => {
@@ -387,33 +411,82 @@ impl CodexMessageProcessor {
}
};
let profiles: HashMap<String, codex_protocol::config_types::ConfigProfile> = cfg
.profiles
.into_iter()
.map(|(k, v)| {
(
k,
// Define this explicitly here to avoid the need to
// implement `From<codex_core::config_profile::ConfigProfile>`
// for the `ConfigProfile` type and introduce a dependency on codex_core
codex_protocol::config_types::ConfigProfile {
model: v.model,
approval_policy: v.approval_policy,
model_reasoning_effort: v.model_reasoning_effort,
},
)
})
.collect();
let user_saved_config: UserSavedConfig = cfg.into();
let response = GetConfigTomlResponse {
approval_policy: cfg.approval_policy,
sandbox_mode: cfg.sandbox_mode,
model_reasoning_effort: cfg.model_reasoning_effort,
profile: cfg.profile,
profiles: Some(profiles),
let response = GetUserSavedConfigResponse {
config: user_saved_config,
};
self.outgoing.send_response(request_id, response).await;
}
async fn exec_one_off_command(&self, request_id: RequestId, params: ExecOneOffCommandParams) {
tracing::debug!("ExecOneOffCommand params: {params:?}");
if params.command.is_empty() {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "command must not be empty".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
let cwd = params.cwd.unwrap_or_else(|| self.config.cwd.clone());
let env = create_env(&self.config.shell_environment_policy);
let timeout_ms = params.timeout_ms;
let exec_params = ExecParams {
command: params.command,
cwd,
timeout_ms,
env,
with_escalated_permissions: None,
justification: None,
};
self.outgoing.send_response(request_id, response).await;
let effective_policy = params
.sandbox_policy
.unwrap_or_else(|| self.config.sandbox_policy.clone());
let sandbox_type = match &effective_policy {
codex_core::protocol::SandboxPolicy::DangerFullAccess => {
codex_core::exec::SandboxType::None
}
_ => get_platform_sandbox().unwrap_or(codex_core::exec::SandboxType::None),
};
tracing::debug!("Sandbox type: {sandbox_type:?}");
let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone();
let outgoing = self.outgoing.clone();
let req_id = request_id;
tokio::spawn(async move {
match codex_core::exec::process_exec_tool_call(
exec_params,
sandbox_type,
&effective_policy,
&codex_linux_sandbox_exe,
None,
)
.await
{
Ok(output) => {
let response = ExecArbitraryCommandResponse {
exit_code: output.exit_code,
stdout: output.stdout.text,
stderr: output.stderr.text,
};
outgoing.send_response(req_id, response).await;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("exec failed: {err}"),
data: None,
};
outgoing.send_error(req_id, error).await;
}
}
});
}
async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) {
@@ -454,6 +527,128 @@ impl CodexMessageProcessor {
}
}
async fn handle_list_conversations(
&self,
request_id: RequestId,
params: ListConversationsParams,
) {
let page_size = params.page_size.unwrap_or(25);
// Decode the optional cursor string to a Cursor via serde (Cursor implements Deserialize from string)
let cursor_obj: Option<RolloutCursor> = match params.cursor {
Some(s) => serde_json::from_str::<RolloutCursor>(&format!("\"{s}\"")).ok(),
None => None,
};
let cursor_ref = cursor_obj.as_ref();
let page = match RolloutRecorder::list_conversations(
&self.config.codex_home,
page_size,
cursor_ref,
)
.await
{
Ok(p) => p,
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to list conversations: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
// Build summaries
let mut items: Vec<ConversationSummary> = Vec::new();
for it in page.items.into_iter() {
let (timestamp, preview) = extract_ts_and_preview(&it.head);
items.push(ConversationSummary {
path: it.path,
preview,
timestamp,
});
}
// Encode next_cursor as a plain string
let next_cursor = match page.next_cursor {
Some(c) => match serde_json::to_value(&c) {
Ok(serde_json::Value::String(s)) => Some(s),
_ => None,
},
None => None,
};
let response = ListConversationsResponse { items, next_cursor };
self.outgoing.send_response(request_id, response).await;
}
async fn handle_resume_conversation(
&self,
request_id: RequestId,
params: ResumeConversationParams,
) {
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = match params.overrides {
Some(overrides) => {
derive_config_from_params(overrides, self.codex_linux_sandbox_exe.clone())
}
None => Ok(self.config.as_ref().clone()),
};
let config = match config {
Ok(cfg) => cfg,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self
.conversation_manager
.resume_conversation_from_rollout(
config,
params.path.clone(),
self.auth_manager.clone(),
)
.await
{
Ok(NewConversation {
conversation_id,
session_configured,
..
}) => {
let event = codex_core::protocol::Event {
id: "".to_string(),
msg: codex_core::protocol::EventMsg::SessionConfigured(
session_configured.clone(),
),
};
self.outgoing.send_event_as_notification(&event, None).await;
// Reply with conversation id + model and initial messages (when present)
let response = codex_protocol::mcp_protocol::ResumeConversationResponse {
conversation_id: ConversationId(conversation_id),
model: session_configured.model.clone(),
initial_messages: session_configured.initial_messages.clone(),
};
self.outgoing.send_response(request_id, response).await;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("error resuming conversation: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
}
}
async fn send_user_message(&self, request_id: RequestId, params: SendUserMessageParams) {
let SendUserMessageParams {
conversation_id,
@@ -620,9 +815,9 @@ impl CodexMessageProcessor {
};
// For now, we send a notification for every event,
// JSON-serializing the `Event` as-is, but we will move
// to creating a special enum for notifications with a
// stable wire format.
// JSON-serializing the `Event` as-is, but these should
// be migrated to be variants of `ServerNotification`
// instead.
let method = format!("codex/event/{}", event.msg);
let mut params = match serde_json::to_value(event.clone()) {
Ok(serde_json::Value::Object(map)) => map,
@@ -799,7 +994,6 @@ fn derive_config_from_params(
include_plan_tool,
include_apply_patch_tool,
include_view_image_tool: None,
disable_response_storage: None,
show_raw_agent_reasoning: None,
tools_web_search_request: None,
};
@@ -890,3 +1084,38 @@ async fn on_exec_approval_response(
error!("failed to submit ExecApproval: {err}");
}
}
fn extract_ts_and_preview(head: &[serde_json::Value]) -> (Option<String>, String) {
let ts = head
.first()
.and_then(|v| v.get("timestamp"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let preview = find_first_user_text(head).unwrap_or_default();
(ts, preview)
}
fn find_first_user_text(head: &[serde_json::Value]) -> Option<String> {
use codex_core::protocol::InputMessageKind;
for v in head.iter() {
let t = v.get("type").and_then(|x| x.as_str()).unwrap_or("");
if t != "message" {
continue;
}
if v.get("role").and_then(|x| x.as_str()) != Some("user") {
continue;
}
if let Some(arr) = v.get("content").and_then(|c| c.as_array()) {
for c in arr.iter() {
if let (Some("input_text"), Some(txt)) =
(c.get("type").and_then(|t| t.as_str()), c.get("text"))
&& let Some(s) = txt.as_str()
&& matches!(InputMessageKind::from(("user", s)), InputMessageKind::Plain)
{
return Some(s.to_string());
}
}
}
}
None
}

View File

@@ -162,7 +162,6 @@ impl CodexToolCallParam {
include_plan_tool,
include_apply_patch_tool: None,
include_view_image_tool: None,
disable_response_storage: None,
show_raw_agent_reasoning: None,
tools_web_search_request: None,
};

View File

@@ -279,6 +279,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::PlanUpdate(_)
| EventMsg::TurnAborted(_)
| EventMsg::ConversationHistory(_)
| EventMsg::UserMessage(_)
| EventMsg::ShutdownComplete => {
// For now, we do not do anything extra for these
// events. Note that

View File

@@ -97,6 +97,9 @@ impl OutgoingMessageSender {
}
}
/// This is used with the MCP server, but not the more general JSON-RPC app
/// server. Prefer [`OutgoingMessageSender::send_server_notification`] where
/// possible.
pub(crate) async fn send_event_as_notification(
&self,
event: &Event,
@@ -123,14 +126,9 @@ impl OutgoingMessageSender {
}
pub(crate) async fn send_server_notification(&self, notification: ServerNotification) {
let method = format!("codex/event/{notification}");
let params = match serde_json::to_value(&notification) {
Ok(serde_json::Value::Object(mut map)) => map.remove("data"),
_ => None,
};
let outgoing_message =
OutgoingMessage::Notification(OutgoingNotification { method, params });
let _ = self.sender.send(outgoing_message);
let _ = self
.sender
.send(OutgoingMessage::AppServerNotification(notification));
}
pub(crate) async fn send_notification(&self, notification: OutgoingNotification) {
@@ -148,6 +146,9 @@ impl OutgoingMessageSender {
pub(crate) enum OutgoingMessage {
Request(OutgoingRequest),
Notification(OutgoingNotification),
/// AppServerNotification is specific to the case where this is run as an
/// "app server" as opposed to an MCP server.
AppServerNotification(ServerNotification),
Response(OutgoingResponse),
Error(OutgoingError),
}
@@ -171,6 +172,21 @@ impl From<OutgoingMessage> for JSONRPCMessage {
params,
})
}
AppServerNotification(notification) => {
let method = notification.to_string();
let params = match notification.to_params() {
Ok(params) => Some(params),
Err(err) => {
warn!("failed to serialize notification params: {err}");
None
}
};
JSONRPCMessage::Notification(JSONRPCNotification {
jsonrpc: JSONRPC_VERSION.into(),
method,
params,
})
}
Response(OutgoingResponse { id, result }) => {
JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
@@ -242,6 +258,7 @@ pub(crate) struct OutgoingError {
mod tests {
use codex_core::protocol::EventMsg;
use codex_core::protocol::SessionConfiguredEvent;
use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification;
use pretty_assertions::assert_eq;
use serde_json::json;
use uuid::Uuid;
@@ -260,6 +277,7 @@ mod tests {
model: "gpt-4o".to_string(),
history_log_id: 1,
history_entry_count: 1000,
initial_messages: None,
}),
};
@@ -289,6 +307,7 @@ mod tests {
model: "gpt-4o".to_string(),
history_log_id: 1,
history_entry_count: 1000,
initial_messages: None,
};
let event = Event {
id: "1".to_string(),
@@ -322,4 +341,29 @@ mod tests {
});
assert_eq!(params.unwrap(), expected_params);
}
#[test]
fn verify_server_notification_serialization() {
let notification =
ServerNotification::LoginChatGptComplete(LoginChatGptCompleteNotification {
login_id: Uuid::nil(),
success: true,
error: None,
});
let jsonrpc_notification: JSONRPCMessage =
OutgoingMessage::AppServerNotification(notification).into();
assert_eq!(
JSONRPCMessage::Notification(JSONRPCNotification {
jsonrpc: "2.0".into(),
method: "loginChatGptComplete".into(),
params: Some(json!({
"loginId": Uuid::nil(),
"success": true,
})),
}),
jsonrpc_notification,
"ensure the strum macros serialize the method field correctly"
);
}
}

View File

@@ -16,8 +16,10 @@ 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::ListConversationsParams;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
use codex_protocol::mcp_protocol::ResumeConversationParams;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserTurnParams;
@@ -240,9 +242,27 @@ impl McpProcess {
self.send_request("getAuthStatus", params).await
}
/// Send a `getConfigToml` JSON-RPC request.
pub async fn send_get_config_toml_request(&mut self) -> anyhow::Result<i64> {
self.send_request("getConfigToml", None).await
/// Send a `getUserSavedConfig` JSON-RPC request.
pub async fn send_get_user_saved_config_request(&mut self) -> anyhow::Result<i64> {
self.send_request("getUserSavedConfig", None).await
}
/// Send a `listConversations` JSON-RPC request.
pub async fn send_list_conversations_request(
&mut self,
params: ListConversationsParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("listConversations", params).await
}
/// Send a `resumeConversation` JSON-RPC request.
pub async fn send_resume_conversation_request(
&mut self,
params: ResumeConversationParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("resumeConversation", params).await
}
/// Send a `loginChatGpt` JSON-RPC request.

View File

@@ -2,10 +2,15 @@ use std::collections::HashMap;
use std::path::Path;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::ConfigProfile;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::mcp_protocol::GetConfigTomlResponse;
use codex_protocol::config_types::Verbosity;
use codex_protocol::mcp_protocol::GetUserSavedConfigResponse;
use codex_protocol::mcp_protocol::Profile;
use codex_protocol::mcp_protocol::SandboxSettings;
use codex_protocol::mcp_protocol::Tools;
use codex_protocol::mcp_protocol::UserSavedConfig;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
@@ -21,22 +26,38 @@ fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
std::fs::write(
config_toml,
r#"
model = "gpt-5"
approval_policy = "on-request"
sandbox_mode = "workspace-write"
model_reasoning_summary = "detailed"
model_reasoning_effort = "high"
model_verbosity = "medium"
profile = "test"
[sandbox_workspace_write]
writable_roots = ["/tmp"]
network_access = true
exclude_tmpdir_env_var = true
exclude_slash_tmp = true
[tools]
web_search = false
view_image = true
[profiles.test]
model = "gpt-4o"
approval_policy = "on-request"
model_reasoning_effort = "high"
model_reasoning_summary = "detailed"
model_verbosity = "medium"
model_provider = "openai"
chatgpt_base_url = "https://api.chatgpt.com"
"#,
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_config_toml_returns_subset() {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn get_config_toml_parses_all_fields() {
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(codex_home.path()).expect("write config.toml");
@@ -49,32 +70,94 @@ async fn get_config_toml_returns_subset() {
.expect("init failed");
let request_id = mcp
.send_get_config_toml_request()
.send_get_user_saved_config_request()
.await
.expect("send getConfigToml");
.expect("send getUserSavedConfig");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getConfigToml timeout")
.expect("getConfigToml response");
.expect("getUserSavedConfig timeout")
.expect("getUserSavedConfig response");
let config: GetConfigTomlResponse = to_response(resp).expect("deserialize config");
let expected = GetConfigTomlResponse {
approval_policy: Some(AskForApproval::OnRequest),
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
model_reasoning_effort: Some(ReasoningEffort::High),
profile: Some("test".to_string()),
profiles: Some(HashMap::from([(
"test".into(),
ConfigProfile {
model: Some("gpt-4o".into()),
approval_policy: Some(AskForApproval::OnRequest),
model_reasoning_effort: Some(ReasoningEffort::High),
},
)])),
let config: GetUserSavedConfigResponse = to_response(resp).expect("deserialize config");
let expected = GetUserSavedConfigResponse {
config: UserSavedConfig {
approval_policy: Some(AskForApproval::OnRequest),
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
sandbox_settings: Some(SandboxSettings {
writable_roots: vec!["/tmp".into()],
network_access: Some(true),
exclude_tmpdir_env_var: Some(true),
exclude_slash_tmp: Some(true),
}),
model: Some("gpt-5".into()),
model_reasoning_effort: Some(ReasoningEffort::High),
model_reasoning_summary: Some(ReasoningSummary::Detailed),
model_verbosity: Some(Verbosity::Medium),
tools: Some(Tools {
web_search: Some(false),
view_image: Some(true),
}),
profile: Some("test".to_string()),
profiles: HashMap::from([(
"test".into(),
Profile {
model: Some("gpt-4o".into()),
approval_policy: Some(AskForApproval::OnRequest),
model_reasoning_effort: Some(ReasoningEffort::High),
model_reasoning_summary: Some(ReasoningSummary::Detailed),
model_verbosity: Some(Verbosity::Medium),
model_provider: Some("openai".into()),
chatgpt_base_url: Some("https://api.chatgpt.com".into()),
},
)]),
},
};
assert_eq!(expected, config);
assert_eq!(config, expected);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_config_toml_empty() {
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
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_user_saved_config_request()
.await
.expect("send getUserSavedConfig");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getUserSavedConfig timeout")
.expect("getUserSavedConfig response");
let config: GetUserSavedConfigResponse = to_response(resp).expect("deserialize config");
let expected = GetUserSavedConfigResponse {
config: UserSavedConfig {
approval_policy: None,
sandbox_mode: None,
sandbox_settings: None,
model: None,
model_reasoning_effort: None,
model_reasoning_summary: None,
model_verbosity: None,
tools: None,
profile: None,
profiles: HashMap::new(),
},
};
assert_eq!(config, expected);
}

View File

@@ -0,0 +1,172 @@
use std::fs;
use std::path::Path;
use codex_protocol::mcp_protocol::ListConversationsParams;
use codex_protocol::mcp_protocol::ListConversationsResponse;
use codex_protocol::mcp_protocol::NewConversationParams; // reused for overrides shape
use codex_protocol::mcp_protocol::ResumeConversationParams;
use codex_protocol::mcp_protocol::ResumeConversationResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
use uuid::Uuid;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_list_and_resume_conversations() {
// Prepare a temporary CODEX_HOME with a few fake rollout files.
let codex_home = TempDir::new().expect("create temp dir");
create_fake_rollout(
codex_home.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"Hello A",
);
create_fake_rollout(
codex_home.path(),
"2025-01-01T13-00-00",
"2025-01-01T13:00:00Z",
"Hello B",
);
create_fake_rollout(
codex_home.path(),
"2025-01-01T12-00-00",
"2025-01-01T12:00:00Z",
"Hello C",
);
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");
// Request first page with size 2
let req_id = mcp
.send_list_conversations_request(ListConversationsParams {
page_size: Some(2),
cursor: None,
})
.await
.expect("send listConversations");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await
.expect("listConversations timeout")
.expect("listConversations resp");
let ListConversationsResponse { items, next_cursor } =
to_response::<ListConversationsResponse>(resp).expect("deserialize response");
assert_eq!(items.len(), 2);
// Newest first; preview text should match
assert_eq!(items[0].preview, "Hello A");
assert_eq!(items[1].preview, "Hello B");
assert!(items[0].path.is_absolute());
assert!(next_cursor.is_some());
// Request the next page using the cursor
let req_id2 = mcp
.send_list_conversations_request(ListConversationsParams {
page_size: Some(2),
cursor: next_cursor,
})
.await
.expect("send listConversations page 2");
let resp2: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id2)),
)
.await
.expect("listConversations page 2 timeout")
.expect("listConversations page 2 resp");
let ListConversationsResponse {
items: items2,
next_cursor: next2,
..
} = to_response::<ListConversationsResponse>(resp2).expect("deserialize response");
assert_eq!(items2.len(), 1);
assert_eq!(items2[0].preview, "Hello C");
assert!(next2.is_some());
// Now resume one of the sessions and expect a SessionConfigured notification and response.
let resume_req_id = mcp
.send_resume_conversation_request(ResumeConversationParams {
path: items[0].path.clone(),
overrides: Some(NewConversationParams {
model: Some("o3".to_string()),
..Default::default()
}),
})
.await
.expect("send resumeConversation");
// Expect a codex/event notification with msg.type == session_configured
let notification: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event"),
)
.await
.expect("session_configured notification timeout")
.expect("session_configured notification");
// Basic shape assertion: ensure event type is session_configured
let msg_type = notification
.params
.as_ref()
.and_then(|p| p.get("msg"))
.and_then(|m| m.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("");
assert_eq!(msg_type, "session_configured");
// Then the response for resumeConversation
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_req_id)),
)
.await
.expect("resumeConversation timeout")
.expect("resumeConversation resp");
let ResumeConversationResponse {
conversation_id, ..
} = to_response::<ResumeConversationResponse>(resume_resp)
.expect("deserialize resumeConversation response");
// conversation id should be a valid UUID
let _ = uuid::Uuid::from_bytes(conversation_id.0.into_bytes());
}
fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, preview: &str) {
let uuid = Uuid::new_v4();
// sessions/YYYY/MM/DD/ derived from filename_ts (YYYY-MM-DDThh-mm-ss)
let year = &filename_ts[0..4];
let month = &filename_ts[5..7];
let day = &filename_ts[8..10];
let dir = codex_home.join("sessions").join(year).join(month).join(day);
fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("create sessions dir: {e}"));
let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl"));
let mut lines = Vec::new();
// Meta line with timestamp
lines.push(json!({"timestamp": meta_rfc3339}).to_string());
// Minimal user message entry as a persisted response item
lines.push(
json!({
"type":"message",
"role":"user",
"content":[{"type":"input_text","text": preview}]
})
.to_string(),
);
fs::write(file_path, lines.join("\n") + "\n")
.unwrap_or_else(|e| panic!("write rollout file: {e}"));
}

View File

@@ -5,5 +5,6 @@ mod codex_tool;
mod config;
mod create_conversation;
mod interrupt;
mod list_resume;
mod login;
mod send_message;

View File

@@ -46,6 +46,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GetUserSavedConfigResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?;
generate_index_ts(out_dir)?;

View File

@@ -17,6 +17,7 @@ mime_guess = "2.0.5"
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
serde_json = "1"
serde_with = { version = "3.14.0", features = ["macros", "base64"] }
strum = "0.27.2"
strum_macros = "0.27.2"
tracing = "0.1.41"

View File

@@ -4,8 +4,6 @@ use strum_macros::Display;
use strum_macros::EnumIter;
use ts_rs::TS;
use crate::protocol::AskForApproval;
/// 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, TS, EnumIter,
@@ -37,7 +35,7 @@ pub enum ReasoningSummary {
/// 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)]
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum Verbosity {
@@ -61,13 +59,3 @@ pub enum SandboxMode {
#[serde(rename = "danger-full-access")]
DangerFullAccess,
}
/// Collection of common configuration options that a user can define as a unit
/// in `config.toml`. Currently only a subset of the fields are supported.
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct ConfigProfile {
pub model: Option<String>,
pub approval_policy: Option<AskForApproval>,
pub model_reasoning_effort: Option<ReasoningEffort>,
}

View File

@@ -2,11 +2,12 @@ use std::collections::HashMap;
use std::fmt::Display;
use std::path::PathBuf;
use crate::config_types::ConfigProfile;
use crate::config_types::ReasoningEffort;
use crate::config_types::ReasoningSummary;
use crate::config_types::SandboxMode;
use crate::config_types::Verbosity;
use crate::protocol::AskForApproval;
use crate::protocol::EventMsg;
use crate::protocol::FileChange;
use crate::protocol::ReviewDecision;
use crate::protocol::SandboxPolicy;
@@ -54,6 +55,18 @@ pub enum ClientRequest {
request_id: RequestId,
params: NewConversationParams,
},
/// List recorded Codex conversations (rollouts) with optional pagination and search.
ListConversations {
#[serde(rename = "id")]
request_id: RequestId,
params: ListConversationsParams,
},
/// Resume a recorded Codex conversation from a rollout file.
ResumeConversation {
#[serde(rename = "id")]
request_id: RequestId,
params: ResumeConversationParams,
},
SendUserMessage {
#[serde(rename = "id")]
request_id: RequestId,
@@ -102,10 +115,16 @@ pub enum ClientRequest {
request_id: RequestId,
params: GetAuthStatusParams,
},
GetConfigToml {
GetUserSavedConfig {
#[serde(rename = "id")]
request_id: RequestId,
},
/// Execute a command (argv vector) under the server's sandbox.
ExecOneOffCommand {
#[serde(rename = "id")]
request_id: RequestId,
params: ExecOneOffCommandParams,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)]
@@ -158,6 +177,56 @@ pub struct NewConversationResponse {
pub model: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ResumeConversationResponse {
pub conversation_id: ConversationId,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_messages: Option<Vec<EventMsg>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)]
#[serde(rename_all = "camelCase")]
pub struct ListConversationsParams {
/// Optional page size; defaults to a reasonable server-side value.
#[serde(skip_serializing_if = "Option::is_none")]
pub page_size: Option<usize>,
/// Opaque pagination cursor returned by a previous call.
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct ConversationSummary {
pub path: PathBuf,
pub preview: String,
/// RFC3339 timestamp string for the session start, if available.
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct ListConversationsResponse {
pub items: Vec<ConversationSummary>,
/// Opaque cursor to pass to the next call to continue after the last item.
/// if None, there are no more items to return.
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct ResumeConversationParams {
/// Absolute path to the rollout JSONL file.
pub path: PathBuf,
/// Optional overrides to apply when spawning the resumed session.
#[serde(skip_serializing_if = "Option::is_none")]
pub overrides: Option<NewConversationParams>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct AddConversationSubscriptionResponse {
@@ -218,6 +287,30 @@ pub struct GetAuthStatusParams {
pub refresh_token: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct ExecOneOffCommandParams {
/// Command argv to execute.
pub command: Vec<String>,
/// Timeout of the command in milliseconds.
/// If not specified, a sensible default is used server-side.
pub timeout_ms: Option<u64>,
/// Optional working directory for the process. Defaults to server config cwd.
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
/// Optional explicit sandbox policy overriding the server default.
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_policy: Option<SandboxPolicy>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct ExecArbitraryCommandResponse {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct GetAuthStatusResponse {
@@ -230,22 +323,81 @@ pub struct GetAuthStatusResponse {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct GetConfigTomlResponse {
pub struct GetUserSavedConfigResponse {
pub config: UserSavedConfig,
}
/// UserSavedConfig contains a subset of the config. It is meant to expose mcp
/// client-configurable settings that can be specified in the NewConversation
/// and SendUserTurn requests.
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct UserSavedConfig {
/// Approvals
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<AskForApproval>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_mode: Option<SandboxMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_settings: Option<SandboxSettings>,
/// Relevant model configuration
/// Model-specific configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_reasoning_effort: Option<ReasoningEffort>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_reasoning_summary: Option<ReasoningSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_verbosity: Option<Verbosity>,
/// Tools
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Tools>,
/// Profiles
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(default)]
pub profiles: HashMap<String, Profile>,
}
/// MCP representation of a [`codex_core::config_profile::ConfigProfile`].
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
pub model: Option<String>,
/// The key in the `model_providers` map identifying the
/// [`ModelProviderInfo`] to use.
pub model_provider: Option<String>,
pub approval_policy: Option<AskForApproval>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
pub chatgpt_base_url: Option<String>,
}
/// MCP representation of a [`codex_core::config::ToolsToml`].
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct Tools {
#[serde(skip_serializing_if = "Option::is_none")]
pub profiles: Option<HashMap<String, ConfigProfile>>,
pub web_search: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub view_image: Option<bool>,
}
/// MCP representation of a [`codex_core::config_types::SandboxWorkspaceWrite`].
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct SandboxSettings {
#[serde(default)]
pub writable_roots: Vec<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exclude_tmpdir_env_var: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exclude_slash_tmp: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
@@ -398,8 +550,8 @@ pub struct AuthStatusChangeNotification {
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS, Display)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
#[serde(tag = "method", content = "params", rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum ServerNotification {
/// Authentication status changed
AuthStatusChange(AuthStatusChangeNotification),
@@ -408,6 +560,15 @@ pub enum ServerNotification {
LoginChatGptComplete(LoginChatGptCompleteNotification),
}
impl ServerNotification {
pub fn to_params(self) -> Result<serde_json::Value, serde_json::Error> {
match self {
ServerNotification::AuthStatusChange(params) => serde_json::to_value(params),
ServerNotification::LoginChatGptComplete(params) => serde_json::to_value(params),
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -42,11 +42,13 @@ pub enum ContentItem {
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseItem {
Message {
#[serde(skip_serializing)]
id: Option<String>,
role: String,
content: Vec<ContentItem>,
},
Reasoning {
#[serde(default)]
id: String,
summary: Vec<ReasoningItemReasoningSummary>,
#[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
@@ -55,6 +57,7 @@ pub enum ResponseItem {
},
LocalShellCall {
/// Set when using the chat completions API.
#[serde(skip_serializing)]
id: Option<String>,
/// Set when using the Responses API.
call_id: Option<String>,
@@ -62,6 +65,7 @@ pub enum ResponseItem {
action: LocalShellAction,
},
FunctionCall {
#[serde(skip_serializing)]
id: Option<String>,
name: String,
// The Responses API returns the function call arguments as a *string* that contains
@@ -82,7 +86,7 @@ pub enum ResponseItem {
output: FunctionCallOutputPayload,
},
CustomToolCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(skip_serializing)]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<String>,
@@ -104,12 +108,25 @@ pub enum ResponseItem {
// "action": {"type":"search","query":"weather: San Francisco, CA"}
// }
WebSearchCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(skip_serializing)]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<String>,
action: WebSearchAction,
},
UnifiedExec {
#[serde(skip_serializing)]
id: Option<String>,
/// Identifier for the interactive shell session. When absent, a new
/// session should be created.
session_id: Option<String>,
/// Characters that should be written to the interactive shell's
/// standard input.
arguments: Vec<String>,
/// Maximum amount of time to wait for additional output after writing
/// to stdin.
timeout_ms: Option<u64>,
},
#[serde(other)]
Other,
@@ -309,6 +326,8 @@ impl std::ops::Deref for FunctionCallOutputPayload {
}
}
// (Moved event mapping logic into codex-core to avoid coupling protocol to UI-facing events.)
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -15,7 +15,7 @@ use mcp_types::CallToolResult;
use mcp_types::Tool as McpTool;
use serde::Deserialize;
use serde::Serialize;
use serde_bytes::ByteBuf;
use serde_with::serde_as;
use strum_macros::Display;
use ts_rs::TS;
use uuid::Uuid;
@@ -27,6 +27,13 @@ use crate::models::ResponseItem;
use crate::parse_command::ParsedCommand;
use crate::plan_tool::UpdatePlanArgs;
/// Open/close tags for special user-input blocks. Used across crates to avoid
/// duplicated hardcoded strings.
pub const USER_INSTRUCTIONS_OPEN_TAG: &str = "<user_instructions>";
pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = "</user_instructions>";
pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = "<environment_context>";
pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = "</environment_context>";
/// Submission Queue Entry - requests from user
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Submission {
@@ -417,6 +424,9 @@ pub enum EventMsg {
/// Agent text output message
AgentMessage(AgentMessageEvent),
/// User/system input message (what was sent to the model)
UserMessage(UserMessageEvent),
/// Agent text output delta message
AgentMessageDelta(AgentMessageDeltaEvent),
@@ -517,6 +527,9 @@ pub struct TokenUsage {
pub total_tokens: u64,
}
// Includes prompts, tools and space to call compact.
const BASELINE_TOKENS: u64 = 12000;
impl TokenUsage {
pub fn is_zero(&self) -> bool {
self.total_tokens == 0
@@ -547,26 +560,22 @@ impl TokenUsage {
/// Estimate the remaining user-controllable percentage of the model's context window.
///
/// `context_window` is the total size of the model's context window.
/// `baseline_used_tokens` should capture tokens that are always present in
/// `BASELINE_TOKENS` should capture tokens that are always present in
/// the context (e.g., system prompt and fixed tool instructions) so that
/// the percentage reflects the portion the user can influence.
///
/// This normalizes both the numerator and denominator by subtracting the
/// baseline, so immediately after the first prompt the UI shows 100% left
/// and trends toward 0% as the user fills the effective window.
pub fn percent_of_context_window_remaining(
&self,
context_window: u64,
baseline_used_tokens: u64,
) -> u8 {
if context_window <= baseline_used_tokens {
pub fn percent_of_context_window_remaining(&self, context_window: u64) -> u8 {
if context_window <= BASELINE_TOKENS {
return 0;
}
let effective_window = context_window - baseline_used_tokens;
let effective_window = context_window - BASELINE_TOKENS;
let used = self
.tokens_in_context_window()
.saturating_sub(baseline_used_tokens);
.saturating_sub(BASELINE_TOKENS);
let remaining = effective_window.saturating_sub(used);
((remaining as f32 / effective_window as f32) * 100.0).clamp(0.0, 100.0) as u8
}
@@ -610,6 +619,47 @@ pub struct AgentMessageEvent {
pub message: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InputMessageKind {
/// Plain user text (default)
Plain,
/// XML-wrapped user instructions (<user_instructions>...)
UserInstructions,
/// XML-wrapped environment context (<environment_context>...)
EnvironmentContext,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UserMessageEvent {
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<InputMessageKind>,
}
impl<T, U> From<(T, U)> for InputMessageKind
where
T: AsRef<str>,
U: AsRef<str>,
{
fn from(value: (T, U)) -> Self {
let (_role, message) = value;
let message = message.as_ref();
let trimmed = message.trim();
if trimmed.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG)
&& trimmed.ends_with(ENVIRONMENT_CONTEXT_CLOSE_TAG)
{
InputMessageKind::EnvironmentContext
} else if trimmed.starts_with(USER_INSTRUCTIONS_OPEN_TAG)
&& trimmed.ends_with(USER_INSTRUCTIONS_CLOSE_TAG)
{
InputMessageKind::UserInstructions
} else {
InputMessageKind::Plain
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentMessageDeltaEvent {
pub delta: String,
@@ -723,22 +773,23 @@ pub struct ExecCommandEndEvent {
pub formatted_output: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ExecOutputStream {
Stdout,
Stderr,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde_as]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct ExecCommandOutputDeltaEvent {
/// Identifier for the ExecCommandBegin that produced this chunk.
pub call_id: String,
/// Which stream produced this chunk.
pub stream: ExecOutputStream,
/// Raw bytes from the stream (may not be valid UTF-8).
#[serde(with = "serde_bytes")]
pub chunk: ByteBuf,
#[serde_as(as = "serde_with::base64::Base64")]
pub chunk: Vec<u8>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -839,6 +890,11 @@ pub struct SessionConfiguredEvent {
/// Current number of entries in the history log.
pub history_entry_count: usize,
/// Optional initial messages (as events) for resumed sessions.
/// When present, UIs can use these to seed the history.
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_messages: Option<Vec<EventMsg>>,
}
/// User's decision in response to an ExecApprovalRequest.
@@ -914,6 +970,7 @@ mod tests {
model: "codex-mini-latest".to_string(),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
}),
};
let serialized = serde_json::to_string(&event).unwrap();
@@ -922,4 +979,21 @@ mod tests {
r#"{"id":"1234","msg":{"type":"session_configured","session_id":"67e55044-10b1-426f-9247-bb680e5fe0c8","model":"codex-mini-latest","history_log_id":0,"history_entry_count":0}}"#
);
}
#[test]
fn vec_u8_as_base64_serialization_and_deserialization() {
let event = ExecCommandOutputDeltaEvent {
call_id: "call21".to_string(),
stream: ExecOutputStream::Stdout,
chunk: vec![1, 2, 3, 4, 5],
};
let serialized = serde_json::to_string(&event).unwrap();
assert_eq!(
r#"{"call_id":"call21","stream":"stdout","chunk":"AQIDBAU="}"#,
serialized,
);
let deserialized: ExecCommandOutputDeltaEvent = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized, event);
}
}

View File

@@ -4,6 +4,7 @@ use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget;
use crate::file_search::FileSearchManager;
use crate::pager_overlay::Overlay;
use crate::resume_picker::ResumeSelection;
use crate::tui;
use crate::tui::TuiEvent;
use codex_ansi_escape::ansi_escape_line;
@@ -12,6 +13,7 @@ use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::protocol::TokenUsage;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -61,6 +63,7 @@ impl App {
config: Config,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
resume_selection: ResumeSelection,
) -> Result<TokenUsage> {
use tokio_stream::StreamExt;
let (app_event_tx, mut app_event_rx) = unbounded_channel();
@@ -70,15 +73,44 @@ impl App {
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
let chat_widget = ChatWidget::new(
config.clone(),
conversation_manager.clone(),
tui.frame_requester(),
app_event_tx.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
);
let chat_widget = match resume_selection {
ResumeSelection::StartFresh | ResumeSelection::Exit => {
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
initial_prompt: initial_prompt.clone(),
initial_images: initial_images.clone(),
enhanced_keys_supported,
};
ChatWidget::new(init, conversation_manager.clone())
}
ResumeSelection::Resume(path) => {
let resumed = conversation_manager
.resume_conversation_from_rollout(
config.clone(),
path.clone(),
auth_manager.clone(),
)
.await
.wrap_err_with(|| {
format!("Failed to resume session from {}", path.display())
})?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
initial_prompt: initial_prompt.clone(),
initial_images: initial_images.clone(),
enhanced_keys_supported,
};
ChatWidget::new_from_existing(
init,
resumed.conversation,
resumed.session_configured,
)
}
};
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
@@ -151,15 +183,6 @@ impl App {
},
)?;
}
TuiEvent::AttachImage {
path,
width,
height,
format_label,
} => {
self.chat_widget
.attach_image(path, width, height, format_label);
}
}
}
Ok(true)
@@ -168,15 +191,15 @@ impl App {
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
match event {
AppEvent::NewSession => {
self.chat_widget = ChatWidget::new(
self.config.clone(),
self.server.clone(),
tui.frame_requester(),
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
);
let init = crate::chatwidget::ChatWidgetInit {
config: self.config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
initial_prompt: None,
initial_images: Vec::new(),
enhanced_keys_supported: self.enhanced_keys_supported,
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
tui.frame_requester().schedule_frame();
}
AppEvent::InsertHistoryCell(cell) => {

View File

@@ -319,14 +319,16 @@ impl App {
) {
let conv = new_conv.conversation;
let session_configured = new_conv.session_configured;
self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(
cfg,
conv,
session_configured,
tui.frame_requester(),
self.app_event_tx.clone(),
self.enhanced_keys_supported,
);
let init = crate::chatwidget::ChatWidgetInit {
config: cfg,
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
initial_prompt: None,
initial_images: Vec::new(),
enhanced_keys_supported: self.enhanced_keys_supported,
};
self.chat_widget =
crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured);
// Trim transcript up to the selected user message and re-render it.
self.trim_transcript_for_backtrack(drop_count);
self.render_transcript_once(tui);

View File

@@ -12,7 +12,7 @@ pub(crate) fn highlight_range_for_nth_last_user(
/// Compute the wrapped display-line offset before `header_idx`, for a given width.
pub(crate) fn wrapped_offset_before(lines: &[Line<'_>], header_idx: usize, width: u16) -> usize {
let before = &lines[0..header_idx];
crate::insert_history::word_wrap_lines(before, width).len()
crate::wrapping::word_wrap_lines(before, width as usize).len()
}
/// Find the header index for the Nth last user message in the transcript.

View File

@@ -11,7 +11,6 @@ use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -37,6 +36,7 @@ use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use crate::clipboard_paste::normalize_pasted_path;
use crate::clipboard_paste::pasted_image_format;
use crate::key_hint;
use codex_file_search::FileMatch;
use std::cell::RefCell;
use std::collections::HashMap;
@@ -67,15 +67,6 @@ struct TokenUsageInfo {
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
model_context_window: Option<u64>,
/// Baseline token count present in the context before the user's first
/// message content is considered. This is used to normalize the
/// "context left" percentage so it reflects the portion the user can
/// influence rather than fixed prompt overhead (system prompt, tool
/// instructions, etc.).
///
/// Preferred source is `cached_input_tokens` from the first turn (when
/// available), otherwise we fall back to 0.
initial_prompt_tokens: u64,
}
pub(crate) struct ChatComposer {
@@ -181,17 +172,10 @@ impl ChatComposer {
last_token_usage: TokenUsage,
model_context_window: Option<u64>,
) {
let initial_prompt_tokens = self
.token_usage_info
.as_ref()
.map(|info| info.initial_prompt_tokens)
.unwrap_or_else(|| last_token_usage.cached_input_tokens.unwrap_or(0));
self.token_usage_info = Some(TokenUsageInfo {
total_token_usage,
last_token_usage,
model_context_window,
initial_prompt_tokens,
});
}
@@ -1259,35 +1243,35 @@ impl WidgetRef for ChatComposer {
}
ActivePopup::None => {
let bottom_line_rect = popup_rect;
let key_hint_style = Style::default().fg(Color::Cyan);
let mut hint = if self.ctrl_c_quit_hint {
let mut hint: Vec<Span<'static>> = if self.ctrl_c_quit_hint {
vec![
" ".into(),
"Ctrl+C again".set_style(key_hint_style),
key_hint::ctrl('C'),
" again".into(),
" to quit".into(),
]
} else {
let newline_hint_key = if self.use_shift_enter_hint {
"Shift+⏎"
key_hint::shift('⏎')
} else {
"Ctrl+J"
key_hint::ctrl('J')
};
vec![
" ".into(),
"".set_style(key_hint_style),
key_hint::plain('⏎'),
" send ".into(),
newline_hint_key.set_style(key_hint_style),
newline_hint_key,
" newline ".into(),
"Ctrl+T".set_style(key_hint_style),
key_hint::ctrl('T'),
" transcript ".into(),
"Ctrl+C".set_style(key_hint_style),
key_hint::ctrl('C'),
" quit".into(),
]
};
if !self.ctrl_c_quit_hint && self.esc_backtrack_hint {
hint.push(" ".into());
hint.push("Esc".set_style(key_hint_style));
hint.push(key_hint::plain("Esc"));
hint.push(" edit prev".into());
}
@@ -1302,10 +1286,7 @@ impl WidgetRef for ChatComposer {
let last_token_usage = &token_usage_info.last_token_usage;
if let Some(context_window) = token_usage_info.model_context_window {
let percent_remaining: u8 = if context_window > 0 {
last_token_usage.percent_of_context_window_remaining(
context_window,
token_usage_info.initial_prompt_tokens,
)
last_token_usage.percent_of_context_window_remaining(context_window)
} else {
100
};
@@ -1635,7 +1616,6 @@ mod tests {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
@@ -1687,13 +1667,12 @@ mod tests {
.draw(|f| f.render_widget_ref(composer, f.area()))
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
assert_snapshot!(name, terminal.backend());
insta::assert_snapshot!(name, terminal.backend());
}
}
#[test]
fn slash_popup_model_first_for_mo_ui() {
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
@@ -1720,7 +1699,7 @@ mod tests {
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
// Visual snapshot should show the slash popup with /model as the first entry.
assert_snapshot!("slash_popup_mo", terminal.backend());
insta::assert_snapshot!("slash_popup_mo", terminal.backend());
}
#[test]

View File

@@ -162,6 +162,8 @@ impl BottomPane {
view.handle_key_event(self, key_event);
if !view.is_complete() {
self.active_view = Some(view);
} else {
self.on_active_view_complete();
}
self.request_redraw();
InputResult::None
@@ -201,6 +203,8 @@ impl BottomPane {
CancellationEvent::Handled => {
if !view.is_complete() {
self.active_view = Some(view);
} else {
self.on_active_view_complete();
}
self.show_ctrl_c_quit_hint();
}
@@ -381,10 +385,27 @@ impl BottomPane {
// Otherwise create a new approval modal overlay.
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
self.pause_status_timer_for_modal();
self.active_view = Some(Box::new(modal));
self.request_redraw()
}
fn on_active_view_complete(&mut self) {
self.resume_status_timer_after_modal();
}
fn pause_status_timer_for_modal(&mut self) {
if let Some(status) = self.status.as_mut() {
status.pause_timer();
}
}
fn resume_status_timer_after_modal(&mut self) {
if let Some(status) = self.status.as_mut() {
status.resume_timer();
}
}
/// Height (terminal rows) required by the current bottom pane.
pub(crate) fn request_redraw(&self) {
self.frame_requester.schedule_frame();

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send J newline T transcript C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send J newline T transcript C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send J newline T transcript C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send J newline T transcript C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send J newline T transcript C quit "

View File

@@ -832,25 +832,10 @@ impl TextArea {
None => true,
};
if needs_recalc {
let mut lines: Vec<Range<usize>> = Vec::new();
for line in textwrap::wrap(
let lines = crate::wrapping::wrap_ranges(
&self.text,
Options::new(width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
)
.iter()
{
match line {
std::borrow::Cow::Borrowed(slice) => {
let start =
unsafe { slice.as_ptr().offset_from(self.text.as_ptr()) as usize };
let end = start + slice.len();
let trailing_spaces =
self.text[end..].chars().take_while(|c| *c == ' ').count();
lines.push(start..end + trailing_spaces + 1);
}
std::borrow::Cow::Owned(_) => unreachable!(),
}
}
);
*cache = Some(WrapCache { width, lines });
}
}

View File

@@ -19,6 +19,7 @@ use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::InputItem;
use codex_core::protocol::InputMessageKind;
use codex_core::protocol::ListCustomPromptsResponseEvent;
use codex_core::protocol::McpListToolsResponseEvent;
use codex_core::protocol::McpToolCallBeginEvent;
@@ -30,6 +31,7 @@ use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TurnAbortReason;
use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::UserMessageEvent;
use codex_core::protocol::WebSearchBeginEvent;
use codex_core::protocol::WebSearchEndEvent;
use codex_protocol::parse_command::ParsedCommand;
@@ -55,6 +57,7 @@ use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::get_git_diff::get_git_diff;
use crate::history_cell;
use crate::history_cell::CommandOutput;
@@ -89,6 +92,16 @@ struct RunningCommand {
parsed_cmd: Vec<ParsedCommand>,
}
/// Common initialization parameters shared by all `ChatWidget` constructors.
pub(crate) struct ChatWidgetInit {
pub(crate) config: Config,
pub(crate) frame_requester: FrameRequester,
pub(crate) app_event_tx: AppEventSender,
pub(crate) initial_prompt: Option<String>,
pub(crate) initial_images: Vec<PathBuf>,
pub(crate) enhanced_keys_supported: bool,
}
pub(crate) struct ChatWidget {
app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender<Op>,
@@ -112,6 +125,9 @@ pub(crate) struct ChatWidget {
frame_requester: FrameRequester,
// Whether to include the initial welcome banner on session configured
show_welcome_banner: bool,
// When resuming an existing session (selected via resume picker), avoid an
// immediate redraw on SessionConfigured to prevent a gratuitous UI flicker.
suppress_session_configured_redraw: bool,
// User messages queued while a turn is in progress
queued_user_messages: VecDeque<UserMessage>,
}
@@ -148,6 +164,10 @@ impl ChatWidget {
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
self.session_id = Some(event.session_id);
let initial_messages = event.initial_messages.clone();
if let Some(messages) = initial_messages {
self.replay_initial_messages(messages);
}
self.add_to_history(history_cell::new_session_info(
&self.config,
event,
@@ -158,7 +178,9 @@ impl ChatWidget {
if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message);
}
self.request_redraw();
if !self.suppress_session_configured_redraw {
self.request_redraw();
}
}
fn on_agent_message(&mut self, message: String) {
@@ -503,6 +525,8 @@ impl ChatWidget {
pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
self.flush_answer_stream_with_separator();
// Emit the proposed command into history (like proposed patches)
self.add_to_history(history_cell::new_proposed_command(&ev.command));
let request = ApprovalRequest::Exec {
id,
@@ -602,14 +626,17 @@ impl ChatWidget {
}
pub(crate) fn new(
config: Config,
common: ChatWidgetInit,
conversation_manager: Arc<ConversationManager>,
frame_requester: FrameRequester,
app_event_tx: AppEventSender,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
) -> Self {
let ChatWidgetInit {
config,
frame_requester,
app_event_tx,
initial_prompt,
initial_images,
enhanced_keys_supported,
} = common;
let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager);
@@ -643,18 +670,24 @@ impl ChatWidget {
session_id: None,
queued_user_messages: VecDeque::new(),
show_welcome_banner: true,
suppress_session_configured_redraw: false,
}
}
/// Create a ChatWidget attached to an existing conversation (e.g., a fork).
pub(crate) fn new_from_existing(
config: Config,
common: ChatWidgetInit,
conversation: std::sync::Arc<codex_core::CodexConversation>,
session_configured: codex_core::protocol::SessionConfiguredEvent,
frame_requester: FrameRequester,
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
) -> Self {
let ChatWidgetInit {
config,
frame_requester,
app_event_tx,
initial_prompt,
initial_images,
enhanced_keys_supported,
} = common;
let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
@@ -675,7 +708,10 @@ impl ChatWidget {
}),
active_exec_cell: None,
config: config.clone(),
initial_user_message: None,
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
),
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
stream: StreamController::new(config),
@@ -687,6 +723,7 @@ impl ChatWidget {
session_id: None,
queued_user_messages: VecDeque::new(),
show_welcome_banner: false,
suppress_session_configured_redraw: true,
}
}
@@ -709,6 +746,17 @@ impl ChatWidget {
self.on_ctrl_c();
return;
}
KeyEvent {
code: KeyCode::Char('v'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
if let Ok((path, info)) = paste_image_to_temp_png() {
self.attach_image(path, info.width, info.height, info.encoded_format.label());
}
return;
}
other if other.kind == KeyEventKind::Press => {
self.bottom_pane.clear_ctrl_c_quit_hint();
}
@@ -950,9 +998,32 @@ impl ChatWidget {
}
}
/// Replay a subset of initial events into the UI to seed the transcript when
/// resuming an existing session. This approximates the live event flow and
/// is intentionally conservative: only safe-to-replay items are rendered to
/// avoid triggering side effects. Event ids are passed as `None` to
/// distinguish replayed events from live ones.
fn replay_initial_messages(&mut self, events: Vec<EventMsg>) {
for msg in events {
if matches!(msg, EventMsg::SessionConfigured(_)) {
continue;
}
// `id: None` indicates a synthetic/fake id coming from replay.
self.dispatch_event_msg(None, msg, true);
}
}
pub(crate) fn handle_codex_event(&mut self, event: Event) {
let Event { id, msg } = event;
self.dispatch_event_msg(Some(id), msg, false);
}
/// Dispatch a protocol `EventMsg` to the appropriate handler.
///
/// `id` is `Some` for live events and `None` for replayed events from
/// `replay_initial_messages()`. Callers should treat `None` as a "fake" id
/// that must not be used to correlate follow-up actions.
fn dispatch_event_msg(&mut self, id: Option<String>, msg: EventMsg, from_replay: bool) {
match msg {
EventMsg::AgentMessageDelta(_)
| EventMsg::AgentReasoningDelta(_)
@@ -990,8 +1061,13 @@ impl ChatWidget {
}
},
EventMsg::PlanUpdate(update) => self.on_plan_update(update),
EventMsg::ExecApprovalRequest(ev) => self.on_exec_approval_request(id, ev),
EventMsg::ApplyPatchApprovalRequest(ev) => self.on_apply_patch_approval_request(id, ev),
EventMsg::ExecApprovalRequest(ev) => {
// For replayed events, synthesize an empty id (these should not occur).
self.on_exec_approval_request(id.clone().unwrap_or_default(), ev)
}
EventMsg::ApplyPatchApprovalRequest(ev) => {
self.on_apply_patch_approval_request(id.clone().unwrap_or_default(), ev)
}
EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev),
EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta),
EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev),
@@ -1010,14 +1086,33 @@ impl ChatWidget {
self.on_background_event(message)
}
EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message),
EventMsg::UserMessage(ev) => {
if from_replay {
self.on_user_message_event(ev);
}
}
EventMsg::ConversationHistory(ev) => {
// Forward to App so it can process backtrack flows.
self.app_event_tx
.send(crate::app_event::AppEvent::ConversationHistory(ev));
}
}
}
fn on_user_message_event(&mut self, event: UserMessageEvent) {
match event.kind {
Some(InputMessageKind::EnvironmentContext)
| Some(InputMessageKind::UserInstructions) => {
// Skip XMLwrapped context blocks in the transcript.
}
Some(InputMessageKind::Plain) | None => {
let message = event.message.trim();
if !message.is_empty() {
self.add_to_history(history_cell::new_user_prompt(message.to_string()));
}
}
}
}
fn request_redraw(&mut self) {
self.frame_requester.schedule_frame();
}
@@ -1290,9 +1385,11 @@ impl WidgetRef for &ChatWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
(&self.bottom_pane).render(bottom_pane_area, buf);
if let Some(cell) = &self.active_exec_cell {
if !active_cell_area.is_empty()
&& let Some(cell) = &self.active_exec_cell
{
let mut active_cell_area = active_cell_area;
active_cell_area.y += 1;
active_cell_area.y = active_cell_area.y.saturating_add(1);
active_cell_area.height -= 1;
cell.render_ref(active_cell_area, buf);
}

View File

@@ -4,9 +4,7 @@ assertion_line: 728
expression: terminal.backend()
---
" "
"? Codex wants to run echo hello world "
" "
"Model wants to run a command "
"this is a test reason such as one that would be produced by the model "
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "

View File

@@ -3,8 +3,6 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"? Codex wants to run echo hello world "
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "
"▌ Approve and run the command "

View File

@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 648
expression: visible_after
---
> Im going to scan the workspace and Cargo manifests to see build profiles and
@@ -22,9 +23,9 @@ expression: visible_after
Main Causes
- Static linking style: Each bin (codex, codex-tui, codex-exec,
codex-mcp-server, etc.) statically links its full dependency graph, so common
code isnt shared at runtime across executables.
- Static linking style: Each bin (codex, codex-tui, codex-exec, codex-mcp-
server, etc.) statically links its full dependency graph, so common code isnt
shared at runtime across executables.
- Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors,
and a TLS stack (rustls by default; OpenSSL on musl). In core, login, tui,
and ollama you enable reqwest with json/stream, which still pulls a large
@@ -39,9 +40,9 @@ expression: visible_after
per bin.
- Panic + backtraces: Default panic = unwind and backtrace support keep
unwinding tables and symbols that add weight.
- Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables
openssl-sys with vendored, compiling OpenSSL into the binary—this adds
multiple megabytes per executable.
- Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables openssl-
sys with vendored, compiling OpenSSL into the binary—this adds multiple
megabytes per executable.
Build-Mode Notes
@@ -52,6 +53,6 @@ expression: visible_after
- Debug builds: cargo build (dev profile) includes full debuginfo, no LTO, and
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.
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.

View File

@@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
" ⏎ send Ctrl+J newline Ctrl+T transc"
" ⏎ send J newline T transcript ⌃"

View File

@@ -12,4 +12,4 @@ expression: visual
Investigating rendering code (0s • Esc to interrupt)
▌Summarize recent commits
⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit
⏎ send J newline T transcript C quit

View File

@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&aborted_long)
---
✗ You canceled the request to run echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&aborted_multi)
---
✗ You canceled the request to run echo line1 ...

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&decision)
---
✔ You approved codex to run echo hello world this time

View File

@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed_multi)
---
• Proposed Command
└ echo line1
echo line2

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed)
---
• Proposed Command
└ echo hello world

View File

@@ -6,5 +6,5 @@ expression: terminal.backend()
" Analyzing (0s • Esc to interrupt) "
" "
"▌ Ask Codex to do anything "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send J newline T transcript C quit "
" "

View File

@@ -4,9 +4,7 @@ assertion_line: 921
expression: terminal.backend()
---
" "
"? Codex wants to run echo 'hello world' "
" "
"Codex wants to run a command "
"this is a test reason such as one that would be produced by the model "
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "

View File

@@ -19,6 +19,7 @@ use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::FileChange;
use codex_core::protocol::InputMessageKind;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::StreamErrorEvent;
@@ -34,6 +35,7 @@ use std::io::BufRead;
use std::io::BufReader;
use std::path::PathBuf;
use tokio::sync::mpsc::unbounded_channel;
use uuid::Uuid;
fn test_config() -> Config {
// Use base defaults to avoid depending on host state.
@@ -126,6 +128,53 @@ fn final_answer_without_newline_is_flushed_immediately() {
);
}
#[test]
fn resumed_initial_messages_render_history() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
let configured = codex_core::protocol::SessionConfiguredEvent {
session_id: Uuid::nil(),
model: "test-model".to_string(),
history_log_id: 0,
history_entry_count: 0,
initial_messages: Some(vec![
EventMsg::UserMessage(codex_core::protocol::UserMessageEvent {
message: "hello from user".to_string(),
kind: Some(InputMessageKind::Plain),
}),
EventMsg::AgentMessage(AgentMessageEvent {
message: "assistant reply".to_string(),
}),
]),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
let cells = drain_insert_history(&mut rx);
let mut merged_lines = Vec::new();
for lines in cells {
let text = lines
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.content.clone())
.collect::<String>();
merged_lines.push(text);
}
let text_blob = merged_lines.join("\n");
assert!(
text_blob.contains("hello from user"),
"expected replayed user message",
);
assert!(
text_blob.contains("assistant reply"),
"expected replayed agent message",
);
}
#[tokio::test(flavor = "current_thread")]
async fn helpers_are_available_and_do_not_panic() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -134,15 +183,15 @@ async fn helpers_are_available_and_do_not_panic() {
let conversation_manager = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key(
"test",
)));
let mut w = ChatWidget::new(
cfg,
conversation_manager,
crate::tui::FrameRequester::test_dummy(),
tx,
None,
Vec::new(),
false,
);
let init = ChatWidgetInit {
config: cfg,
frame_requester: crate::tui::FrameRequester::test_dummy(),
app_event_tx: tx,
initial_prompt: None,
initial_images: Vec::new(),
enhanced_keys_supported: false,
};
let mut w = ChatWidget::new(init, conversation_manager);
// Basic construction sanity.
let _ = &mut w;
}
@@ -184,6 +233,7 @@ fn make_chatwidget_manual() -> (
frame_requester: crate::tui::FrameRequester::test_dummy(),
show_welcome_banner: true,
queued_user_messages: std::collections::VecDeque::new(),
suppress_session_configured_redraw: false,
};
(widget, rx, op_rx)
}
@@ -215,6 +265,104 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String {
s
}
// (removed experimental resize snapshot test)
#[test]
fn exec_approval_emits_proposed_command_and_decision_history() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
// Trigger an exec approval request with a short, single-line command
let ev = ExecApprovalRequestEvent {
call_id: "call-short".into(),
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
};
chat.handle_codex_event(Event {
id: "sub-short".into(),
msg: EventMsg::ExecApprovalRequest(ev),
});
// Snapshot the Proposed Command cell emitted into history
let proposed = drain_insert_history(&mut rx)
.pop()
.expect("expected proposed command cell");
assert_snapshot!(
"exec_approval_history_proposed_short",
lines_to_single_string(&proposed)
);
// Approve via keyboard and verify a concise decision history line is added
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let decision = drain_insert_history(&mut rx)
.pop()
.expect("expected decision cell in history");
assert_snapshot!(
"exec_approval_history_decision_approved_short",
lines_to_single_string(&decision)
);
}
#[test]
fn exec_approval_decision_truncates_multiline_and_long_commands() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
// Multiline command: should render proposed command fully in history with prefixes
let ev_multi = ExecApprovalRequestEvent {
call_id: "call-multi".into(),
command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
};
chat.handle_codex_event(Event {
id: "sub-multi".into(),
msg: EventMsg::ExecApprovalRequest(ev_multi),
});
let proposed_multi = drain_insert_history(&mut rx)
.pop()
.expect("expected proposed multiline command cell");
assert_snapshot!(
"exec_approval_history_proposed_multiline",
lines_to_single_string(&proposed_multi)
);
// Deny via keyboard; decision snippet should be single-line and elided with " ..."
chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
let aborted_multi = drain_insert_history(&mut rx)
.pop()
.expect("expected aborted decision cell (multiline)");
assert_snapshot!(
"exec_approval_history_decision_aborted_multiline",
lines_to_single_string(&aborted_multi)
);
// Very long single-line command: decision snippet should be truncated <= 80 chars with trailing ...
let long = format!("echo {}", "a".repeat(200));
let ev_long = ExecApprovalRequestEvent {
call_id: "call-long".into(),
command: vec!["bash".into(), "-lc".into(), long.clone()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
};
chat.handle_codex_event(Event {
id: "sub-long".into(),
msg: EventMsg::ExecApprovalRequest(ev_long),
});
drain_insert_history(&mut rx); // proposed cell not needed for this assertion
chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
let aborted_long = drain_insert_history(&mut rx)
.pop()
.expect("expected aborted decision cell (long)");
assert_snapshot!(
"exec_approval_history_decision_aborted_long",
lines_to_single_string(&aborted_long)
);
}
// --- Small helpers to tersely drive exec begin/end and snapshot active cell ---
fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) {
// Build the full command vec and parse it using core's parser,
@@ -664,7 +812,9 @@ fn approval_modal_exec_snapshot() {
call_id: "call-approve-cmd".into(),
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: Some("Model wants to run a command".into()),
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
};
chat.handle_codex_event(Event {
id: "sub-approve".into(),
@@ -857,7 +1007,9 @@ fn status_widget_and_approval_modal_snapshot() {
call_id: "call-approve-exec".into(),
command: vec!["echo".into(), "hello world".into()],
cwd: std::path::PathBuf::from("/tmp"),
reason: Some("Codex wants to run a command".into()),
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
};
chat.handle_codex_event(Event {
id: "sub-approve-exec".into(),
@@ -1025,8 +1177,6 @@ fn apply_patch_manual_approval_adjusts_header() {
#[test]
fn apply_patch_manual_flow_snapshot() {
use insta::assert_snapshot;
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
let mut proposed_changes = HashMap::new();
@@ -1597,5 +1747,5 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
}
let visual = vt_lines.join("\n");
insta::assert_snapshot!(visual);
assert_snapshot!(visual);
}

View File

@@ -13,6 +13,36 @@ pub struct Cli {
#[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)]
pub images: Vec<PathBuf>,
/// Open an interactive picker to resume a previous session recorded on disk
/// instead of starting a new one.
///
/// Notes:
/// - Mutually exclusive with `--continue`.
/// - The picker displays recent sessions and a preview of the first real user
/// message to help you select the right one.
#[arg(
long = "resume",
default_value_t = false,
conflicts_with = "continue",
hide = true
)]
pub resume: bool,
/// Continue the most recent conversation without showing the picker.
///
/// Notes:
/// - Mutually exclusive with `--resume`.
/// - If no recorded sessions are found, this behaves like starting fresh.
/// - Equivalent to picking the newest item in the resume picker.
#[arg(
id = "continue",
long = "continue",
default_value_t = false,
conflicts_with = "resume",
hide = true
)]
pub r#continue: bool,
/// Model the agent should use.
#[arg(long, short = 'm')]
pub model: Option<String>,

View File

@@ -2,8 +2,14 @@ use crate::diff_render::create_diff_summary;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::markdown::append_markdown;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::slash_command::SlashCommand;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines;
use base64::Engine;
use codex_ansi_escape::ansi_escape_line;
use codex_common::create_config_summary_entries;
@@ -11,6 +17,7 @@ use codex_common::elapsed::format_duration;
use codex_core::auth::get_auth_file;
use codex_core::auth::try_read_auth_json;
use codex_core::config::Config;
use codex_core::config_types::ReasoningSummaryFormat;
use codex_core::plan_tool::PlanItemArg;
use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
@@ -95,8 +102,7 @@ impl HistoryCell for UserHistoryCell {
let wrapped = textwrap::wrap(
&self.message,
textwrap::Options::new(wrap_width as usize)
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit) // Match textarea wrap
.word_splitter(textwrap::WordSplitter::NoHyphenation),
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), // Match textarea wrap
);
for line in wrapped {
@@ -130,28 +136,16 @@ impl AgentMessageCell {
impl HistoryCell for AgentMessageCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
// We want:
// - First visual line: "> " prefix (collapse with header logic)
// - All subsequent visual lines: two-space prefix
let mut is_first_visual = true;
let wrap_width = width.saturating_sub(2); // account for prefix
for line in &self.lines {
let wrapped =
crate::insert_history::word_wrap_lines(std::slice::from_ref(line), wrap_width);
for (i, piece) in wrapped.into_iter().enumerate() {
let mut spans = Vec::with_capacity(piece.spans.len() + 1);
spans.push(if is_first_visual && i == 0 && self.is_first_line {
word_wrap_lines(
&self.lines,
RtOptions::new(width as usize)
.initial_indent(if self.is_first_line {
"> ".into()
} else {
" ".into()
});
spans.extend(piece.spans.into_iter());
out.push(spans.into());
}
is_first_visual = false;
}
out
})
.subsequent_indent(" ".into()),
)
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
@@ -276,13 +270,13 @@ impl ExecCell {
}
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut out: Vec<Line<'static>> = Vec::new();
let active_start_time = self
.calls
.iter()
.find(|c| c.output.is_none())
.and_then(|c| c.start_time);
lines.push(Line::from(vec![
out.push(Line::from(vec![
if self.is_active() {
// Show an animated spinner while exploring
spinner(active_start_time)
@@ -297,7 +291,7 @@ impl ExecCell {
},
]));
let mut calls = self.calls.clone();
let mut first = true;
let mut out_indented = Vec::new();
while !calls.is_empty() {
let mut call = calls.remove(0);
if call
@@ -370,39 +364,24 @@ impl ExecCell {
lines
};
for (title, line) in call_lines {
let prefix_len = 4 + title.len() + 1; // " └ " + title + " "
let wrapped = crate::insert_history::word_wrap_lines(
&[line.into()],
width.saturating_sub(prefix_len as u16),
let line = Line::from(line);
let initial_indent = Line::from(vec![title.cyan(), " ".into()]);
let subsequent_indent = " ".repeat(initial_indent.width()).into();
let wrapped = word_wrap_line(
&line,
RtOptions::new(width as usize)
.initial_indent(initial_indent)
.subsequent_indent(subsequent_indent),
);
let mut first_sub = true;
for mut line in wrapped {
let mut spans = Vec::with_capacity(line.spans.len() + 1);
spans.push(if first {
first = false;
"".dim()
} else {
" ".into()
});
if first_sub {
first_sub = false;
spans.push(title.cyan());
spans.push(" ".into());
} else {
spans.push(" ".repeat(title.width() + 1).into());
}
spans.extend(line.spans.into_iter());
line.spans = spans;
lines.push(line);
}
push_owned_lines(&wrapped, &mut out_indented);
}
}
lines
out.extend(prefix_lines(out_indented, "".dim(), " ".into()));
out
}
fn command_display_lines(&self, width: u16) -> Vec<Line<'static>> {
use textwrap::Options as TwOptions;
use textwrap::WordSplitter;
let mut lines: Vec<Line<'static>> = Vec::new();
let [call] = &self.calls.as_slice() else {
@@ -422,38 +401,28 @@ impl ExecCell {
// "• Running " (including trailing space) as the reserved prefix width.
// If the command contains newlines, always use the multi-line variant.
let reserved = "• Running ".width();
let mut branch_consumed = false;
if !cmd_display.contains('\n')
&& cmd_display.width() < (width as usize).saturating_sub(reserved)
let mut body_lines: Vec<Line<'static>> = Vec::new();
let highlighted_lines = crate::render::highlight::highlight_bash_to_lines(&cmd_display);
if highlighted_lines.len() == 1
&& highlighted_lines[0].width() < (width as usize).saturating_sub(reserved)
{
lines.push(Line::from(vec![
bullet,
" ".into(),
title.bold(),
" ".into(),
cmd_display.clone().into(),
]));
let mut line = Line::from(vec![bullet, " ".into(), title.bold(), " ".into()]);
line.extend(highlighted_lines[0].clone());
lines.push(line);
} else {
branch_consumed = true;
lines.push(vec![bullet, " ".into(), title.bold()].into());
// Wrap the command line.
for (i, line) in cmd_display.lines().enumerate() {
let wrapped = textwrap::wrap(
line,
TwOptions::new(width as usize)
.initial_indent(" ")
.subsequent_indent(" ")
.word_splitter(WordSplitter::NoHyphenation),
);
lines.extend(wrapped.into_iter().enumerate().map(|(j, l)| {
if i == 0 && j == 0 {
vec!["".dim(), l[4..].to_string().into()].into()
} else {
l.to_string().into()
}
}));
for hl_line in highlighted_lines.iter() {
let opts = crate::wrapping::RtOptions::new((width as usize).saturating_sub(4))
.initial_indent("".into())
.subsequent_indent(" ".into())
// Hyphenation likes to break words on hyphens, which is bad for bash scripts --because-of-flags.
.word_splitter(textwrap::WordSplitter::NoHyphenation);
let wrapped_borrowed = crate::wrapping::word_wrap_line(hl_line, opts);
body_lines.extend(wrapped_borrowed.iter().map(|l| line_to_static(l)));
}
}
if let Some(output) = call.output.as_ref()
@@ -464,25 +433,13 @@ impl ExecCell {
.join("\n");
if !out.trim().is_empty() {
// Wrap the output.
for (i, line) in out.lines().enumerate() {
let wrapped = textwrap::wrap(
line,
TwOptions::new(width as usize - 4)
.word_splitter(WordSplitter::NoHyphenation),
);
lines.extend(wrapped.into_iter().map(|l| {
Line::from(vec![
if i == 0 && !branch_consumed {
"".dim()
} else {
" ".dim()
},
l.to_string().dim(),
])
}));
for line in out.lines() {
let wrapped = textwrap::wrap(line, TwOptions::new(width as usize - 4));
body_lines.extend(wrapped.into_iter().map(|l| Line::from(l.to_string().dim())));
}
}
}
lines.extend(prefix_lines(body_lines, "".dim(), " ".into()));
lines
}
}
@@ -646,6 +603,7 @@ pub(crate) fn new_session_info(
session_id: _,
history_log_id: _,
history_entry_count: _,
initial_messages: _,
} = event;
if is_first_event {
let cwd_str = match relativize_to_home(&config.cwd) {
@@ -992,6 +950,12 @@ pub(crate) fn new_status_output(
lines.push("".into());
// 💻 Client
let cli_version = crate::version::CODEX_CLI_VERSION;
lines.push(vec![padded_emoji("💻").into(), "Client".bold()].into());
lines.push(vec![" • CLI Version: ".into(), cli_version.into()].into());
lines.push("".into());
// 📊 Token Usage
lines.push(vec!["📊 ".into(), "Token Usage".bold()].into());
if let Some(session_id) = session_id {
@@ -1076,14 +1040,6 @@ pub(crate) fn new_mcp_tools_output(
lines.push(vec![" • Command: ".into(), cmd_display.into()].into());
}
if let Some(env) = cfg.env.as_ref()
&& !env.is_empty()
{
let mut env_pairs: Vec<String> = env.iter().map(|(k, v)| format!("{k}={v}")).collect();
env_pairs.sort();
lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into());
}
if names.is_empty() {
lines.push(" • Tools: (none)".into());
} else {
@@ -1146,33 +1102,9 @@ impl HistoryCell for PlanUpdateCell {
.into_iter()
.map(|s| s.to_string().set_style(step_style).into())
.collect();
prefix_lines(step_text, &box_str.into(), &" ".into())
prefix_lines(step_text, box_str.into(), " ".into())
};
fn prefix_lines(
lines: Vec<Line<'static>>,
initial_prefix: &Span<'static>,
subsequent_prefix: &Span<'static>,
) -> Vec<Line<'static>> {
lines
.into_iter()
.enumerate()
.map(|(i, l)| {
Line::from(
[
vec![if i == 0 {
initial_prefix.clone()
} else {
subsequent_prefix.clone()
}],
l.spans,
]
.concat(),
)
})
.collect()
}
let mut lines: Vec<Line<'static>> = vec![];
lines.push(vec!["".into(), "Updated Plan".bold()].into());
@@ -1193,7 +1125,7 @@ impl HistoryCell for PlanUpdateCell {
indented_lines.extend(render_step(status, step));
}
}
lines.extend(prefix_lines(indented_lines, &"".into(), &" ".into()));
lines.extend(prefix_lines(indented_lines, "".into(), " ".into()));
lines
}
@@ -1237,6 +1169,26 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
PlainHistoryCell { lines }
}
/// Create a new history cell for a proposed command approval.
/// Renders a header and the command preview similar to how proposed patches
/// show a header and summary.
pub(crate) fn new_proposed_command(command: &[String]) -> PlainHistoryCell {
let cmd = strip_bash_lc_and_escape(command);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(vec!["".into(), "Proposed Command".bold()]));
let cmd_lines: Vec<Line<'static>> = cmd
.lines()
.map(|part| Line::from(part.to_string()))
.collect();
let initial_prefix: Span<'static> = "".dim();
let subsequent_prefix: Span<'static> = " ".into();
lines.extend(prefix_lines(cmd_lines, initial_prefix, subsequent_prefix));
PlainHistoryCell { lines }
}
pub(crate) fn new_reasoning_block(
full_reasoning_buffer: String,
config: &Config,
@@ -1251,34 +1203,39 @@ pub(crate) fn new_reasoning_summary_block(
full_reasoning_buffer: String,
config: &Config,
) -> Vec<Box<dyn HistoryCell>> {
if config.use_experimental_reasoning_summary {
if config.model_family.reasoning_summary_format == ReasoningSummaryFormat::Experimental {
// Experimental format is following:
// ** header **
//
// reasoning summary
//
// So we need to strip header from reasoning summary
let full_reasoning_buffer = full_reasoning_buffer.trim();
if let Some(open) = full_reasoning_buffer.find("**") {
let after_open = &full_reasoning_buffer[(open + 2)..];
if let Some(close) = after_open.find("**") {
let after_close_idx = open + 2 + close + 2;
let header_buffer = full_reasoning_buffer[..after_close_idx].to_string();
let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string();
// if we don't have anything beyond `after_close_idx`
// then we don't have a summary to inject into history
if after_close_idx < full_reasoning_buffer.len() {
let header_buffer = full_reasoning_buffer[..after_close_idx].to_string();
let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string();
let mut header_lines: Vec<Line<'static>> = Vec::new();
header_lines.push(Line::from("Thinking".magenta().italic()));
append_markdown(&header_buffer, &mut header_lines, config);
let mut header_lines: Vec<Line<'static>> = Vec::new();
header_lines.push(Line::from("Thinking".magenta().italic()));
append_markdown(&header_buffer, &mut header_lines, config);
let mut summary_lines: Vec<Line<'static>> = Vec::new();
summary_lines.push(Line::from("Thinking".magenta().bold()));
append_markdown(&summary_buffer, &mut summary_lines, config);
let mut summary_lines: Vec<Line<'static>> = Vec::new();
summary_lines.push(Line::from("Thinking".magenta().bold()));
append_markdown(&summary_buffer, &mut summary_lines, config);
return vec![
Box::new(TranscriptOnlyHistoryCell {
lines: header_lines,
}),
Box::new(AgentMessageCell::new(summary_lines, true)),
];
return vec![
Box::new(TranscriptOnlyHistoryCell {
lines: header_lines,
}),
Box::new(AgentMessageCell::new(summary_lines, true)),
];
}
}
}
}
@@ -1376,6 +1333,34 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
#[cfg(test)]
mod tests {
use super::*;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
fn test_config() -> Config {
Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
std::env::temp_dir(),
)
.expect("config")
}
fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect()
}
fn render_transcript(cell: &dyn HistoryCell) -> Vec<String> {
render_lines(&cell.transcript_lines())
}
#[test]
fn coalesces_sequential_reads_within_one_call() {
@@ -1416,16 +1401,7 @@ mod tests {
);
let lines = cell.display_lines(80);
let rendered = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
@@ -1498,16 +1474,7 @@ mod tests {
);
let lines = cell.display_lines(80);
let rendered = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
@@ -1545,16 +1512,7 @@ mod tests {
Duration::from_millis(1),
);
let lines = cell.display_lines(80);
let rendered = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
@@ -1586,16 +1544,7 @@ mod tests {
// Small width to force wrapping on both lines
let width: u16 = 28;
let lines = cell.display_lines(width);
let rendered = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
@@ -1622,16 +1571,7 @@ mod tests {
);
// Wide enough that it fits inline
let lines = cell.display_lines(80);
let rendered = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
@@ -1658,16 +1598,7 @@ mod tests {
Duration::from_millis(1),
);
let lines = cell.display_lines(24);
let rendered = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
@@ -1694,16 +1625,7 @@ mod tests {
Duration::from_millis(1),
);
let lines = cell.display_lines(80);
let rendered = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
@@ -1731,16 +1653,7 @@ mod tests {
Duration::from_millis(1),
);
let lines = cell.display_lines(28);
let rendered = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
@@ -1841,17 +1754,7 @@ mod tests {
// Small width to force wrapping more clearly. Effective wrap width is width-1 due to the ▌ prefix.
let width: u16 = 12;
let lines = cell.display_lines(width);
let rendered = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
@@ -1883,16 +1786,7 @@ mod tests {
let cell = new_plan_update(update);
// Narrow width to force wrapping for both the note and steps
let lines = cell.display_lines(32);
let rendered = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
@@ -1914,16 +1808,91 @@ mod tests {
let cell = new_plan_update(update);
let lines = cell.display_lines(40);
let rendered = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() {
let mut config = test_config();
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
let cells =
new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &config);
assert_eq!(cells.len(), 1);
let rendered = render_transcript(cells[0].as_ref());
assert_eq!(rendered, vec!["thinking", "Detailed reasoning goes here."]);
}
#[test]
fn reasoning_summary_block_falls_back_when_header_is_missing() {
let mut config = test_config();
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
let cells = new_reasoning_summary_block(
"**High level reasoning without closing".to_string(),
&config,
);
assert_eq!(cells.len(), 1);
let rendered = render_transcript(cells[0].as_ref());
assert_eq!(
rendered,
vec!["thinking", "**High level reasoning without closing"]
);
}
#[test]
fn reasoning_summary_block_falls_back_when_summary_is_missing() {
let mut config = test_config();
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
let cells = new_reasoning_summary_block(
"**High level reasoning without closing**".to_string(),
&config,
);
assert_eq!(cells.len(), 1);
let rendered = render_transcript(cells[0].as_ref());
assert_eq!(
rendered,
vec!["thinking", "High level reasoning without closing"]
);
let cells = new_reasoning_summary_block(
"**High level reasoning without closing**\n\n ".to_string(),
&config,
);
assert_eq!(cells.len(), 1);
let rendered = render_transcript(cells[0].as_ref());
assert_eq!(
rendered,
vec!["thinking", "High level reasoning without closing"]
);
}
#[test]
fn reasoning_summary_block_splits_header_and_summary_when_present() {
let mut config = test_config();
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
let cells = new_reasoning_summary_block(
"**High level plan**\n\nWe should fix the bug next.".to_string(),
&config,
);
assert_eq!(cells.len(), 2);
let header_lines = render_transcript(cells[0].as_ref());
assert_eq!(header_lines, vec!["Thinking", "High level plan"]);
let summary_lines = render_transcript(cells[1].as_ref());
assert_eq!(
summary_lines,
vec!["codex", "Thinking", "We should fix the bug next."]
)
}
}

View File

@@ -3,6 +3,7 @@ use std::io;
use std::io::Write;
use crate::tui;
use crate::wrapping::word_wrap_lines_borrowed;
use crossterm::Command;
use crossterm::cursor::MoveTo;
use crossterm::queue;
@@ -18,8 +19,6 @@ use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::text::Line;
use ratatui::text::Span;
use textwrap::Options as TwOptions;
use textwrap::WordSplitter;
/// Insert `lines` above the viewport using the terminal's backend writer
/// (avoids direct stdout references).
@@ -44,7 +43,7 @@ pub fn insert_history_lines_to_writer<B, W>(
// Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same
// formatting as the TUI. This avoids character-level hard wrapping by the terminal.
let wrapped = word_wrap_lines(&lines, area.width.max(1));
let wrapped = word_wrap_lines_borrowed(&lines, area.width.max(1) as usize);
let wrapped_lines = wrapped.len() as u16;
let cursor_top = if area.bottom() < screen_size.height {
// If the viewport is not at the bottom of the screen, scroll it down to make room.
@@ -98,7 +97,7 @@ pub fn insert_history_lines_to_writer<B, W>(
for line in wrapped {
queue!(writer, Print("\r\n")).ok();
write_spans(writer, line.iter()).ok();
write_spans(writer, &line).ok();
}
queue!(writer, ResetScrollRegion).ok();
@@ -223,7 +222,7 @@ impl ModifierDiff {
fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()>
where
I: Iterator<Item = &'a Span<'a>>,
I: IntoIterator<Item = &'a Span<'a>>,
{
let mut fg = Color::Reset;
let mut bg = Color::Reset;
@@ -262,129 +261,6 @@ where
)
}
/// Word-aware wrapping for a list of `Line`s preserving styles.
pub(crate) fn word_wrap_lines<'a, I>(lines: I, width: u16) -> Vec<Line<'static>>
where
I: IntoIterator<Item = &'a Line<'a>>,
{
let mut out = Vec::new();
let w = width.max(1) as usize;
for line in lines {
out.extend(word_wrap_line(line, w));
}
out
}
fn word_wrap_line(line: &Line, width: usize) -> Vec<Line<'static>> {
if width == 0 {
return vec![to_owned_line(line)];
}
// Concatenate content and keep span boundaries for later re-slicing.
let mut flat = String::new();
let mut span_bounds = Vec::new(); // (start_byte, end_byte, style)
let mut cursor = 0usize;
for s in &line.spans {
let text = s.content.as_ref();
let start = cursor;
flat.push_str(text);
cursor += text.len();
span_bounds.push((start, cursor, s.style));
}
// Use textwrap for robust word-aware wrapping; no hyphenation, no breaking words.
let opts = TwOptions::new(width)
.break_words(false)
.word_splitter(WordSplitter::NoHyphenation);
let wrapped = textwrap::wrap(&flat, &opts);
if wrapped.len() <= 1 {
return vec![to_owned_line(line)];
}
// Map wrapped pieces back to byte ranges in `flat` sequentially.
let mut start_cursor = 0usize;
let mut out: Vec<Line<'static>> = Vec::with_capacity(wrapped.len());
for piece in wrapped {
let piece_str: &str = &piece;
if piece_str.is_empty() {
out.push(Line {
style: line.style,
alignment: line.alignment,
spans: Vec::new(),
});
continue;
}
// Find the next occurrence of piece_str at or after start_cursor.
// textwrap preserves order, so a linear scan is sufficient.
if let Some(rel) = flat[start_cursor..].find(piece_str) {
let s = start_cursor + rel;
let e = s + piece_str.len();
out.push(slice_line_spans(line, &span_bounds, s, e));
start_cursor = e;
} else {
// Fallback: slice by length from cursor.
let s = start_cursor;
let e = (start_cursor + piece_str.len()).min(flat.len());
out.push(slice_line_spans(line, &span_bounds, s, e));
start_cursor = e;
}
}
out
}
fn to_owned_line(l: &Line<'_>) -> Line<'static> {
Line {
style: l.style,
alignment: l.alignment,
spans: l
.spans
.iter()
.map(|s| Span {
style: s.style,
content: std::borrow::Cow::Owned(s.content.to_string()),
})
.collect(),
}
}
fn slice_line_spans(
original: &Line<'_>,
span_bounds: &[(usize, usize, ratatui::style::Style)],
start_byte: usize,
end_byte: usize,
) -> Line<'static> {
let mut acc: Vec<Span<'static>> = Vec::new();
for (i, (s, e, style)) in span_bounds.iter().enumerate() {
if *e <= start_byte {
continue;
}
if *s >= end_byte {
break;
}
let seg_start = start_byte.max(*s);
let seg_end = end_byte.min(*e);
if seg_end > seg_start {
let local_start = seg_start - *s;
let local_end = seg_end - *s;
let content = original.spans[i].content.as_ref();
let slice = &content[local_start..local_end];
acc.push(Span {
style: *style,
content: std::borrow::Cow::Owned(slice.to_string()),
});
}
if *e >= end_byte {
break;
}
}
Line {
style: original.style,
alignment: original.alignment,
spans: acc,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -416,38 +292,4 @@ mod tests {
String::from_utf8(expected).unwrap()
);
}
#[test]
fn line_height_counts_double_width_emoji() {
let line = "😀😀😀".into(); // each emoji ~ width 2
assert_eq!(word_wrap_line(&line, 4).len(), 2);
assert_eq!(word_wrap_line(&line, 2).len(), 3);
assert_eq!(word_wrap_line(&line, 6).len(), 1);
}
#[test]
fn word_wrap_does_not_split_words_simple_english() {
let sample = "Years passed, and Willowmere thrived in peace and friendship. Miras herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
let line = sample.into();
// Force small width to exercise wrapping at spaces.
let wrapped = word_wrap_lines(&[line], 40);
let joined: String = wrapped
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
!joined.contains("bo\nth"),
"word 'both' should not be split across lines:\n{joined}"
);
assert!(
!joined.contains("Willowm\nere"),
"should not split inside words:\n{joined}"
);
}
}

View File

@@ -0,0 +1,49 @@
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::text::Span;
use std::fmt::Display;
#[cfg(test)]
const ALT_PREFIX: &str = "";
#[cfg(all(not(test), target_os = "macos"))]
const ALT_PREFIX: &str = "";
#[cfg(all(not(test), not(target_os = "macos")))]
const ALT_PREFIX: &str = "Alt+";
#[cfg(test)]
const CTRL_PREFIX: &str = "";
#[cfg(all(not(test), target_os = "macos"))]
const CTRL_PREFIX: &str = "";
#[cfg(all(not(test), not(target_os = "macos")))]
const CTRL_PREFIX: &str = "Ctrl+";
#[cfg(test)]
const SHIFT_PREFIX: &str = "";
#[cfg(all(not(test), target_os = "macos"))]
const SHIFT_PREFIX: &str = "";
#[cfg(all(not(test), not(target_os = "macos")))]
const SHIFT_PREFIX: &str = "Shift+";
fn key_hint_style() -> Style {
Style::default().fg(Color::Cyan)
}
fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> {
Span::styled(format!("{prefix}{key}"), key_hint_style())
}
pub(crate) fn ctrl(key: impl Display) -> Span<'static> {
modifier_span(CTRL_PREFIX, key)
}
pub(crate) fn alt(key: impl Display) -> Span<'static> {
modifier_span(ALT_PREFIX, key)
}
pub(crate) fn shift(key: impl Display) -> Span<'static> {
modifier_span(SHIFT_PREFIX, key)
}
pub(crate) fn plain(key: impl Display) -> Span<'static> {
Span::styled(format!("{key}"), key_hint_style())
}

View File

@@ -7,6 +7,7 @@ use app::App;
use codex_core::AuthManager;
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
use codex_core::CodexAuth;
use codex_core::RolloutRecorder;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
@@ -41,12 +42,14 @@ mod file_search;
mod get_git_diff;
mod history_cell;
pub mod insert_history;
mod key_hint;
pub mod live_wrap;
mod markdown;
mod markdown_stream;
pub mod onboarding;
mod pager_overlay;
mod render;
mod resume_picker;
mod session_log;
mod shimmer;
mod slash_command;
@@ -55,6 +58,8 @@ mod streaming;
mod text_formatting;
mod tui;
mod user_approval_widget;
mod version;
mod wrapping;
// Internal vt100-based replay tests live as a separate source file to keep them
// close to the widget code. Include them in unit tests.
@@ -126,7 +131,6 @@ pub async fn run_main(
include_plan_tool: Some(true),
include_apply_patch_tool: None,
include_view_image_tool: None,
disable_response_storage: cli.oss.then_some(true),
show_raw_agent_reasoning: cli.oss.then_some(true),
tools_web_search_request: cli.web_search.then_some(true),
};
@@ -299,7 +303,13 @@ async fn run_ratatui_app(
// Initialize high-fidelity session event logging if enabled.
session_log::maybe_init(&config);
let Cli { prompt, images, .. } = cli;
let Cli {
prompt,
images,
resume,
r#continue,
..
} = cli;
let auth_manager = AuthManager::shared(
config.codex_home.clone(),
@@ -312,13 +322,11 @@ async fn run_ratatui_app(
if should_show_onboarding {
let directory_trust_decision = run_onboarding_app(
OnboardingScreenArgs {
codex_home: config.codex_home.clone(),
cwd: config.cwd.clone(),
show_login_screen: should_show_login_screen(login_status, &config),
show_trust_screen: should_show_trust_screen,
login_status,
preferred_auth_method: config.preferred_auth_method,
auth_manager: auth_manager.clone(),
config: config.clone(),
},
&mut tui,
)
@@ -329,7 +337,37 @@ async fn run_ratatui_app(
}
}
let app_result = App::run(&mut tui, auth_manager, config, prompt, images).await;
let resume_selection = if r#continue {
match RolloutRecorder::list_conversations(&config.codex_home, 1, None).await {
Ok(page) => page
.items
.first()
.map(|it| resume_picker::ResumeSelection::Resume(it.path.clone()))
.unwrap_or(resume_picker::ResumeSelection::StartFresh),
Err(_) => resume_picker::ResumeSelection::StartFresh,
}
} else if resume {
match resume_picker::run_resume_picker(&mut tui, &config.codex_home).await? {
resume_picker::ResumeSelection::Exit => {
restore();
session_log::log_session_end();
return Ok(codex_core::protocol::TokenUsage::default());
}
other => other,
}
} else {
resume_picker::ResumeSelection::StartFresh
};
let app_result = App::run(
&mut tui,
auth_manager,
config,
prompt,
images,
resume_selection,
)
.await;
restore();
// Mark the end of the recorded session.

View File

@@ -2,6 +2,7 @@
use codex_core::AuthManager;
use codex_core::auth::CLIENT_ID;
use codex_core::config::Config;
use codex_login::ServerOptions;
use codex_login::ShutdownHandle;
use codex_login::run_login_server;
@@ -113,6 +114,7 @@ pub(crate) struct AuthModeWidget {
pub login_status: LoginStatus,
pub preferred_auth_method: AuthMode,
pub auth_manager: Arc<AuthManager>,
pub config: Config,
}
impl AuthModeWidget {
@@ -314,7 +316,11 @@ impl AuthModeWidget {
}
self.error = None;
let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string());
let opts = ServerOptions::new(
self.codex_home.clone(),
CLIENT_ID.to_string(),
self.config.responses_originator_header.clone(),
);
match run_login_server(opts) {
Ok(child) => {
let sign_in_state = self.sign_in_state.clone();

View File

@@ -1,4 +1,5 @@
use codex_core::AuthManager;
use codex_core::config::Config;
use codex_core::git_info::get_git_repo_root;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -21,7 +22,6 @@ use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use color_eyre::eyre::Result;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
@@ -53,26 +53,25 @@ pub(crate) struct OnboardingScreen {
}
pub(crate) struct OnboardingScreenArgs {
pub codex_home: PathBuf,
pub cwd: PathBuf,
pub show_trust_screen: bool,
pub show_login_screen: bool,
pub login_status: LoginStatus,
pub preferred_auth_method: AuthMode,
pub auth_manager: Arc<AuthManager>,
pub config: Config,
}
impl OnboardingScreen {
pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
let OnboardingScreenArgs {
codex_home,
cwd,
show_trust_screen,
show_login_screen,
login_status,
preferred_auth_method,
auth_manager,
config,
} = args;
let preferred_auth_method = config.preferred_auth_method;
let cwd = config.cwd.clone();
let codex_home = config.codex_home.clone();
let mut steps: Vec<Step> = vec![Step::Welcome(WelcomeWidget {
is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated),
})];
@@ -84,8 +83,9 @@ impl OnboardingScreen {
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: codex_home.clone(),
login_status,
preferred_auth_method,
auth_manager,
preferred_auth_method,
config,
}))
}
let is_git_repo = get_git_repo_root(&cwd).is_some();

View File

@@ -1,7 +1,7 @@
use std::io::Result;
use std::time::Duration;
use crate::insert_history;
use crate::render::line_utils::push_owned_lines;
use crate::tui;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
@@ -77,6 +77,7 @@ struct PagerView {
scroll_offset: usize,
title: String,
wrap_cache: Option<WrapCache>,
last_content_height: Option<usize>,
}
impl PagerView {
@@ -86,12 +87,14 @@ impl PagerView {
scroll_offset,
title,
wrap_cache: None,
last_content_height: None,
}
}
fn render(&mut self, area: Rect, buf: &mut Buffer) {
self.render_header(area, buf);
let content_area = self.scroll_area(area);
self.update_last_content_height(content_area.height);
self.ensure_wrapped(content_area.width);
// Compute page bounds without holding an immutable borrow on cache while mutating self
let wrapped_len = self
@@ -119,6 +122,7 @@ impl PagerView {
) {
self.render_header(area, buf);
let content_area = self.scroll_area(area);
self.update_last_content_height(content_area.height);
self.ensure_wrapped(content_area.width);
// Compute page bounds first to avoid borrow conflicts
let wrapped_len = self
@@ -250,6 +254,10 @@ impl PagerView {
Ok(())
}
fn update_last_content_height(&mut self, height: u16) {
self.last_content_height = Some(height as usize);
}
fn scroll_area(&self, area: Rect) -> Rect {
let mut area = area;
area.y = area.y.saturating_add(1);
@@ -279,9 +287,9 @@ impl PagerView {
let mut wrapped: Vec<Line<'static>> = Vec::new();
let mut src_idx: Vec<usize> = Vec::new();
for (i, line) in self.lines.iter().enumerate() {
let ws = insert_history::word_wrap_lines(std::slice::from_ref(line), width);
let ws = crate::wrapping::word_wrap_line(line, width as usize);
src_idx.extend(std::iter::repeat_n(i, ws.len()));
wrapped.extend(ws);
push_owned_lines(&ws, &mut wrapped);
}
self.wrap_cache = Some(WrapCache {
width,
@@ -337,6 +345,24 @@ impl PagerView {
}
std::borrow::Cow::Owned(out)
}
fn is_scrolled_to_bottom(&self) -> bool {
if self.scroll_offset == usize::MAX {
return true;
}
let Some(cache) = &self.wrap_cache else {
return false;
};
let Some(height) = self.last_content_height else {
return false;
};
if cache.wrapped.is_empty() {
return true;
}
let visible = height.min(cache.wrapped.len());
let max_scroll = cache.wrapped.len().saturating_sub(visible);
self.scroll_offset >= max_scroll
}
}
pub(crate) struct TranscriptOverlay {
@@ -359,8 +385,12 @@ impl TranscriptOverlay {
}
pub(crate) fn insert_lines(&mut self, lines: Vec<Line<'static>>) {
let follow_bottom = self.view.is_scrolled_to_bottom();
self.view.lines.extend(lines);
self.view.wrap_cache = None;
if follow_bottom {
self.view.scroll_offset = usize::MAX;
}
}
pub(crate) fn set_highlight_range(&mut self, range: Option<(usize, usize)>) {
@@ -541,6 +571,39 @@ mod tests {
assert_snapshot!(term.backend());
}
#[test]
fn transcript_overlay_keeps_scroll_pinned_at_bottom() {
let mut overlay =
TranscriptOverlay::new((0..20).map(|i| Line::from(format!("line{i}"))).collect());
let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw");
assert!(
overlay.view.is_scrolled_to_bottom(),
"expected initial render to leave view at bottom"
);
overlay.insert_lines(vec!["tail".into()]);
assert_eq!(overlay.view.scroll_offset, usize::MAX);
}
#[test]
fn transcript_overlay_preserves_manual_scroll_position() {
let mut overlay =
TranscriptOverlay::new((0..20).map(|i| Line::from(format!("line{i}"))).collect());
let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw");
overlay.view.scroll_offset = 0;
overlay.insert_lines(vec!["tail".into()]);
assert_eq!(overlay.view.scroll_offset, 0);
}
#[test]
fn static_overlay_snapshot_basic() {
// Prepare a static overlay with a few lines and a title

View File

@@ -0,0 +1,145 @@
use codex_core::bash::try_parse_bash;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
/// Convert the full bash script into per-line styled content by first
/// computing operator-dimmed spans across the entire script, then splitting
/// by newlines and dimming heredoc body lines. Performs a single parse and
/// reuses it for both highlighting and heredoc detection.
pub(crate) fn highlight_bash_to_lines(script: &str) -> Vec<Line<'static>> {
// Parse once; use the tree for both highlighting and heredoc body detection.
let spans: Vec<Span<'static>> = if let Some(tree) = try_parse_bash(script) {
// Single walk: collect operator ranges and heredoc rows.
let root = tree.root_node();
let mut cursor = root.walk();
let mut stack = vec![root];
let mut ranges: Vec<(usize, usize)> = Vec::new();
while let Some(node) = stack.pop() {
if !node.is_named() && !node.is_extra() {
let kind = node.kind();
let is_quote = matches!(kind, "\"" | "'" | "`");
let is_whitespace = kind.trim().is_empty();
if !is_quote && !is_whitespace {
ranges.push((node.start_byte(), node.end_byte()));
}
} else if node.kind() == "heredoc_body" {
ranges.push((node.start_byte(), node.end_byte()));
}
for child in node.children(&mut cursor) {
stack.push(child);
}
}
if ranges.is_empty() {
ranges.push((script.len(), script.len()));
}
ranges.sort_by_key(|(st, _)| *st);
let mut spans: Vec<Span<'static>> = Vec::new();
let mut i = 0usize;
for (start, end) in ranges.into_iter() {
let dim_start = start.max(i);
let dim_end = end;
if dim_start < dim_end {
if dim_start > i {
spans.push(script[i..dim_start].to_string().into());
}
spans.push(script[dim_start..dim_end].to_string().dim());
i = dim_end;
}
}
if i < script.len() {
spans.push(script[i..].to_string().into());
}
spans
} else {
vec![script.to_string().into()]
};
// Split spans into lines preserving style boundaries and highlights across newlines.
let mut lines: Vec<Line<'static>> = vec![Line::from("")];
for sp in spans {
let style = sp.style;
let text = sp.content.into_owned();
for (i, part) in text.split('\n').enumerate() {
if i > 0 {
lines.push(Line::from(""));
}
if part.is_empty() {
continue;
}
let span = Span {
style,
content: std::borrow::Cow::Owned(part.to_string()),
};
if let Some(last) = lines.last_mut() {
last.spans.push(span);
}
}
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::style::Modifier;
#[test]
fn dims_expected_bash_operators() {
let s = "echo foo && bar || baz | qux & (echo hi)";
let lines = highlight_bash_to_lines(s);
let reconstructed: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.clone())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert_eq!(reconstructed, s);
fn is_dim(span: &Span<'_>) -> bool {
span.style.add_modifier.contains(Modifier::DIM)
}
let dimmed: Vec<String> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|sp| is_dim(sp))
.map(|sp| sp.content.clone().into_owned())
.collect();
assert_eq!(dimmed, vec!["&&", "||", "|", "&", "(", ")"]);
}
#[test]
fn does_not_dim_quotes_but_dims_other_punct() {
let s = "echo \"hi\" > out.txt; echo 'ok'";
let lines = highlight_bash_to_lines(s);
let reconstructed: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.clone())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert_eq!(reconstructed, s);
fn is_dim(span: &Span<'_>) -> bool {
span.style.add_modifier.contains(Modifier::DIM)
}
let dimmed: Vec<String> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|sp| is_dim(sp))
.map(|sp| sp.content.clone().into_owned())
.collect();
assert!(dimmed.contains(&">".to_string()));
assert!(dimmed.contains(&";".to_string()));
assert!(!dimmed.contains(&"\"".to_string()));
assert!(!dimmed.contains(&"'".to_string()));
}
}

View File

@@ -34,3 +34,26 @@ pub fn is_blank_line_spaces_only(line: &Line<'_>) -> bool {
.iter()
.all(|s| s.content.is_empty() || s.content.chars().all(|c| c == ' '))
}
/// Prefix each line with `initial_prefix` for the first line and
/// `subsequent_prefix` for following lines. Returns a new Vec of owned lines.
pub fn prefix_lines(
lines: Vec<Line<'static>>,
initial_prefix: Span<'static>,
subsequent_prefix: Span<'static>,
) -> Vec<Line<'static>> {
lines
.into_iter()
.enumerate()
.map(|(i, l)| {
let mut spans = Vec::with_capacity(l.spans.len() + 1);
spans.push(if i == 0 {
initial_prefix.clone()
} else {
subsequent_prefix.clone()
});
spans.extend(l.spans);
Line::from(spans)
})
.collect()
}

View File

@@ -1,2 +1,3 @@
pub mod highlight;
pub mod line_utils;
pub mod markdown_utils;

View File

@@ -0,0 +1,504 @@
use std::path::Path;
use std::path::PathBuf;
use chrono::DateTime;
use chrono::TimeZone;
use chrono::Utc;
use codex_core::ConversationItem;
use codex_core::ConversationsPage;
use codex_core::Cursor;
use codex_core::RolloutRecorder;
use codex_core::protocol::InputMessageKind;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
use tokio_stream::StreamExt;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
const PAGE_SIZE: usize = 25;
#[derive(Debug, Clone)]
pub enum ResumeSelection {
StartFresh,
Resume(PathBuf),
Exit,
}
/// Interactive session picker that lists recorded rollout files with simple
/// search and pagination. Shows the first user input as the preview, relative
/// time (e.g., "5 seconds ago"), and the absolute path.
pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<ResumeSelection> {
let alt = AltScreenGuard::enter(tui);
let mut state = PickerState::new(codex_home.to_path_buf(), alt.tui.frame_requester());
state.load_page(None).await?;
state.request_frame();
let mut events = alt.tui.event_stream();
while let Some(ev) = events.next().await {
match ev {
TuiEvent::Key(key) => {
if matches!(key.kind, KeyEventKind::Release) {
continue;
}
if let Some(sel) = state.handle_key(key).await? {
return Ok(sel);
}
}
TuiEvent::Draw => {
draw_picker(alt.tui, &state)?;
}
// Ignore paste and attach-image in picker
_ => {}
}
}
// Fallback treat as cancel/new
Ok(ResumeSelection::StartFresh)
}
/// RAII guard that ensures we leave the alt-screen on scope exit.
struct AltScreenGuard<'a> {
tui: &'a mut Tui,
}
impl<'a> AltScreenGuard<'a> {
fn enter(tui: &'a mut Tui) -> Self {
let _ = tui.enter_alt_screen();
Self { tui }
}
}
impl Drop for AltScreenGuard<'_> {
fn drop(&mut self) {
let _ = self.tui.leave_alt_screen();
}
}
struct PickerState {
codex_home: PathBuf,
requester: FrameRequester,
// pagination
pagination: Pagination,
// data
all_rows: Vec<Row>, // unfiltered rows for current page
filtered_rows: Vec<Row>,
selected: usize,
// search
query: String,
}
#[derive(Debug, Clone)]
struct Pagination {
current_anchor: Option<Cursor>,
backstack: Vec<Option<Cursor>>, // track previous anchors for ←/a
next_cursor: Option<Cursor>,
page_index: usize,
}
#[derive(Clone)]
struct Row {
path: PathBuf,
preview: String,
ts: Option<DateTime<Utc>>,
}
impl PickerState {
fn new(codex_home: PathBuf, requester: FrameRequester) -> Self {
Self {
codex_home,
requester,
pagination: Pagination {
current_anchor: None,
backstack: vec![None],
next_cursor: None,
page_index: 0,
},
all_rows: Vec::new(),
filtered_rows: Vec::new(),
selected: 0,
query: String::new(),
}
}
fn request_frame(&self) {
self.requester.schedule_frame();
}
async fn handle_key(&mut self, key: KeyEvent) -> Result<Option<ResumeSelection>> {
match key.code {
KeyCode::Esc => return Ok(Some(ResumeSelection::StartFresh)),
KeyCode::Char('c')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
return Ok(Some(ResumeSelection::Exit));
}
KeyCode::Enter => {
if let Some(row) = self.filtered_rows.get(self.selected) {
return Ok(Some(ResumeSelection::Resume(row.path.clone())));
}
}
KeyCode::Up => {
if self.selected > 0 {
self.selected -= 1;
}
self.request_frame();
}
KeyCode::Down => {
if self.selected + 1 < self.filtered_rows.len() {
self.selected += 1;
}
self.request_frame();
}
KeyCode::Left | KeyCode::Char('a') => {
self.prev_page().await?;
}
KeyCode::Right | KeyCode::Char('d') => {
self.next_page().await?;
}
KeyCode::Backspace => {
self.query.pop();
self.apply_filter();
}
KeyCode::Char(c) => {
// basic text input for search
if !key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
&& !key.modifiers.contains(crossterm::event::KeyModifiers::ALT)
{
self.query.push(c);
self.apply_filter();
}
}
_ => {}
}
Ok(None)
}
async fn prev_page(&mut self) -> Result<()> {
if self.pagination.page_index == 0 {
return Ok(());
}
// current_anchor points to the page we just loaded; backstack[page_index-1] is the anchor to reload
if self.pagination.page_index > 0 {
self.pagination.page_index -= 1;
let anchor = self
.pagination
.backstack
.get(self.pagination.page_index)
.cloned()
.flatten();
self.pagination.current_anchor = anchor.clone();
self.load_page(anchor.as_ref()).await?;
}
Ok(())
}
async fn next_page(&mut self) -> Result<()> {
if let Some(next) = self.pagination.next_cursor.clone() {
// Record the anchor for the page we are moving to at index new_index
let new_index = self.pagination.page_index + 1;
if self.pagination.backstack.len() <= new_index {
self.pagination.backstack.resize(new_index + 1, None);
}
self.pagination.backstack[new_index] = Some(next.clone());
self.pagination.current_anchor = Some(next.clone());
self.pagination.page_index = new_index;
let anchor = self.pagination.current_anchor.clone();
self.load_page(anchor.as_ref()).await?;
}
Ok(())
}
async fn load_page(&mut self, anchor: Option<&Cursor>) -> Result<()> {
let page = RolloutRecorder::list_conversations(&self.codex_home, PAGE_SIZE, anchor).await?;
self.pagination.next_cursor = page.next_cursor.clone();
self.all_rows = to_rows(page);
self.apply_filter();
// reset selection on new page
self.selected = 0;
Ok(())
}
fn apply_filter(&mut self) {
if self.query.is_empty() {
self.filtered_rows = self.all_rows.clone();
} else {
let q = self.query.to_lowercase();
self.filtered_rows = self
.all_rows
.iter()
.filter(|r| r.preview.to_lowercase().contains(&q))
.cloned()
.collect();
}
if self.selected >= self.filtered_rows.len() {
self.selected = self.filtered_rows.len().saturating_sub(1);
}
self.request_frame();
}
}
fn to_rows(page: ConversationsPage) -> Vec<Row> {
use std::cmp::Reverse;
let mut rows: Vec<Row> = page
.items
.into_iter()
.filter_map(|it| head_to_row(&it))
.collect();
// Ensure newest-first ordering within the page by timestamp when available.
let epoch = Utc.timestamp_opt(0, 0).single().unwrap_or_else(Utc::now);
rows.sort_by_key(|r| Reverse(r.ts.unwrap_or(epoch)));
rows
}
fn head_to_row(item: &ConversationItem) -> Option<Row> {
let mut ts: Option<DateTime<Utc>> = None;
if let Some(first) = item.head.first()
&& let Some(t) = first.get("timestamp").and_then(|v| v.as_str())
&& let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(t)
{
ts = Some(parsed.with_timezone(&Utc));
}
let preview = find_first_user_text(&item.head)?;
let preview = preview.trim().to_string();
if preview.is_empty() {
return None;
}
Some(Row {
path: item.path.clone(),
preview,
ts,
})
}
/// Return the first plain user text from the JSONL `head` of a rollout.
///
/// Strategy: scan for the first `{ type: "message", role: "user" }` entry and
/// then return the first `content` item where `{ type: "input_text" }` that is
/// classified as `InputMessageKind::Plain` (i.e., not wrapped in
/// `<user_instructions>` or `<environment_context>` tags).
fn find_first_user_text(head: &[serde_json::Value]) -> Option<String> {
for v in head.iter() {
let t = v.get("type").and_then(|x| x.as_str()).unwrap_or("");
if t != "message" {
continue;
}
if v.get("role").and_then(|x| x.as_str()) != Some("user") {
continue;
}
if let Some(arr) = v.get("content").and_then(|c| c.as_array()) {
for c in arr.iter() {
if let (Some("input_text"), Some(txt)) =
(c.get("type").and_then(|t| t.as_str()), c.get("text"))
&& let Some(s) = txt.as_str()
{
// Skip XML-wrapped user_instructions/environment_context blocks and
// return the first plain user text we find.
if matches!(InputMessageKind::from(("user", s)), InputMessageKind::Plain) {
return Some(s.to_string());
}
}
}
}
}
None
}
fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
// Render full-screen overlay
let height = tui.terminal.size()?.height;
tui.draw(height, |frame| {
let area = frame.area();
let [header, search, list, hint] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(area.height.saturating_sub(3)),
Constraint::Length(1),
])
.areas(area);
// Header
frame.render_widget_ref(
Line::from(vec!["Resume a previous session".bold().cyan()]),
header,
);
// Search line
let q = if state.query.is_empty() {
"Type to search".dim().to_string()
} else {
format!("Search: {}", state.query)
};
frame.render_widget_ref(Line::from(q), search);
// List
render_list(frame, list, state);
// Hint line
let hint_line: Line = vec![
"Enter".bold(),
" to resume ".into(),
"Esc".bold(),
" to start new ".into(),
"Ctrl+C".into(),
" to quit ".dim(),
"←/a".into(),
" prev ".dim(),
"→/d".into(),
" next".dim(),
]
.into();
frame.render_widget_ref(hint_line, hint);
})
}
fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) {
let rows = &state.filtered_rows;
if rows.is_empty() {
frame.render_widget_ref(Line::from("No sessions found".italic().dim()), area);
return;
}
// Compute how many rows fit (1 line per item)
let capacity = area.height as usize;
let start = state.selected.saturating_sub(capacity.saturating_sub(1));
let visible = &rows[start..rows.len().min(start + capacity)];
let mut y = area.y;
for (idx, row) in visible.iter().enumerate() {
let is_sel = start + idx == state.selected;
let marker = if is_sel { "> ".bold() } else { " ".into() };
let ts = row
.ts
.map(human_time_ago)
.unwrap_or_else(|| "".to_string())
.dim();
let max_cols = area.width.saturating_sub(6) as usize;
let preview = truncate_text(&row.preview, max_cols);
let line: Line = vec![marker, ts, " ".into(), preview.into()].into();
let rect = Rect::new(area.x, y, area.width, 1);
frame.render_widget_ref(line, rect);
y = y.saturating_add(1);
}
}
fn human_time_ago(ts: DateTime<Utc>) -> String {
let now = Utc::now();
let delta = now - ts;
let secs = delta.num_seconds();
if secs < 60 {
let n = secs.max(0);
if n == 1 {
format!("{n} second ago")
} else {
format!("{n} seconds ago")
}
} else if secs < 60 * 60 {
let m = secs / 60;
if m == 1 {
format!("{m} minute ago")
} else {
format!("{m} minutes ago")
}
} else if secs < 60 * 60 * 24 {
let h = secs / 3600;
if h == 1 {
format!("{h} hour ago")
} else {
format!("{h} hours ago")
}
} else {
let d = secs / (60 * 60 * 24);
if d == 1 {
format!("{d} day ago")
} else {
format!("{d} days ago")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn head_with_ts_and_user_text(ts: &str, texts: &[&str]) -> Vec<serde_json::Value> {
vec![
json!({ "timestamp": ts }),
json!({
"type": "message",
"role": "user",
"content": texts
.iter()
.map(|t| json!({ "type": "input_text", "text": *t }))
.collect::<Vec<_>>()
}),
]
}
#[test]
fn skips_user_instructions_and_env_context() {
let head = vec![
json!({ "timestamp": "2025-01-01T00:00:00Z" }),
json!({
"type": "message",
"role": "user",
"content": [
{ "type": "input_text", "text": "<user_instructions>hi</user_instructions>" }
]
}),
json!({
"type": "message",
"role": "user",
"content": [
{ "type": "input_text", "text": "<environment_context>cwd</environment_context>" }
]
}),
json!({
"type": "message",
"role": "user",
"content": [ { "type": "input_text", "text": "real question" } ]
}),
];
let first = find_first_user_text(&head);
assert_eq!(first.as_deref(), Some("real question"));
}
#[test]
fn to_rows_sorts_descending_by_timestamp() {
// Construct two items with different timestamps and real user text.
let a = ConversationItem {
path: PathBuf::from("/tmp/a.jsonl"),
head: head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["A"]),
};
let b = ConversationItem {
path: PathBuf::from("/tmp/b.jsonl"),
head: head_with_ts_and_user_text("2025-01-02T00:00:00Z", &["B"]),
};
let rows = to_rows(ConversationsPage {
items: vec![a, b],
next_cursor: None,
num_scanned_files: 0,
reached_scan_cap: false,
});
assert_eq!(rows.len(), 2);
// Expect the newer timestamp (B) first
assert!(rows[0].preview.contains('B'));
assert!(rows[1].preview.contains('A'));
}
}

View File

@@ -1,9 +1,10 @@
---
source: tui/src/history_cell.rs
assertion_line: 1942
expression: rendered
---
• Ran
└ first_token_is_long_
enough_to_wrap
second_token_is_also
_long_enough_to_wrap
└ first_token_is_long_enou
gh_to_wrap
second_token_is_also_lon
g_enough_to_wrap

View File

@@ -1,5 +1,6 @@
---
source: tui/src/history_cell.rs
assertion_line: 1797
expression: rendered
---
• Ran

View File

@@ -1,9 +1,9 @@
---
source: tui/src/history_cell.rs
assertion_line: 1869
expression: rendered
---
• Ran
└ a_very_long_toke
n_without_spaces
_to_force_wrappi
ng
└ a_very_long_token_wi
thout_spaces_to_
force_wrapping

View File

@@ -5,7 +5,7 @@ expression: terminal.backend()
" Working (0s • Esc to interrupt) "
" ↳ first "
" ↳ second "
" Alt+↑ edit "
" ↑ edit "
" "
" "
" "

View File

@@ -14,10 +14,9 @@ use ratatui::widgets::WidgetRef;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use textwrap::Options as TwOptions;
use textwrap::WordSplitter;
pub(crate) struct StatusIndicatorWidget {
/// Animated header text (defaults to "Working").
@@ -25,17 +24,38 @@ pub(crate) struct StatusIndicatorWidget {
/// Queued user messages to display under the status line.
queued_messages: Vec<String>,
start_time: Instant,
elapsed_running: Duration,
last_resume_at: Instant,
is_paused: bool,
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
}
// Format elapsed seconds into a compact human-friendly form used by the status line.
// Examples: 0s, 59s, 1m00s, 59m59s, 1h00m00s, 2h03m09s
fn fmt_elapsed_compact(elapsed_secs: u64) -> String {
if elapsed_secs < 60 {
return format!("{elapsed_secs}s");
}
if elapsed_secs < 3600 {
let minutes = elapsed_secs / 60;
let seconds = elapsed_secs % 60;
return format!("{minutes}m{seconds:02}s");
}
let hours = elapsed_secs / 3600;
let minutes = (elapsed_secs % 3600) / 60;
let seconds = elapsed_secs % 60;
format!("{hours}h{minutes:02}m{seconds:02}s")
}
impl StatusIndicatorWidget {
pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
Self {
header: String::from("Working"),
queued_messages: Vec::new(),
start_time: Instant::now(),
elapsed_running: Duration::ZERO,
last_resume_at: Instant::now(),
is_paused: false,
app_event_tx,
frame_requester,
@@ -49,11 +69,8 @@ impl StatusIndicatorWidget {
let mut total: u16 = 1; // status line
let text_width = inner_width.saturating_sub(3); // account for " ↳ " prefix
if text_width > 0 {
let opts = TwOptions::new(text_width)
.break_words(false)
.word_splitter(WordSplitter::NoHyphenation);
for q in &self.queued_messages {
let wrapped = textwrap::wrap(q, &opts);
let wrapped = textwrap::wrap(q, text_width);
let lines = wrapped.len().min(3) as u16;
total = total.saturating_add(lines);
if wrapped.len() > 3 {
@@ -87,6 +104,43 @@ impl StatusIndicatorWidget {
// Ensure a redraw so changes are visible.
self.frame_requester.schedule_frame();
}
pub(crate) fn pause_timer(&mut self) {
self.pause_timer_at(Instant::now());
}
pub(crate) fn resume_timer(&mut self) {
self.resume_timer_at(Instant::now());
}
pub(crate) fn pause_timer_at(&mut self, now: Instant) {
if self.is_paused {
return;
}
self.elapsed_running += now.saturating_duration_since(self.last_resume_at);
self.is_paused = true;
}
pub(crate) fn resume_timer_at(&mut self, now: Instant) {
if !self.is_paused {
return;
}
self.last_resume_at = now;
self.is_paused = false;
self.frame_requester.schedule_frame();
}
fn elapsed_seconds_at(&self, now: Instant) -> u64 {
let mut elapsed = self.elapsed_running;
if !self.is_paused {
elapsed += now.saturating_duration_since(self.last_resume_at);
}
elapsed.as_secs()
}
fn elapsed_seconds(&self) -> u64 {
self.elapsed_seconds_at(Instant::now())
}
}
impl WidgetRef for StatusIndicatorWidget {
@@ -98,14 +152,15 @@ impl WidgetRef for StatusIndicatorWidget {
// Schedule next animation frame.
self.frame_requester
.schedule_frame_in(Duration::from_millis(32));
let elapsed = self.start_time.elapsed().as_secs();
let elapsed = self.elapsed_seconds();
let pretty_elapsed = fmt_elapsed_compact(elapsed);
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
let mut spans = vec![" ".into()];
spans.extend(shimmer_spans(&self.header));
spans.extend(vec![
" ".into(),
format!("({elapsed}s").dim(),
format!("({pretty_elapsed}").dim(),
"Esc".dim().bold(),
" to interrupt)".dim(),
]);
@@ -115,11 +170,8 @@ impl WidgetRef for StatusIndicatorWidget {
lines.push(Line::from(spans));
// Wrap queued messages using textwrap and show up to the first 3 lines per message.
let text_width = area.width.saturating_sub(3); // " ↳ " prefix
let opts = TwOptions::new(text_width as usize)
.break_words(false)
.word_splitter(WordSplitter::NoHyphenation);
for q in &self.queued_messages {
let wrapped = textwrap::wrap(q, &opts);
let wrapped = textwrap::wrap(q, text_width as usize);
for (i, piece) in wrapped.iter().take(3).enumerate() {
let prefix = if i == 0 { "" } else { " " };
let content = format!("{prefix}{piece}");
@@ -130,7 +182,8 @@ impl WidgetRef for StatusIndicatorWidget {
}
}
if !self.queued_messages.is_empty() {
lines.push(Line::from(vec![" ".into(), "Alt+↑".cyan(), " edit".into()]).dim());
let shortcut = key_hint::alt("");
lines.push(Line::from(vec![" ".into(), shortcut, " edit".into()]).dim());
}
let paragraph = Paragraph::new(lines);
@@ -143,11 +196,28 @@ mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::time::Duration;
use std::time::Instant;
use tokio::sync::mpsc::unbounded_channel;
use pretty_assertions::assert_eq;
#[test]
fn fmt_elapsed_compact_formats_seconds_minutes_hours() {
assert_eq!(fmt_elapsed_compact(0), "0s");
assert_eq!(fmt_elapsed_compact(1), "1s");
assert_eq!(fmt_elapsed_compact(59), "59s");
assert_eq!(fmt_elapsed_compact(60), "1m00s");
assert_eq!(fmt_elapsed_compact(61), "1m01s");
assert_eq!(fmt_elapsed_compact(3 * 60 + 5), "3m05s");
assert_eq!(fmt_elapsed_compact(59 * 60 + 59), "59m59s");
assert_eq!(fmt_elapsed_compact(3600), "1h00m00s");
assert_eq!(fmt_elapsed_compact(3600 + 60 + 1), "1h01m01s");
assert_eq!(fmt_elapsed_compact(25 * 3600 + 2 * 60 + 3), "25h02m03s");
}
#[test]
fn renders_with_working_header() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -159,7 +229,7 @@ mod tests {
terminal
.draw(|f| w.render_ref(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(terminal.backend());
insta::assert_snapshot!(terminal.backend());
}
#[test]
@@ -173,7 +243,7 @@ mod tests {
terminal
.draw(|f| w.render_ref(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(terminal.backend());
insta::assert_snapshot!(terminal.backend());
}
#[test]
@@ -188,6 +258,27 @@ mod tests {
terminal
.draw(|f| w.render_ref(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(terminal.backend());
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn timer_pauses_when_requested() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut widget = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
let baseline = Instant::now();
widget.last_resume_at = baseline;
let before_pause = widget.elapsed_seconds_at(baseline + Duration::from_secs(5));
assert_eq!(before_pause, 5);
widget.pause_timer_at(baseline + Duration::from_secs(5));
let paused_elapsed = widget.elapsed_seconds_at(baseline + Duration::from_secs(10));
assert_eq!(paused_elapsed, before_pause);
widget.resume_timer_at(baseline + Duration::from_secs(10));
let after_resume = widget.elapsed_seconds_at(baseline + Duration::from_secs(13));
assert_eq!(after_resume, before_pause + 3);
}
}

View File

@@ -1,7 +1,6 @@
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;
@@ -20,10 +19,7 @@ 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;
@@ -38,7 +34,6 @@ 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;
@@ -154,12 +149,6 @@ pub enum TuiEvent {
Key(KeyEvent),
Paste(String),
Draw,
AttachImage {
path: PathBuf,
width: u32,
height: u32,
format_label: &'static str,
},
}
pub struct Tui {
@@ -305,29 +294,6 @@ impl Tui {
select! {
Some(Ok(event)) = crossterm_events.next() => {
match event {
// 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,
..
}) => {
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!(

View File

@@ -11,6 +11,8 @@ use std::path::PathBuf;
use codex_core::config::Config;
use codex_core::default_client::create_client;
use crate::version::CODEX_CLI_VERSION;
pub fn get_upgrade_version(config: &Config) -> Option<String> {
let version_file = version_filepath(config);
let info = read_version_info(&version_file).ok();
@@ -31,8 +33,7 @@ pub fn get_upgrade_version(config: &Config) -> Option<String> {
}
info.and_then(|info| {
let current_version = env!("CARGO_PKG_VERSION");
if is_newer(&info.latest_version, current_version).unwrap_or(false) {
if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(false) {
Some(info.latest_version)
} else {
None

View File

@@ -30,6 +30,7 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::text_formatting::truncate_text;
/// Request coming from the agent that needs user approval.
pub(crate) enum ApprovalRequest {
@@ -110,45 +111,11 @@ pub(crate) struct UserApprovalWidget {
done: bool,
}
fn to_command_display<'a>(
first_line: Vec<Span<'a>>,
cmd: String,
last_line: Vec<Span<'a>>,
) -> Vec<Line<'a>> {
let command_lines: Vec<Span> = cmd.lines().map(|line| line.to_string().dim()).collect();
let mut lines: Vec<Line<'a>> = vec![];
let mut first_line = first_line.clone();
if command_lines.len() == 1 {
first_line.push(command_lines[0].clone());
first_line.extend(last_line);
} else {
for line in command_lines {
lines.push(vec![" ".into(), line].into());
}
let last_line = last_line.clone();
lines.push(Line::from(last_line));
}
lines.insert(0, Line::from(first_line));
lines
}
impl UserApprovalWidget {
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
let confirmation_prompt = match &approval_request {
ApprovalRequest::Exec {
command, reason, ..
} => {
let cmd = strip_bash_lc_and_escape(command);
let mut contents: Vec<Line> = to_command_display(
vec!["? ".fg(Color::Cyan), "Codex wants to run ".bold()],
cmd,
vec![],
);
contents.push(Line::from(""));
ApprovalRequest::Exec { reason, .. } => {
let mut contents: Vec<Line> = vec![];
if let Some(reason) = reason {
contents.push(Line::from(reason.clone().italic()));
contents.push(Line::from(""));
@@ -258,62 +225,61 @@ impl UserApprovalWidget {
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
match &self.approval_request {
ApprovalRequest::Exec { command, .. } => {
let cmd = strip_bash_lc_and_escape(command);
// TODO: move this rendering into history_cell.
let mut lines: Vec<Line<'static>> = vec![];
let full_cmd = strip_bash_lc_and_escape(command);
// Construct a concise, single-line summary of the command:
// - If multi-line, take the first line and append " ...".
// - Truncate to 80 graphemes.
let mut snippet = match full_cmd.split_once('\n') {
Some((first, _)) => format!("{first} ..."),
None => full_cmd.clone(),
};
// Enforce the 80 character length limit.
snippet = truncate_text(&snippet, 80);
// Result line based on decision.
let mut result_spans: Vec<Span<'static>> = Vec::new();
match decision {
ReviewDecision::Approved => {
lines.extend(to_command_display(
vec![
" ".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
],
cmd,
vec![" this time".bold()],
));
result_spans.extend(vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet.clone().dim(),
" this time".bold(),
]);
}
ReviewDecision::ApprovedForSession => {
lines.extend(to_command_display(
vec![
" ".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
],
cmd,
vec![" every time this session".bold()],
));
result_spans.extend(vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet.clone().dim(),
" every time this session".bold(),
]);
}
ReviewDecision::Denied => {
lines.extend(to_command_display(
vec![
" ".fg(Color::Red),
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
],
cmd,
vec![],
));
result_spans.extend(vec![
"".fg(Color::Red),
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
snippet.clone().dim(),
]);
}
ReviewDecision::Abort => {
lines.extend(to_command_display(
vec![
" ".fg(Color::Red),
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
],
cmd,
vec![],
));
result_spans.extend(vec![
"".fg(Color::Red),
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
snippet.clone().dim(),
]);
}
}
let mut lines: Vec<Line<'static>> = vec![Line::from(result_spans)];
if !feedback.trim().is_empty() {
lines.push(Line::from("feedback:"));
for l in feedback.lines() {

View File

@@ -0,0 +1,2 @@
/// The current Codex CLI version as embedded at compile time.
pub const CODEX_CLI_VERSION: &str = env!("CARGO_PKG_VERSION");

Some files were not shown because too many files have changed in this diff Show More