Compare commits

..

22 Commits

Author SHA1 Message Date
Ahmed Ibrahim
4e6bc85fc4 oss 2025-09-02 15:19:29 -07:00
Ahmed Ibrahim
8bb57afc84 oss 2025-09-02 15:18:04 -07:00
Ahmed Ibrahim
a8324c5d94 progress 2025-09-02 14:52:44 -07:00
Jeremy Rose
46e35a2345 tui: fix extra blank lines in streamed agent messages (#3065)
Fixes excessive blank lines appearing during agent message streaming.

- Only insert a separator blank line for new, non-streaming history
cells.
- Streaming continuations now append without adding a spacer,
eliminating extra gaps between chunks.

Affected area: TUI display of agent messages (tui/src/app.rs).
2025-09-02 13:45:51 -07:00
Reuben Narad
7bcdc5cc7c fix config reference table (#3063)
3 quick fixes to docs/config.md

- Fix the reference table so option lists render correctly
- Corrected the default `stream_max_retries` to 5 (Old: 10)
- Update example approval_policy to untrusted (Old: unless-allow-listed)
2025-09-02 13:03:11 -07:00
Michael Bolin
4b426f7e1e fix: leverage windows-11-arm for Windows ARM builds (#3062)
This is in support of https://github.com/openai/codex/issues/2979.

Once we have a release out, we can update the npm module and the VS Code
extension to take advantage of this.
2025-09-02 12:56:09 -07:00
Jeremy Rose
fcb62a0fa5 tui: hide '/init' suggestion when AGENTS.md exists (#3038)
Hide the “/init” suggestion in the new-session banner when an
`AGENTS.md` exists anywhere from the repo root down to the current
working directory.

Changes
- Conditional suggestion: use `discover_project_doc_paths(config)` to
suppress `/init` when agents docs are present.
- TUI style cleanup: switch banner construction to `Stylize` helpers
(`.bold()`, `.dim()`, `.into()`), avoiding `Span::styled`/`Span::raw`.
- Fixture update: remove `/init` line in
`tui/tests/fixtures/ideal-binary-response.txt` to match the new banner.

Validation
- Ran formatting and scoped lint fixes: `just fmt` and `just fix -p
codex-tui`.
- Tests: `cargo test -p codex-tui` passed (`176 passed, 0 failed`).

Notes
- No change to the `/init` command itself; only the welcome banner now
adapts based on presence of `AGENTS.md`.
2025-09-02 12:04:32 -07:00
Ahmed Ibrahim
eb40fe3451 Add logs to know when we users are changing the model (#3060) 2025-09-02 17:59:07 +00:00
Jeremy Rose
b32c79e371 tui: fix laggy typing (#2922)
we were checking every typed character to see if it was an image. this
involved going to disk, which was slow.

this was a bad interaction between image paste support and burst-paste
detection.
2025-09-02 10:35:29 -07:00
Jeremy Rose
e442ecedab rework message styling (#2877)
https://github.com/user-attachments/assets/cf07f62b-1895-44bb-b9c3-7a12032eb371
2025-09-02 17:29:58 +00:00
Lionel Cheng
3f8d6021ac Fix: Adapt pr template with correct link following doc refacto (#2982)
This PR fixes the link of contributing page in Pull Request template to
the right one following the migration of the section to a dedicated
file.

Signed-off-by: lionelchg <lionel.cheng@hotmail.fr>
2025-09-02 13:05:52 -04:00
Uhyeon Park
7ac6194c22 Bug fix: ignore Enter on empty input to avoid queuing blank messages (#3047)
## Summary
Pressing Enter with an empty composer was treated as a submission, which
queued a blank message while a task was running. This PR suppresses
submission when there is no text and no attachments.

## Root Cause

- ChatComposer returned Submitted even when the trimmed text was empty.
ChatWidget then queued it during a running task, leading to an empty
item appearing in the queued list and being popped later with no effect.

## Changes
- ChatComposer Enter handling: if trimmed text is empty and there are no
attached images, return None instead of Submitted.
- No changes to ChatWidget; behavior naturally stops queuing blanks at
the source.

## Code Paths

- Modified: `tui/src/bottom_pane/chat_composer.rs`
- Tests added:
    - `tui/src/bottom_pane/chat_composer.rs`: `empty_enter_returns_none`
- `tui/src/chatwidget/tests.rs`:
`empty_enter_during_task_does_not_queue`

## Result

### Before


https://github.com/user-attachments/assets/a40e2f6d-42ba-4a82-928b-8f5458f5884d

### After



https://github.com/user-attachments/assets/958900b7-a566-44fc-b16c-b80380739c92
2025-09-02 13:05:45 -04:00
Jeremy Rose
619436c58f remove extra quote from disabled-command message (#3035)
there was an extra ' floating around for some reason.
2025-09-02 09:46:41 -07:00
dependabot[bot]
1cc6b97227 chore(deps): bump regex-lite from 0.1.6 to 0.1.7 in /codex-rs (#3010)
Bumps [regex-lite](https://github.com/rust-lang/regex) from 0.1.6 to
0.1.7.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rust-lang/regex/blob/master/CHANGELOG.md">regex-lite's
changelog</a>.</em></p>
<blockquote>
<h1>0.1.79</h1>
<ul>
<li>Require regex-syntax 0.3.8.</li>
</ul>
<h1>0.1.78</h1>
<ul>
<li>[PR <a
href="https://redirect.github.com/rust-lang/regex/issues/290">#290</a>](<a
href="https://redirect.github.com/rust-lang/regex/pull/290">rust-lang/regex#290</a>):
Fixes bug <a
href="https://redirect.github.com/rust-lang/regex/issues/289">#289</a>,
which caused some regexes with a certain combination
of literals to match incorrectly.</li>
</ul>
<h1>0.1.77</h1>
<ul>
<li>[PR <a
href="https://redirect.github.com/rust-lang/regex/issues/281">#281</a>](<a
href="https://redirect.github.com/rust-lang/regex/pull/281">rust-lang/regex#281</a>):
Fixes bug <a
href="https://redirect.github.com/rust-lang/regex/issues/280">#280</a>
by disabling all literal optimizations when a pattern
is partially anchored.</li>
</ul>
<h1>0.1.76</h1>
<ul>
<li>Tweak criteria for using the Teddy literal matcher.</li>
</ul>
<h1>0.1.75</h1>
<ul>
<li>[PR <a
href="https://redirect.github.com/rust-lang/regex/issues/275">#275</a>](<a
href="https://redirect.github.com/rust-lang/regex/pull/275">rust-lang/regex#275</a>):
Improves match verification performance in the Teddy SIMD searcher.</li>
<li>[PR <a
href="https://redirect.github.com/rust-lang/regex/issues/278">#278</a>](<a
href="https://redirect.github.com/rust-lang/regex/pull/278">rust-lang/regex#278</a>):
Replaces slow substring loop in the Teddy SIMD searcher with
Aho-Corasick.</li>
<li>Implemented DoubleEndedIterator on regex set match iterators.</li>
</ul>
<h1>0.1.74</h1>
<ul>
<li>Release regex-syntax 0.3.5 with a minor bug fix.</li>
<li>Fix bug <a
href="https://redirect.github.com/rust-lang/regex/issues/272">#272</a>.</li>
<li>Fix bug <a
href="https://redirect.github.com/rust-lang/regex/issues/277">#277</a>.</li>
<li>[PR <a
href="https://redirect.github.com/rust-lang/regex/issues/270">#270</a>](<a
href="https://redirect.github.com/rust-lang/regex/pull/270">rust-lang/regex#270</a>):
Fixes bugs <a
href="https://redirect.github.com/rust-lang/regex/issues/264">#264</a>,
<a
href="https://redirect.github.com/rust-lang/regex/issues/268">#268</a>
and an unreported where the DFA cache size could be
drastically underestimated in some cases (leading to high unexpected
memory
usage).</li>
</ul>
<h1>0.1.73</h1>
<ul>
<li>Release <code>regex-syntax 0.3.4</code>.</li>
<li>Bump <code>regex-syntax</code> dependency version for
<code>regex</code> to <code>0.3.4</code>.</li>
</ul>
<h1>0.1.72</h1>
<ul>
<li>[PR <a
href="https://redirect.github.com/rust-lang/regex/issues/262">#262</a>](<a
href="https://redirect.github.com/rust-lang/regex/pull/262">rust-lang/regex#262</a>):
Fixes a number of small bugs caught by fuzz testing (AFL).</li>
</ul>
<h1>0.1.71</h1>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="45c3da7681"><code>45c3da7</code></a>
regex-lite-0.1.7</li>
<li><a
href="873ed800c5"><code>873ed80</code></a>
regex-automata-0.4.10</li>
<li><a
href="ea834f8e1f"><code>ea834f8</code></a>
regex-syntax-0.8.6</li>
<li><a
href="86836fbe84"><code>86836fb</code></a>
changelog: 1.11.2</li>
<li><a
href="63a26c1a7f"><code>63a26c1</code></a>
cargo: ensure that 'perf' doesn't enable 'std' implicitly (<a
href="https://redirect.github.com/rust-lang/regex/issues/1150">#1150</a>)</li>
<li><a
href="dd96592be2"><code>dd96592</code></a>
doc: clarify CRLF mode effect</li>
<li><a
href="931dae0192"><code>931dae0</code></a>
cargo: point <code>repository</code> metadata to clonable URLs</li>
<li><a
href="a66fde6e80"><code>a66fde6</code></a>
doc: remove references to non-existent parameters</li>
<li><a
href="1873e96a7b"><code>1873e96</code></a>
automata: add <code>DFA::set_prefilter</code> method to the DFA
types</li>
<li><a
href="89ff15310b"><code>89ff153</code></a>
doc: fix misspelling typo</li>
<li>Additional commits viewable in <a
href="https://github.com/rust-lang/regex/compare/regex-lite-0.1.6...regex-lite-0.1.7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=regex-lite&package-manager=cargo&previous-version=0.1.6&new-version=0.1.7)](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-02 09:09:17 -07:00
Michael Bolin
7eee69d821 fix: try to populate the Windows cache for release builds when PRs are put up for review (#2884)
Windows release builds take close to 12 minutes whereas Mac/Linux are
closer to 5. Let's see if this speeds things up?
2025-08-28 23:48:29 -07:00
Michael Bolin
65636802f7 fix: drop Mutexes earlier in MCP server (#2878) 2025-08-28 22:50:16 -07:00
Michael Bolin
c988ce28fe fix: drop Mutex before calling tx_approve.send() (#2876) 2025-08-28 22:49:29 -07:00
Michael Bolin
cb2f952143 fix: remove unnecessary flush() calls (#2873)
Because we are writing to a pipe, these `flush()` calls are unnecessary,
so removing these saves us one syscall per write in these two cases.
2025-08-28 22:41:10 -07:00
Jeremy Rose
7d734bff65 suggest just fix -p in agents.md (#2881) 2025-08-28 22:32:53 -07:00
Michael Bolin
970e466ab3 fix: switch to unbounded channel (#2874)
#2747 encouraged me to audit our codebase for similar issues, as now I
am particularly suspicious that our flaky tests are due to a racy
deadlock.

I asked Codex to audit our code, and one of its suggestions was this:

> **High-Risk Patterns**
>
> All `send_*` methods await on a bounded
`mpsc::Sender<OutgoingMessage>`. If the writer blocks, the channel fills
and the processor task blocks on send, stops draining incoming requests,
and stdin reader eventually blocks on its send. This creates a
backpressure deadlock cycle across the three tasks.
>
> **Recommendations**
> * Server outgoing path: break the backpressure cycle
> * Option A (minimal risk): Change `OutgoingMessageSender` to use an
unbounded channel to decouple producer from stdout. Add rate logging so
floods are visible.
> * Option B (bounded + drop policy): Change `send_*` to try_send and
drop messages (or coalesce) when the queue is full, logging a warning.
This prevents processor stalls at the cost of losing messages under
extreme backpressure.
> * Option C (two-stage buffer): Keep bounded channel, but have a
dedicated “egress” task that drains an unbounded internal queue, writing
to stdout with retries and a shutdown timeout. This centralizes
backpressure policy.

So this PR is Option A.

Indeed, we previously used a bounded channel with a capacity of `128`,
but as we discovered recently with #2776, there are certainly cases
where we can get flooded with events.

That said, `test_shell_command_approval_triggers_elicitation` just
failed one one build when I put up this PR, so clearly we are not out of
the woods yet...

**Update:** I think I found the true source of the deadlock! See
https://github.com/openai/codex/pull/2876
2025-08-28 22:20:10 -07:00
Michael Bolin
5d2d3002ef fix: specify --profile to cargo clippy in CI (#2871)
Today we had a breakage in the release build that went unnoticed by CI.
Here is what happened:

- https://github.com/openai/codex/pull/2242 originally added some logic
to do release builds to prevent this from happening
- https://github.com/openai/codex/pull/2276 undid that change to try to
speed things up by removing the step to build all the individual crates
in release mode, assuming the `cargo check` call was sufficient
coverage, which it would have been, had it specified `--profile`

This PR adds `--profile` to the `cargo check` step so we should get the
desired coverage from our build matrix.

Indeed, enabling this in our CI uncovered a warning that is only present
in release mode that was going unnoticed.
2025-08-28 21:43:40 -07:00
agro
bb30996f7c Bugfix: Prevents brew install codex in comment to be executed (#2868)
The default install command causes unexpected code to be executed:

```
npm install -g @openai/codex # Alternatively: `brew install codex`
```

The problem is some environment will treat # as literal string, not
start of comment. Therefore the user will execute this instead (because
it's in backtick)

```
brew install codex
```

And then the npm command will error (because it's trying to install
package #)
2025-08-28 21:40:28 -07:00
412 changed files with 3118 additions and 159501 deletions

View File

@@ -21,6 +21,10 @@
"windows-x86_64": {
"regex": "^codex-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex.exe"
},
"windows-aarch64": {
"regex": "^codex-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex.exe"
}
}
}

View File

@@ -1,6 +1,6 @@
# External (non-OpenAI) Pull Request Requirements
Before opening this Pull Request, please read the "Contributing" section of the README or your PR may be closed:
https://github.com/openai/codex#contributing
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.

View File

@@ -100,15 +100,26 @@ jobs:
- runner: windows-latest
target: x86_64-pc-windows-msvc
profile: dev
- runner: windows-11-arm
target: aarch64-pc-windows-msvc
profile: dev
# Also run representative release builds on Mac and Linux because
# there could be release-only build errors we want to catch.
# Hopefully this also pre-populates the build cache to speed up
# releases.
- runner: macos-14
target: aarch64-apple-darwin
profile: release
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
profile: release
- runner: windows-latest
target: x86_64-pc-windows-msvc
profile: release
- runner: windows-11-arm
target: aarch64-pc-windows-msvc
profile: release
steps:
- uses: actions/checkout@v5
@@ -134,7 +145,7 @@ jobs:
- name: cargo clippy
id: clippy
run: cargo clippy --target ${{ matrix.target }} --all-features --tests -- -D warnings
run: cargo clippy --target ${{ matrix.target }} --all-features --tests --profile ${{ matrix.profile }} -- -D warnings
# Running `cargo build` from the workspace root builds the workspace using
# the union of all features from third-party crates. This can mask errors

View File

@@ -72,6 +72,8 @@ jobs:
target: aarch64-unknown-linux-gnu
- runner: windows-latest
target: x86_64-pc-windows-msvc
- runner: windows-11-arm
target: aarch64-pc-windows-msvc
steps:
- uses: actions/checkout@v5
@@ -87,7 +89,7 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ github.workspace }}/codex-rs/target/
key: cargo-release-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }}
key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }}
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Install musl build tools

View File

@@ -8,7 +8,7 @@ In the codex-rs folder where the rust code lives:
- You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations.
- Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate.
Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Additionally, run the tests:
Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `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 these commands to finalize.

View File

@@ -14,10 +14,16 @@
### Installing and running Codex CLI
Install globally with your preferred package manager:
Install globally with your preferred package manager. If you use npm:
```shell
npm install -g @openai/codex # Alternatively: `brew install codex`
npm install -g @openai/codex
```
Alternatively, if you use Homebrew:
```shell
brew install codex
```
Then simply run `codex` to get started:

12
codex-rs/Cargo.lock generated
View File

@@ -973,11 +973,13 @@ dependencies = [
"diffy",
"image",
"insta",
"itertools 0.14.0",
"lazy_static",
"libc",
"mcp-types",
"once_cell",
"path-clean",
"pathdiff",
"pretty_assertions",
"rand 0.9.2",
"ratatui",
@@ -3377,6 +3379,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.1"
@@ -3907,9 +3915,9 @@ dependencies = [
[[package]]
name = "regex-lite"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a"
checksum = "943f41321c63ef1c92fd763bfe054d2668f7f225a5c29f0105903dc2fc04ba30"
[[package]]
name = "regex-syntax"

View File

@@ -116,7 +116,9 @@ pub enum ApplyPatchFileChange {
Add {
content: String,
},
Delete,
Delete {
content: String,
},
Update {
unified_diff: String,
move_path: Option<PathBuf>,
@@ -210,7 +212,18 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
}
Hunk::DeleteFile { .. } => {
changes.insert(path, ApplyPatchFileChange::Delete);
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(e) => {
return MaybeApplyPatchVerified::CorrectnessError(
ApplyPatchError::IoError(IoError {
context: format!("Failed to read {}", path.display()),
source: e,
}),
);
}
};
changes.insert(path, ApplyPatchFileChange::Delete { content });
}
Hunk::UpdateFile {
move_path, chunks, ..

View File

@@ -31,7 +31,7 @@ mime_guess = "2.0"
os_info = "3.12.0"
portable-pty = "0.9.0"
rand = "0.9"
regex-lite = "0.1.6"
regex-lite = "0.1.7"
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"

View File

@@ -109,7 +109,9 @@ pub(crate) fn convert_apply_patch_to_protocol(
ApplyPatchFileChange::Add { content } => FileChange::Add {
content: content.clone(),
},
ApplyPatchFileChange::Delete => FileChange::Delete,
ApplyPatchFileChange::Delete { content } => FileChange::Delete {
content: content.clone(),
},
ApplyPatchFileChange::Update {
unified_diff,
move_path,

View File

@@ -19,6 +19,7 @@ use crate::ModelProviderInfo;
use crate::client_common::Prompt;
use crate::client_common::ResponseEvent;
use crate::client_common::ResponseStream;
use crate::config::Config;
use crate::error::CodexErr;
use crate::error::Result;
use crate::model_family::ModelFamily;
@@ -34,6 +35,7 @@ pub(crate) async fn stream_chat_completions(
model_family: &ModelFamily,
client: &reqwest::Client,
provider: &ModelProviderInfo,
config: &Config,
) -> Result<ResponseStream> {
// Build messages array
let mut messages = Vec::<serde_json::Value>::new();
@@ -129,10 +131,26 @@ pub(crate) async fn stream_chat_completions(
"content": output,
}));
}
ResponseItem::Reasoning { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::Other => {
// Omit these items from the conversation history.
ResponseItem::Reasoning {
id: _,
summary,
content,
encrypted_content: _,
} => {
if !config.skip_reasoning_in_chat_completions {
// There is no clear way of sending reasoning items over chat completions.
// We are sending it as an assistant message.
tracing::info!("reasoning item: {:?}", item);
let reasoning =
format!("Reasoning Summary: {summary:?}, Reasoning Content: {content:?}");
messages.push(json!({
"role": "assistant",
"content": reasoning,
}));
}
}
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {
tracing::info!("omitting item from chat completions: {:?}", item);
continue;
}
}
@@ -350,6 +368,8 @@ async fn process_chat_sse<S>(
}
if let Some(reasoning) = maybe_text {
// Accumulate so we can emit a terminal Reasoning item at end-of-turn.
reasoning_text.push_str(&reasoning);
let _ = tx_event
.send(Ok(ResponseEvent::ReasoningContentDelta(reasoning)))
.await;

View File

@@ -110,6 +110,7 @@ impl ModelClient {
&self.config.model_family,
&self.client,
&self.provider,
&self.config,
)
.await?;

View File

@@ -657,9 +657,17 @@ impl Session {
}
pub fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) {
let mut state = self.state.lock_unchecked();
if let Some(tx_approve) = state.pending_approvals.remove(sub_id) {
tx_approve.send(decision).ok();
let entry = {
let mut state = self.state.lock_unchecked();
state.pending_approvals.remove(sub_id)
};
match entry {
Some(tx_approve) => {
tx_approve.send(decision).ok();
}
None => {
warn!("No pending approval found for sub_id: {sub_id}");
}
}
}

View File

@@ -185,6 +185,10 @@ 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,
/// When `true`, reasoning items in Chat Completions input will be skipped.
/// Defaults to `false`.
pub skip_reasoning_in_chat_completions: bool,
}
impl Config {
@@ -497,6 +501,10 @@ pub struct ConfigToml {
/// All characters are inserted as they are received, and no buffering
/// or placeholder replacement will occur for fast keypress bursts.
pub disable_paste_burst: Option<bool>,
/// When set to `true`, reasoning items will be skipped from Chat Completions input.
/// Defaults to `false`.
pub skip_reasoning_in_chat_completions: Option<bool>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
@@ -807,6 +815,9 @@ impl Config {
.unwrap_or(false),
include_view_image_tool,
disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false),
skip_reasoning_in_chat_completions: cfg
.skip_reasoning_in_chat_completions
.unwrap_or(false),
};
Ok(config)
}
@@ -1177,6 +1188,7 @@ disable_response_storage = true
use_experimental_streamable_shell_tool: false,
include_view_image_tool: true,
disable_paste_burst: false,
skip_reasoning_in_chat_completions: false,
},
o3_profile_config
);
@@ -1235,6 +1247,7 @@ disable_response_storage = true
use_experimental_streamable_shell_tool: false,
include_view_image_tool: true,
disable_paste_burst: false,
skip_reasoning_in_chat_completions: false,
};
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
@@ -1308,6 +1321,7 @@ disable_response_storage = true
use_experimental_streamable_shell_tool: false,
include_view_image_tool: true,
disable_paste_burst: false,
skip_reasoning_in_chat_completions: false,
};
assert_eq!(expected_zdr_profile_config, zdr_profile_config);

View File

@@ -10,7 +10,35 @@ use tokio::process::Command;
use tokio::time::Duration as TokioDuration;
use tokio::time::timeout;
use crate::util::is_inside_git_repo;
/// Return `true` if the project folder specified by the `Config` is inside a
/// Git repository.
///
/// The check walks up the directory hierarchy looking for a `.git` file or
/// directory (note `.git` can be a file that contains a `gitdir` entry). This
/// approach does **not** require the `git` binary or the `git2` crate and is
/// therefore fairly lightweight.
///
/// Note that this does **not** detect *worktrees* created with
/// `git worktree add` where the checkout lives outside the main repository
/// directory. If you need Codex to work from such a checkout simply pass the
/// `--allow-no-git-exec` CLI flag that disables the repo requirement.
pub fn get_git_repo_root(base_dir: &Path) -> Option<PathBuf> {
let mut dir = base_dir.to_path_buf();
loop {
if dir.join(".git").exists() {
return Some(dir);
}
// Pop one component (go up one directory). `pop` returns false when
// we have reached the filesystem root.
if !dir.pop() {
break;
}
}
None
}
/// Timeout for git commands to prevent freezing on large repositories
const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
@@ -94,9 +122,7 @@ pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
if !is_inside_git_repo(cwd) {
return None;
}
get_git_repo_root(cwd)?;
let remotes = get_git_remotes(cwd).await?;
let branches = branch_ancestry(cwd).await?;
@@ -440,7 +466,7 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
}
/// Resolve the path that should be used for trust checks. Similar to
/// `[utils::is_inside_git_repo]`, but resolves to the root of the main
/// `[get_git_repo_root]`, but resolves to the root of the main
/// repository. Handles worktrees.
pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };

View File

@@ -20,22 +20,6 @@ pub enum ParsedCommand {
query: Option<String>,
path: Option<String>,
},
Format {
cmd: String,
tool: Option<String>,
targets: Option<Vec<String>>,
},
Test {
cmd: String,
},
Lint {
cmd: String,
tool: Option<String>,
targets: Option<Vec<String>>,
},
Noop {
cmd: String,
},
Unknown {
cmd: String,
},
@@ -50,10 +34,6 @@ impl From<ParsedCommand> for codex_protocol::parse_command::ParsedCommand {
ParsedCommand::Read { cmd, name } => P::Read { cmd, name },
ParsedCommand::ListFiles { cmd, path } => P::ListFiles { cmd, path },
ParsedCommand::Search { cmd, query, path } => P::Search { cmd, query, path },
ParsedCommand::Format { cmd, tool, targets } => P::Format { cmd, tool, targets },
ParsedCommand::Test { cmd } => P::Test { cmd },
ParsedCommand::Lint { cmd, tool, targets } => P::Lint { cmd, tool, targets },
ParsedCommand::Noop { cmd } => P::Noop { cmd },
ParsedCommand::Unknown { cmd } => P::Unknown { cmd },
}
}
@@ -122,7 +102,7 @@ mod tests {
assert_parsed(
&vec_str(&["bash", "-lc", inner]),
vec![ParsedCommand::Unknown {
cmd: "git status | wc -l".to_string(),
cmd: "git status".to_string(),
}],
);
}
@@ -244,6 +224,17 @@ mod tests {
);
}
#[test]
fn cd_then_cat_is_single_read() {
assert_parsed(
&shlex_split_safe("cd foo && cat foo.txt"),
vec![ParsedCommand::Read {
cmd: "cat foo.txt".to_string(),
name: "foo.txt".to_string(),
}],
);
}
#[test]
fn supports_ls_with_pipe() {
let inner = "ls -la | sed -n '1,120p'";
@@ -315,27 +306,6 @@ mod tests {
);
}
#[test]
fn supports_npm_run_with_forwarded_args() {
assert_parsed(
&vec_str(&[
"npm",
"run",
"lint",
"--",
"--max-warnings",
"0",
"--format",
"json",
]),
vec![ParsedCommand::Lint {
cmd: "npm run lint -- --max-warnings 0 --format json".to_string(),
tool: Some("npm-script:lint".to_string()),
targets: None,
}],
);
}
#[test]
fn supports_grep_recursive_current_dir() {
assert_parsed(
@@ -396,173 +366,10 @@ mod tests {
fn supports_cd_and_rg_files() {
assert_parsed(
&shlex_split_safe("cd codex-rs && rg --files"),
vec![
ParsedCommand::Unknown {
cmd: "cd codex-rs".to_string(),
},
ParsedCommand::Search {
cmd: "rg --files".to_string(),
query: None,
path: None,
},
],
);
}
#[test]
fn echo_then_cargo_test_sequence() {
assert_parsed(
&shlex_split_safe("echo Running tests... && cargo test --all-features --quiet"),
vec![ParsedCommand::Test {
cmd: "cargo test --all-features --quiet".to_string(),
}],
);
}
#[test]
fn supports_cargo_fmt_and_test_with_config() {
assert_parsed(
&shlex_split_safe(
"cargo fmt -- --config imports_granularity=Item && cargo test -p core --all-features",
),
vec![
ParsedCommand::Format {
cmd: "cargo fmt -- --config 'imports_granularity=Item'".to_string(),
tool: Some("cargo fmt".to_string()),
targets: None,
},
ParsedCommand::Test {
cmd: "cargo test -p core --all-features".to_string(),
},
],
);
}
#[test]
fn recognizes_rustfmt_and_clippy() {
assert_parsed(
&shlex_split_safe("rustfmt src/main.rs"),
vec![ParsedCommand::Format {
cmd: "rustfmt src/main.rs".to_string(),
tool: Some("rustfmt".to_string()),
targets: Some(vec!["src/main.rs".to_string()]),
}],
);
assert_parsed(
&shlex_split_safe("cargo clippy -p core --all-features -- -D warnings"),
vec![ParsedCommand::Lint {
cmd: "cargo clippy -p core --all-features -- -D warnings".to_string(),
tool: Some("cargo clippy".to_string()),
targets: None,
}],
);
}
#[test]
fn recognizes_pytest_go_and_tools() {
assert_parsed(
&shlex_split_safe(
"pytest -k 'Login and not slow' tests/test_login.py::TestLogin::test_ok",
),
vec![ParsedCommand::Test {
cmd: "pytest -k 'Login and not slow' tests/test_login.py::TestLogin::test_ok"
.to_string(),
}],
);
assert_parsed(
&shlex_split_safe("go fmt ./..."),
vec![ParsedCommand::Format {
cmd: "go fmt ./...".to_string(),
tool: Some("go fmt".to_string()),
targets: Some(vec!["./...".to_string()]),
}],
);
assert_parsed(
&shlex_split_safe("go test ./pkg -run TestThing"),
vec![ParsedCommand::Test {
cmd: "go test ./pkg -run TestThing".to_string(),
}],
);
assert_parsed(
&shlex_split_safe("eslint . --max-warnings 0"),
vec![ParsedCommand::Lint {
cmd: "eslint . --max-warnings 0".to_string(),
tool: Some("eslint".to_string()),
targets: Some(vec![".".to_string()]),
}],
);
assert_parsed(
&shlex_split_safe("prettier -w ."),
vec![ParsedCommand::Format {
cmd: "prettier -w .".to_string(),
tool: Some("prettier".to_string()),
targets: Some(vec![".".to_string()]),
}],
);
}
#[test]
fn recognizes_jest_and_vitest_filters() {
assert_parsed(
&shlex_split_safe("jest -t 'should work' src/foo.test.ts"),
vec![ParsedCommand::Test {
cmd: "jest -t 'should work' src/foo.test.ts".to_string(),
}],
);
assert_parsed(
&shlex_split_safe("vitest -t 'runs' src/foo.test.tsx"),
vec![ParsedCommand::Test {
cmd: "vitest -t runs src/foo.test.tsx".to_string(),
}],
);
}
#[test]
fn recognizes_npx_and_scripts() {
assert_parsed(
&shlex_split_safe("npx eslint src"),
vec![ParsedCommand::Lint {
cmd: "npx eslint src".to_string(),
tool: Some("eslint".to_string()),
targets: Some(vec!["src".to_string()]),
}],
);
assert_parsed(
&shlex_split_safe("npx prettier -c ."),
vec![ParsedCommand::Format {
cmd: "npx prettier -c .".to_string(),
tool: Some("prettier".to_string()),
targets: Some(vec![".".to_string()]),
}],
);
assert_parsed(
&shlex_split_safe("pnpm run lint -- --max-warnings 0"),
vec![ParsedCommand::Lint {
cmd: "pnpm run lint -- --max-warnings 0".to_string(),
tool: Some("pnpm-script:lint".to_string()),
targets: None,
}],
);
assert_parsed(
&shlex_split_safe("npm test"),
vec![ParsedCommand::Test {
cmd: "npm test".to_string(),
}],
);
assert_parsed(
&shlex_split_safe("yarn test"),
vec![ParsedCommand::Test {
cmd: "yarn test".to_string(),
vec![ParsedCommand::Search {
cmd: "rg --files".to_string(),
query: None,
path: None,
}],
);
}
@@ -770,6 +577,51 @@ mod tests {
);
}
#[test]
fn parses_mixed_sequence_with_pipes_semicolons_and_or() {
// Provided long command sequence combining sequencing, pipelines, and ORs.
let inner = "pwd; ls -la; rg --files -g '!target' | wc -l; rg -n '^\\[workspace\\]' -n Cargo.toml || true; rg -n '^\\[package\\]' -n */Cargo.toml || true; cargo --version; rustc --version; cargo clippy --workspace --all-targets --all-features -q";
let args = vec_str(&["bash", "-lc", inner]);
let expected = vec![
ParsedCommand::Unknown {
cmd: "pwd".to_string(),
},
ParsedCommand::ListFiles {
cmd: shlex_join(&shlex_split_safe("ls -la")),
path: None,
},
ParsedCommand::Search {
cmd: shlex_join(&shlex_split_safe("rg --files -g '!target'")),
query: None,
path: Some("!target".to_string()),
},
ParsedCommand::Search {
cmd: shlex_join(&shlex_split_safe("rg -n '^\\[workspace\\]' -n Cargo.toml")),
query: Some("^\\[workspace\\]".to_string()),
path: Some("Cargo.toml".to_string()),
},
ParsedCommand::Search {
cmd: shlex_join(&shlex_split_safe("rg -n '^\\[package\\]' -n */Cargo.toml")),
query: Some("^\\[package\\]".to_string()),
path: Some("Cargo.toml".to_string()),
},
ParsedCommand::Unknown {
cmd: shlex_join(&shlex_split_safe("cargo --version")),
},
ParsedCommand::Unknown {
cmd: shlex_join(&shlex_split_safe("rustc --version")),
},
ParsedCommand::Unknown {
cmd: shlex_join(&shlex_split_safe(
"cargo clippy --workspace --all-targets --all-features -q",
)),
},
];
assert_parsed(&args, expected);
}
#[test]
fn strips_true_in_sequence() {
// `true` should be dropped from parsed sequences
@@ -867,159 +719,6 @@ mod tests {
);
}
#[test]
fn pnpm_test_is_parsed_as_test() {
assert_parsed(
&shlex_split_safe("pnpm test"),
vec![ParsedCommand::Test {
cmd: "pnpm test".to_string(),
}],
);
}
#[test]
fn pnpm_exec_vitest_is_unknown() {
// From commands_combined: cd codex-cli && pnpm exec vitest run tests/... --threads=false --passWithNoTests
let inner = "cd codex-cli && pnpm exec vitest run tests/file-tag-utils.test.ts --threads=false --passWithNoTests";
assert_parsed(
&shlex_split_safe(inner),
vec![
ParsedCommand::Unknown {
cmd: "cd codex-cli".to_string(),
},
ParsedCommand::Unknown {
cmd: "pnpm exec vitest run tests/file-tag-utils.test.ts '--threads=false' --passWithNoTests".to_string(),
},
],
);
}
#[test]
fn cargo_test_with_crate() {
assert_parsed(
&shlex_split_safe("cargo test -p codex-core parse_command::"),
vec![ParsedCommand::Test {
cmd: "cargo test -p codex-core parse_command::".to_string(),
}],
);
}
#[test]
fn cargo_test_with_crate_2() {
assert_parsed(
&shlex_split_safe(
"cd core && cargo test -q parse_command::tests::bash_dash_c_pipeline_parsing parse_command::tests::fd_file_finder_variants",
),
vec![ParsedCommand::Test {
cmd: "cargo test -q parse_command::tests::bash_dash_c_pipeline_parsing parse_command::tests::fd_file_finder_variants".to_string(),
}],
);
}
#[test]
fn cargo_test_with_crate_3() {
assert_parsed(
&shlex_split_safe("cd core && cargo test -q parse_command::tests"),
vec![ParsedCommand::Test {
cmd: "cargo test -q parse_command::tests".to_string(),
}],
);
}
#[test]
fn cargo_test_with_crate_4() {
assert_parsed(
&shlex_split_safe("cd core && cargo test --all-features parse_command -- --nocapture"),
vec![ParsedCommand::Test {
cmd: "cargo test --all-features parse_command -- --nocapture".to_string(),
}],
);
}
// Additional coverage for other common tools/frameworks
#[test]
fn recognizes_black_and_ruff() {
// black formats Python code
assert_parsed(
&shlex_split_safe("black src"),
vec![ParsedCommand::Format {
cmd: "black src".to_string(),
tool: Some("black".to_string()),
targets: Some(vec!["src".to_string()]),
}],
);
// ruff check is a linter; ensure we collect targets
assert_parsed(
&shlex_split_safe("ruff check ."),
vec![ParsedCommand::Lint {
cmd: "ruff check .".to_string(),
tool: Some("ruff".to_string()),
targets: Some(vec![".".to_string()]),
}],
);
// ruff format is a formatter
assert_parsed(
&shlex_split_safe("ruff format pkg/"),
vec![ParsedCommand::Format {
cmd: "ruff format pkg/".to_string(),
tool: Some("ruff".to_string()),
targets: Some(vec!["pkg/".to_string()]),
}],
);
}
#[test]
fn recognizes_pnpm_monorepo_test_and_npm_format_script() {
// pnpm -r test in a monorepo should still parse as a test action
assert_parsed(
&shlex_split_safe("pnpm -r test"),
vec![ParsedCommand::Test {
cmd: "pnpm -r test".to_string(),
}],
);
// npm run format should be recognized as a format action
assert_parsed(
&shlex_split_safe("npm run format -- -w ."),
vec![ParsedCommand::Format {
cmd: "npm run format -- -w .".to_string(),
tool: Some("npm-script:format".to_string()),
targets: None,
}],
);
}
#[test]
fn yarn_test_is_parsed_as_test() {
assert_parsed(
&shlex_split_safe("yarn test"),
vec![ParsedCommand::Test {
cmd: "yarn test".to_string(),
}],
);
}
#[test]
fn pytest_file_only_and_go_run_regex() {
// pytest invoked with a file path should be captured as a filter
assert_parsed(
&shlex_split_safe("pytest tests/test_example.py"),
vec![ParsedCommand::Test {
cmd: "pytest tests/test_example.py".to_string(),
}],
);
// go test with -run regex should capture the filter
assert_parsed(
&shlex_split_safe("go test ./... -run '^TestFoo$'"),
vec![ParsedCommand::Test {
cmd: "go test ./... -run '^TestFoo$'".to_string(),
}],
);
}
#[test]
fn grep_with_query_and_path() {
assert_parsed(
@@ -1090,30 +789,6 @@ mod tests {
);
}
#[test]
fn eslint_with_config_path_and_target() {
assert_parsed(
&shlex_split_safe("eslint -c .eslintrc.json src"),
vec![ParsedCommand::Lint {
cmd: "eslint -c .eslintrc.json src".to_string(),
tool: Some("eslint".to_string()),
targets: Some(vec!["src".to_string()]),
}],
);
}
#[test]
fn npx_eslint_with_config_path_and_target() {
assert_parsed(
&shlex_split_safe("npx eslint -c .eslintrc src"),
vec![ParsedCommand::Lint {
cmd: "npx eslint -c .eslintrc src".to_string(),
tool: Some("eslint".to_string()),
targets: Some(vec!["src".to_string()]),
}],
);
}
#[test]
fn fd_file_finder_variants() {
assert_parsed(
@@ -1202,16 +877,13 @@ fn simplify_once(commands: &[ParsedCommand]) -> Option<Vec<ParsedCommand>> {
return Some(commands[1..].to_vec());
}
// cd foo && [any Test command] => [any Test command]
// cd foo && [any command] => [any command] (keep non-cd when a cd is followed by something)
if let Some(idx) = commands.iter().position(|pc| match pc {
ParsedCommand::Unknown { cmd } => {
shlex_split(cmd).is_some_and(|t| t.first().map(|s| s.as_str()) == Some("cd"))
}
_ => false,
}) && commands
.iter()
.skip(idx + 1)
.any(|pc| matches!(pc, ParsedCommand::Test { .. }))
}) && commands.len() > idx + 1
{
let mut out = Vec::with_capacity(commands.len() - 1);
out.extend_from_slice(&commands[..idx]);
@@ -1220,10 +892,10 @@ fn simplify_once(commands: &[ParsedCommand]) -> Option<Vec<ParsedCommand>> {
}
// cmd || true => cmd
if let Some(idx) = commands.iter().position(|pc| match pc {
ParsedCommand::Noop { cmd } => cmd == "true",
_ => false,
}) {
if let Some(idx) = commands
.iter()
.position(|pc| matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"))
{
let mut out = Vec::with_capacity(commands.len() - 1);
out.extend_from_slice(&commands[..idx]);
out.extend_from_slice(&commands[idx + 1..]);
@@ -1377,75 +1049,6 @@ fn skip_flag_values<'a>(args: &'a [String], flags_with_vals: &[&str]) -> Vec<&'a
out
}
/// Common flags for ESLint that take a following value and should not be
/// considered positional targets.
const ESLINT_FLAGS_WITH_VALUES: &[&str] = &[
"-c",
"--config",
"--parser",
"--parser-options",
"--rulesdir",
"--plugin",
"--max-warnings",
"--format",
];
fn collect_non_flag_targets(args: &[String]) -> Option<Vec<String>> {
let mut targets = Vec::new();
let mut skip_next = false;
for (i, a) in args.iter().enumerate() {
if a == "--" {
break;
}
if skip_next {
skip_next = false;
continue;
}
if a == "-p"
|| a == "--package"
|| a == "--features"
|| a == "-C"
|| a == "--config"
|| a == "--config-path"
|| a == "--out-dir"
|| a == "-o"
|| a == "--run"
|| a == "--max-warnings"
|| a == "--format"
{
if i + 1 < args.len() {
skip_next = true;
}
continue;
}
if a.starts_with('-') {
continue;
}
targets.push(a.clone());
}
if targets.is_empty() {
None
} else {
Some(targets)
}
}
fn collect_non_flag_targets_with_flags(
args: &[String],
flags_with_vals: &[&str],
) -> Option<Vec<String>> {
let targets: Vec<String> = skip_flag_values(args, flags_with_vals)
.into_iter()
.filter(|a| !a.starts_with('-'))
.cloned()
.collect();
if targets.is_empty() {
None
} else {
Some(targets)
}
}
fn is_pathish(s: &str) -> bool {
s == "."
|| s == ".."
@@ -1514,47 +1117,6 @@ fn parse_find_query_and_path(tail: &[String]) -> (Option<String>, Option<String>
(query, path)
}
fn classify_npm_like(tool: &str, tail: &[String], full_cmd: &[String]) -> Option<ParsedCommand> {
let mut r = tail;
if tool == "pnpm" && r.first().map(|s| s.as_str()) == Some("-r") {
r = &r[1..];
}
let mut script_name: Option<String> = None;
if r.first().map(|s| s.as_str()) == Some("run") {
script_name = r.get(1).cloned();
} else {
let is_test_cmd = (tool == "npm" && r.first().map(|s| s.as_str()) == Some("t"))
|| ((tool == "npm" || tool == "pnpm" || tool == "yarn")
&& r.first().map(|s| s.as_str()) == Some("test"));
if is_test_cmd {
script_name = Some("test".to_string());
}
}
if let Some(name) = script_name {
let lname = name.to_lowercase();
if lname == "test" || lname == "unit" || lname == "jest" || lname == "vitest" {
return Some(ParsedCommand::Test {
cmd: shlex_join(full_cmd),
});
}
if lname == "lint" || lname == "eslint" {
return Some(ParsedCommand::Lint {
cmd: shlex_join(full_cmd),
tool: Some(format!("{tool}-script:{name}")),
targets: None,
});
}
if lname == "format" || lname == "fmt" || lname == "prettier" {
return Some(ParsedCommand::Format {
cmd: shlex_join(full_cmd),
tool: Some(format!("{tool}-script:{name}")),
targets: None,
});
}
}
None
}
fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
let [bash, flag, script] = original else {
return None;
@@ -1586,7 +1148,7 @@ fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
.map(|tokens| summarize_main_tokens(&tokens))
.collect();
if commands.len() > 1 {
commands.retain(|pc| !matches!(pc, ParsedCommand::Noop { .. }));
commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"));
}
if commands.len() == 1 {
// If we reduced to a single command, attribute the full original script
@@ -1655,27 +1217,7 @@ fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
}
}
}
ParsedCommand::Format {
tool, targets, cmd, ..
} => ParsedCommand::Format {
cmd: cmd.clone(),
tool,
targets,
},
ParsedCommand::Test { cmd, .. } => ParsedCommand::Test { cmd: cmd.clone() },
ParsedCommand::Lint {
tool, targets, cmd, ..
} => ParsedCommand::Lint {
cmd: cmd.clone(),
tool,
targets,
},
ParsedCommand::Unknown { .. } => ParsedCommand::Unknown {
cmd: script.clone(),
},
ParsedCommand::Noop { .. } => ParsedCommand::Noop {
cmd: script.clone(),
},
other => other,
})
.collect();
}
@@ -1728,124 +1270,6 @@ fn drop_small_formatting_commands(mut commands: Vec<Vec<String>>) -> Vec<Vec<Str
fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand {
match main_cmd.split_first() {
Some((head, tail)) if head == "true" && tail.is_empty() => ParsedCommand::Noop {
cmd: shlex_join(main_cmd),
},
// (sed-specific logic handled below in dedicated arm returning Read)
Some((head, tail))
if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("fmt") =>
{
ParsedCommand::Format {
cmd: shlex_join(main_cmd),
tool: Some("cargo fmt".to_string()),
targets: collect_non_flag_targets(&tail[1..]),
}
}
Some((head, tail))
if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("clippy") =>
{
ParsedCommand::Lint {
cmd: shlex_join(main_cmd),
tool: Some("cargo clippy".to_string()),
targets: collect_non_flag_targets(&tail[1..]),
}
}
Some((head, tail))
if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("test") =>
{
ParsedCommand::Test {
cmd: shlex_join(main_cmd),
}
}
Some((head, tail)) if head == "rustfmt" => ParsedCommand::Format {
cmd: shlex_join(main_cmd),
tool: Some("rustfmt".to_string()),
targets: collect_non_flag_targets(tail),
},
Some((head, tail)) if head == "go" && tail.first().map(|s| s.as_str()) == Some("fmt") => {
ParsedCommand::Format {
cmd: shlex_join(main_cmd),
tool: Some("go fmt".to_string()),
targets: collect_non_flag_targets(&tail[1..]),
}
}
Some((head, tail)) if head == "go" && tail.first().map(|s| s.as_str()) == Some("test") => {
ParsedCommand::Test {
cmd: shlex_join(main_cmd),
}
}
Some((head, _)) if head == "pytest" => ParsedCommand::Test {
cmd: shlex_join(main_cmd),
},
Some((head, tail)) if head == "eslint" => {
// Treat configuration flags with values (e.g. `-c .eslintrc`) as non-targets.
let targets = collect_non_flag_targets_with_flags(tail, ESLINT_FLAGS_WITH_VALUES);
ParsedCommand::Lint {
cmd: shlex_join(main_cmd),
tool: Some("eslint".to_string()),
targets,
}
}
Some((head, tail)) if head == "prettier" => ParsedCommand::Format {
cmd: shlex_join(main_cmd),
tool: Some("prettier".to_string()),
targets: collect_non_flag_targets(tail),
},
Some((head, tail)) if head == "black" => ParsedCommand::Format {
cmd: shlex_join(main_cmd),
tool: Some("black".to_string()),
targets: collect_non_flag_targets(tail),
},
Some((head, tail))
if head == "ruff" && tail.first().map(|s| s.as_str()) == Some("check") =>
{
ParsedCommand::Lint {
cmd: shlex_join(main_cmd),
tool: Some("ruff".to_string()),
targets: collect_non_flag_targets(&tail[1..]),
}
}
Some((head, tail))
if head == "ruff" && tail.first().map(|s| s.as_str()) == Some("format") =>
{
ParsedCommand::Format {
cmd: shlex_join(main_cmd),
tool: Some("ruff".to_string()),
targets: collect_non_flag_targets(&tail[1..]),
}
}
Some((head, _)) if (head == "jest" || head == "vitest") => ParsedCommand::Test {
cmd: shlex_join(main_cmd),
},
Some((head, tail))
if head == "npx" && tail.first().map(|s| s.as_str()) == Some("eslint") =>
{
let targets = collect_non_flag_targets_with_flags(&tail[1..], ESLINT_FLAGS_WITH_VALUES);
ParsedCommand::Lint {
cmd: shlex_join(main_cmd),
tool: Some("eslint".to_string()),
targets,
}
}
Some((head, tail))
if head == "npx" && tail.first().map(|s| s.as_str()) == Some("prettier") =>
{
ParsedCommand::Format {
cmd: shlex_join(main_cmd),
tool: Some("prettier".to_string()),
targets: collect_non_flag_targets(&tail[1..]),
}
}
// NPM-like scripts including yarn
Some((tool, tail)) if (tool == "pnpm" || tool == "npm" || tool == "yarn") => {
if let Some(cmd) = classify_npm_like(tool, tail, main_cmd) {
cmd
} else {
ParsedCommand::Unknown {
cmd: shlex_join(main_cmd),
}
}
}
Some((head, tail)) if head == "ls" => {
// Avoid treating option values as paths (e.g., ls -I "*.test.js").
let candidates = skip_flag_values(

View File

@@ -222,7 +222,7 @@ fn is_write_patch_constrained_to_writable_paths(
for (path, change) in action.changes() {
match change {
ApplyPatchFileChange::Add { .. } | ApplyPatchFileChange::Delete => {
ApplyPatchFileChange::Add { .. } | ApplyPatchFileChange::Delete { .. } => {
if !is_path_writable(path) {
return false;
}

View File

@@ -578,7 +578,12 @@ index {ZERO_OID}..{right_oid}
fs::write(&file, "x\n").unwrap();
let mut acc = TurnDiffTracker::new();
let del_changes = HashMap::from([(file.clone(), FileChange::Delete)]);
let del_changes = HashMap::from([(
file.clone(),
FileChange::Delete {
content: "x\n".to_string(),
},
)]);
acc.on_patch_begin(&del_changes);
// Simulate apply: delete the file from disk.
@@ -741,7 +746,12 @@ index {left_oid}..{right_oid}
assert_eq!(first, expected_first);
// Next: introduce a brand-new path b.txt into baseline snapshots via a delete change.
let del_b = HashMap::from([(b.clone(), FileChange::Delete)]);
let del_b = HashMap::from([(
b.clone(),
FileChange::Delete {
content: "z\n".to_string(),
},
)]);
acc.on_patch_begin(&del_b);
// Simulate apply: delete b.txt.
let baseline_mode = file_mode_for_path(&b).unwrap_or(FileMode::Regular);

View File

@@ -1,4 +1,3 @@
use std::path::Path;
use std::time::Duration;
use rand::Rng;
@@ -12,33 +11,3 @@ pub(crate) fn backoff(attempt: u64) -> Duration {
let jitter = rand::rng().random_range(0.9..1.1);
Duration::from_millis((base as f64 * jitter) as u64)
}
/// Return `true` if the project folder specified by the `Config` is inside a
/// Git repository.
///
/// The check walks up the directory hierarchy looking for a `.git` file or
/// directory (note `.git` can be a file that contains a `gitdir` entry). This
/// approach does **not** require the `git` binary or the `git2` crate and is
/// therefore fairly lightweight.
///
/// Note that this does **not** detect *worktrees* created with
/// `git worktree add` where the checkout lives outside the main repository
/// directory. If you need Codex to work from such a checkout simply pass the
/// `--allow-no-git-exec` CLI flag that disables the repo requirement.
pub fn is_inside_git_repo(base_dir: &Path) -> bool {
let mut dir = base_dir.to_path_buf();
loop {
if dir.join(".git").exists() {
return true;
}
// Pop one component (go up one directory). `pop` returns false when
// we have reached the filesystem root.
if !dir.pop() {
break;
}
}
false
}

View File

@@ -404,13 +404,16 @@ impl EventProcessor for EventProcessorWithHumanOutput {
println!("{}", line.style(self.green));
}
}
FileChange::Delete => {
FileChange::Delete { content } => {
let header = format!(
"{} {}",
format_file_change(change),
path.to_string_lossy()
);
println!("{}", header.style(self.magenta));
for line in content.lines() {
println!("{}", line.style(self.red));
}
}
FileChange::Update {
unified_diff,
@@ -560,7 +563,7 @@ fn escape_command(command: &[String]) -> String {
fn format_file_change(change: &FileChange) -> &'static str {
match change {
FileChange::Add { .. } => "A",
FileChange::Delete => "D",
FileChange::Delete { .. } => "D",
FileChange::Update {
move_path: Some(_), ..
} => "R",

View File

@@ -13,13 +13,13 @@ use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::git_info::get_git_repo_root;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::util::is_inside_git_repo;
use codex_login::AuthManager;
use codex_ollama::DEFAULT_OSS_MODEL;
use codex_protocol::config_types::SandboxMode;
@@ -183,7 +183,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
// is using.
event_processor.print_config_summary(&config, &prompt);
if !skip_git_repo_check && !is_inside_git_repo(&config.cwd.to_path_buf()) {
if !skip_git_repo_check && get_git_repo_root(&config.cwd.to_path_buf()).is_none() {
eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified.");
std::process::exit(1);
}

View File

@@ -1,318 +0,0 @@
#!/usr/bin/env python3
import argparse
import concurrent.futures
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from typing import Iterable, List, Optional, Set, Tuple, Dict, Any
def _run(cmd: List[str]) -> Tuple[int, str, str]:
proc = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
return proc.returncode, proc.stdout, proc.stderr
def require_gh():
if shutil.which("gh") is None:
print("Error: GitHub CLI 'gh' not found. Please install and authenticate.", file=sys.stderr)
sys.exit(1)
def require_pr2md(script_dir: str) -> str:
# Prefer pr2md next to this script; fallback to PATH
local = os.path.join(script_dir, "pr2md")
if os.path.isfile(local) and os.access(local, os.X_OK):
return local
if shutil.which("pr2md"):
return "pr2md"
print("Error: 'pr2md' not found next to this script or in PATH.", file=sys.stderr)
sys.exit(1)
def parse_repo_from_url(url: str) -> Optional[str]:
u = url.strip()
if not u:
return None
if "github.com:" in u:
path = u.split("github.com:", 1)[1]
elif "github.com/" in u:
path = u.split("github.com/", 1)[1]
elif u.startswith("github.com/"):
path = u.split("github.com/", 1)[1]
else:
return None
if path.endswith(".git"):
path = path[:-4]
parts = path.strip("/").split("/")
if len(parts) >= 2:
return f"{parts[0]}/{parts[1]}"
return None
def detect_repo_from_git() -> Optional[str]:
code, out, _ = _run(["git", "rev-parse", "--is-inside-work-tree"])
if code != 0 or out.strip() != "true":
return None
code, origin_url, _ = _run(["git", "config", "--get", "remote.origin.url"])
if code != 0:
return None
return parse_repo_from_url(origin_url)
def detect_repo_root() -> Optional[str]:
code, out, _ = _run(["git", "rev-parse", "--show-toplevel"])
if code != 0:
return None
return out.strip()
def iso8601(dt: datetime) -> str:
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def list_review_comment_prs(repo: str, reviewer: str, since_iso: str) -> Set[int]:
prs: Set[int] = set()
page = 1
reviewer_lc = reviewer.lower()
while True:
path = f"/repos/{repo}/pulls/comments?per_page=100&page={page}&since={since_iso}"
code, out, err = _run(["gh", "api", path])
if code != 0:
print(f"Error: failed to fetch review comments: {err.strip()}", file=sys.stderr)
sys.exit(1)
try:
batch = json.loads(out)
except json.JSONDecodeError as e:
print(f"Error: could not parse review comments JSON: {e}", file=sys.stderr)
sys.exit(1)
if not batch:
break
for c in batch:
user = (c.get("user") or {}).get("login", "").lower()
if user != reviewer_lc:
continue
pr_url = c.get("pull_request_url") or ""
# Expect .../pulls/<number>
try:
pr_number = int(pr_url.rstrip("/").split("/")[-1])
prs.add(pr_number)
except Exception:
continue
# Progress line for discovery
print(f"discover: page={page} batch={len(batch)} unique_prs={len(prs)}", file=sys.stderr, flush=True)
if len(batch) < 100:
break
page += 1
if page > 50:
break
return prs
def list_recent_prs(repo: str, days: int) -> List[int]:
# As a fallback: list PRs updated in the window via gh and parse numbers.
# Uses GitHub search qualifiers supported by `gh pr list --search`.
since_date = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
code, out, err = _run([
"gh",
"pr",
"list",
"-R",
repo,
"--state",
"all",
"--search",
f"updated:>={since_date}",
"--json",
"number",
])
if code != 0:
print(f"Error: failed to list recent PRs: {err.strip()}", file=sys.stderr)
sys.exit(1)
try:
data = json.loads(out)
except json.JSONDecodeError:
return []
return [int(x.get("number")) for x in data if isinstance(x.get("number"), int)]
def ensure_dir(path: str):
os.makedirs(path, exist_ok=True)
def run_pr2md(pr2md_path: str, repo: str, pr_number: int, reviewer: str) -> Tuple[int, str, Optional[str]]:
"""Return (pr_number, status, markdown)."""
try:
cmd = [pr2md_path, str(pr_number), repo, "--reviewer", reviewer]
code, out, err = _run(cmd)
if code != 0:
return pr_number, f"error: {err.strip() or 'pr2md failed'}", None
return pr_number, "ok", out
except Exception as e:
return pr_number, f"error: {e}", None
def dedupe(seq: Iterable[int]) -> List[int]:
seen = set()
out: List[int] = []
for n in seq:
if n not in seen:
seen.add(n)
out.append(n)
return out
def main():
parser = argparse.ArgumentParser(
prog="lastprs",
description=(
"Fetch PRs a reviewer commented on in the last N days and render each via pr2md.\n"
"Writes a consolidated reviewers/<reviewer>.json with all raw PR markdowns."
),
)
parser.add_argument("days", type=int, help="Number of days to look back (N)")
parser.add_argument("reviewer", help="GitHub login of the reviewer")
parser.add_argument(
"repo",
nargs="?",
help="Repository in 'owner/repo' form; inferred from git origin if omitted",
)
parser.add_argument(
"--jobs",
"-j",
type=int,
default=min(8, (os.cpu_count() or 4)),
help="Parallel jobs when invoking pr2md (default: min(8, CPUs))",
)
args = parser.parse_args()
if args.days <= 0:
print("Error: days must be a positive integer.", file=sys.stderr)
sys.exit(2)
require_gh()
script_dir = os.path.dirname(os.path.abspath(__file__))
pr2md_path = require_pr2md(script_dir)
repo = args.repo or detect_repo_from_git()
if not repo:
print(
"Error: Could not determine repository from git origin. Pass repo as 'owner/repo'.",
file=sys.stderr,
)
sys.exit(2)
# Compute window
since = datetime.now(timezone.utc) - timedelta(days=args.days)
since_iso = iso8601(since)
since_date = since.strftime("%Y-%m-%d")
print(f"Discovering PRs for reviewer={args.reviewer} since={since_date} in repo={repo}…", file=sys.stderr)
# Identify PRs with review comments by reviewer since the cutoff
pr_set = list_review_comment_prs(repo, args.reviewer, since_iso)
if not pr_set:
# Fallback: scan recently updated PRs and check comments per-PR
recent = list_recent_prs(repo, args.days)
pr_set = set()
reviewer_lc = args.reviewer.lower()
total_recent = len(recent)
print(f"Fallback: scanning {total_recent} recent PRs for comments by {args.reviewer}…", file=sys.stderr)
for idx, pr_num in enumerate(recent, start=1):
# Query review comments for this PR and filter by user + since
page = 1
found = False
while True:
path = f"/repos/{repo}/pulls/{pr_num}/comments?per_page=100&page={page}"
code, out, err = _run(["gh", "api", path])
if code != 0:
break
try:
batch = json.loads(out)
except json.JSONDecodeError:
break
if not batch:
break
for c in batch:
user = (c.get("user") or {}).get("login", "").lower()
created_at = c.get("created_at") or c.get("updated_at") or ""
if user == reviewer_lc and created_at >= since_iso:
found = True
break
if found or len(batch) < 100:
break
page += 1
if page > 20:
break
if found:
pr_set.add(pr_num)
if idx % 10 == 0 or idx == total_recent:
print(f"scan: {idx}/{total_recent} matched={len(pr_set)}", file=sys.stderr, flush=True)
prs = sorted(dedupe(pr_set))
if not prs:
print(
f"No PRs in {repo} with review comments from {args.reviewer} in the last {args.days} days.",
file=sys.stderr,
)
return
# Determine reviewers JSON path under the repo root
repo_root = detect_repo_root() or os.getcwd()
reviewers_dir = os.path.join(repo_root, "reviewers")
ensure_dir(reviewers_dir)
out_json = os.path.join(reviewers_dir, f"{args.reviewer}.json")
# Run pr2md in parallel and collect
print(f"Found {len(prs)} PR(s). Rendering to reviewers/{args.reviewer}.json", file=sys.stderr)
results: List[Tuple[int, str, Optional[str]]] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as ex:
futs = [
ex.submit(run_pr2md, pr2md_path, repo, pr_num, args.reviewer)
for pr_num in prs
]
completed = 0
total = len(futs)
for fut in concurrent.futures.as_completed(futs):
results.append(fut.result())
completed += 1
if completed % 5 == 0 or completed == total:
print(f"render: {completed}/{total}", file=sys.stderr, flush=True)
ok = sum(1 for _, s, _ in results if s == "ok")
failures = [(n, s) for n, s, _ in results if s != "ok"]
for n, s in failures:
print(f"PR {n}: {s}", file=sys.stderr)
# Build JSON
now = iso8601(datetime.now(timezone.utc))
prs_json: List[Dict[str, Any]] = []
for pr_number, status, md in sorted(results, key=lambda t: t[0]):
if status == "ok" and md is not None:
prs_json.append({"number": pr_number, "markdown": md})
data: Dict[str, Any] = {
"repo": repo,
"reviewer": args.reviewer,
"generated_at": now,
"days": args.days,
"prs": prs_json,
}
with open(out_json, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
print(f"Done. {ok}/{len(prs)} succeeded. Wrote {out_json}")
if __name__ == "__main__":
main()

View File

@@ -129,10 +129,7 @@ impl McpClient {
error!("failed to write newline to child stdin");
break;
}
if stdin.flush().await.is_err() {
error!("failed to flush child stdin");
break;
}
// No explicit flush needed on a pipe; write_all is sufficient.
}
Err(e) => error!("failed to serialize JSONRPCMessage: {e}"),
}
@@ -365,7 +362,11 @@ impl McpClient {
}
};
if let Some(tx) = pending.lock().await.remove(&id) {
let tx_opt = {
let mut guard = pending.lock().await;
guard.remove(&id)
};
if let Some(tx) = tx_opt {
// Ignore send errors the receiver might have been dropped.
let _ = tx.send(JSONRPCMessage::Response(resp));
} else {
@@ -383,7 +384,11 @@ impl McpClient {
RequestId::String(_) => return, // see comment above
};
if let Some(tx) = pending.lock().await.remove(&id) {
let tx_opt = {
let mut guard = pending.lock().await;
guard.remove(&id)
};
if let Some(tx) = tx_opt {
let _ = tx.send(JSONRPCMessage::Error(err));
}
}

View File

@@ -59,7 +59,7 @@ pub async fn run_main(
// Set up channels.
let (incoming_tx, mut incoming_rx) = mpsc::channel::<JSONRPCMessage>(CHANNEL_CAPACITY);
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingMessage>(CHANNEL_CAPACITY);
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
// Task: read from stdin, push to `incoming_tx`.
let stdin_reader_handle = tokio::spawn({
@@ -134,10 +134,6 @@ pub async fn run_main(
error!("Failed to write newline to stdout: {e}");
break;
}
if let Err(e) = stdout.flush().await {
error!("Failed to flush stdout: {e}");
break;
}
}
Err(e) => error!("Failed to serialize JSONRPCMessage: {e}"),
}

View File

@@ -24,12 +24,12 @@ use crate::error_code::INTERNAL_ERROR_CODE;
/// Sends messages to the client and manages request callbacks.
pub(crate) struct OutgoingMessageSender {
next_request_id: AtomicI64,
sender: mpsc::Sender<OutgoingMessage>,
sender: mpsc::UnboundedSender<OutgoingMessage>,
request_id_to_callback: Mutex<HashMap<RequestId, oneshot::Sender<Result>>>,
}
impl OutgoingMessageSender {
pub(crate) fn new(sender: mpsc::Sender<OutgoingMessage>) -> Self {
pub(crate) fn new(sender: mpsc::UnboundedSender<OutgoingMessage>) -> Self {
Self {
next_request_id: AtomicI64::new(0),
sender,
@@ -55,7 +55,7 @@ impl OutgoingMessageSender {
method: method.to_string(),
params,
});
let _ = self.sender.send(outgoing_message).await;
let _ = self.sender.send(outgoing_message);
rx_approve
}
@@ -81,7 +81,7 @@ impl OutgoingMessageSender {
match serde_json::to_value(response) {
Ok(result) => {
let outgoing_message = OutgoingMessage::Response(OutgoingResponse { id, result });
let _ = self.sender.send(outgoing_message).await;
let _ = self.sender.send(outgoing_message);
}
Err(err) => {
self.send_error(
@@ -130,17 +130,17 @@ impl OutgoingMessageSender {
};
let outgoing_message =
OutgoingMessage::Notification(OutgoingNotification { method, params });
let _ = self.sender.send(outgoing_message).await;
let _ = self.sender.send(outgoing_message);
}
pub(crate) async fn send_notification(&self, notification: OutgoingNotification) {
let outgoing_message = OutgoingMessage::Notification(notification);
let _ = self.sender.send(outgoing_message).await;
let _ = self.sender.send(outgoing_message);
}
pub(crate) async fn send_error(&self, id: RequestId, error: JSONRPCErrorError) {
let outgoing_message = OutgoingMessage::Error(OutgoingError { id, error });
let _ = self.sender.send(outgoing_message).await;
let _ = self.sender.send(outgoing_message);
}
}
@@ -250,7 +250,7 @@ mod tests {
#[tokio::test]
async fn test_send_event_as_notification() {
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingMessage>(2);
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
let event = Event {
@@ -281,7 +281,7 @@ mod tests {
#[tokio::test]
async fn test_send_event_as_notification_with_meta() {
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingMessage>(2);
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
let session_configured_event = SessionConfiguredEvent {

View File

@@ -1,360 +0,0 @@
#!/usr/bin/env python3
import argparse
import json
import shutil
import subprocess
import sys
from collections import defaultdict
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote
def _run(cmd: List[str]) -> Tuple[int, str, str]:
proc = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
return proc.returncode, proc.stdout, proc.stderr
def require_gh():
if shutil.which("gh") is None:
print("Error: GitHub CLI 'gh' not found. Please install and authenticate.", file=sys.stderr)
sys.exit(1)
def iso_to_utc_str(iso: Optional[str]) -> str:
if not iso:
return ""
try:
# Handle both Z and offset formats
if iso.endswith("Z"):
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
else:
dt = datetime.fromisoformat(iso)
dt_utc = dt.astimezone(timezone.utc)
return dt_utc.strftime("%Y-%m-%d %H:%M:%S UTC")
except Exception:
return iso
def pr_view(repo: str, pr_number: int) -> Dict[str, Any]:
fields = [
"number",
"title",
"body",
"url",
"author",
"createdAt",
"updatedAt",
"additions",
"deletions",
"changedFiles",
"commits",
"baseRefName",
"headRefName",
"headRepositoryOwner",
]
code, out, err = _run(["gh", "pr", "view", str(pr_number), "-R", repo, "--json", ",".join(fields)])
if code != 0:
print(f"Error: failed to fetch PR via gh: {err.strip()}", file=sys.stderr)
sys.exit(1)
try:
data = json.loads(out)
except json.JSONDecodeError as e:
print(f"Error: failed to parse gh JSON output: {e}", file=sys.stderr)
sys.exit(1)
return data
def pr_combined_diff(repo: str, pr: Dict[str, Any]) -> str:
# Prefer a single combined diff between base and head.
base = pr.get("baseRefName")
head_branch = pr.get("headRefName")
head_owner = (pr.get("headRepositoryOwner") or {}).get("login")
if not base or not head_branch:
# Fallback to gh pr diff if fields unavailable
code, out, err = _run(["gh", "pr", "diff", str(pr.get("number")), "-R", repo, "--color=never"])
if code != 0:
print(f"Error: failed to fetch PR diff: {err.strip()}", file=sys.stderr)
sys.exit(1)
return out.rstrip()
base_owner = repo.split("/", 1)[0]
if head_owner and head_owner != base_owner:
head = f"{head_owner}:{head_branch}"
else:
head = head_branch
path = f"/repos/{repo}/compare/{quote(base, safe='')}...{quote(head, safe='')}"
code, out, err = _run(["gh", "api", "-H", "Accept: application/vnd.github.v3.diff", path])
if code == 0 and out.strip():
return out.rstrip()
# Fallback
code, out, err = _run(["gh", "pr", "diff", str(pr.get("number")), "-R", repo, "--color=never"])
if code != 0:
print(f"Error: failed to fetch PR diff: {err.strip()}", file=sys.stderr)
sys.exit(1)
return out.rstrip()
def pr_review_comments(repo: str, pr_number: int) -> List[Dict[str, Any]]:
# Pull Request Review Comments (code comments). Fetch up to 1000 via pages of 100.
all_comments: List[Dict[str, Any]] = []
page = 1
while True:
path = f"/repos/{repo}/pulls/{pr_number}/comments?per_page=100&page={page}"
code, out, err = _run(["gh", "api", path])
if code != 0:
print(f"Error: failed to fetch review comments: {err.strip()}", file=sys.stderr)
sys.exit(1)
try:
batch = json.loads(out)
except json.JSONDecodeError:
print("Error: could not parse review comments JSON.", file=sys.stderr)
sys.exit(1)
if not batch:
break
all_comments.extend(batch)
if len(batch) < 100:
break
page += 1
if page > 10: # safety cap
break
return all_comments
def parse_repo_from_url(url: str) -> Optional[str]:
u = url.strip()
if not u:
return None
# Common forms:
# - SSH scp-like: <user>@github.com:owner/repo.git
# - SSH URL: ssh://<user>@github.com/owner/repo.git
# - HTTPS: https://github.com/owner/repo(.git)
# - Bare: github.com/owner/repo(.git)
if "github.com:" in u:
# scp-like syntax
path = u.split("github.com:", 1)[1]
elif "github.com/" in u:
path = u.split("github.com/", 1)[1]
elif u.startswith("github.com/"):
path = u.split("github.com/", 1)[1]
else:
return None
# Remove trailing .git if present
if path.endswith(".git"):
path = path[:-4]
# Keep only owner/repo
parts = path.strip("/").split("/")
if len(parts) >= 2:
return f"{parts[0]}/{parts[1]}"
return None
def detect_repo_from_git() -> Optional[str]:
# Ensure we're inside a git repo
code, out, _ = _run(["git", "rev-parse", "--is-inside-work-tree"])
if code != 0 or out.strip() != "true":
return None
code, origin_url, _ = _run(["git", "config", "--get", "remote.origin.url"])
if code != 0:
return None
return parse_repo_from_url(origin_url)
def blockquote(text: str) -> str:
lines = text.splitlines() or [""]
return "\n".join("> " + ln for ln in lines)
def format_header(pr: Dict[str, Any]) -> str:
number = pr.get("number")
title = pr.get("title", "")
url = pr.get("url", "")
author_login = (pr.get("author") or {}).get("login", "")
created = iso_to_utc_str(pr.get("createdAt"))
updated = iso_to_utc_str(pr.get("updatedAt"))
additions = pr.get("additions", 0)
deletions = pr.get("deletions", 0)
changed_files = pr.get("changedFiles", 0)
commits_obj = pr.get("commits")
if isinstance(commits_obj, dict):
if "totalCount" in commits_obj and isinstance(commits_obj["totalCount"], (int, float)):
commits_count = int(commits_obj["totalCount"]) # GraphQL connection
elif "nodes" in commits_obj and isinstance(commits_obj["nodes"], list):
commits_count = len(commits_obj["nodes"]) # fallback
else:
commits_count = None
elif isinstance(commits_obj, list):
commits_count = len(commits_obj)
elif isinstance(commits_obj, (int, float)):
commits_count = int(commits_obj)
else:
commits_count = None
commits_str = str(commits_count) if commits_count is not None else "?"
lines = []
lines.append(f"# PR #{number}: {title}")
lines.append("")
lines.append(f"- URL: {url}")
lines.append(f"- Author: {author_login}")
lines.append(f"- Created: {created}")
lines.append(f"- Updated: {updated}")
lines.append(f"- Changes: +{additions}/-{deletions}, Files changed: {changed_files}, Commits: {commits_str}")
return "\n".join(lines)
def format_description(body: Optional[str]) -> str:
desc = body or ""
desc = desc.strip()
if not desc:
desc = "(No description.)"
return f"\n## Description\n\n{desc}\n"
def format_diff(diff_text: str) -> str:
return f"\n## Full Diff\n\n```diff\n{diff_text}\n```\n"
def format_review_comments(comments: List[Dict[str, Any]], reviewer: Optional[str]) -> str:
if reviewer:
reviewer_lc = reviewer.lower()
comments = [c for c in comments if ((c.get("user") or {}).get("login", "").lower() == reviewer_lc)]
if not comments:
return "\n## Review Comments\n\n(No review comments.)\n"
# Group by file path, preserve PR order but sort paths for stable output
by_path: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for c in comments:
by_path[c.get("path", "(unknown)")].append(c)
out_lines: List[str] = []
out_lines.append("\n## Review Comments\n")
for path in sorted(by_path.keys()):
out_lines.append(f"### {path}\n")
for c in by_path[path]:
created = iso_to_utc_str(c.get("created_at"))
url = c.get("html_url", "")
diff_hunk = c.get("diff_hunk", "").rstrip()
body = c.get("body", "")
out_lines.append(f"- Created: {created} | Link: {url}")
out_lines.append("")
if diff_hunk:
out_lines.append("```diff")
out_lines.append(diff_hunk)
out_lines.append("```")
out_lines.append("")
if body:
out_lines.append(blockquote(body))
out_lines.append("")
return "\n".join(out_lines).rstrip() + "\n"
def main():
parser = argparse.ArgumentParser(
prog="pr2md",
description=(
"Render a GitHub PR into Markdown including description, full diff, and review comments.\n"
"Requires GitHub CLI (gh) to be installed and authenticated."
),
)
parser.add_argument(
"pr_number",
nargs="?",
help="Pull request number (optional; auto-detect from current branch if omitted)",
)
parser.add_argument(
"repo",
nargs="?",
help="Repository in 'owner/repo' form; inferred from git origin if omitted",
)
parser.add_argument("--reviewer", help="Only include comments from this reviewer (login)")
args = parser.parse_args()
require_gh()
# Disambiguate single positional arg: if only one is provided and it looks like owner/repo,
# treat it as repo, not PR number.
if args.pr_number and not args.repo and "/" in args.pr_number and not args.pr_number.isdigit():
args.repo, args.pr_number = args.pr_number, None
repo = args.repo or detect_repo_from_git()
if not repo:
print(
"Error: Could not determine repository from git origin. Pass repo as 'owner/repo'.",
file=sys.stderr,
)
sys.exit(2)
# Determine PR number: use provided, else try to find open/draft PR for current branch
pr_number: Optional[int]
if args.pr_number:
try:
pr_number = int(args.pr_number)
except ValueError:
print("Error: PR number must be an integer.", file=sys.stderr)
sys.exit(2)
else:
# Detect from current branch
code, branch_out, _ = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
branch = branch_out.strip() if code == 0 else ""
if not branch or branch == "HEAD":
print("Error: Not on a branch. Provide a PR number explicitly.", file=sys.stderr)
sys.exit(2)
# Query open PRs and find one with matching head branch and owner
owner = repo.split("/", 1)[0]
code, out, err = _run([
"gh", "pr", "list", "-R", repo, "--state", "open",
"--json", "number,headRefName,isDraft,headRepositoryOwner",
])
if code != 0:
print(f"Error: failed to list PRs: {err.strip()}", file=sys.stderr)
sys.exit(1)
try:
pr_list = json.loads(out)
except json.JSONDecodeError:
print("Error: failed to parse PR list JSON.", file=sys.stderr)
sys.exit(1)
candidates = [
pr for pr in pr_list
if pr.get("headRefName") == branch and ((pr.get("headRepositoryOwner") or {}).get("login") == owner)
]
if not candidates:
# Relax owner constraint if none found
candidates = [pr for pr in pr_list if pr.get("headRefName") == branch]
if not candidates:
print(
f"Error: No open PR found for branch '{branch}'. Provide a PR number.",
file=sys.stderr,
)
sys.exit(2)
# If multiple, pick the first
pr_number = int(candidates[0]["number"])
pr = pr_view(repo, pr_number)
diff_text = pr_combined_diff(repo, pr)
comments = pr_review_comments(repo, pr_number)
parts = [
format_header(pr),
format_description(pr.get("body")),
format_diff(diff_text),
format_review_comments(comments, args.reviewer),
]
sys.stdout.write("\n".join(p.rstrip() for p in parts if p))
if __name__ == "__main__":
main()

View File

@@ -17,22 +17,6 @@ pub enum ParsedCommand {
query: Option<String>,
path: Option<String>,
},
Format {
cmd: String,
tool: Option<String>,
targets: Option<Vec<String>>,
},
Test {
cmd: String,
},
Lint {
cmd: String,
tool: Option<String>,
targets: Option<Vec<String>>,
},
Noop {
cmd: String,
},
Unknown {
cmd: String,
},

View File

@@ -869,7 +869,9 @@ pub enum FileChange {
Add {
content: String,
},
Delete,
Delete {
content: String,
},
Update {
unified_diff: String,
move_path: Option<PathBuf>,

File diff suppressed because it is too large Load Diff

View File

@@ -1,206 +0,0 @@
#!/usr/bin/env python3
import argparse
import concurrent.futures
import os
import re
import shutil
import subprocess
import sys
from typing import List, Optional, Tuple
def _run(cmd: List[str], input_text: Optional[str] = None) -> Tuple[int, str, str]:
proc = subprocess.run(
cmd,
input=input_text,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
return proc.returncode, proc.stdout, proc.stderr
def require(cmd: str, hint: str):
if shutil.which(cmd) is None:
print(f"Error: required command '{cmd}' not found. {hint}", file=sys.stderr)
sys.exit(1)
def detect_repo_root() -> Optional[str]:
code, out, _ = _run(["git", "rev-parse", "--show-toplevel"])
if code != 0:
return None
return out.strip()
def ensure_dir(path: str):
os.makedirs(path, exist_ok=True)
def pr_file_paths(out_dir: str) -> List[str]:
if not os.path.isdir(out_dir):
return []
paths = []
for name in os.listdir(out_dir):
if re.match(r"PR-\d+\.md$", name):
paths.append(os.path.join(out_dir, name))
# Sort by PR number ascending
def prnum(p: str) -> int:
m = re.search(r"(\d+)", os.path.basename(p))
return int(m.group(1)) if m else 0
return sorted(paths, key=prnum)
def extract_pr_number(path: str) -> int:
m = re.search(r"(\d+)", os.path.basename(path))
return int(m.group(1)) if m else 0
def build_prompt(contents: str, reviewer: str, out_path: str) -> str:
# We rely on `codex exec --output-last-message {out_path}` to write the
# final message to disk. Instruct the agent to ONLY produce the final
# document as its last message (no meta commentary), to avoid clutter.
return (
f"{contents}\n---\n"
f"Summarize the takeaways from this PR review by {reviewer} into a concise, generalizable, and practical guide with two checklists: DOs and DON'Ts. "
f"Add short, accurate code examples in fenced code blocks to illustrate each key point. "
f"Output ONLY the final document as your final message — no preamble, no status notes, no explanations about saving files. "
f"The CLI will save your final message to {out_path}."
)
def run_codex_exec(prompt: str, last_message_file: Optional[str] = None) -> Tuple[int, str, str]:
# Prefer a globally installed `codex`; fall back to cargo if needed.
if shutil.which("codex") is not None:
cmd = ["codex", "-c", "model_reasoning_effort=high", "exec"]
if last_message_file:
cmd.extend(["--output-last-message", last_message_file])
return _run(cmd, input_text=prompt)
# Fallback: use cargo run (may build; slower but reliable in dev)
cmd = [
"cargo",
"run",
"--quiet",
"--bin",
"codex",
"--",
"-c",
"model_reasoning_effort=high",
"exec",
]
if last_message_file:
cmd.extend(["--output-last-message", last_message_file])
return _run(cmd, input_text=prompt)
def study_one(pr_md_path: str, reviewer: str, out_dir: str) -> Tuple[str, str]:
pr_num = extract_pr_number(pr_md_path)
try:
with open(pr_md_path, "r", encoding="utf-8") as f:
contents = f.read()
ensure_dir(out_dir)
out_path = os.path.join(out_dir, f"PR-{pr_num}-study.md")
prompt = build_prompt(contents, reviewer, out_path)
code, out, err = run_codex_exec(prompt, last_message_file=out_path)
if code != 0:
return pr_md_path, f"error: codex exec failed (exit {code}): {err.strip()}"
# If Codex did not write the file for some reason, fall back to captured stdout.
# Note: we only fallback when the output file is missing/empty to avoid
# overwriting a valid summary produced by Codex.
if (not os.path.isfile(out_path)) or os.path.getsize(out_path) == 0:
try:
with open(out_path, "w", encoding="utf-8") as f:
f.write(out)
except Exception as e:
return pr_md_path, f"error: failed to write fallback output: {e}"
return pr_md_path, "ok"
except Exception as e:
return pr_md_path, f"error: {e}"
def main():
parser = argparse.ArgumentParser(
prog="study",
description=(
"Generate PR markdowns via lastprs, then summarize each via `codex exec`.\n"
"Writes summaries to prs/<reviewer>/study/PR-<num>-study.md."
),
)
parser.add_argument("days", type=int, help="Number of days to look back (N)")
parser.add_argument("reviewer", help="GitHub login of the reviewer")
parser.add_argument(
"repo",
nargs="?",
help="Repository in 'owner/repo' form; inferred from git origin if omitted (passed through to lastprs)",
)
parser.add_argument(
"--jobs",
"-j",
type=int,
default=10,
help="Parallel jobs for summaries (default: 10)",
)
parser.add_argument(
"--skip-generate",
action="store_true",
help="Skip running lastprs and reuse existing prs/<reviewer>/ files",
)
args = parser.parse_args()
if args.days <= 0:
print("Error: days must be a positive integer.", file=sys.stderr)
sys.exit(2)
# Check dependencies
require("gh", "Install GitHub CLI: https://cli.github.com")
# lastprs is shipped with this repo; prefer local copy, then PATH
script_dir = os.path.dirname(os.path.abspath(__file__))
lastprs_path = os.path.join(script_dir, "lastprs")
if not (os.path.isfile(lastprs_path) and os.access(lastprs_path, os.X_OK)):
require("lastprs", "Ensure the lastprs helper script is on PATH or present in this folder.")
lastprs_path = "lastprs"
# Determine paths
repo_root = detect_repo_root() or os.getcwd()
prs_dir = os.path.join(repo_root, "prs", args.reviewer)
summaries_dir = os.path.join(prs_dir, "study")
# 1) Generate PR markdowns if not skipping
if not args.skip_generate:
cmd = [lastprs_path, str(args.days), args.reviewer]
if args.repo:
cmd.append(args.repo)
print("Generating PR markdowns via lastprs…", file=sys.stderr)
code, out, err = _run(cmd)
if code != 0:
print(f"Error: lastprs failed (exit {code}): {err.strip()}", file=sys.stderr)
sys.exit(code)
# Echo a short summary
sys.stderr.write(out.strip() + "\n")
# 2) Discover PR files
files = pr_file_paths(prs_dir)
if not files:
print(f"No PR markdowns found in {prs_dir}.", file=sys.stderr)
sys.exit(0)
print(f"Summarizing {len(files)} PR(s) to {summaries_dir}")
# 3) Summarize via codex exec
results: List[Tuple[str, str]] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as ex:
futs = [ex.submit(study_one, p, args.reviewer, summaries_dir) for p in files]
for fut in concurrent.futures.as_completed(futs):
results.append(fut.result())
ok = sum(1 for _, s in results if s == "ok")
failures = [(p, s) for p, s in results if s != "ok"]
for p, s in failures:
print(f"{os.path.basename(p)}: {s}", file=sys.stderr)
print(f"Done. {ok}/{len(files)} summaries succeeded.")
if __name__ == "__main__":
main()

View File

@@ -49,6 +49,7 @@ image = { version = "^0.25.6", default-features = false, features = [
"jpeg",
"png",
] }
itertools = "0.14.0"
lazy_static = "1"
mcp-types = { path = "../mcp-types" }
once_cell = "1"
@@ -87,6 +88,7 @@ unicode-segmentation = "1.12.0"
unicode-width = "0.1"
url = "2"
uuid = "1"
pathdiff = "0.2"
[target.'cfg(unix)'.dependencies]
libc = "0.2"

View File

@@ -43,6 +43,7 @@ pub(crate) struct App {
// Pager overlay state (Transcript or Static like Diff)
pub(crate) overlay: Option<Overlay>,
pub(crate) deferred_history_lines: Vec<Line<'static>>,
has_emitted_history_lines: bool,
pub(crate) enhanced_keys_supported: bool,
@@ -51,9 +52,6 @@ pub(crate) struct App {
// Esc-backtracking state grouped
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
// Whether the terminal has focus (tracked via TuiEvent::FocusChanged)
pub(crate) app_focused: Arc<AtomicBool>,
}
impl App {
@@ -94,9 +92,9 @@ impl App {
transcript_lines: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
app_focused: Arc::new(AtomicBool::new(true)),
};
let tui_events = tui.event_stream();
@@ -128,10 +126,6 @@ impl App {
TuiEvent::Key(key_event) => {
self.handle_key_event(tui, key_event).await;
}
TuiEvent::FocusChanged(focused) => {
self.chat_widget.set_input_focus(focused);
self.app_focused.store(focused, Ordering::Relaxed);
}
TuiEvent::Paste(pasted) => {
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
// but tui-textarea expects \n. Normalize CR to LF.
@@ -185,27 +179,28 @@ impl App {
);
tui.frame_requester().schedule_frame();
}
AppEvent::InsertHistoryLines(lines) => {
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_lines(lines.clone());
tui.frame_requester().schedule_frame();
}
self.transcript_lines.extend(lines.clone());
if self.overlay.is_some() {
self.deferred_history_lines.extend(lines);
} else {
tui.insert_history_lines(lines);
}
}
AppEvent::InsertHistoryCell(cell) => {
let cell_transcript = cell.transcript_lines();
let mut cell_transcript = cell.transcript_lines();
if !cell.is_stream_continuation() && !self.transcript_lines.is_empty() {
cell_transcript.insert(0, Line::from(""));
}
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_lines(cell_transcript.clone());
tui.frame_requester().schedule_frame();
}
self.transcript_lines.extend(cell_transcript.clone());
let display = cell.display_lines();
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
if !display.is_empty() {
// Only insert a separating blank line for new cells that are not
// part of an ongoing stream. Streaming continuations should not
// accrue extra blank lines between chunks.
if !cell.is_stream_continuation() {
if self.has_emitted_history_lines {
display.insert(0, Line::from(""));
} else {
self.has_emitted_history_lines = true;
}
}
if self.overlay.is_some() {
self.deferred_history_lines.extend(display);
} else {

View File

@@ -1,7 +1,6 @@
use codex_core::protocol::ConversationHistoryResponseEvent;
use codex_core::protocol::Event;
use codex_file_search::FileMatch;
use ratatui::text::Line;
use crate::history_cell::HistoryCell;
@@ -40,7 +39,6 @@ pub(crate) enum AppEvent {
/// Result of computing a `/diff` command.
DiffResult(String),
InsertHistoryLines(Vec<Line<'static>>),
InsertHistoryCell(Box<dyn HistoryCell>),
StartCommitAnimation,

View File

@@ -27,6 +27,7 @@ use super::command_popup::CommandPopup;
use super::file_search_popup::FileSearchPopup;
use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use crate::bottom_pane::paste_burst::FlushResult;
use crate::slash_command::SlashCommand;
use codex_protocol::custom_prompts::CustomPrompt;
@@ -223,7 +224,7 @@ impl ChatComposer {
let placeholder = format!("[Pasted Content {char_count} chars]");
self.textarea.insert_element(&placeholder);
self.pending_pastes.push((placeholder, pasted));
} else if self.handle_paste_image_path(pasted.clone()) {
} else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) {
self.textarea.insert_str(" ");
} else {
self.textarea.insert_str(&pasted);
@@ -298,12 +299,7 @@ impl ChatComposer {
}
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
let now = Instant::now();
if let Some(pasted) = self.paste_burst.flush_if_due(now) {
let _ = self.handle_paste(pasted);
return true;
}
false
self.handle_paste_burst_flush(Instant::now())
}
pub(crate) fn is_in_paste_burst(&self) -> bool {
@@ -396,9 +392,11 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Tab, ..
} => {
// Ensure popup filtering/selection reflects the latest composer text
// before applying completion.
let first_line = self.textarea.text().lines().next().unwrap_or("");
popup.on_composer_text_change(first_line.to_string());
if let Some(sel) = popup.selected_item() {
let first_line = self.textarea.text().lines().next().unwrap_or("");
match sel {
CommandItem::Builtin(cmd) => {
let starts_with_cmd = first_line
@@ -837,7 +835,12 @@ impl ChatComposer {
}
self.pending_pastes.clear();
// If there is neither text nor attachments, suppress submission entirely.
let has_attachments = !self.attached_images.is_empty();
text = text.trim().to_string();
if text.is_empty() && !has_attachments {
return (InputResult::None, true);
}
if !text.is_empty() {
self.history.record_local_submission(&text);
}
@@ -848,15 +851,36 @@ impl ChatComposer {
}
}
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
match self.paste_burst.flush_if_due(now) {
FlushResult::Paste(pasted) => {
self.handle_paste(pasted);
true
}
FlushResult::Typed(ch) => {
// Mirror insert_str() behavior so popups stay in sync when a
// pending fast char flushes as normal typed input.
self.textarea.insert_str(ch.to_string().as_str());
// Keep popup sync consistent with key handling: prefer slash popup; only
// sync file popup when slash popup is NOT active.
self.sync_command_popup();
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.dismissed_file_popup_token = None;
} else {
self.sync_file_search_popup();
}
true
}
FlushResult::None => false,
}
}
/// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// If we have a buffered non-bracketed paste burst and enough time has
// elapsed since the last char, flush it before handling a new input.
let now = Instant::now();
if let Some(pasted) = self.paste_burst.flush_if_due(now) {
// Reuse normal paste path (handles large-paste placeholders).
self.handle_paste(pasted);
}
self.handle_paste_burst_flush(now);
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
if matches!(input.code, KeyCode::Enter)
@@ -1520,6 +1544,33 @@ mod tests {
}
}
#[test]
fn empty_enter_returns_none() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Ensure composer is empty and press Enter.
assert!(composer.textarea.text().is_empty());
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::None => {}
other => panic!("expected None for empty enter, got: {other:?}"),
}
}
#[test]
fn handle_paste_large_uses_placeholder_and_replaces_on_submit() {
use crossterm::event::KeyCode;
@@ -1640,6 +1691,66 @@ mod tests {
}
}
#[test]
fn slash_popup_model_first_for_mo_ui() {
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Type "/mo" humanlike so paste-burst doesnt interfere.
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
let mut terminal = match Terminal::new(TestBackend::new(60, 4)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
};
terminal
.draw(|f| f.render_widget_ref(composer, f.area()))
.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());
}
#[test]
fn slash_popup_model_first_for_mo_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
match &composer.active_popup {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "model")
}
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt selected for '/mo'")
}
None => panic!("no selected command for '/mo'"),
},
_ => panic!("slash popup not active after typing '/mo'"),
}
}
// Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
use crossterm::event::KeyCode;

View File

@@ -238,6 +238,20 @@ mod tests {
}
}
#[test]
fn model_is_first_suggestion_for_mo() {
let mut popup = CommandPopup::new(Vec::new());
popup.on_composer_text_change("/mo".to_string());
let matches = popup.filtered_items();
match matches.first() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt ranked before '/model' for '/mo'")
}
None => panic!("expected at least one match for '/mo'"),
}
}
#[test]
fn prompt_discovery_lists_custom_prompts() {
let prompts = vec![

View File

@@ -2,7 +2,6 @@
use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use crate::notifications;
use crate::tui::FrameRequester;
use crate::user_approval_widget::ApprovalRequest;
use bottom_pane_view::BottomPaneView;
@@ -100,64 +99,48 @@ impl BottomPane {
}
}
/// Update whether the bottom pane's composer has input focus.
pub(crate) fn set_has_input_focus(&mut self, has_focus: bool) {
self.has_input_focus = has_focus;
// Use existing API to propagate focus to the composer without changing the
// current Ctrl-C hint visibility.
self.composer
.set_ctrl_c_quit_hint(self.ctrl_c_quit_hint, self.has_input_focus);
self.request_redraw();
}
pub fn desired_height(&self, width: u16) -> u16 {
let top_margin = if self.active_view.is_some() { 0 } else { 1 };
// Always reserve one blank row above the pane for visual spacing.
let top_margin = 1;
// Base height depends on whether a modal/overlay is active.
let mut base = if let Some(view) = self.active_view.as_ref() {
view.desired_height(width)
} else {
self.composer.desired_height(width)
let base = match self.active_view.as_ref() {
Some(view) => view.desired_height(width),
None => self.composer.desired_height(width).saturating_add(
self.status
.as_ref()
.map_or(0, |status| status.desired_height(width)),
),
};
// If a status indicator is active and no modal is covering the composer,
// include its height above the composer.
if self.active_view.is_none()
&& let Some(status) = self.status.as_ref()
{
base = base.saturating_add(status.desired_height(width));
}
// Account for bottom padding rows. Top spacing is handled in layout().
base.saturating_add(Self::BOTTOM_PAD_LINES)
.saturating_add(top_margin)
}
fn layout(&self, area: Rect) -> [Rect; 2] {
// Prefer showing the status header when space is extremely tight.
// Drop the top spacer if there is only one row available.
let mut top_margin = if self.active_view.is_some() { 0 } else { 1 };
if area.height <= 1 {
top_margin = 0;
}
let status_height = if self.active_view.is_none() {
if let Some(status) = self.status.as_ref() {
status.desired_height(area.width)
} else {
0
}
// At small heights, bottom pane takes the entire height.
let (top_margin, bottom_margin) = if area.height <= BottomPane::BOTTOM_PAD_LINES + 1 {
(0, 0)
} else {
0
(1, BottomPane::BOTTOM_PAD_LINES)
};
let [_, status, content, _] = Layout::vertical([
Constraint::Max(top_margin),
Constraint::Max(status_height),
Constraint::Min(1),
Constraint::Max(BottomPane::BOTTOM_PAD_LINES),
])
.areas(area);
[status, content]
let area = Rect {
x: area.x,
y: area.y + top_margin,
width: area.width,
height: area.height - top_margin - bottom_margin,
};
match self.active_view.as_ref() {
Some(_) => [Rect::ZERO, area],
None => {
let status_height = self
.status
.as_ref()
.map_or(0, |status| status.desired_height(area.width));
Layout::vertical([Constraint::Max(status_height), Constraint::Min(1)]).areas(area)
}
}
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
@@ -384,29 +367,6 @@ impl BottomPane {
/// Called when the agent requests user approval.
pub fn push_approval_request(&mut self, request: ApprovalRequest) {
if !self.has_input_focus {
// Send a system notification whenever an approval dialog is about to be shown.
match &request {
ApprovalRequest::Exec { command, .. } => {
let preview = command.join(" ");
let msg = format!("Approve \"{preview}\"?");
notifications::send_os_notification(&msg);
}
ApprovalRequest::ApplyPatch {
reason, grant_root, ..
} => {
let msg = if let Some(root) = grant_root {
format!("Approve patch changes? Grant write to {}", root.display())
} else if let Some(r) = reason {
format!("Approve patch changes? {r}")
} else {
"Approve patch changes?".to_string()
};
notifications::send_os_notification(&msg);
}
}
}
let request = if let Some(view) = self.active_view.as_mut() {
match view.try_consume_approval_request(request) {
Some(request) => request,
@@ -739,7 +699,7 @@ mod tests {
pane.set_task_running(true);
// Height=2 → composer visible; status is hidden to preserve composer. Spacer may collapse.
// Height=2 → status on one row, composer on the other.
let area2 = Rect::new(0, 0, 20, 2);
let mut buf2 = Buffer::empty(area2);
(&pane).render_ref(area2, &mut buf2);
@@ -755,8 +715,8 @@ mod tests {
"expected composer to be visible on one of the rows: row0={row0:?}, row1={row1:?}"
);
assert!(
!row0.contains("Working") && !row1.contains("Working"),
"status header should be hidden when height=2"
row0.contains("Working") || row1.contains("Working"),
"expected status header to be visible at height=2: row0={row0:?}, row1={row1:?}"
);
// Height=1 → no padding; single row is the composer (status hidden).

View File

@@ -35,6 +35,12 @@ pub(crate) struct RetroGrab {
pub grabbed: String,
}
pub(crate) enum FlushResult {
Paste(String),
Typed(char),
None,
}
impl PasteBurst {
/// Recommended delay to wait between simulated keypresses (or before
/// scheduling a UI tick) so that a pending fast keystroke is flushed
@@ -95,24 +101,24 @@ impl PasteBurst {
/// now emit that char as normal typed input.
///
/// Returns None if the timeout has not elapsed or there is nothing to flush.
pub fn flush_if_due(&mut self, now: Instant) -> Option<String> {
pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
let timed_out = self
.last_plain_char_time
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
if timed_out && self.is_active_internal() {
self.active = false;
let out = std::mem::take(&mut self.buffer);
Some(out)
FlushResult::Paste(out)
} else if timed_out {
// If we were saving a single fast char and no burst followed,
// flush it as normal typed input.
if let Some((ch, _at)) = self.pending_first_char.take() {
Some(ch.to_string())
FlushResult::Typed(ch)
} else {
None
FlushResult::None
}
} else {
None
FlushResult::None
}
}

View File

@@ -0,0 +1,8 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"▌/mo "
"▌ "
"▌/model choose what model and reasoning effort to use "
"▌/mention mention a file "

View File

@@ -245,7 +245,6 @@ impl TextArea {
} => self.delete_backward_word(),
KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {

View File

@@ -101,7 +101,6 @@ pub(crate) struct ChatWidget {
// Stream lifecycle controller
stream: StreamController,
running_commands: HashMap<String, RunningCommand>,
pending_exec_completions: Vec<(Vec<String>, Vec<ParsedCommand>, CommandOutput)>,
task_complete_pending: bool,
// Queue of interruptive UI events deferred during an active write cycle
interrupts: InterruptManager,
@@ -113,7 +112,6 @@ pub(crate) struct ChatWidget {
frame_requester: FrameRequester,
// Whether to include the initial welcome banner on session configured
show_welcome_banner: bool,
last_history_was_exec: bool,
// User messages queued while a turn is in progress
queued_user_messages: VecDeque<UserMessage>,
}
@@ -333,6 +331,7 @@ impl ChatWidget {
auto_approved: event.auto_approved,
},
event.changes,
&self.config.cwd,
));
}
@@ -442,14 +441,14 @@ impl ChatWidget {
self.task_complete_pending = false;
}
// A completed stream indicates non-exec content was just inserted.
// Reset the exec header grouping so the next exec shows its header.
self.last_history_was_exec = false;
self.flush_interrupt_queue();
}
}
#[inline]
fn handle_streaming_delta(&mut self, delta: String) {
// Before streaming agent content, flush any active exec cell group.
self.flush_active_exec_cell();
let sink = AppEventHistorySink(self.app_event_tx.clone());
self.stream.begin(&sink);
self.stream.push_and_maybe_commit(&delta, &sink);
@@ -462,31 +461,29 @@ impl ChatWidget {
Some(rc) => (rc.command, rc.parsed_cmd),
None => (vec![ev.call_id.clone()], Vec::new()),
};
self.pending_exec_completions.push((
command,
parsed,
CommandOutput {
exit_code: ev.exit_code,
stdout: ev.stdout.clone(),
stderr: ev.stderr.clone(),
formatted_output: ev.formatted_output.clone(),
},
));
if self.running_commands.is_empty() {
self.active_exec_cell = None;
let pending = std::mem::take(&mut self.pending_exec_completions);
for (command, parsed, output) in pending {
let include_header = !self.last_history_was_exec;
let cell = history_cell::new_completed_exec_command(
command,
parsed,
output,
include_header,
ev.duration,
);
self.add_to_history(cell);
self.last_history_was_exec = true;
if self.active_exec_cell.is_none() {
// This should have been created by handle_exec_begin_now, but in case it wasn't,
// create it now.
self.active_exec_cell = Some(history_cell::new_active_exec_command(
ev.call_id.clone(),
command,
parsed,
));
}
if let Some(cell) = self.active_exec_cell.as_mut() {
cell.complete_call(
&ev.call_id,
CommandOutput {
exit_code: ev.exit_code,
stdout: ev.stdout.clone(),
stderr: ev.stderr.clone(),
formatted_output: ev.formatted_output.clone(),
},
ev.duration,
);
if cell.should_flush() {
self.flush_active_exec_cell();
}
}
}
@@ -495,9 +492,9 @@ impl ChatWidget {
&mut self,
event: codex_core::protocol::PatchApplyEndEvent,
) {
if event.success {
self.add_to_history(history_cell::new_patch_apply_success(event.stdout));
} else {
// If the patch was successful, just let the "Edited" block stand.
// Otherwise, add a failure block.
if !event.success {
self.add_to_history(history_cell::new_patch_apply_failure(event.stderr));
}
}
@@ -523,6 +520,7 @@ impl ChatWidget {
self.add_to_history(history_cell::new_patch_event(
PatchEventType::ApprovalRequest,
ev.changes.clone(),
&self.config.cwd,
));
let request = ApprovalRequest::ApplyPatch {
@@ -543,19 +541,28 @@ impl ChatWidget {
parsed_cmd: ev.parsed_cmd.clone(),
},
);
// Accumulate parsed commands into a single active Exec cell so they stack
match self.active_exec_cell.as_mut() {
Some(exec) => {
exec.parsed.extend(ev.parsed_cmd);
}
_ => {
let include_header = !self.last_history_was_exec;
if let Some(exec) = &self.active_exec_cell {
if let Some(new_exec) = exec.with_added_call(
ev.call_id.clone(),
ev.command.clone(),
ev.parsed_cmd.clone(),
) {
self.active_exec_cell = Some(new_exec);
} else {
// Make a new cell.
self.flush_active_exec_cell();
self.active_exec_cell = Some(history_cell::new_active_exec_command(
ev.command,
ev.parsed_cmd,
include_header,
ev.call_id.clone(),
ev.command.clone(),
ev.parsed_cmd.clone(),
));
}
} else {
self.active_exec_cell = Some(history_cell::new_active_exec_command(
ev.call_id.clone(),
ev.command.clone(),
ev.parsed_cmd.clone(),
));
}
// Request a redraw so the working header and command list are visible immediately.
@@ -585,7 +592,7 @@ impl ChatWidget {
Constraint::Max(
self.active_exec_cell
.as_ref()
.map_or(0, |c| c.desired_height(area.width)),
.map_or(0, |c| c.desired_height(area.width) + 1),
),
Constraint::Min(self.bottom_pane.desired_height(area.width)),
])
@@ -627,13 +634,11 @@ impl ChatWidget {
last_token_usage: TokenUsage::default(),
stream: StreamController::new(config),
running_commands: HashMap::new(),
pending_exec_completions: Vec::new(),
task_complete_pending: false,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
session_id: None,
last_history_was_exec: false,
queued_user_messages: VecDeque::new(),
show_welcome_banner: true,
}
@@ -673,13 +678,11 @@ impl ChatWidget {
last_token_usage: TokenUsage::default(),
stream: StreamController::new(config),
running_commands: HashMap::new(),
pending_exec_completions: Vec::new(),
task_complete_pending: false,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
session_id: None,
last_history_was_exec: false,
queued_user_messages: VecDeque::new(),
show_welcome_banner: false,
}
@@ -690,13 +693,7 @@ impl ChatWidget {
+ self
.active_exec_cell
.as_ref()
.map_or(0, |c| c.desired_height(width))
}
/// Update input focus state for the bottom pane/composer.
pub(crate) fn set_input_focus(&mut self, has_focus: bool) {
self.bottom_pane.set_has_input_focus(has_focus);
self.request_redraw();
.map_or(0, |c| c.desired_height(width) + 1)
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
@@ -772,7 +769,7 @@ impl ChatWidget {
fn dispatch_command(&mut self, cmd: SlashCommand) {
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
"'/'{}' is disabled while a task is in progress.",
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.add_to_history(history_cell::new_error_event(message));
@@ -897,18 +894,15 @@ impl ChatWidget {
fn flush_active_exec_cell(&mut self) {
if let Some(active) = self.active_exec_cell.take() {
self.last_history_was_exec = true;
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(active)));
}
}
fn add_to_history(&mut self, cell: impl HistoryCell + 'static) {
// Only break exec grouping if the cell renders visible lines.
let has_display_lines = !cell.display_lines().is_empty();
self.flush_active_exec_cell();
if has_display_lines {
self.last_history_was_exec = false;
if !cell.display_lines(u16::MAX).is_empty() {
// Only break exec grouping if the cell renders visible lines.
self.flush_active_exec_cell();
}
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
@@ -1034,7 +1028,6 @@ impl ChatWidget {
let cell = cell.into_failed();
// Insert finalized exec into history and keep grouping consistent.
self.add_to_history(cell);
self.last_history_was_exec = true;
}
}
@@ -1089,6 +1082,7 @@ impl ChatWidget {
let is_current = preset.model == current_model && preset.effort == current_effort;
let model_slug = preset.model.to_string();
let effort = preset.effort;
let current_model = current_model.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
@@ -1100,6 +1094,13 @@ impl ChatWidget {
}));
tx.send(AppEvent::UpdateModel(model_slug.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort));
tracing::info!(
"New model: {}, New effort: {}, Current model: {}, Current effort: {}",
model_slug.clone(),
effort,
current_model,
current_effort
);
})];
items.push(SelectionItem {
name,
@@ -1290,6 +1291,9 @@ impl WidgetRef for &ChatWidget {
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 {
let mut active_cell_area = active_cell_area;
active_cell_area.y += 1;
active_cell_area.height -= 1;
cell.render_ref(active_cell_area, buf);
}
}

View File

@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&approved_lines)
---
• Change Approved foo.txt (+1 -0)

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed_lines)
---
• Proposed Change foo.txt (+1 -0)
1 +hello

View File

@@ -3,6 +3,7 @@ source: tui/src/chatwidget/tests.rs
assertion_line: 728
expression: terminal.backend()
---
" "
"? Codex wants to run echo hello world "
" "
"Model wants to run a command "

View File

@@ -0,0 +1,11 @@
---
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,8 +1,9 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 763
assertion_line: 794
expression: terminal.backend()
---
" "
"The model wants to apply changes "
" "
"This will grant write access to /tmp for the remainder of this session. "

View File

@@ -0,0 +1,57 @@
---
source: tui/src/chatwidget/tests.rs
expression: visible_after
---
> Im going to scan the workspace and Cargo manifests to see build profiles and
dependencies that impact binary size. Then Ill summarize the main causes.
• Explored
└ List ls -la
Read Cargo.toml
• Ran
└ for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama
tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo;
done
• Explored
└ Read Cargo.toml
> Heres whats driving size in this workspaces binaries.
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.
- 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
transitive set.
- Image/terminal stack: codex-tui includes image (with jpeg), ratatui,
crossterm, and ratatui-image, which together contribute significant code size
(decoders, pixel ops, terminal backends).
- Parsers/VMs: tree-sitter + tree-sitter-bash (in core and apply-patch) and
starlark (in execpolicy) include sizeable parser/VM tables and runtimes.
- Tokio runtime: Broad tokio features (rt-multi-thread, macros, process,
signal) across many crates inflate code size even if only subsets are used
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.
Build-Mode Notes
- Release settings: You use lto = "fat" and codegen-units = 1 (good for size),
but strip = "symbols" keeps debuginfo. Debuginfo is often the largest single
contributor; if you build in release with that setting, binaries can still
be large.
- 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.

View File

@@ -1,7 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 779
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
" "
" ⏎ send Ctrl+J newline Ctrl+T transc"

View File

@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 779
expression: terminal.backend()
---
" "

View File

@@ -1,7 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 807
expression: terminal.backend()
---
" Thinking (0s • Esc to interrupt) "
"▌ Ask Codex to do anything "
" "

View File

@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 807
expression: terminal.backend()
---
" "

View File

@@ -0,0 +1,15 @@
---
source: tui/src/chatwidget/tests.rs
expression: visual
---
> Im going to search the repo for where “Change Approved” is rendered to update
that view.
• Explored
└ Search Change Approved
Read diff_render.rs
Investigating rendering code (0s • Esc to interrupt)
▌Summarize recent commits
⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit

View File

@@ -2,5 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: combined
---
codex
Here is the result.
> Here is the result.

View File

@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob
---
🖐  '/model' is disabled while a task is in progress.

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob1
---
⠋ Exploring
└ List ls -la

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob2
---
• Explored
└ List ls -la

View File

@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob3
---
⠋ Exploring
└ List ls -la
Read foo.txt

View File

@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob4
---
• Explored
└ List ls -la
Read foo.txt

View File

@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob5
---
• Explored
└ List ls -la
Read foo.txt

View File

@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob6
---
• Explored
└ List ls -la
Read foo.txt, bar.txt

View File

@@ -2,5 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: combined
---
codex
Here is the result.
> Here is the result.

View File

@@ -2,5 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: exec_blob
---
>_
✗ ⌨sleep 1
• Ran sleep 1

View File

@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 878
expression: terminal.backend()
---
" "

View File

@@ -1,8 +1,9 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 851
assertion_line: 921
expression: terminal.backend()
---
" "
"? Codex wants to run echo 'hello world' "
" "
"Codex wants to run a command "

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
pub(crate) const DEFAULT_WRAP_COLS: u16 = 80;

View File

@@ -1,16 +1,17 @@
use crossterm::terminal;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use crate::common::DEFAULT_WRAP_COLS;
use codex_core::protocol::FileChange;
use crate::exec_command::relativize_to_home;
use crate::history_cell::PatchEventType;
use codex_core::git_info::get_git_repo_root;
use codex_core::protocol::FileChange;
const SPACES_AFTER_LINE_NUMBER: usize = 6;
@@ -22,205 +23,199 @@ enum DiffLineType {
}
pub(crate) fn create_diff_summary(
title: &str,
changes: &HashMap<PathBuf, FileChange>,
event_type: PatchEventType,
cwd: &Path,
wrap_cols: usize,
) -> Vec<RtLine<'static>> {
struct FileSummary {
display_path: String,
added: usize,
removed: usize,
}
let count_from_unified = |diff: &str| -> (usize, usize) {
if let Ok(patch) = diffy::Patch::from_str(diff) {
patch
.hunks()
.iter()
.flat_map(|h| h.lines())
.fold((0, 0), |(a, d), l| match l {
diffy::Line::Insert(_) => (a + 1, d),
diffy::Line::Delete(_) => (a, d + 1),
_ => (a, d),
})
} else {
// Fallback: manual scan to preserve counts even for unparsable diffs
let mut adds = 0usize;
let mut dels = 0usize;
for l in diff.lines() {
if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
continue;
}
match l.as_bytes().first() {
Some(b'+') => adds += 1,
Some(b'-') => dels += 1,
_ => {}
}
let rows = collect_rows(changes);
let header_kind = match event_type {
PatchEventType::ApplyBegin { auto_approved } => {
if auto_approved {
HeaderKind::Edited
} else {
HeaderKind::ChangeApproved
}
(adds, dels)
}
PatchEventType::ApprovalRequest => HeaderKind::ProposedChange,
};
let mut files: Vec<FileSummary> = Vec::new();
for (path, change) in changes.iter() {
match change {
FileChange::Add { content } => files.push(FileSummary {
display_path: path.display().to_string(),
added: content.lines().count(),
removed: 0,
}),
FileChange::Delete => files.push(FileSummary {
display_path: path.display().to_string(),
added: 0,
removed: std::fs::read_to_string(path)
.ok()
.map(|s| s.lines().count())
.unwrap_or(0),
}),
FileChange::Update {
unified_diff,
move_path,
} => {
let (added, removed) = count_from_unified(unified_diff);
let display_path = if let Some(new_path) = move_path {
format!("{}{}", path.display(), new_path.display())
} else {
path.display().to_string()
};
files.push(FileSummary {
display_path,
added,
removed,
});
}
}
}
let file_count = files.len();
let total_added: usize = files.iter().map(|f| f.added).sum();
let total_removed: usize = files.iter().map(|f| f.removed).sum();
let noun = if file_count == 1 { "file" } else { "files" };
let mut out: Vec<RtLine<'static>> = Vec::new();
// Header
let mut header_spans: Vec<RtSpan<'static>> = Vec::new();
header_spans.push(RtSpan::styled(
title.to_owned(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
header_spans.push(RtSpan::raw(" to "));
header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
header_spans.push(RtSpan::raw("("));
header_spans.push(RtSpan::styled(
format!("+{total_added}"),
Style::default().fg(Color::Green),
));
header_spans.push(RtSpan::raw(" "));
header_spans.push(RtSpan::styled(
format!("-{total_removed}"),
Style::default().fg(Color::Red),
));
header_spans.push(RtSpan::raw(")"));
out.push(RtLine::from(header_spans));
// Dimmed per-file lines with prefix
for (idx, f) in files.iter().enumerate() {
let mut spans: Vec<RtSpan<'static>> = Vec::new();
spans.push(RtSpan::raw(f.display_path.clone()));
// Show per-file +/- counts only when there are multiple files
if file_count > 1 {
spans.push(RtSpan::raw(" ("));
spans.push(RtSpan::styled(
format!("+{}", f.added),
Style::default().fg(Color::Green),
));
spans.push(RtSpan::raw(" "));
spans.push(RtSpan::styled(
format!("-{}", f.removed),
Style::default().fg(Color::Red),
));
spans.push(RtSpan::raw(")"));
}
let mut line = RtLine::from(spans);
let prefix = if idx == 0 { "" } else { " " };
line.spans.insert(0, prefix.into());
line.spans
.iter_mut()
.for_each(|span| span.style = span.style.add_modifier(Modifier::DIM));
out.push(line);
}
let show_details = matches!(
event_type,
PatchEventType::ApplyBegin {
auto_approved: true
} | PatchEventType::ApprovalRequest
);
if show_details {
out.extend(render_patch_details(changes));
}
out
render_changes_block(rows, wrap_cols, header_kind, cwd)
}
fn render_patch_details(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
let mut out: Vec<RtLine<'static>> = Vec::new();
let term_cols: usize = terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(DEFAULT_WRAP_COLS.into());
// Shared row for per-file presentation
#[derive(Clone)]
struct Row {
#[allow(dead_code)]
path: PathBuf,
move_path: Option<PathBuf>,
added: usize,
removed: usize,
change: FileChange,
}
for (index, (path, change)) in changes.iter().enumerate() {
let is_first_file = index == 0;
// Add separator only between files (not at the very start)
if !is_first_file {
out.push(RtLine::from(vec![
RtSpan::raw(" "),
RtSpan::styled("...", style_dim()),
]));
fn collect_rows(changes: &HashMap<PathBuf, FileChange>) -> Vec<Row> {
let mut rows: Vec<Row> = Vec::new();
for (path, change) in changes.iter() {
let (added, removed) = match change {
FileChange::Add { content } => (content.lines().count(), 0),
FileChange::Delete { content } => (0, content.lines().count()),
FileChange::Update { unified_diff, .. } => calculate_add_remove_from_diff(unified_diff),
};
let move_path = match change {
FileChange::Update {
move_path: Some(new),
..
} => Some(new.clone()),
_ => None,
};
rows.push(Row {
path: path.clone(),
move_path,
added,
removed,
change: change.clone(),
});
}
rows.sort_by_key(|r| r.path.clone());
rows
}
enum HeaderKind {
ProposedChange,
Edited,
ChangeApproved,
}
fn render_changes_block(
rows: Vec<Row>,
wrap_cols: usize,
header_kind: HeaderKind,
cwd: &Path,
) -> Vec<RtLine<'static>> {
let mut out: Vec<RtLine<'static>> = Vec::new();
let term_cols = wrap_cols;
fn render_line_count_summary(added: usize, removed: usize) -> Vec<RtSpan<'static>> {
let mut spans = Vec::new();
spans.push("(".into());
spans.push(format!("+{added}").green());
spans.push(" ".into());
spans.push(format!("-{removed}").red());
spans.push(")".into());
spans
}
let render_path = |row: &Row| -> Vec<RtSpan<'static>> {
let mut spans = Vec::new();
spans.push(display_path_for(&row.path, cwd).into());
if let Some(move_path) = &row.move_path {
spans.push(format!("{}", display_path_for(move_path, cwd)).into());
}
match change {
spans
};
// Header
let total_added: usize = rows.iter().map(|r| r.added).sum();
let total_removed: usize = rows.iter().map(|r| r.removed).sum();
let file_count = rows.len();
let noun = if file_count == 1 { "file" } else { "files" };
let mut header_spans: Vec<RtSpan<'static>> = vec!["".into()];
match header_kind {
HeaderKind::ProposedChange => {
header_spans.push("Proposed Change".bold());
if let [row] = &rows[..] {
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push(format!(" to {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
}
HeaderKind::Edited => {
if let [row] = &rows[..] {
let verb = match &row.change {
FileChange::Add { .. } => "Added",
FileChange::Delete { .. } => "Deleted",
_ => "Edited",
};
header_spans.push(verb.bold());
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push("Edited".bold());
header_spans.push(format!(" {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
}
HeaderKind::ChangeApproved => {
header_spans.push("Change Approved".bold());
if let [row] = &rows[..] {
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push(format!(" {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
}
}
out.push(RtLine::from(header_spans));
// For Change Approved, we only show the header summary and no per-file/diff details.
if matches!(header_kind, HeaderKind::ChangeApproved) {
return out;
}
for (idx, r) in rows.into_iter().enumerate() {
// Insert a blank separator between file chunks (except before the first)
if idx > 0 {
out.push("".into());
}
// File header line (skip when single-file header already shows the name)
let skip_file_header =
matches!(header_kind, HeaderKind::ProposedChange | HeaderKind::Edited)
&& file_count == 1;
if !skip_file_header {
let mut header: Vec<RtSpan<'static>> = Vec::new();
header.push("".dim());
header.extend(render_path(&r));
header.push(" ".into());
header.extend(render_line_count_summary(r.added, r.removed));
out.push(RtLine::from(header));
}
match r.change {
FileChange::Add { content } => {
for (i, raw) in content.lines().enumerate() {
let ln = i + 1;
out.extend(push_wrapped_diff_line(
ln,
i + 1,
DiffLineType::Insert,
raw,
term_cols,
));
}
}
FileChange::Delete => {
let original = std::fs::read_to_string(path).unwrap_or_default();
for (i, raw) in original.lines().enumerate() {
let ln = i + 1;
FileChange::Delete { content } => {
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
ln,
i + 1,
DiffLineType::Delete,
raw,
term_cols,
));
}
}
FileChange::Update {
unified_diff,
move_path: _,
} => {
if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
FileChange::Update { unified_diff, .. } => {
if let Ok(patch) = diffy::Patch::from_str(&unified_diff) {
let mut is_first_hunk = true;
for h in patch.hunks() {
// Render a simple separator between non-contiguous hunks
// instead of diff-style @@ headers.
if !is_first_hunk {
out.push(RtLine::from(vec![
RtSpan::raw(" "),
RtSpan::styled("", style_dim()),
]));
out.push(RtLine::from(vec![" ".into(), "".dim()]));
}
is_first_hunk = false;
@@ -265,13 +260,41 @@ fn render_patch_details(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'s
}
}
}
out.push(RtLine::from(RtSpan::raw("")));
}
out
}
fn display_path_for(path: &Path, cwd: &Path) -> String {
let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) {
(Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo,
_ => false,
};
let chosen = if path_in_same_repo {
pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf())
} else {
relativize_to_home(path).unwrap_or_else(|| path.to_path_buf())
};
chosen.display().to_string()
}
fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) {
if let Ok(patch) = diffy::Patch::from_str(diff) {
patch
.hunks()
.iter()
.flat_map(|h| h.lines())
.fold((0, 0), |(a, d), l| match l {
diffy::Line::Insert(_) => (a + 1, d),
diffy::Line::Delete(_) => (a, d + 1),
diffy::Line::Context(_) => (a, d),
})
} else {
// For unparsable diffs, return 0 for both counts.
(0, 0)
}
}
fn push_wrapped_diff_line(
line_number: usize,
kind: DiffLineType,
@@ -290,10 +313,10 @@ fn push_wrapped_diff_line(
let prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
let mut first = true;
let (sign_opt, line_style) = match kind {
DiffLineType::Insert => (Some('+'), Some(style_add())),
DiffLineType::Delete => (Some('-'), Some(style_del())),
DiffLineType::Context => (None, None),
let (sign_char, line_style) = match kind {
DiffLineType::Insert => ('+', style_add()),
DiffLineType::Delete => ('-', style_del()),
DiffLineType::Context => (' ', style_context()),
};
let mut lines: Vec<RtLine<'static>> = Vec::new();
@@ -301,9 +324,7 @@ fn push_wrapped_diff_line(
// Fit the content for the current terminal row:
// compute how many columns are available after the prefix, then split
// at a UTF-8 character boundary so this row's chunk fits exactly.
let available_content_cols = term_cols
.saturating_sub(if first { prefix_cols + 1 } else { prefix_cols })
.max(1);
let available_content_cols = term_cols.saturating_sub(prefix_cols + 1).max(1);
let split_at_byte_index = remaining_text
.char_indices()
.nth(available_content_cols)
@@ -313,41 +334,22 @@ fn push_wrapped_diff_line(
remaining_text = rest;
if first {
let mut spans: Vec<RtSpan<'static>> = Vec::new();
spans.push(RtSpan::raw(indent));
spans.push(RtSpan::styled(ln_str.clone(), style_dim()));
spans.push(RtSpan::raw(" ".repeat(gap_after_ln)));
// Always include a sign character at the start of the displayed chunk
// ('+' for insert, '-' for delete, ' ' for context) so gutters align.
let sign_char = sign_opt.unwrap_or(' ');
let display_chunk = format!("{sign_char}{chunk}");
let content_span = match line_style {
Some(style) => RtSpan::styled(display_chunk, style),
None => RtSpan::raw(display_chunk),
};
spans.push(content_span);
let mut line = RtLine::from(spans);
if let Some(style) = line_style {
line.style = line.style.patch(style);
}
lines.push(line);
// Build gutter (indent + line number + spacing) as a dimmed span
let gutter = format!("{indent}{ln_str}{}", " ".repeat(gap_after_ln));
// Content with a sign ('+'/'-'/' ') styled per diff kind
let content = format!("{sign_char}{chunk}");
lines.push(RtLine::from(vec![
RtSpan::styled(gutter, style_gutter()),
RtSpan::styled(content, line_style),
]));
first = false;
} else {
// Continuation lines keep a space for the sign column so content aligns
let hang_prefix = format!(
"{indent}{}{} ",
" ".repeat(ln_str.len()),
" ".repeat(gap_after_ln)
);
let content_span = match line_style {
Some(style) => RtSpan::styled(chunk.to_string(), style),
None => RtSpan::raw(chunk.to_string()),
};
let mut line = RtLine::from(vec![RtSpan::raw(hang_prefix), content_span]);
if let Some(style) = line_style {
line.style = line.style.patch(style);
}
lines.push(line);
let gutter = format!("{indent}{} ", " ".repeat(ln_str.len() + gap_after_ln));
lines.push(RtLine::from(vec![
RtSpan::styled(gutter, style_gutter()),
RtSpan::styled(chunk.to_string(), line_style),
]));
}
if remaining_text.is_empty() {
break;
@@ -356,10 +358,14 @@ fn push_wrapped_diff_line(
lines
}
fn style_dim() -> Style {
fn style_gutter() -> Style {
Style::default().add_modifier(Modifier::DIM)
}
fn style_context() -> Style {
Style::default()
}
fn style_add() -> Style {
Style::default().fg(Color::Green)
}
@@ -378,6 +384,12 @@ mod tests {
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
fn diff_summary_for_tests(
changes: &HashMap<PathBuf, FileChange>,
event_type: PatchEventType,
) -> Vec<RtLine<'static>> {
create_diff_summary(changes, event_type, &PathBuf::from("/"), 80)
}
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
@@ -391,6 +403,23 @@ mod tests {
assert_snapshot!(name, terminal.backend());
}
fn snapshot_lines_text(name: &str, lines: &[RtLine<'static>]) {
// Convert Lines to plain text rows and trim trailing spaces so it's
// easier to validate indentation visually in snapshots.
let text = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.map(|s| s.trim_end().to_string())
.collect::<Vec<_>>()
.join("\n");
assert_snapshot!(name, text);
}
#[test]
fn ui_snapshot_add_details() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
@@ -401,8 +430,7 @@ mod tests {
},
);
let lines =
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("add_details", lines, 80, 10);
}
@@ -423,8 +451,7 @@ mod tests {
},
);
let lines =
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("update_details_with_rename", lines, 80, 12);
}
@@ -435,11 +462,10 @@ mod tests {
let long_line = "this is a very long line that should wrap across multiple terminal columns and continue";
// Call the wrapping function directly so we can precisely control the width
let lines =
push_wrapped_diff_line(1, DiffLineType::Insert, long_line, DEFAULT_WRAP_COLS.into());
let lines = push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80);
// Render into a small terminal to capture the visual layout
snapshot_lines("wrap_behavior_insert", lines, DEFAULT_WRAP_COLS + 10, 8);
snapshot_lines("wrap_behavior_insert", lines, 90, 8);
}
#[test]
@@ -458,8 +484,7 @@ mod tests {
},
);
let lines =
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("single_line_replacement_counts", lines, 80, 8);
}
@@ -480,8 +505,7 @@ mod tests {
},
);
let lines =
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("blank_context_line", lines, 80, 10);
}
@@ -503,10 +527,232 @@ mod tests {
},
);
let lines =
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
// Height is large enough to show both hunks and the separator
snapshot_lines("vertical_ellipsis_between_hunks", lines, 80, 16);
}
#[test]
fn ui_snapshot_apply_update_block() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
let original = "line one\nline two\nline three\n";
let modified = "line one\nline two changed\nline three\n";
let patch = diffy::create_patch(original, modified).to_string();
changes.insert(
PathBuf::from("example.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
for (name, auto_approved) in [
("apply_update_block", true),
("apply_update_block_manual", false),
] {
let lines =
diff_summary_for_tests(&changes, PatchEventType::ApplyBegin { auto_approved });
snapshot_lines(name, lines, 80, 12);
}
}
#[test]
fn ui_snapshot_apply_update_with_rename_block() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
let original = "A\nB\nC\n";
let modified = "A\nB changed\nC\n";
let patch = diffy::create_patch(original, modified).to_string();
changes.insert(
PathBuf::from("old_name.rs"),
FileChange::Update {
unified_diff: patch,
move_path: Some(PathBuf::from("new_name.rs")),
},
);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
snapshot_lines("apply_update_with_rename_block", lines, 80, 12);
}
#[test]
fn ui_snapshot_apply_multiple_files_block() {
// Two files: one update and one add, to exercise combined header and per-file rows
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
// File a.txt: single-line replacement (one delete, one insert)
let patch_a = diffy::create_patch("one\n", "one changed\n").to_string();
changes.insert(
PathBuf::from("a.txt"),
FileChange::Update {
unified_diff: patch_a,
move_path: None,
},
);
// File b.txt: newly added with one line
changes.insert(
PathBuf::from("b.txt"),
FileChange::Add {
content: "new\n".to_string(),
},
);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
snapshot_lines("apply_multiple_files_block", lines, 80, 14);
}
#[test]
fn ui_snapshot_apply_add_block() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("new_file.txt"),
FileChange::Add {
content: "alpha\nbeta\n".to_string(),
},
);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
snapshot_lines("apply_add_block", lines, 80, 10);
}
#[test]
fn ui_snapshot_apply_delete_block() {
// Write a temporary file so the delete renderer can read original content
let tmp_path = PathBuf::from("tmp_delete_example.txt");
std::fs::write(&tmp_path, "first\nsecond\nthird\n").expect("write tmp file");
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
tmp_path.clone(),
FileChange::Delete {
content: "first\nsecond\nthird\n".to_string(),
},
);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
// Cleanup best-effort; rendering has already read the file
let _ = std::fs::remove_file(&tmp_path);
snapshot_lines("apply_delete_block", lines, 80, 12);
}
#[test]
fn ui_snapshot_apply_update_block_wraps_long_lines() {
// Create a patch with a long modified line to force wrapping
let original = "line 1\nshort\nline 3\n";
let modified = "line 1\nshort this_is_a_very_long_modified_line_that_should_wrap_across_multiple_terminal_columns_and_continue_even_further_beyond_eighty_columns_to_force_multiple_wraps\nline 3\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("long_example.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = create_diff_summary(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
&PathBuf::from("/"),
72,
);
// Render with backend width wider than wrap width to avoid Paragraph auto-wrap.
snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12);
}
#[test]
fn ui_snapshot_apply_update_block_wraps_long_lines_text() {
// This mirrors the desired layout example: sign only on first inserted line,
// subsequent wrapped pieces start aligned under the line number gutter.
let original = "1\n2\n3\n4\n";
let modified = "1\nadded long line which wraps and_if_there_is_a_long_token_it_will_be_broken\n3\n4 context line which also wraps across\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("wrap_demo.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let mut lines = create_diff_summary(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
&PathBuf::from("/"),
28,
);
// Drop the combined header for this text-only snapshot
if !lines.is_empty() {
lines.remove(0);
}
snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines);
}
#[test]
fn ui_snapshot_apply_update_block_relativizes_path() {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
let abs_old = cwd.join("abs_old.rs");
let abs_new = cwd.join("abs_new.rs");
let original = "X\nY\n";
let modified = "X changed\nY\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
abs_old.clone(),
FileChange::Update {
unified_diff: patch,
move_path: Some(abs_new.clone()),
},
);
let lines = create_diff_summary(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
&cwd,
80,
);
snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,8 @@ use ratatui::text::Span;
use textwrap::Options as TwOptions;
use textwrap::WordSplitter;
/// Insert `lines` above the viewport.
/// Insert `lines` above the viewport using the terminal's backend writer
/// (avoids direct stdout references).
pub(crate) fn insert_history_lines(terminal: &mut tui::Terminal, lines: Vec<Line>) {
let mut out = std::io::stdout();
insert_history_lines_to_writer(terminal, &mut out, lines);
@@ -262,7 +263,10 @@ where
}
/// Word-aware wrapping for a list of `Line`s preserving styles.
pub(crate) fn word_wrap_lines(lines: &[Line], width: u16) -> Vec<Line<'static>> {
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 {

View File

@@ -34,7 +34,6 @@ mod chatwidget;
mod citation_regex;
mod cli;
mod clipboard_paste;
mod common;
pub mod custom_terminal;
mod diff_render;
mod exec_command;
@@ -45,7 +44,6 @@ pub mod insert_history;
pub mod live_wrap;
mod markdown;
mod markdown_stream;
mod notifications;
pub mod onboarding;
mod pager_overlay;
mod render;
@@ -65,8 +63,6 @@ mod chatwidget_stream_tests;
#[cfg(not(debug_assertions))]
mod updates;
#[cfg(not(debug_assertions))]
use color_eyre::owo_colors::OwoColorize;
pub use cli::Cli;

View File

@@ -1,62 +0,0 @@
use std::process::Command;
/// Send a simple OS notification with a fixed app title.
/// Best-effort and silently ignores errors if the platform/tooling is unavailable.
pub fn send_os_notification(message: &str) {
#[cfg(target_os = "macos")]
{
fn detect_bundle_id() -> Option<&'static str> {
use std::env;
// Common terminal mappings.
let term_program = env::var("TERM_PROGRAM").unwrap_or_default();
match term_program.as_str() {
"Apple_Terminal" => Some("com.apple.Terminal"),
"iTerm.app" | "iTerm2" | "iTerm2.app" => Some("com.googlecode.iterm2"),
"WezTerm" => Some("com.github.wez.wezterm"),
"Alacritty" => Some("io.alacritty"),
other => {
// Fallback heuristics.
let term = env::var("TERM").unwrap_or_default();
if other.to_lowercase().contains("kitty") || term.contains("xterm-kitty") {
Some("net.kovidgoyal.kitty")
} else {
None
}
}
}
}
// Prefer terminal-notifier on macOS and attempt to activate the current terminal on click.
let mut cmd = Command::new("terminal-notifier");
cmd.arg("-title").arg("Codex").arg("-message").arg(message);
if let Some(bundle) = detect_bundle_id() {
cmd.arg("-activate").arg(bundle);
}
let _ = cmd.spawn();
}
#[cfg(all(unix, not(target_os = "macos")))]
{
// Use notify-send if available (Linux/BSD). Title first, then body.
let _ = Command::new("notify-send")
.arg("Codex")
.arg(message)
.spawn();
}
#[cfg(target_os = "windows")]
{
// Best-effort: try a lightweight Toast via PowerShell if available.
// Fall back to no-op if this fails.
let ps = r#"
Add-Type -AssemblyName System.Windows.Forms | Out-Null
[System.Windows.Forms.MessageBox]::Show($args[0], 'Codex') | Out-Null
"#;
let _ = Command::new("powershell")
.arg("-NoProfile")
.arg("-Command")
.arg(ps)
.arg(message)
.spawn();
}
}

View File

@@ -1,4 +1,4 @@
use codex_core::util::is_inside_git_repo;
use codex_core::git_info::get_git_repo_root;
use codex_login::AuthManager;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -88,7 +88,7 @@ impl OnboardingScreen {
auth_manager,
}))
}
let is_git_repo = is_inside_git_repo(&cwd);
let is_git_repo = get_git_repo_root(&cwd).is_some();
let highlighted = if is_git_repo {
TrustDirectorySelection::Trust
} else {

View File

@@ -140,16 +140,6 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
});
LOGGER.write_json_line(value);
}
// Internal UI events; still log for fidelity, but avoid heavy payloads.
AppEvent::InsertHistoryLines(lines) => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "insert_history",
"lines": lines.len(),
});
LOGGER.write_json_line(value);
}
AppEvent::InsertHistoryCell(cell) => {
let value = json!({
"ts": now_ts(),

View File

@@ -1,9 +1,9 @@
---
source: tui/src/diff_render.rs
assertion_line: 765
expression: terminal.backend()
---
"proposed patch to 1 file (+2 -0) "
" └ README.md "
"• Proposed Change README.md (+2 -0) "
" 1 +first line "
" 2 +second line "
" "
@@ -12,3 +12,4 @@ expression: terminal.backend()
" "
" "
" "
" "

View File

@@ -0,0 +1,14 @@
---
source: tui/src/diff_render.rs
expression: terminal.backend()
---
"• Added new_file.txt (+2 -0) "
" 1 +alpha "
" 2 +beta "
" "
" "
" "
" "
" "
" "
" "

View File

@@ -0,0 +1,16 @@
---
source: tui/src/diff_render.rs
expression: terminal.backend()
---
"• Deleted tmp_delete_example.txt (+0 -3) "
" 1 -first "
" 2 -second "
" 3 -third "
" "
" "
" "
" "
" "
" "
" "
" "

View File

@@ -0,0 +1,18 @@
---
source: tui/src/diff_render.rs
expression: terminal.backend()
---
"• Edited 2 files (+2 -1) "
" └ a.txt (+1 -1) "
" 1 -one "
" 1 +one changed "
" "
" └ b.txt (+1 -0) "
" 1 +new "
" "
" "
" "
" "
" "
" "
" "

View File

@@ -0,0 +1,17 @@
---
source: tui/src/diff_render.rs
assertion_line: 748
expression: terminal.backend()
---
"• Edited example.txt (+1 -1) "
" 1 line one "
" 2 -line two "
" 2 +line two changed "
" 3 line three "
" "
" "
" "
" "
" "
" "
" "

View File

@@ -0,0 +1,16 @@
---
source: tui/src/diff_render.rs
expression: terminal.backend()
---
"• Change Approved example.txt (+1 -1) "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "

View File

@@ -0,0 +1,15 @@
---
source: tui/src/diff_render.rs
assertion_line: 748
expression: terminal.backend()
---
"• Edited abs_old.rs → abs_new.rs (+1 -1) "
" 1 -X "
" 1 +X changed "
" 2 Y "
" "
" "
" "
" "
" "
" "

View File

@@ -0,0 +1,17 @@
---
source: tui/src/diff_render.rs
assertion_line: 748
expression: terminal.backend()
---
"• Edited long_example.txt (+1 -1) "
" 1 line 1 "
" 2 -short "
" 2 +short this_is_a_very_long_modified_line_that_should_wrap_acro "
" ss_multiple_terminal_columns_and_continue_even_further_beyond "
" _eighty_columns_to_force_multiple_wraps "
" 3 line 3 "
" "
" "
" "
" "
" "

View File

@@ -0,0 +1,16 @@
---
source: tui/src/diff_render.rs
expression: text
---
1 1
2 -2
2 +added long line w
hich wraps and_if
_there_is_a_long_
token_it_will_be_
broken
3 3
4 -4
4 +4 context line wh
ich also wraps ac
ross

View File

@@ -0,0 +1,17 @@
---
source: tui/src/diff_render.rs
assertion_line: 748
expression: terminal.backend()
---
"• Edited old_name.rs → new_name.rs (+1 -1) "
" 1 A "
" 2 -B "
" 2 +B changed "
" 3 C "
" "
" "
" "
" "
" "
" "
" "

View File

@@ -1,9 +1,9 @@
---
source: tui/src/diff_render.rs
assertion_line: 765
expression: terminal.backend()
---
"proposed patch to 1 file (+1 -1) "
" └ example.txt "
"• Proposed Change example.txt (+1 -1) "
" 1 "
" 2 -Y "
" 2 +Y changed "
@@ -12,3 +12,4 @@ expression: terminal.backend()
" "
" "
" "
" "

View File

@@ -1,12 +1,13 @@
---
source: tui/src/diff_render.rs
assertion_line: 765
expression: terminal.backend()
---
"proposed patch to 1 file (+1 -1) "
" └ README.md "
"• Proposed Change README.md (+1 -1) "
" 1 -# Codex CLI (Rust Implementation) "
" 1 +# Codex CLI (Rust Implementation) banana "
" "
" "
" "
" "
" "

View File

@@ -1,9 +1,9 @@
---
source: tui/src/diff_render.rs
assertion_line: 765
expression: terminal.backend()
---
"proposed patch to 1 file (+1 -1) "
" └ src/lib.rs → src/lib_new.rs "
"• Proposed Change src/lib.rs → src/lib_new.rs (+1 -1) "
" 1 line one "
" 2 -line two "
" 2 +line two changed "
@@ -14,3 +14,4 @@ expression: terminal.backend()
" "
" "
" "
" "

View File

@@ -1,9 +1,9 @@
---
source: tui/src/diff_render.rs
assertion_line: 765
expression: terminal.backend()
---
"proposed patch to 1 file (+2 -2) "
" └ example.txt "
"• Proposed Change example.txt (+2 -2) "
" 1 line 1 "
" 2 -line 2 "
" 2 +line two changed "
@@ -18,3 +18,4 @@ expression: terminal.backend()
" 9 +line nine changed "
" 10 line 10 "
" "
" "

View File

@@ -0,0 +1,6 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Explored
└ Read auth.rs, shimmer.rs

View File

@@ -0,0 +1,7 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Explored
└ Search shimmer_spans
Read shimmer.rs, status_indicator_widget.rs

View File

@@ -0,0 +1,8 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Explored
└ Search shimmer_spans
Read shimmer.rs
Read status_indicator_widget.rs

View File

@@ -0,0 +1,9 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Ran
└ first_token_is_long_
enough_to_wrap
second_token_is_also
_long_enough_to_wrap

View File

@@ -0,0 +1,7 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Ran
└ echo one
echo two

View File

@@ -0,0 +1,9 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Ran
└ set -o pipefail
cargo test
--all-features
--quiet

View File

@@ -0,0 +1,20 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Updated Plan
└ Ill update Grafana call
error handling by adding
retries and clearer
messages when the backend is
unreachable.
✔ Investigate existing error
paths and logging around
HTTP timeouts
□ Harden Grafana client
error handling with retry/
backoff and userfriendly
messages
□ Add tests for transient
failure scenarios and
surfacing to the UI

View File

@@ -0,0 +1,7 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Updated Plan
└ □ Define error taxonomy
□ Implement mapping to user messages

View File

@@ -0,0 +1,14 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Ran
└ echo
this_is_a_very_long_
single_token_that_wi
ll_wrap_across_the_a
vailable_width
error: first line on
stderr
error: second line on
stderr

View File

@@ -0,0 +1,5 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Ran echo ok

View File

@@ -0,0 +1,9 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Ran
└ a_very_long_toke
n_without_spaces
_to_force_wrappi
ng

View File

@@ -0,0 +1,15 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Ran seq 1 10 1>&2 && false
└ 1
2
3
4
5
6
7
8
9
10

View File

@@ -0,0 +1,8 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
▌one two
▌three four
▌five six
▌seven

View File

@@ -1,3 +1,5 @@
use crate::history_cell;
use crate::history_cell::HistoryCell;
use codex_core::config::Config;
use ratatui::text::Line;
@@ -6,7 +8,7 @@ use super::StreamState;
/// Sink for history insertions and animation control.
pub(crate) trait HistorySink {
fn insert_history(&self, lines: Vec<Line<'static>>);
fn insert_history_cell(&self, cell: Box<dyn HistoryCell>);
fn start_commit_animation(&self);
fn stop_commit_animation(&self);
}
@@ -15,9 +17,9 @@ pub(crate) trait HistorySink {
pub(crate) struct AppEventHistorySink(pub(crate) crate::app_event_sender::AppEventSender);
impl HistorySink for AppEventHistorySink {
fn insert_history(&self, lines: Vec<Line<'static>>) {
fn insert_history_cell(&self, cell: Box<dyn crate::history_cell::HistoryCell>) {
self.0
.send(crate::app_event::AppEvent::InsertHistoryLines(lines))
.send(crate::app_event::AppEvent::InsertHistoryCell(cell))
}
fn start_commit_animation(&self) {
self.0
@@ -66,10 +68,6 @@ impl StreamController {
// leave header state unchanged; caller decides when to reset
}
fn emit_header_if_needed(&mut self, out_lines: &mut Lines) -> bool {
self.header.maybe_emit(out_lines)
}
/// Begin an answer stream. Does not emit header yet; it is emitted on first commit.
pub(crate) fn begin(&mut self, _sink: &impl HistorySink) {
// Starting a new stream cancels any pending finish-from-previous-stream animation.
@@ -124,10 +122,11 @@ impl StreamController {
out_lines.extend(step.history);
}
if !out_lines.is_empty() {
let mut lines_with_header: Lines = Vec::new();
self.emit_header_if_needed(&mut lines_with_header);
lines_with_header.extend(out_lines);
sink.insert_history(lines_with_header);
// Insert as a HistoryCell so display drops the header while transcript keeps it.
sink.insert_history_cell(Box::new(history_cell::AgentMessageCell::new(
out_lines,
self.header.maybe_emit_header(),
)));
}
// Cleanup
@@ -159,11 +158,10 @@ impl StreamController {
}
let step = { self.state.step() };
if !step.history.is_empty() {
let mut lines: Lines = Vec::new();
self.emit_header_if_needed(&mut lines);
let mut out = lines;
out.extend(step.history);
sink.insert_history(out);
sink.insert_history_cell(Box::new(history_cell::AgentMessageCell::new(
step.history,
self.header.maybe_emit_header(),
)));
}
let is_idle = self.state.is_idle();
@@ -244,8 +242,9 @@ mod tests {
}
}
impl HistorySink for TestSink {
fn insert_history(&self, lines: Vec<Line<'static>>) {
self.lines.borrow_mut().push(lines);
fn insert_history_cell(&self, cell: Box<dyn crate::history_cell::HistoryCell>) {
// For tests, store the transcript representation of the cell.
self.lines.borrow_mut().push(cell.transcript_lines());
}
fn start_commit_animation(&self) {}
fn stop_commit_animation(&self) {}

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